feat(daemon): finalize design package endpoint (closes #450) (#832)

* feat(daemon): scaffold /api/projects/:id/finalize/anthropic (refs #450)

Phase C of the PR 2 plan for issue #450: scaffold the route + module
shape so subsequent phases (D-I) land function bodies and tests against
a stable surface that already passes typecheck.

What lands here:
- apps/daemon/src/finalize-design.ts: module-level constants
  (DEFAULT_BASE_URL, DEFAULT_MAX_TOKENS=16000, INPUT_BODY_CAP_BYTES=384KiB,
  LOCK_FILENAME=.finalize.lock, OUTPUT_FILENAME=DESIGN.md,
  DEFAULT_TIMEOUT_MS=120s); inline interfaces for the request/response
  shape (kept out of packages/contracts per scope rules); two error
  classes - FinalizePackageLockedError (mirrors PR #493's
  TranscriptExportLockedError) and FinalizeUpstreamError (carries upstream
  HTTP status for the route's error mapping); function stub that throws
  "not yet implemented".
- apps/daemon/tests/finalize-design.test.ts: vitest harness with
  describe.skip placeholder so the file imports cleanly. Real cases land
  in phases D-I. Default-import of node:fs (per memory: vi.spyOn cannot
  redefine on the frozen ESM Module Namespace; CJS exports object is
  mutable).
- apps/daemon/src/server.ts: route handler at
  POST /api/projects/:id/finalize/anthropic, slotted next to the
  existing :id/deploy* family. Validates apiKey/model non-empty, optional
  baseUrl via the existing validateExternalApiBaseUrl closure (forbidden
  -> 403, invalid -> 400), optional maxTokens positive number; calls
  getProject (404 on miss); calls finalizeDesignPackage (which throws,
  caught and mapped to 500 for now); maps known error classes
  (FinalizePackageLockedError -> 409, FinalizeUpstreamError -> 502)
  pre-emptively.

Path shape rationale (Bryan-confirmed): project-scoped path matches every
sibling /api/projects/:id/* route in server.ts (deploy, deployments,
deploy/preflight); provider-namespaced segment leaves a clean expansion
line for /api/projects/:id/finalize/openai etc. as follow-ups.

Field-name rationale: apiKey, baseUrl, model, maxTokens match
ProxyStreamRequest verbatim (packages/contracts/src/api/proxy.ts:8-19)
so a future caller can reuse the same body shape. baseUrl is optional
here (intentional divergence from the proxy at server.ts which requires
it) so standard Anthropic users do not need to set it; Bedrock /
self-hosted-proxy users still can.

Verification: pnpm --filter @open-design/daemon typecheck exits 0;
finalize-design.test.ts loads cleanly with 1 skipped placeholder; no
other tests touched.

Refs nexu-io/open-design#450 (PR 2 scaffold; pipeline body in subsequent
commits)

* feat(daemon): transcript truncation helper for /finalize prompt

Phase D of the PR 2 plan for issue #450: lands the helper that bounds
the transcript section of the synthesis prompt.

Why this exists: real-world signal at authoring time was a local project
transcript already at 3.95 MB. Anthropic's claude-opus-4-7 context cap
is roughly 200K tokens (~700 KB at typical density). Inserting an
unbounded transcript would 4xx upstream on the first real call. This
helper keeps the on-disk .transcript.jsonl lossless (PR #493's contract)
while making the prompt-inclusion bounded.

Strategy:
- Cap output at INPUT_BODY_CAP_BYTES (384 KiB) so the prompt has room
  for the system prompt + design system body + current artifact + room
  for the synthesis output.
- Always preserve the header line - it carries projectId, schemaVersion,
  conversation/message counts, attachment counts; synthesis quality
  depends on knowing the original sizes.
- Split equal byte budgets between head and tail so both project genesis
  and most-recent intent survive. Two thinking segments separated only
  by mid-session truncation lose the same kind of boundary that PR #493
  preserves between thinking blocks - that's accepted; smarter
  semantic chunking is a follow-up.
- Insert a single `{"kind":"truncated","reason":"size","omittedBytes":N}`
  sentinel JSON line between the head and tail so a synthesis consumer
  can detect the gap. omittedBytes is the difference between the
  original UTF-8 byte length and the output's UTF-8 byte length.
- If the head + tail budgets together cover the whole body (e.g. all
  message lines are tiny), no marker is emitted - the output is the
  input verbatim.

Tests:
- "returns the input verbatim when the JSONL fits under the 384 KiB cap"
  pins that small transcripts pass through unchanged with no marker.
- "head+tail truncates with a single marker line when the JSONL exceeds
  the 384 KiB cap" pins that output is bounded, header survives, exactly
  one marker emitted with non-zero omittedBytes, both ends of the body
  preserved, and at least one middle message omitted.

Suite delta: +2 tests in finalize-design.test.ts.

Refs nexu-io/open-design#450

* fix(daemon): resolve noUncheckedIndexedAccess in truncateTranscriptForPrompt

D1 (0eaa123) shipped with `body[headIndex]` and `body[i]` typed as
`string | undefined` under TypeScript's `noUncheckedIndexedAccess`
strict mode. Local typecheck would have caught it but the prior
verification piped through `tail` which masked the non-zero exit code
of `tsc`. Coalesce each access via `?? ''` (the array is from
`String.split('\n')` so undefined elements are not actually reachable;
the coalesce is a type-narrowing convenience, not a behavior change).

Verification: `pnpm --filter @open-design/daemon typecheck` exits 0;
`pnpm --filter @open-design/daemon test finalize-design` shows 2/2 +
1 skipped, identical to the pre-fix run.

Refs nexu-io/open-design#450

* feat(daemon): current-artifact resolver for /finalize

Phase E of the PR 2 plan for issue #450: resolves which artifact (if
any) accompanies the transcript + design system in the synthesis prompt.

Priority order (Bryan-locked in plan §6):
  1. The file referenced by tabs.is_active = 1 IF an
     <name>.artifact.json sidecar exists on disk. Sidecar presence is
     the discriminator: an inferred manifest from
     `inferLegacyManifest` (e.g. for a bare .html with no sidecar)
     does NOT count, and an active tab pointing at a non-artifact file
     (.md, .txt) falls through.
  2. Newest project file with a real .artifact.json sidecar, sorted by
     manifest.updatedAt descending. Files without an updatedAt sort
     last so legacy pre-streaming manifests do not get accidentally
     promoted.
  3. Returns null - "no artifact in scope". The Phase H caller will
     emit `artifact: null` in the response and the prompt's "Current
     artifact" section will read "none".

Sidecar presence is checked via `existsSync` on the on-disk path, NOT
via the `artifactManifest` field returned by readProjectFile/listFiles
(those run inferLegacyManifest as a fallback for known kinds, which
would otherwise cause a bare .html with no sidecar to look like an
artifact).

Tests:
- "returns the active-tab artifact when its sidecar is present, even
  if a newer artifact exists elsewhere": pinned.html (older
  updatedAt) is in the active tab; newer.html (newer updatedAt) is
  not. Resolver returns pinned.html - intent (active tab) beats
  recency.
- "falls through to newest .artifact.json when active tab points at a
  non-artifact file": README.md is the active tab (no sidecar);
  design.html has a real sidecar. Resolver falls through and returns
  design.html.
- "returns null when no active tab and no .artifact.json sidecars
  exist": only a README.md is in the project; no tabs row. Resolver
  returns null.

Suite delta: +3 tests in finalize-design.test.ts (5 active total).

Refs nexu-io/open-design#450

* feat(daemon): synthesis prompt construction for /finalize

Phase F of the PR 2 plan for issue #450: builds the system + user
prompts that get sent to Anthropic's Messages API in the synthesis
call. Pure function; no IO, no side effects.

System prompt (literal, stored as a module-level constant): instructs
Claude to emit a DESIGN.md document with a fixed 7-heading structure
(# DESIGN.md / ## Summary / ## Brand & Voice / ## Information
Architecture / ## Components & Patterns / ## Visual System / ## Open
Questions / ## Provenance). The Provenance section is required to list
project ID, design system, current artifact, transcript message count,
and the UTC generation timestamp.

User prompt (built at runtime): structured payload with the truncated
transcript JSONL, the design system body, and the current artifact
body, each under a ## heading. Missing inputs (no design system
selected, no artifact in scope) produce explicit "none" headings +
parenthetical placeholder body so Claude does not hallucinate content
for absent sections.

Truncation is the caller's concern - this function does not
re-truncate. The caller (Phase H pipeline) feeds in a JSONL that has
already been bounded by truncateTranscriptForPrompt.

Tests:
- "includes the transcript JSONL verbatim and the generation context":
  pins all section headings, the transcript body verbatim, the design
  system body verbatim, the artifact body verbatim, and every
  generation-context line.
- "falls back to \"none\" + parenthetical when no design system is
  selected": designSystemId=null and designSystemBody=null -> heading
  reads "## Active design system: none" with the parenthetical body.
- "falls back to \"none\" + parenthetical when no artifact is in
  scope": artifact=null -> heading reads "## Current artifact: none"
  with the parenthetical body.

Suite delta: +3 tests in finalize-design.test.ts (8 active total).

Refs nexu-io/open-design#450

* feat(daemon): Anthropic call + retry strategy for /finalize

Phase G of the PR 2 plan for issue #450: lands the upstream Claude
Messages API call with a single transient-error retry, plus the
response extractor that turns Anthropic's content array into the
DESIGN.md body.

What lands here:
- appendVersionedApiPath: inlined from the connectionTest helper at
  apps/daemon/src/connectionTest.ts:188-195 (it is not exported there).
  Appends /v1/messages when the base URL has no /vN segment, otherwise
  appends /messages directly. Same semantics; ~5 lines.
- callAnthropicWithRetry: POSTs to <base>/v1/messages with the canonical
  Anthropic headers (content-type, x-api-key, anthropic-version:
  2023-06-01) and body shape ({ model, max_tokens, system, messages,
  stream:false }). One retry on transient (HTTP 429 or 5xx); on terminal
  failure throws FinalizeUpstreamError carrying the upstream HTTP
  status and raw body text. The route handler in Phase I maps status
  to AUTH_FAILED / RATE_LIMITED / UPSTREAM_FAILED and runs the body
  through redactSecrets before exposing it as `details`.
- extractDesignMd: concatenates content[].text for every block where
  type === 'text', preserving order. Throws FinalizeUpstreamError(502)
  on three malformed-response shapes: non-object payload, missing
  content array, zero text blocks. The route handler maps the throw
  to 502 UPSTREAM_FAILED so synthesis cannot land a half-empty
  DESIGN.md on disk.
- Test-only `_sleepMs` injection on the call params so the retry-delay
  sleep is instant under vitest. Default sleep uses setTimeout.

Retry posture (1 retry on transient) is opinionated; the maintainer's
"standard exponential backoff" answer was directional and a single
retry matches the existing daemon's posture (transcript export and
connectionTest do zero retries) while staying inside the daemon's
blocking-fast posture for /finalize.

Tests:
- callAnthropicWithRetry: throws on 401 with no retry; retries once
  on 429 and resolves on second 200; throws after both 5xx attempts;
  propagates AbortError when signal is pre-aborted.
- extractDesignMd: concatenates ordered text blocks; throws on
  missing content array; throws on content with zero text blocks.

A spurious typecheck error from `exactOptionalPropertyTypes` (signal
typed as AbortSignal | undefined where RequestInit expects
AbortSignal | null) was resolved by conditionally spreading signal
into the RequestInit literal.

Suite delta: +7 tests in finalize-design.test.ts (15 active total).

Refs nexu-io/open-design#450

* feat(daemon): wire /finalize pipeline end-to-end

Phase H of the PR 2 plan for issue #450: stitches together every
phase D-G primitive into the full finalizeDesignPackage pipeline that
the route handler in Phase I will expose over HTTP.

Pipeline (in execution order, all inside a try/finally that always
releases the lockfile):
1. getProject(db, projectId): defensive 404 (the route validates first;
   this throw catches direct CLI/script callers).
2. mkdirSync(<projectDir>, { recursive: true }): some projects have DB
   rows but no on-disk dir yet (PR #493's same fix).
3. fs.openSync(.finalize.lock, 'wx'): EEXIST -> FinalizePackageLockedError
   (mirror PR #493's TranscriptExportLockedError).
4. exportProjectTranscript(db, projectsRoot, projectId, { now }): produces
   .transcript.jsonl on disk; we read the body and run it through
   truncateTranscriptForPrompt to bound the prompt-inclusion size.
5. readDesignSystem(designSystemsRoot, designSystemId): returns null when
   the project has no design_system_id selected, when the design system
   directory does not exist, or when the DESIGN.md file is missing.
6. resolveCurrentArtifact(db, projectsRoot, projectId): active tab ->
   newest .artifact.json by manifest.updatedAt -> null.
7. buildSynthesisPrompt({...}): system + user prompt (per Phase F).
8. callAnthropicWithRetry({...}): one retry on 429/5xx; throws
   FinalizeUpstreamError on terminal failure.
9. extractDesignMd(payload): concatenates content[].text blocks; throws
   FinalizeUpstreamError(502) on malformed shape.
10. Atomic write: writeFileSync({flag:'wx'}) -> reopen for fsync ->
    rename. Errors unlink tmp before rethrowing.
11. Lock release in finally (always closeSync + unlinkSync).

Bounded blocking: the function uses its own AbortController + 120s
timeout when the caller does not supply a signal. Caller-supplied
signal takes precedence.

Type tightening: switched the local Db interface to
`type Db = Database.Database` (better-sqlite3) so the function signature
is compatible with `exportProjectTranscript`'s typed parameter. Source
file already had a `better-sqlite3` import in claude-design-import area
of the daemon, so no new dependency.

Tests:
- "writes DESIGN.md atomically on the happy path": end-to-end with
  seeded project + conversation + 2 messages + design system on disk;
  asserts file at exact path + body bytes match the fetch mock.
- "response carries every documented field with correct types":
  designMdPath/bytesWritten/model/inputTokens/outputTokens/artifact/
  transcriptMessageCount/designSystemId all present and typed.
- "emits design system 'none' in the prompt when no design_system_id is
  set": fetch mock asserts on the body it receives.
- "throws FinalizePackageLockedError when .finalize.lock is already
  held": pre-create lockfile; assert throw + DESIGN.md not written +
  pre-existing lock NOT unlinked (we did not own it).
- "replaces an existing DESIGN.md atomically on a second finalize":
  inject a sentinel between two finalize calls; assert sentinel is
  gone after second run.
- "cleans up tmp file AND lock file on every error path": mock
  fs.writeFileSync to throw on the tmp path; assert no DESIGN.md.tmp.*
  remain, no DESIGN.md, no .finalize.lock.
- "uses the default https://api.anthropic.com baseUrl when baseUrl is
  omitted": fetch URL begins with the default; baseUrl=undefined path.

vi.restoreAllMocks() now runs in afterEach so the writeFileSync spy
from the cleanup test does not leak into subsequent tests.

Suite delta: +7 tests in finalize-design.test.ts (22 active total).

Refs nexu-io/open-design#450

* feat(daemon): /finalize HTTP route handler + error mapping

Phase I of the PR 2 plan for issue #450: replaces the Phase C stub's
catch-all 500 with status-aware error mapping that surfaces the right
HTTP status + error code for each documented failure mode, and adds
HTTP-layer tests that boot startServer to exercise the route's
validation branches.

Route handler changes:
- :id format guard: an inline regex matching isSafeId at
  apps/daemon/src/projects.ts:556-558 rejects unsafe ids with 400
  BAD_REQUEST before any DB or filesystem work. Without this, an id
  like 'bad!id' would either fail getProject as 404 (wrong code) or
  reach the function and throw 'invalid project id' (mapped to 500).
- FinalizeUpstreamError mapping is now status-aware:
  - upstream 401 -> 401 AUTH_FAILED
  - upstream 429 -> 429 RATE_LIMITED
  - upstream 5xx (or our own 502 sentinel for malformed responses)
    -> 502 UPSTREAM_FAILED
  In all cases the upstream raw text is run through redactSecrets so
  the apiKey cannot leak through `details` even if the upstream
  echoes the inbound headers.
- AbortError mapping: when the 120s AbortController fires (or the
  caller pre-aborted the signal), surface as 503 TIMEOUT.
- Default case: console.error the error per daemon convention; client
  sees 500 INTERNAL with the message routed through redactSecrets.
- Imported redactSecrets alongside the existing connectionTest
  imports (apps/daemon/src/server.ts:51).

HTTP-layer tests (boot startServer({port:0,returnServer:true}) once
in beforeAll, mirror the proxy-routes.test.ts pattern):
- "400 BAD_REQUEST when baseUrl is not a valid URL (test #13)":
  baseUrl='not-a-url'.
- "403 FORBIDDEN when baseUrl points at a private internal IP (test
  #14)": baseUrl='http://10.0.0.1'. Note: validateBaseUrl explicitly
  allows loopback (for local OpenAI-compatible servers) and only
  blocks non-loopback private IPs (10/8, 172.16/12, 192.168/16,
  fc00::/7, fe80::/10).
- "400 BAD_REQUEST when apiKey is missing (test #15)": apiKey omitted.
- "400 BAD_REQUEST when :id contains characters outside the safe-id
  regex (test #16)": id='bad!id' contains '!' which is not in
  [A-Za-z0-9._-].

Suite delta: +4 tests (26 active in finalize-design.test.ts).
Full daemon suite: 1078/1078 pass; baseline+26 (the +5 above plan
target reflects retry+extract split into more granular unit tests
than originally enumerated; all real, none skipped).

Refs nexu-io/open-design#450

* fix(daemon): tighten isSafeId to reject pure-dot project ids

Addresses the P1 path-traversal finding from @lefarcen on PR #832
(https://github.com/nexu-io/open-design/pull/832#discussion_r3202512644).

The pre-fix `isSafeId` at apps/daemon/src/projects.ts:556-558 used
regex `/^[A-Za-z0-9._-]{1,128}$/` which permitted pure-dot ids
(`.`, `..`, `...`) because `.` is in the character class. `projectDir`
and `resolveProjectDir` both delegated to `isSafeId`, so an id of
`..` would resolve to the PARENT of `.od/projects/` via `path.join`.

Threat model (per @lefarcen):
- An attacker creates a project row whose stored id is `..` (or
  another pure-dot variant) — for instance via a workflow that
  writes the row directly without going through the API. Subsequent
  finalize/write ops keyed by that id then escape the project tree.
- A direct CLI / scripted caller passing `..` as the project id
  reaches the function without HTTP normalization saving us. (Express
  normalizes %2e%2e to .. and collapses path segments, which yields
  404 for the URL `/api/projects/%2e%2e/...` in practice — but that's
  Express's protection, not ours.)

Fix:
- isSafeId now explicitly rejects pure-dot ids (`/^\.+$/.test(id)`)
  before the char-class regex check. Empty string and inputs longer
  than 128 chars are also rejected explicitly so the function fails
  closed on edge cases.
- isSafeId is now exported from apps/daemon/src/projects.ts so the
  /finalize route handler in apps/daemon/src/server.ts can use the
  same validator instead of re-implementing the regex inline. This
  prevents drift between the route guard and the projectDir guard,
  which was how this hole originally appeared.

Tests (in finalize-design.test.ts because that's where the threat was
flagged; isSafeId is daemon-wide so a dedicated test file would also
work):
- isSafeId rejects `.`, `..`, `...`, `....`
- isSafeId rejects ids with `/`, `\`, `!`, leading whitespace
- isSafeId rejects empty string and >128 chars
- isSafeId rejects non-string inputs (null/undefined/number)
- isSafeId accepts plain ids, ids with mid-string dots, UUIDs, single chars

Suite delta: +7 tests (33 active in finalize-design.test.ts).
Full daemon suite: 1085/1085.

Refs nexu-io/open-design#832

* fix(daemon): address PR #832 P1 findings — imported folders + network 502

Addresses two of the three P1 findings from @lefarcen on PR #832:

1. Imported-folder projects route DESIGN.md to metadata.baseDir
   (https://github.com/nexu-io/open-design/pull/832#discussion_r3202512656,
   also flagged independently by @chatgpt-codex-connector at #discussion_r3202430470)

   The pipeline previously called `projectDir(projectsRoot, projectId)`
   unconditionally, which resolves to `.od/projects/<id>`. For projects
   created via /api/import/folder the project row's `metadata.baseDir`
   carries the user's actual folder; without threading metadata through,
   finalize would silently land DESIGN.md in the hidden daemon data dir
   and the current-artifact resolver would miss the user's real files.

   Fix: switch from `projectDir` to `resolveProjectDir(projectsRoot,
   projectId, metadata)` in both `finalizeDesignPackage` and
   `resolveCurrentArtifact`. Thread `project.metadata` (from
   `getProject`'s normalized row) through both call paths. The resolver
   gets a new optional `metadata` parameter; native projects pass null
   and get identical behavior.

2. Network failures and JSON parse errors now map to 502 UPSTREAM_FAILED
   (https://github.com/nexu-io/open-design/pull/832#discussion_r3202512661)

   Pre-fix, only HTTP-non-OK responses were wrapped as
   FinalizeUpstreamError. DNS failures (ECONNREFUSED, ENOTFOUND), fetch
   TypeErrors, and `response.json()` SyntaxErrors fell through to the
   route's catch-all and surfaced as 500 INTERNAL — incorrect: those are
   upstream-level failures, not daemon bugs.

   Fix:
   - Wrap callAnthropicWithRetry in a try/catch that passes
     FinalizeUpstreamError and AbortError through verbatim, but rewraps
     any other thrown error as FinalizeUpstreamError(502, '', message).
   - Wrap response.json() in a try/catch that rewraps SyntaxError as
     FinalizeUpstreamError(502, '', "upstream Anthropic returned non-JSON
     body: ...").
   - The route handler's existing FinalizeUpstreamError mapping then
     correctly maps these to 502 with the message in `details` (run
     through redactSecrets first).

Tests:
- "writes DESIGN.md under metadata.baseDir for imported-folder projects":
  inserts a project row with metadata.baseDir pointing at a
  user-folder temp dir; asserts result.designMdPath lands there AND
  the hidden .od/projects/<id> dir does NOT contain a DESIGN.md.
- "rewraps fetch network rejection as FinalizeUpstreamError(502)":
  fetchImpl throws TypeError with cause.code='ENOTFOUND'; assert thrown
  error has name=FinalizeUpstreamError and status=502.
- "rewraps 200 with non-JSON body as FinalizeUpstreamError(502)":
  fetchImpl returns 200 with text/html body; response.json() throws
  SyntaxError internally; assert FinalizeUpstreamError(502).

Suite delta: +3 tests (36 active in finalize-design.test.ts).
Full daemon suite: green at last check; will re-verify before push.

Refs nexu-io/open-design#832

* refactor(daemon): move /finalize DTOs to contracts + map error codes + validate active-tab

Addresses the P2 and P3 findings from @lefarcen on PR #832:

P2 — Error codes + DTOs not in packages/contracts
  https://github.com/nexu-io/open-design/pull/832#discussion_r3202512673

  Reverses my plan's locked decision #10 ("no contracts changes in this
  PR; inline the request/response types"). That rule came from the
  predecessor PROMPT brief's anti-pattern table; @lefarcen's review is
  fresher signal and supersedes it. Drift risk between the daemon's
  inline types and any future PR 3 web client is real.

  - New contracts module: packages/contracts/src/api/finalize.ts with
    FinalizeAnthropicRequest / FinalizeArtifactRef /
    FinalizeAnthropicResponse. Re-exported from the package root and
    made addressable via `@open-design/contracts/api/finalize` subpath.
  - Daemon source imports the canonical types from contracts and
    re-exports the public type names so internal references keep
    working without touching every call site.
  - Daemon-local error codes remapped to existing ApiErrorCode union
    members (apps/daemon/src/server.ts), per @lefarcen's suggested
    mapping:
      FINALIZE_IN_PROGRESS -> CONFLICT
      AUTH_FAILED          -> UNAUTHORIZED
      UPSTREAM_FAILED      -> UPSTREAM_UNAVAILABLE
      TIMEOUT              -> UPSTREAM_UNAVAILABLE (status 503)
      INTERNAL             -> INTERNAL_ERROR
    HTTP status codes are unchanged; only the `code` field in the
    error JSON body changed.

P3 — Active-tab name not validated before sidecar probe
  https://github.com/nexu-io/open-design/pull/832#discussion_r3202512684

  resolveCurrentArtifact now runs the active tab's name through
  validateProjectPath BEFORE composing it into a path.join expression.
  An invalid tab (traversal segments, absolute path, null byte,
  reserved segment) causes resolveCurrentArtifact to fall through to
  the newest-artifact branch rather than abort or probe outside the
  project directory.

Tests:
- "falls through (does not throw) when active tab name contains
  traversal segments": injects a malformed `tabs.name =
  '../../../etc/passwd'` row directly via SQL (bypassing production
  tab-creation validation), seeds a real artifact, asserts the
  resolver returns the real artifact rather than the malformed name.

Suite delta: +1 test (37 active in finalize-design.test.ts).
Full daemon suite: 1089/1089 green.

Refs nexu-io/open-design#832

* fix(contracts): publish /api/finalize as standalone runtime entrypoint

Addresses @mrcfps's CI-red review on PR #832
(https://github.com/nexu-io/open-design/pull/832, inline comment on
packages/contracts/package.json).

The previous J3 commit added `./api/finalize` as a type-only subpath:
the entry had only a `types` field, no `default`. That broke the
contracts package-runtime gate (packages/contracts/tests/package-
runtime.test.ts:38-47) which asserts every exports entry exposes both
a `.mjs` runtime and a `.d.ts` types target. mrcfps proposed two fixes;
this commit takes path B — make finalize a first-class published
module rather than a type-only re-export from the package root.

Path B vs path A (a peer-AI second opinion via /collaborate confirmed):
under NodeNext + ESM with exports-map semantics, TypeScript validates
re-exported symbols against the published module-identity surface.
Because the previous J3 had `./api/finalize` neither declared as an
exports-map entry nor materialized as a standalone .mjs, TS omitted
the re-exported names during package boundary analysis. Even at
runtime `import('@open-design/contracts').FINALIZE_SCHEMA_VERSION`
worked from the bundled index.mjs but the type-checker rejected it.
Path B aligns the runtime and declaration surfaces.

Changes:
- packages/contracts/esbuild.config.mjs: add `./src/api/finalize.ts`
  to entryPoints so dist/api/finalize.mjs is generated as a standalone
  module rather than only inlined into the bundled root.
- packages/contracts/package.json: re-add `./api/finalize` to the
  exports map with both `default: ./dist/api/finalize.mjs` AND
  `types: ./dist/api/finalize.d.ts`. Mirrors `./api/connectionTest`'s
  shape (the canonical pattern for first-class submodule entries).
- packages/contracts/src/api/finalize.ts: keep the runtime export
  `FINALIZE_SCHEMA_VERSION = 1` (giving the standalone module a real
  value to emit beyond the type-only interfaces) and update the
  doc-comment now that the standalone .mjs is wired.
- apps/daemon/src/finalize-design.ts: switch the type import from
  the inline declarations introduced in the prior J3 fallback to
  `import type { ... } from '@open-design/contracts/api/finalize'`.
  Re-export the names so internal references inside finalize-design.ts
  keep working without touching every call site.

Verified:
- node --input-type=module -e "import('@open-design/contracts/api/finalize').then(m=>console.log(JSON.stringify(Object.keys(m))))"
  prints ["FINALIZE_SCHEMA_VERSION"] — runtime resolution clean.
- pnpm --filter @open-design/contracts test: 6/6 (including both
  package-runtime.test.ts cases on the rebuilt exports map).
- pnpm --filter @open-design/daemon typecheck: exits 0.
- pnpm --filter @open-design/daemon test: 1089/1089 (no regression vs
  the prior J3 number).

Refs nexu-io/open-design#832

---------

Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
This commit is contained in:
Bryan A 2026-05-08 07:52:11 -04:00 committed by GitHub
parent 1e8926271b
commit e13adf2e63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1773 additions and 3 deletions

View file

@ -0,0 +1,677 @@
// One-shot synthesis of a project's design intent into a `DESIGN.md` artifact
// at <projectDir>/DESIGN.md. The endpoint takes the SQLite-backed transcript
// (via `exportProjectTranscript` from PR #493), the project's active design
// system body, and the project's "current artifact" (active artifact tab,
// fallback to newest .artifact.json by manifest.updatedAt, fallback null),
// runs them through Claude's Messages API, and writes the synthesized
// Markdown back to disk atomically.
//
// Per-project lockfile semantics (`.finalize.lock`) mirror PR #493's
// transcript-export hygiene. A second concurrent finalize throws
// `FinalizePackageLockedError`. Stale-lock recovery (e.g. after a crash)
// is out of scope; operators clear via `rm <projectDir>/.finalize.lock`.
//
// API key, base URL, and model flow in via the route's request body
// (matching the proxy at `apps/daemon/src/server.ts`'s
// `/api/proxy/anthropic/stream`). The daemon does NOT store provider
// credentials. `baseUrl` is optional here (intentional divergence from
// the proxy, which requires it) so standard Anthropic users don't need
// to set it; Bedrock / self-hosted-proxy users still can.
//
// Inline `PersistedAgentEvent` shape is restated in this file (the daemon
// tsconfig does not resolve the `@open-design/contracts/api/chat` subpath
// export — verified during PR #493). Schema-mismatch tests in the test
// file would catch any drift between this restated union and the contract.
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import * as path from 'node:path';
import Database from 'better-sqlite3';
import type {
FinalizeAnthropicRequest,
FinalizeAnthropicResponse,
FinalizeArtifactRef,
} from '@open-design/contracts/api/finalize';
import { getProject } from './db.js';
import { readDesignSystem } from './design-systems.js';
import {
listFiles,
readProjectFile,
resolveProjectDir,
validateProjectPath,
} from './projects.js';
import { exportProjectTranscript } from './transcript-export.js';
// Re-export the request/response types so existing daemon-internal
// imports (and the route handler) keep their referenced names. The
// canonical definitions live in @open-design/contracts/api/finalize
// per @lefarcen's P2 review feedback on PR #832, with a real runtime
// entrypoint per @mrcfps's review feedback on the same PR.
export type {
FinalizeAnthropicRequest,
FinalizeAnthropicResponse,
FinalizeArtifactRef,
};
const DEFAULT_BASE_URL = 'https://api.anthropic.com';
const DEFAULT_MAX_TOKENS = 16000;
const INPUT_BODY_CAP_BYTES = 384 * 1024;
const LOCK_FILENAME = '.finalize.lock';
const OUTPUT_FILENAME = 'DESIGN.md';
const DEFAULT_TIMEOUT_MS = 120_000;
export interface FinalizeOptions {
apiKey: string;
baseUrl?: string;
model: string;
maxTokens?: number;
now?: () => Date;
fetchImpl?: typeof globalThis.fetch;
signal?: AbortSignal;
}
export class FinalizePackageLockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'FinalizePackageLockedError';
}
}
/**
* Upstream Anthropic call failure with a meaningful HTTP status the route
* handler can map to one of the documented error codes (401/429/502).
*/
export class FinalizeUpstreamError extends Error {
status: number;
rawText: string;
constructor(status: number, rawText: string, message?: string) {
super(message || `upstream Anthropic returned ${status}`);
this.name = 'FinalizeUpstreamError';
this.status = status;
this.rawText = rawText;
}
}
type Db = Database.Database;
interface ResolvedArtifact {
name: string;
body: string;
manifest: { kind?: string; updatedAt?: string; title?: string; entry?: string } | null;
}
/**
* Resolve the project's "current artifact" for the synthesis prompt.
*
* Priority order:
* 1. The file referenced by `tabs.is_active = 1` IF it has an
* `<name>.artifact.json` sidecar present on disk. "Sidecar
* presence" is the discriminator: an inferred manifest (e.g. for
* a bare `.html` file with no sidecar) does NOT count, and an
* active tab pointing at a non-artifact file (`.md`, `.txt`)
* falls through.
* 2. The newest project file with a real `.artifact.json` sidecar,
* sorted by `manifest.updatedAt` descending. Files without an
* `updatedAt` (legacy pre-streaming manifests) sort last.
* 3. `null` no artifact in scope. Caller emits `artifact: null`
* in the response and the prompt's "Current artifact" section
* reads "none".
*
* `metadata` is the project row's `metadata` field (from `getProject`).
* For imported-folder projects, `metadata.baseDir` redirects file IO
* to the user's actual folder; without it, this resolver would only
* look under `.od/projects/<id>` and miss the real artifacts.
*
* Sidecar presence is checked via `existsSync` on the on-disk path so
* the resolver does not depend on `inferLegacyManifest`'s heuristic.
*/
export async function resolveCurrentArtifact(
db: Db,
projectsRoot: string,
projectId: string,
metadata?: { baseDir?: string } | null,
): Promise<ResolvedArtifact | null> {
const dir = resolveProjectDir(projectsRoot, projectId, metadata ?? undefined);
const activeTabRow = db
.prepare(`SELECT name FROM tabs WHERE project_id = ? AND is_active = 1 LIMIT 1`)
.get(projectId) as { name?: unknown } | undefined;
const activeTabName =
activeTabRow && typeof activeTabRow.name === 'string' ? activeTabRow.name : null;
if (activeTabName) {
// Validate the tab name BEFORE composing it into a filesystem path.
// A malformed tab (e.g. `../../../etc/passwd` written by an attacker
// with DB write access) would otherwise probe outside the project
// dir via path.join. validateProjectPath throws on traversal
// segments, absolute paths, null bytes, and reserved segments.
// Invalid tab names fall through to the newest-artifact branch
// rather than aborting finalize. P3 finding from @lefarcen on PR #832.
let safeTabName: string | null = null;
try {
safeTabName = validateProjectPath(activeTabName);
} catch {
safeTabName = null;
}
if (safeTabName) {
const sidecarPath = path.join(dir, `${safeTabName}.artifact.json`);
if (fs.existsSync(sidecarPath)) {
const file = await readProjectFile(
projectsRoot,
projectId,
safeTabName,
metadata ?? undefined,
);
return {
name: file.name,
body: file.buffer.toString('utf8'),
manifest: file.artifactManifest ?? null,
};
}
}
// Active tab points at a non-artifact file (or an unsafe name) — fall
// through to the newest-artifact branch.
}
const files = await listFiles(projectsRoot, projectId, { metadata: metadata ?? undefined });
const candidates = files
.filter((f: { name: string; artifactManifest?: { updatedAt?: string } | null }) => {
// Require a real sidecar on disk; an inferred manifest does not count.
return fs.existsSync(path.join(dir, `${f.name}.artifact.json`));
})
.map((f: { name: string; artifactManifest?: { updatedAt?: string } | null }) => ({
name: f.name,
updatedAt:
f.artifactManifest && typeof f.artifactManifest.updatedAt === 'string'
? f.artifactManifest.updatedAt
: '',
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); // descending; '' sorts last
if (candidates.length > 0) {
const newest = await readProjectFile(
projectsRoot,
projectId,
candidates[0]!.name,
metadata ?? undefined,
);
return {
name: newest.name,
body: newest.buffer.toString('utf8'),
manifest: newest.artifactManifest ?? null,
};
}
return null;
}
export async function finalizeDesignPackage(
db: Db,
projectsRoot: string,
designSystemsRoot: string,
projectId: string,
options: FinalizeOptions,
): Promise<FinalizeAnthropicResponse> {
const project = getProject(db, projectId);
if (!project) {
// Defensive — the route handler validates this and returns 404 before
// reaching here. Kept for direct (non-HTTP) callers, e.g. CLI scripts.
throw new Error(`project not found: ${projectId}`);
}
// Imported-folder projects (created via /api/import/folder) carry
// `metadata.baseDir` and write to the user's actual folder rather than
// `.od/projects/<id>`. resolveProjectDir handles both shapes; calling
// bare `projectDir` would silently land DESIGN.md in the hidden daemon
// data dir for these projects (PR #832 P1 finding from @lefarcen).
const projectMetadata = (project as { metadata?: { baseDir?: string } | null }).metadata ?? null;
const dir = resolveProjectDir(projectsRoot, projectId, projectMetadata ?? undefined);
// For imported-folder projects, `dir` is the user's own directory and
// already exists; mkdirSync is a no-op (recursive:true is idempotent).
// For native projects, it lazily creates `.od/projects/<id>`.
fs.mkdirSync(dir, { recursive: true });
const finalPath = path.join(dir, OUTPUT_FILENAME);
const lockPath = path.join(dir, LOCK_FILENAME);
const tmpPath = path.join(
dir,
`${OUTPUT_FILENAME}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`,
);
const now = options.now ?? (() => new Date());
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
let lockFd: number | null = null;
try {
lockFd = fs.openSync(lockPath, 'wx');
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException)?.code === 'EEXIST') {
throw new FinalizePackageLockedError(
`finalize is already in progress for project ${projectId}`,
);
}
throw err;
}
try {
// Phase 3: export transcript via the PR #493 primitive. Returns the
// disk path; we read the body and run it through the truncation
// policy so a 4 MB transcript does not blow Anthropic's context.
const transcriptResult = exportProjectTranscript(db, projectsRoot, projectId, { now });
const transcriptJsonl = fs.readFileSync(transcriptResult.path, 'utf8');
const truncatedJsonl = truncateTranscriptForPrompt(transcriptJsonl);
// Phase 4: design system. Project may not have one selected; readDesignSystem
// returns null on missing DESIGN.md so the prompt's design-system section
// gracefully falls back to "(no design system selected for this project)".
const designSystemId =
typeof (project as { designSystemId?: unknown }).designSystemId === 'string'
? ((project as { designSystemId: string }).designSystemId)
: null;
const designSystemBody = designSystemId
? await readDesignSystem(designSystemsRoot, designSystemId)
: null;
// Phase 5: current artifact (active tab → newest .artifact.json → null).
// Thread metadata so imported-folder projects discover the real artifacts
// under metadata.baseDir rather than the empty `.od/projects/<id>` dir.
const artifact = await resolveCurrentArtifact(
db,
projectsRoot,
projectId,
projectMetadata,
);
// Phase 6: build prompt.
const { systemPrompt, userPrompt } = buildSynthesisPrompt({
projectId,
transcriptJsonl: truncatedJsonl,
transcriptMessageCount: transcriptResult.messageCount,
designSystemId,
designSystemBody,
artifact,
now: now(),
});
// Phase 7: Anthropic call with bounded blocking timeout. We use our own
// AbortController if the caller did not pass one; either way the call
// bounds at DEFAULT_TIMEOUT_MS.
//
// Network errors (DNS, ECONNREFUSED, ECONNRESET) and JSON parse errors
// on the response body are rewrapped as FinalizeUpstreamError(502) so
// the route handler maps them to 502 UPSTREAM_FAILED rather than 500
// INTERNAL. Per @lefarcen P1 review on PR #832: only HTTP-non-OK
// responses were previously wrapped, leaving DNS/parse failures to
// surface as generic 500s.
const ownController = options.signal ? null : new AbortController();
const timeoutId = ownController
? setTimeout(() => ownController.abort(), DEFAULT_TIMEOUT_MS)
: null;
let response: Response;
try {
const callParams: AnthropicCallParams = {
apiKey: options.apiKey,
baseUrl,
model: options.model,
maxTokens,
systemPrompt,
userPrompt,
};
const signalToUse = options.signal ?? ownController?.signal;
if (signalToUse) callParams.signal = signalToUse;
if (options.fetchImpl) callParams.fetchImpl = options.fetchImpl;
try {
response = await callAnthropicWithRetry(callParams);
} catch (err: unknown) {
if (err instanceof FinalizeUpstreamError) throw err;
const errName =
err && typeof err === 'object' && 'name' in err
? (err as { name?: unknown }).name
: '';
if (errName === 'AbortError') throw err; // route handler maps to 503
// Network-level failure (TypeError from fetch, ENOTFOUND/ECONNREFUSED
// via cause.code, etc.) — rewrap as upstream failure so the route
// handler maps to 502 UPSTREAM_FAILED with redacted details.
const message = err instanceof Error ? err.message : String(err);
throw new FinalizeUpstreamError(502, '', `upstream network error: ${message}`);
}
} finally {
if (timeoutId !== null) clearTimeout(timeoutId);
}
// Phase 8: extract DESIGN.md body and usage counters. A 200 with a body
// that isn't valid JSON (or isn't an object) is treated as an upstream
// failure rather than letting JSON.parse's SyntaxError surface as 500.
let payload: unknown;
try {
payload = await response.json();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
throw new FinalizeUpstreamError(
502,
'',
`upstream Anthropic returned non-JSON body: ${message}`,
);
}
const designMd = extractDesignMd(payload);
const usage = (payload as { usage?: { input_tokens?: number; output_tokens?: number } }).usage ?? {};
const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0;
// Phase 9: atomic write. Mirror PR #493: writeFileSync({flag:'wx'}) →
// reopen for fsync → rename. On any failure unlink tmp; rethrow so the
// route handler maps the error.
const encoded = Buffer.from(designMd, 'utf8');
try {
fs.writeFileSync(tmpPath, encoded, { flag: 'wx' });
const fsyncFd = fs.openSync(tmpPath, 'r+');
try {
fs.fsyncSync(fsyncFd);
} finally {
fs.closeSync(fsyncFd);
}
fs.renameSync(tmpPath, finalPath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
// tmp may not exist if writeFileSync threw before creating it
}
throw err;
}
return {
designMdPath: finalPath,
bytesWritten: encoded.length,
model: options.model,
inputTokens,
outputTokens,
artifact: artifact
? {
name: artifact.name,
updatedAt:
artifact.manifest && typeof artifact.manifest.updatedAt === 'string'
? artifact.manifest.updatedAt
: null,
}
: null,
transcriptMessageCount: transcriptResult.messageCount,
designSystemId,
};
} finally {
if (lockFd !== null) {
try {
fs.closeSync(lockFd);
} catch {
// ignore close-after-error
}
try {
fs.unlinkSync(lockPath);
} catch {
// lock may already be gone if disk vanished; not fatal
}
}
}
}
/**
* Append `/v1/<suffix>` to a base URL, but only if the URL does not
* already include a `/vN` segment. Mirrors the helper inlined in
* `apps/daemon/src/connectionTest.ts:188-195` (not exported there).
*/
export function appendVersionedApiPath(baseUrl: string, suffix: string): string {
const url = new URL(baseUrl);
const pathname = url.pathname.replace(/\/+$/, '');
url.pathname = /\/v\d+(\/|$)/.test(pathname) ? `${pathname}${suffix}` : `${pathname}/v1${suffix}`;
return url.toString();
}
export interface AnthropicCallParams {
apiKey: string;
baseUrl: string;
model: string;
maxTokens: number;
systemPrompt: string;
userPrompt: string;
signal?: AbortSignal;
fetchImpl?: typeof globalThis.fetch;
/** Test-only: skip the inter-attempt sleep so retries are instant. */
_sleepMs?: (ms: number) => Promise<void>;
}
const defaultSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
/**
* Call Anthropic's Messages API once, retrying once on a transient
* upstream failure (HTTP 429 or 5xx). On a terminal failure, throw a
* `FinalizeUpstreamError` carrying the upstream HTTP status and raw
* body text the route handler maps the status to one of
* AUTH_FAILED / RATE_LIMITED / UPSTREAM_FAILED and runs the raw body
* through `redactSecrets` before exposing it as `details` on the
* error JSON.
*
* Retry posture (1 retry) is opinionated; the maintainer's
* "standard exponential backoff" answer was directional and a single
* retry matches the existing daemon's posture (transcript export and
* connectionTest do zero retries).
*/
export async function callAnthropicWithRetry(
params: AnthropicCallParams,
): Promise<Response> {
const fetchImpl = params.fetchImpl ?? globalThis.fetch;
const sleep = params._sleepMs ?? defaultSleep;
const url = appendVersionedApiPath(params.baseUrl, '/messages');
const headers: Record<string, string> = {
'content-type': 'application/json',
'x-api-key': params.apiKey,
'anthropic-version': '2023-06-01',
};
const body = JSON.stringify({
model: params.model,
max_tokens: params.maxTokens,
system: params.systemPrompt,
messages: [{ role: 'user', content: params.userPrompt }],
stream: false,
});
for (let attempt = 0; attempt <= 1; attempt += 1) {
const init: RequestInit = { method: 'POST', headers, body };
if (params.signal) init.signal = params.signal;
const response = await fetchImpl(url, init);
if (response.ok) return response;
const transient = response.status === 429 || response.status >= 500;
if (!transient || attempt === 1) {
const text = await response.text().catch(() => '');
throw new FinalizeUpstreamError(response.status, text);
}
// Linear backoff: 1s on attempt 0. Two retries would extend to 2s on
// attempt 1 — kept at one retry to stay within the daemon's blocking-
// fast posture for `/finalize`.
await sleep(1000 * (attempt + 1));
}
// Loop above always returns or throws within two iterations. This is
// unreachable; satisfies TypeScript control-flow analysis.
throw new Error('callAnthropicWithRetry: unreachable');
}
/**
* Extract the Markdown body from Anthropic's Messages API response.
* Concatenates `content[].text` for every block where `type === 'text'`,
* preserving order. Throws `FinalizeUpstreamError(502)` if the response
* shape is unexpected (no content array, no text blocks) synthesis
* cannot proceed, and the route handler maps the throw to
* `502 UPSTREAM_FAILED` rather than producing an empty DESIGN.md on disk.
*/
export function extractDesignMd(payload: unknown): string {
if (!payload || typeof payload !== 'object') {
throw new FinalizeUpstreamError(502, '', 'upstream Anthropic response was not an object');
}
const content = (payload as { content?: unknown }).content;
if (!Array.isArray(content)) {
throw new FinalizeUpstreamError(
502,
'',
'upstream Anthropic response had no content array',
);
}
let out = '';
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const b = block as { type?: unknown; text?: unknown };
if (b.type === 'text' && typeof b.text === 'string') out += b.text;
}
if (out.length === 0) {
throw new FinalizeUpstreamError(
502,
'',
'upstream Anthropic response contained no text blocks',
);
}
return out;
}
const SYSTEM_PROMPT = `You are a senior product designer synthesizing a finalized design package
from a multi-turn design session. Your output is a single Markdown document
named DESIGN.md that captures the durable design intent of the work so a
fresh contributor (human or LLM) can reconstruct context without replaying
the full chat.
Output structure (Markdown headings exactly as below):
# DESIGN.md
## Summary
## Brand & Voice
## Information Architecture
## Components & Patterns
## Visual System
## Open Questions
## Provenance
The Provenance section MUST list:
- Project ID
- Design system (or "none" if not selected)
- Current artifact (file name, or "none" if not in scope)
- Transcript message count
- Generated UTC timestamp
Output the Markdown body only. No preamble, no chat-style framing, no
"Here's your DESIGN.md" prefix. Do not invent facts not supported by the
inputs; if an input is missing or empty, the corresponding section should
say so explicitly rather than fabricating content.`;
export interface SynthesisPromptInput {
projectId: string;
transcriptJsonl: string;
transcriptMessageCount: number;
designSystemId: string | null;
designSystemBody: string | null;
artifact: ResolvedArtifact | null;
now: Date;
}
export interface SynthesisPromptOutput {
systemPrompt: string;
userPrompt: string;
}
/**
* Build the system + user prompts for the Anthropic Messages API call.
* Inputs are verbatim except for the transcript (which the caller has
* already passed through `truncateTranscriptForPrompt` this function
* does not re-truncate). Missing inputs (no design system selected, no
* artifact in scope) produce explicit "none"/parenthetical placeholders
* so Claude does not hallucinate content for absent sections.
*/
export function buildSynthesisPrompt(input: SynthesisPromptInput): SynthesisPromptOutput {
const designSystemHeader = input.designSystemId ?? 'none';
const designSystemBody =
input.designSystemBody && input.designSystemBody.trim().length > 0
? input.designSystemBody
: '(no design system selected for this project)';
const artifactHeader = input.artifact ? input.artifact.name : 'none';
const artifactBody = input.artifact
? input.artifact.body
: '(no artifact in scope for this finalize)';
const userPrompt =
`The following inputs describe the design session for project ${input.projectId}.\n\n` +
`## Transcript (JSONL)\n${input.transcriptJsonl}\n\n` +
`## Active design system: ${designSystemHeader}\n${designSystemBody}\n\n` +
`## Current artifact: ${artifactHeader}\n${artifactBody}\n\n` +
`## Generation context\n` +
`- Generated at: ${input.now.toISOString()}\n` +
`- Project ID: ${input.projectId}\n` +
`- Transcript message count: ${input.transcriptMessageCount}\n\n` +
`Synthesize DESIGN.md per the system instructions.`;
return { systemPrompt: SYSTEM_PROMPT, userPrompt };
}
/**
* Truncate a JSONL transcript body so it fits inside Claude's context
* window when fed into a synthesis prompt. The on-disk transcript stays
* untouched (PR #493's lossless contract); this function operates on a
* copy that lives only in the prompt.
*
* Strategy: keep the header line (line 0); if the remaining body exceeds
* INPUT_BODY_CAP_BYTES (minus the header + marker reservation), retain
* head and tail lines in roughly equal byte budgets and drop the middle
* with a single sentinel JSON line:
*
* {"kind":"truncated","reason":"size","omittedBytes":<N>}
*
* `omittedBytes` is the difference between the original UTF-8 byte
* length and the truncated output's UTF-8 byte length, so a synthesis
* consumer can detect the gap.
*
* If head + tail budgets together cover the whole body (e.g. all message
* lines are tiny), no marker is emitted; the output is the input
* verbatim.
*/
export function truncateTranscriptForPrompt(jsonl: string): string {
const buf = Buffer.from(jsonl, 'utf8');
if (buf.byteLength <= INPUT_BODY_CAP_BYTES) return jsonl;
const lines = jsonl.split('\n');
const header = lines[0] ?? '';
const body = lines.slice(1);
const markerLine = '{"kind":"truncated","reason":"size","omittedBytes":__N__}';
const reservedBytes =
Buffer.byteLength(header + '\n', 'utf8') +
Buffer.byteLength(markerLine + '\n', 'utf8') +
64;
const perSideBudget = Math.floor((INPUT_BODY_CAP_BYTES - reservedBytes) / 2);
const headLines: string[] = [];
let headBytes = 0;
let headIndex = 0;
for (; headIndex < body.length; headIndex += 1) {
const line = body[headIndex] ?? '';
const lineBytes = Buffer.byteLength(line + '\n', 'utf8');
if (headBytes + lineBytes > perSideBudget) break;
headLines.push(line);
headBytes += lineBytes;
}
const tailLines: string[] = [];
let tailBytes = 0;
for (let i = body.length - 1; i >= headIndex; i -= 1) {
const line = body[i] ?? '';
const lineBytes = Buffer.byteLength(line + '\n', 'utf8');
if (tailBytes + lineBytes > perSideBudget) break;
tailLines.unshift(line);
tailBytes += lineBytes;
}
if (headLines.length + tailLines.length >= body.length) {
// Head + tail covers the whole body — no truncation needed beyond the
// marker reservation. Return verbatim.
return [header, ...headLines, ...tailLines].join('\n');
}
const without = [header, ...headLines, ...tailLines].join('\n');
const omittedBytes = buf.byteLength - Buffer.byteLength(without, 'utf8');
const marker = markerLine.replace('__N__', String(omittedBytes));
return [header, ...headLines, marker, ...tailLines].join('\n');
}

View file

@ -553,8 +553,20 @@ function toProjectPath(raw) {
return raw.split(path.sep).join('/');
}
function isSafeId(id) {
return typeof id === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(id);
// Validates an id string for use as a path segment under a daemon-managed
// directory (`.od/projects/<id>`, `design-systems/<id>`, etc.). The character
// class allows dots so ids like `my-project.v2` work, but pure-dot ids
// (`.`, `..`, `...`) MUST be rejected — they pass the char-class check but
// resolve to the parent directory when fed into `path.join`. Without the
// pure-dot guard, an attacker could create a project row with id `..` (or
// reach this code via a percent-encoded URL like `/api/projects/%2e%2e/...`
// which Express decodes before the route handler sees it) and steer
// finalize / write operations outside `.od/projects/`.
export function isSafeId(id) {
if (typeof id !== 'string') return false;
if (id.length === 0 || id.length > 128) return false;
if (/^\.+$/.test(id)) return false; // reject `.`, `..`, `...`, etc.
return /^[A-Za-z0-9._-]+$/.test(id);
}
const EXT_MIME = {

View file

@ -52,11 +52,17 @@ import { renderDesignSystemPreview } from './design-system-preview.js';
import { renderDesignSystemShowcase } from './design-system-showcase.js';
import { createChatRunService } from './runs.js';
import {
redactSecrets,
testAgentConnection,
testProviderConnection,
validateBaseUrl,
} from './connectionTest.js';
import { importClaudeDesignZip } from './claude-design-import.js';
import {
finalizeDesignPackage,
FinalizePackageLockedError,
FinalizeUpstreamError,
} from './finalize-design.js';
import { listPromptTemplates, readPromptTemplate } from './prompt-templates.js';
import { buildDocumentPreview } from './document-preview.js';
import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js';
@ -106,6 +112,7 @@ import {
deleteProjectFile,
detectEntryFile,
ensureProject,
isSafeId,
listFiles,
mimeFor,
projectDir,
@ -3768,6 +3775,101 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
}
});
app.post('/api/projects/:id/finalize/anthropic', async (req, res) => {
const { apiKey, baseUrl, model, maxTokens } = req.body || {};
try {
// Centralized path-traversal guard. `isSafeId` (apps/daemon/src/projects.ts)
// rejects pure-dot ids (`.`, `..`, etc.) which would otherwise pass
// the char-class regex and resolve to the parent directory under
// path.join. Express decodes percent-encoded `%2e%2e` to `..` before
// we see it, so this check covers both URL-supplied and stored-row
// attack vectors.
if (!isSafeId(req.params.id)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
}
if (typeof apiKey !== 'string' || !apiKey.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'apiKey is required');
}
if (typeof model !== 'string' || !model.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'model is required');
}
if (baseUrl !== undefined) {
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseUrl must be a non-empty string when provided');
}
const validated = validateExternalApiBaseUrl(baseUrl);
if (validated.error) {
return sendApiError(
res,
validated.forbidden ? 403 : 400,
validated.forbidden ? 'FORBIDDEN' : 'BAD_REQUEST',
validated.error,
);
}
}
if (maxTokens !== undefined && (typeof maxTokens !== 'number' || maxTokens <= 0)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'maxTokens must be a positive number when provided');
}
const project = getProject(db, req.params.id);
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const result = await finalizeDesignPackage(
db,
PROJECTS_DIR,
DESIGN_SYSTEMS_DIR,
req.params.id,
{ apiKey, baseUrl, model, maxTokens },
);
res.json(result);
} catch (err) {
// Concurrent finalize - the lockfile was already held by another
// call. Caller can retry after a short wait; not a client error.
// Maps to the shared CONFLICT code per @lefarcen P2 on PR #832.
if (err instanceof FinalizePackageLockedError) {
return sendApiError(res, 409, 'CONFLICT', err.message);
}
// Upstream Anthropic error - status-aware mapping using shared
// ApiErrorCode values. Run the raw upstream body through
// redactSecrets so the API key cannot leak even if Anthropic
// echoes the inbound headers. Codes per @lefarcen P2 on PR #832:
// 401 -> UNAUTHORIZED, 429 -> RATE_LIMITED, others -> UPSTREAM_UNAVAILABLE.
if (err instanceof FinalizeUpstreamError) {
const safeDetails = redactSecrets(err.rawText || '', [apiKey]);
const init = safeDetails ? { details: safeDetails } : {};
if (err.status === 401) {
return sendApiError(res, 401, 'UNAUTHORIZED', err.message, init);
}
if (err.status === 429) {
return sendApiError(res, 429, 'RATE_LIMITED', err.message, init);
}
return sendApiError(res, 502, 'UPSTREAM_UNAVAILABLE', err.message, init);
}
// The blocking call hit our 120s AbortController timeout - or the
// caller passed an already-aborted signal. Either way, surface as
// 503 with the shared UPSTREAM_UNAVAILABLE code (no dedicated
// TIMEOUT code in the contracts ApiErrorCode union).
const errName =
err && typeof err === 'object' && 'name' in err ? (err as { name?: unknown }).name : '';
if (errName === 'AbortError') {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'finalize timed out');
}
// Unexpected runtime failure (file IO, db access, prompt build).
// Log via console.error per the daemon convention; client sees a
// generic 500 with the shared INTERNAL_ERROR code. Run the message
// through redactSecrets defensively.
console.error('[finalize/anthropic]', err);
const safeMsg = redactSecrets(String(err?.message || err), [apiKey]);
return sendApiError(res, 500, 'INTERNAL_ERROR', safeMsg);
}
});
app.post(
'/api/projects/:id/deployments/:deploymentId/check-link',
async (req, res) => {

View file

@ -0,0 +1,911 @@
// @ts-nocheck
// Tests for `apps/daemon/src/finalize-design.ts` — fills in across phases
// D-I. Phase D adds the truncation helper tests; phases E-I extend.
//
// Per memory `project_open_design_493_merged.md`: this file uses
// `import fs from 'node:fs'` (default import) so `vi.spyOn(fs, '<fn>')`
// can redefine properties on the underlying CJS exports object. ESM
// namespace import (`import * as fs from 'node:fs'`) gives a frozen
// Module Namespace Object that `vi.spyOn` cannot mutate.
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import type http from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
closeDatabase,
insertConversation,
insertProject,
openDatabase,
upsertMessage,
} from '../src/db.js';
import { isSafeId, writeProjectFile } from '../src/projects.js';
import {
appendVersionedApiPath,
buildSynthesisPrompt,
callAnthropicWithRetry,
extractDesignMd,
finalizeDesignPackage,
FinalizePackageLockedError,
FinalizeUpstreamError,
resolveCurrentArtifact,
truncateTranscriptForPrompt,
} from '../src/finalize-design.js';
void appendVersionedApiPath;
// Touch the imports so the unused-import linter stays quiet on the scaffold.
void finalizeDesignPackage;
void FinalizePackageLockedError;
void FinalizeUpstreamError;
const PROJECT_ID = 'project-1';
let tempDir: string | null = null;
let projectsRoot: string | null = null;
afterEach(() => {
closeDatabase();
vi.restoreAllMocks();
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
tempDir = null;
projectsRoot = null;
});
function setupResolverFixture(): { db: any; projectsRoot: string } {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-finalize-'));
const db = openDatabase(tempDir);
insertProject(db, {
id: PROJECT_ID,
name: 'Project',
createdAt: 1,
updatedAt: 1,
});
projectsRoot = path.join(tempDir, 'projects');
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
return { db, projectsRoot };
}
function setActiveTab(db: any, name: string) {
db.prepare(
`INSERT INTO tabs (project_id, name, position, is_active) VALUES (?, ?, ?, 1)`,
).run(PROJECT_ID, name, 0);
}
const HEADER = JSON.stringify({
kind: 'header',
schemaVersion: 2,
projectId: 'proj-1',
exportedAt: '2026-05-07T14:00:00.000Z',
conversationCount: 1,
messageCount: 100,
});
function buildSyntheticJsonl(messageCount: number, perMessageBytes: number): string {
// Each message line is roughly `perMessageBytes` long after stringify.
const lines = [HEADER, JSON.stringify({ kind: 'conversation', id: 'c1', title: 't', createdAt: 1, updatedAt: 1 })];
const padBytes = Math.max(0, perMessageBytes - 80);
const filler = 'x'.repeat(padBytes);
for (let i = 0; i < messageCount; i += 1) {
lines.push(JSON.stringify({
kind: 'message',
id: `m${i}`,
role: 'user',
position: i,
blocks: [{ type: 'text', text: `msg-${i}-${filler}` }],
}));
}
return lines.join('\n') + '\n';
}
describe('truncateTranscriptForPrompt', () => {
it('returns the input verbatim when the JSONL fits under the 384 KiB cap', () => {
// 50 messages at ~100 bytes each = ~5 KB total; well under the cap.
const jsonl = buildSyntheticJsonl(50, 100);
expect(Buffer.byteLength(jsonl, 'utf8')).toBeLessThan(384 * 1024);
const out = truncateTranscriptForPrompt(jsonl);
expect(out).toBe(jsonl);
expect(out).not.toContain('"kind":"truncated"');
// Every message line round-trips.
for (let i = 0; i < 50; i += 1) {
expect(out).toContain(`"id":"m${i}"`);
}
});
it('head+tail truncates with a single marker line when the JSONL exceeds the 384 KiB cap', () => {
// 800 messages at ~1 KB each = ~800 KB total; comfortably above the cap.
const jsonl = buildSyntheticJsonl(800, 1024);
expect(Buffer.byteLength(jsonl, 'utf8')).toBeGreaterThan(384 * 1024);
const out = truncateTranscriptForPrompt(jsonl);
// Output is bounded by the cap (allow a small tolerance for the
// marker + reservation slack).
expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(384 * 1024);
// Header line survives.
expect(out.split('\n')[0]).toBe(HEADER);
// Exactly one truncation marker present, with a non-zero omittedBytes.
const markerMatches = out.match(/\{"kind":"truncated","reason":"size","omittedBytes":\d+\}/g);
expect(markerMatches).not.toBeNull();
expect(markerMatches).toHaveLength(1);
const omittedBytes = Number(markerMatches![0].match(/"omittedBytes":(\d+)/)![1]);
expect(omittedBytes).toBeGreaterThan(0);
// Both ends preserved: first message after header survives; last
// message before the trailing newline survives.
expect(out).toContain('"id":"m0"');
expect(out).toContain('"id":"m799"');
// Middle messages (e.g. m400) should NOT all survive — at least one
// must be omitted; otherwise we wouldn't have needed the marker.
const surviving = (out.match(/"id":"m\d+"/g) || []).map((s) => Number(s.match(/m(\d+)/)![1]));
expect(surviving.length).toBeLessThan(800);
expect(surviving).toContain(0);
expect(surviving).toContain(799);
});
});
describe('resolveCurrentArtifact', () => {
it('returns the active-tab artifact when its sidecar is present, even if a newer artifact exists elsewhere', async () => {
const { db, projectsRoot } = setupResolverFixture();
// Older artifact - active tab points here.
await writeProjectFile(projectsRoot, PROJECT_ID, 'pinned.html', '<p>pinned body</p>', {
artifactManifest: {
version: 1,
kind: 'html',
title: 'Pinned',
entry: 'pinned.html',
renderer: 'html',
exports: ['html'],
updatedAt: '2026-05-01T00:00:00.000Z',
},
});
// Newer artifact - NOT in the active tab.
await writeProjectFile(projectsRoot, PROJECT_ID, 'newer.html', '<p>newer body</p>', {
artifactManifest: {
version: 1,
kind: 'html',
title: 'Newer',
entry: 'newer.html',
renderer: 'html',
exports: ['html'],
updatedAt: '2026-05-07T00:00:00.000Z',
},
});
setActiveTab(db, 'pinned.html');
const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
expect(out).not.toBeNull();
expect(out!.name).toBe('pinned.html');
expect(out!.body).toBe('<p>pinned body</p>');
});
it('falls through to newest .artifact.json when active tab points at a non-artifact file', async () => {
const { db, projectsRoot } = setupResolverFixture();
// README.md - no artifact sidecar - active tab points here.
await writeProjectFile(projectsRoot, PROJECT_ID, 'README.md', '# notes\n');
// The actual artifact - NOT in active tab.
await writeProjectFile(projectsRoot, PROJECT_ID, 'design.html', '<p>design</p>', {
artifactManifest: {
version: 1,
kind: 'html',
title: 'Design',
entry: 'design.html',
renderer: 'html',
exports: ['html'],
updatedAt: '2026-05-07T00:00:00.000Z',
},
});
setActiveTab(db, 'README.md');
const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
expect(out).not.toBeNull();
expect(out!.name).toBe('design.html');
expect(out!.body).toBe('<p>design</p>');
});
it('returns null when no active tab and no .artifact.json sidecars exist', async () => {
const { db, projectsRoot } = setupResolverFixture();
// README.md only - no artifact sidecars anywhere.
await writeProjectFile(projectsRoot, PROJECT_ID, 'README.md', '# notes\n');
const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
expect(out).toBeNull();
});
// PR #832 P3 fix from @lefarcen: a malformed tabs row (e.g. an
// attacker with DB write access setting tabs.name = `../../../etc/passwd`)
// would otherwise cause path.join to compose a probe URL outside the
// project dir before readProjectFile's path-safety check kicked in.
// Post-fix: the resolver runs the tab name through validateProjectPath
// first; an invalid name falls through to the newest-artifact branch.
it('falls through (does not throw) when active tab name contains traversal segments', async () => {
const { db, projectsRoot } = setupResolverFixture();
// A real artifact exists.
await writeProjectFile(projectsRoot, PROJECT_ID, 'design.html', '<p>real</p>', {
artifactManifest: {
version: 1,
kind: 'html',
title: 'Design',
entry: 'design.html',
renderer: 'html',
exports: ['html'],
updatedAt: '2026-05-07T00:00:00.000Z',
},
});
// Inject a malformed tabs row directly via SQL — bypasses the
// production tab-creation code path which would normally validate.
db.prepare(
`INSERT INTO tabs (project_id, name, position, is_active) VALUES (?, ?, 0, 1)`,
).run(PROJECT_ID, '../../../etc/passwd');
const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
// The resolver must NOT throw and must NOT return the malformed name.
// It falls through to the newest-artifact branch and returns the
// real artifact instead.
expect(out).not.toBeNull();
expect(out!.name).toBe('design.html');
});
});
describe('buildSynthesisPrompt', () => {
const FIXED_NOW = new Date('2026-05-07T14:00:00.000Z');
const TRANSCRIPT_FIXTURE =
JSON.stringify({ kind: 'header', schemaVersion: 2, projectId: 'p1', messageCount: 2 }) +
'\n' +
JSON.stringify({ kind: 'message', id: 'm1', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }) +
'\n' +
JSON.stringify({ kind: 'message', id: 'm2', role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] }) +
'\n';
it('includes the transcript JSONL verbatim and the generation context', () => {
const out = buildSynthesisPrompt({
projectId: 'p1',
transcriptJsonl: TRANSCRIPT_FIXTURE,
transcriptMessageCount: 2,
designSystemId: 'shadcn',
designSystemBody: '# shadcn\nminimal\n',
artifact: { name: 'design.html', body: '<p>artifact</p>', manifest: null },
now: FIXED_NOW,
});
expect(out.systemPrompt).toContain('# DESIGN.md');
expect(out.systemPrompt).toContain('## Provenance');
expect(out.userPrompt).toContain('## Transcript (JSONL)');
expect(out.userPrompt).toContain(TRANSCRIPT_FIXTURE);
expect(out.userPrompt).toContain('## Active design system: shadcn');
expect(out.userPrompt).toContain('# shadcn\nminimal\n');
expect(out.userPrompt).toContain('## Current artifact: design.html');
expect(out.userPrompt).toContain('<p>artifact</p>');
expect(out.userPrompt).toContain('Generated at: 2026-05-07T14:00:00.000Z');
expect(out.userPrompt).toContain('Project ID: p1');
expect(out.userPrompt).toContain('Transcript message count: 2');
expect(out.userPrompt).toContain('Synthesize DESIGN.md per the system instructions.');
});
it('falls back to "none" + parenthetical when no design system is selected', () => {
const out = buildSynthesisPrompt({
projectId: 'p1',
transcriptJsonl: TRANSCRIPT_FIXTURE,
transcriptMessageCount: 2,
designSystemId: null,
designSystemBody: null,
artifact: { name: 'design.html', body: '<p>artifact</p>', manifest: null },
now: FIXED_NOW,
});
expect(out.userPrompt).toContain('## Active design system: none');
expect(out.userPrompt).toContain('(no design system selected for this project)');
});
it('falls back to "none" + parenthetical when no artifact is in scope', () => {
const out = buildSynthesisPrompt({
projectId: 'p1',
transcriptJsonl: TRANSCRIPT_FIXTURE,
transcriptMessageCount: 2,
designSystemId: 'shadcn',
designSystemBody: '# shadcn\n',
artifact: null,
now: FIXED_NOW,
});
expect(out.userPrompt).toContain('## Current artifact: none');
expect(out.userPrompt).toContain('(no artifact in scope for this finalize)');
});
});
describe('callAnthropicWithRetry', () => {
const baseParams = {
apiKey: 'sk-test-key',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
maxTokens: 16000,
systemPrompt: 'sys',
userPrompt: 'usr',
_sleepMs: async () => {},
};
function jsonResponse(status: number, body: any): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function textResponse(status: number, body: string): Response {
return new Response(body, { status });
}
it('throws FinalizeUpstreamError(401) on auth failure (no retry on 4xx non-429)', async () => {
const fetchImpl = vi.fn(async () => textResponse(401, '{"error":{"type":"authentication_error","message":"invalid x-api-key"}}'));
await expect(callAnthropicWithRetry({ ...baseParams, fetchImpl })).rejects.toMatchObject({
name: 'FinalizeUpstreamError',
status: 401,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('retries once on 429 and resolves when the second response succeeds', async () => {
const ok = jsonResponse(200, { content: [{ type: 'text', text: 'DESIGN.md body' }], usage: { input_tokens: 1, output_tokens: 1 } });
const fetchImpl = vi
.fn<any, any>()
.mockResolvedValueOnce(textResponse(429, '{"error":"rate limited"}'))
.mockResolvedValueOnce(ok);
const response = await callAnthropicWithRetry({ ...baseParams, fetchImpl });
expect(response.status).toBe(200);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it('throws FinalizeUpstreamError(503) when both attempts return 5xx', async () => {
const fetchImpl = vi
.fn<any, any>()
.mockResolvedValueOnce(textResponse(503, 'service unavailable'))
.mockResolvedValueOnce(textResponse(503, 'service unavailable'));
await expect(callAnthropicWithRetry({ ...baseParams, fetchImpl })).rejects.toMatchObject({
name: 'FinalizeUpstreamError',
status: 503,
});
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it('propagates AbortError from fetch when the signal is aborted', async () => {
const controller = new AbortController();
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
// Mirror native fetch's behavior: throw AbortError when init.signal is aborted.
if (init?.signal?.aborted) {
const err = new Error('aborted');
err.name = 'AbortError';
throw err;
}
throw new Error('fetch should never be called with non-aborted signal in this test');
});
controller.abort();
await expect(
callAnthropicWithRetry({ ...baseParams, fetchImpl, signal: controller.signal }),
).rejects.toMatchObject({ name: 'AbortError' });
});
});
describe('extractDesignMd', () => {
it('concatenates text blocks in order', () => {
const payload = {
content: [
{ type: 'text', text: '# DESIGN.md\n## Summary\n' },
{ type: 'text', text: 'body continues here.\n' },
],
usage: { input_tokens: 1, output_tokens: 1 },
};
expect(extractDesignMd(payload)).toBe('# DESIGN.md\n## Summary\nbody continues here.\n');
});
it('throws FinalizeUpstreamError(502) when the response shape has no content array', () => {
expect(() => extractDesignMd({ unexpected: true })).toThrow(FinalizeUpstreamError);
try {
extractDesignMd({ unexpected: true });
} catch (err: any) {
expect(err.status).toBe(502);
}
});
it('throws FinalizeUpstreamError(502) when content array has zero text blocks', () => {
expect(() => extractDesignMd({ content: [{ type: 'tool_use', id: 'x', name: 'y', input: {} }] })).toThrow(FinalizeUpstreamError);
});
});
describe('finalizeDesignPackage (pipeline integration)', () => {
function setupPipeline(
opts: { designSystemId?: string | null; designSystemBody?: string | null } = {},
): { db: any; projectsRoot: string; designSystemsRoot: string } {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-finalize-pipe-'));
const designSystemsRoot = path.join(tempDir, 'design-systems');
fs.mkdirSync(designSystemsRoot, { recursive: true });
if (opts.designSystemId && opts.designSystemBody !== null) {
fs.mkdirSync(path.join(designSystemsRoot, opts.designSystemId), { recursive: true });
fs.writeFileSync(
path.join(designSystemsRoot, opts.designSystemId, 'DESIGN.md'),
opts.designSystemBody ?? '# default DESIGN.md\n',
);
}
const db = openDatabase(tempDir);
insertProject(db, {
id: PROJECT_ID,
name: 'Project',
designSystemId: opts.designSystemId === undefined ? 'shadcn' : opts.designSystemId,
createdAt: 1,
updatedAt: 1,
});
projectsRoot = path.join(tempDir, 'projects');
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
insertConversation(db, {
id: 'c1',
projectId: PROJECT_ID,
title: 'Greeting',
createdAt: 100,
updatedAt: 100,
});
upsertMessage(db, 'c1', {
id: 'm1',
role: 'user',
content: '',
events: [{ kind: 'text', text: 'design me a landing page' }],
});
upsertMessage(db, 'c1', {
id: 'm2',
role: 'assistant',
content: '',
events: [{ kind: 'text', text: 'here is the html' }],
});
return { db, projectsRoot, designSystemsRoot };
}
function happyFetch(designMd: string): typeof globalThis.fetch {
return vi.fn(async () =>
new Response(
JSON.stringify({
content: [{ type: 'text', text: designMd }],
usage: { input_tokens: 1234, output_tokens: 567 },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
) as any;
}
it('writes DESIGN.md atomically on the happy path', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline({
designSystemId: 'shadcn',
designSystemBody: '# shadcn\n## tone\nminimal, opinionated.\n',
});
const fetchImpl = happyFetch('# DESIGN.md\n## Summary\nA landing page.\n');
const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl,
} as any);
expect(result.designMdPath).toBe(path.join(projectsRoot, PROJECT_ID, 'DESIGN.md'));
expect(fs.existsSync(result.designMdPath)).toBe(true);
expect(fs.readFileSync(result.designMdPath, 'utf8')).toBe(
'# DESIGN.md\n## Summary\nA landing page.\n',
);
// No leftover .tmp files.
const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
expect(dirEntries.filter((n) => n.startsWith('DESIGN.md.tmp.'))).toEqual([]);
// Lock is released.
expect(dirEntries).not.toContain('.finalize.lock');
});
it('response carries every documented field with correct types', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline({
designSystemId: 'shadcn',
designSystemBody: '# shadcn\n',
});
const fetchImpl = happyFetch('# DESIGN.md\nbody\n');
const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl,
} as any);
expect(typeof result.designMdPath).toBe('string');
expect(typeof result.bytesWritten).toBe('number');
expect(result.bytesWritten).toBeGreaterThan(0);
expect(result.model).toBe('claude-opus-4-7');
expect(result.inputTokens).toBe(1234);
expect(result.outputTokens).toBe(567);
expect(result.artifact).toBeNull(); // no artifact seeded
expect(result.transcriptMessageCount).toBe(2);
expect(result.designSystemId).toBe('shadcn');
});
it('emits design system "none" in the prompt when no design_system_id is set', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline({
designSystemId: null,
});
const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => {
// Capture the body for assertion.
const body = JSON.parse(init.body as string);
expect(body.system).toContain('# DESIGN.md');
expect(body.messages[0].content).toContain('## Active design system: none');
expect(body.messages[0].content).toContain(
'(no design system selected for this project)',
);
return new Response(
JSON.stringify({
content: [{ type: 'text', text: '# DESIGN.md\nbody\n' }],
usage: { input_tokens: 1, output_tokens: 1 },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: fetchImpl as any,
} as any);
expect(result.designSystemId).toBeNull();
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('throws FinalizePackageLockedError when .finalize.lock is already held', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
const lockPath = path.join(projectsRoot, PROJECT_ID, '.finalize.lock');
fs.writeFileSync(lockPath, '');
const fetchImpl = happyFetch('# DESIGN.md\nbody\n');
await expect(
finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl,
} as any),
).rejects.toBeInstanceOf(FinalizePackageLockedError);
// DESIGN.md must NOT have been written; the pre-existing lock prevented it.
expect(fs.existsSync(path.join(projectsRoot, PROJECT_ID, 'DESIGN.md'))).toBe(false);
// The pre-existing lock must remain — we did not own it, so we must not unlink.
expect(fs.existsSync(lockPath)).toBe(true);
});
it('replaces an existing DESIGN.md atomically on a second finalize', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
const finalPath = path.join(projectsRoot, PROJECT_ID, 'DESIGN.md');
await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: happyFetch('# DESIGN.md\nfirst run\n'),
} as any);
expect(fs.readFileSync(finalPath, 'utf8')).toBe('# DESIGN.md\nfirst run\n');
// Inject a sentinel between finalize calls.
fs.writeFileSync(finalPath, 'sentinel\n');
expect(fs.readFileSync(finalPath, 'utf8')).toBe('sentinel\n');
await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: happyFetch('# DESIGN.md\nsecond run\n'),
} as any);
expect(fs.readFileSync(finalPath, 'utf8')).toBe('# DESIGN.md\nsecond run\n');
});
it('cleans up tmp file AND lock file on every error path', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
// Force a crash mid-write: spy on writeFileSync and throw on the
// DESIGN.md.tmp.* path. Other writeFileSync usages (e.g. transcript-
// export within this same call) must continue to work.
const realWrite = fs.writeFileSync;
vi.spyOn(fs, 'writeFileSync').mockImplementation((p: any, ...rest: any[]) => {
if (typeof p === 'string' && p.includes('DESIGN.md.tmp.')) {
throw new Error('disk full');
}
return (realWrite as any)(p, ...rest);
});
await expect(
finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: happyFetch('# DESIGN.md\nbody\n'),
} as any),
).rejects.toThrow(/disk full/);
const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
expect(dirEntries.filter((n) => n.startsWith('DESIGN.md.tmp.'))).toEqual([]);
expect(dirEntries).not.toContain('DESIGN.md');
expect(dirEntries).not.toContain('.finalize.lock');
});
it('uses the default https://api.anthropic.com baseUrl when baseUrl is omitted', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
const fetchImpl = vi.fn(async (url: string) => {
expect(url.startsWith('https://api.anthropic.com/v1/messages')).toBe(true);
return new Response(
JSON.stringify({
content: [{ type: 'text', text: '# DESIGN.md\n' }],
usage: { input_tokens: 1, output_tokens: 1 },
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
// baseUrl deliberately omitted
model: 'claude-opus-4-7',
fetchImpl: fetchImpl as any,
} as any);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
// PR #832 P1 fix from @lefarcen: imported-folder projects (created via
// /api/import/folder) carry metadata.baseDir which redirects file IO to
// the user's actual folder. Pre-fix, the pipeline called projectDir()
// unconditionally so DESIGN.md landed in the hidden .od/projects/<id>
// dir instead of metadata.baseDir; the resolver also missed the user's
// real artifacts. Post-fix, both call sites use resolveProjectDir.
it('writes DESIGN.md under metadata.baseDir for imported-folder projects', async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-finalize-imported-'));
const designSystemsRoot = path.join(tempDir, 'design-systems');
fs.mkdirSync(designSystemsRoot, { recursive: true });
fs.mkdirSync(path.join(designSystemsRoot, 'shadcn'), { recursive: true });
fs.writeFileSync(path.join(designSystemsRoot, 'shadcn', 'DESIGN.md'), '# shadcn\n');
// The user's actual folder lives outside .od/projects/.
const userFolder = path.join(tempDir, 'user-imported-folder');
fs.mkdirSync(userFolder, { recursive: true });
const db = openDatabase(tempDir);
insertProject(db, {
id: PROJECT_ID,
name: 'Imported',
designSystemId: 'shadcn',
metadata: { baseDir: userFolder },
createdAt: 1,
updatedAt: 1,
});
projectsRoot = path.join(tempDir, 'projects');
fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
insertConversation(db, {
id: 'c1',
projectId: PROJECT_ID,
title: 't',
createdAt: 100,
updatedAt: 100,
});
upsertMessage(db, 'c1', {
id: 'm1',
role: 'user',
content: '',
events: [{ kind: 'text', text: 'hi' }],
});
const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: happyFetch('# DESIGN.md\nimported folder body\n'),
} as any);
// DESIGN.md must land in the user's actual folder, NOT the hidden
// `.od/projects/<id>` dir.
expect(result.designMdPath).toBe(path.join(userFolder, 'DESIGN.md'));
expect(fs.existsSync(result.designMdPath)).toBe(true);
expect(fs.readFileSync(result.designMdPath, 'utf8')).toBe(
'# DESIGN.md\nimported folder body\n',
);
// The hidden daemon data dir should NOT have a DESIGN.md.
expect(fs.existsSync(path.join(projectsRoot, PROJECT_ID, 'DESIGN.md'))).toBe(false);
});
// PR #832 P1 fix from @lefarcen: network failures (DNS, ECONNREFUSED,
// fetch TypeError) used to fall through the route's catch-all and surface
// as 500 INTERNAL. Post-fix they are rewrapped as
// FinalizeUpstreamError(502, ...) inside the function so the route maps
// to 502 UPSTREAM_UNAVAILABLE with redacted details.
it('rewraps fetch network rejection as FinalizeUpstreamError(502)', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
const networkError = new TypeError('fetch failed');
(networkError as any).cause = { code: 'ENOTFOUND' };
const fetchImpl = vi.fn(async () => {
throw networkError;
});
await expect(
finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://nonexistent.invalid',
model: 'claude-opus-4-7',
fetchImpl: fetchImpl as any,
} as any),
).rejects.toMatchObject({
name: 'FinalizeUpstreamError',
status: 502,
});
});
it('rewraps 200 with non-JSON body as FinalizeUpstreamError(502)', async () => {
const { db, projectsRoot, designSystemsRoot } = setupPipeline();
// 200 OK with text/html body — response.json() will throw SyntaxError.
const fetchImpl = vi.fn(async () =>
new Response('<html>upstream proxy error</html>', {
status: 200,
headers: { 'content-type': 'text/html' },
}),
);
await expect(
finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
fetchImpl: fetchImpl as any,
} as any),
).rejects.toMatchObject({
name: 'FinalizeUpstreamError',
status: 502,
});
});
});
// HTTP-layer tests for the route handler's validation. Boot the daemon
// once via startServer({ port: 0, returnServer: true }) and send real
// HTTP POSTs. These tests exercise the validation branches the function-
// level tests above cannot reach (validateExternalApiBaseUrl is a
// closure inside startServer, not exported).
describe('POST /api/projects/:id/finalize/anthropic — HTTP-layer validation', () => {
let server: http.Server;
let serverBaseUrl: string;
beforeAll(async () => {
const { startServer } = await import('../src/server.js');
const started = (await startServer({ port: 0, returnServer: true })) as unknown as {
url: string;
server: http.Server;
};
serverBaseUrl = started.url;
server = started.server;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function postJson(id: string, body: unknown): Promise<Response> {
return fetch(`${serverBaseUrl}/api/projects/${id}/finalize/anthropic`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
it('400 BAD_REQUEST when baseUrl is not a valid URL (test #13)', async () => {
const res = await postJson('p1', {
apiKey: 'sk-test',
baseUrl: 'not-a-url',
model: 'claude-opus-4-7',
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.code).toBe('BAD_REQUEST');
});
it('403 FORBIDDEN when baseUrl points at a private internal IP (test #14)', async () => {
// validateBaseUrl explicitly allows loopback (for local OpenAI-compatible
// servers) but blocks private internal IPs (10/8, 172.16/12, 192.168/16,
// fc00::/7, fe80::/10) — see apps/daemon/src/connectionTest.ts:158-185.
const res = await postJson('p1', {
apiKey: 'sk-test',
baseUrl: 'http://10.0.0.1',
model: 'claude-opus-4-7',
});
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error.code).toBe('FORBIDDEN');
});
it('400 BAD_REQUEST when apiKey is missing (test #15)', async () => {
const res = await postJson('p1', {
// apiKey deliberately omitted
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.code).toBe('BAD_REQUEST');
expect(body.error.message.toLowerCase()).toContain('apikey');
});
it('400 BAD_REQUEST when :id contains characters outside the safe-id regex (test #16)', async () => {
// isSafeId allows only [A-Za-z0-9._-]{1,128}. An id like `bad!id`
// contains `!` and must be rejected before any DB or filesystem work.
const res = await postJson('bad!id', {
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-7',
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error.code).toBe('BAD_REQUEST');
expect(body.error.message.toLowerCase()).toContain('project id');
});
});
// Path-traversal regression coverage flagged by @lefarcen on PR #832.
//
// The threat: pre-fix `isSafeId` regex `/^[A-Za-z0-9._-]{1,128}$/` allowed
// pure-dot ids (`.`, `..`, `...`) because `.` is in the character class.
// `projectDir` and `resolveProjectDir` both delegated to `isSafeId` so they
// inherited the hole; an id of `..` would resolve to the PARENT of
// `.od/projects/` via `path.join`. The HTTP layer happens to reject this
// today because Express normalizes `%2e%2e` to `..` and collapses the
// path before the route handler sees it (yielding 404), but a direct CLI
// or scripted caller would still reach the function and trigger the
// traversal — and a stored project row whose id is `..` would also slip
// past `isSafeId` checks downstream of the handler.
//
// Unit-test isSafeId directly so the hole stays closed regardless of
// which call site is exercised.
describe('isSafeId — path-traversal regression', () => {
it('rejects a single dot', () => {
expect(isSafeId('.')).toBe(false);
});
it('rejects a double dot (parent-traversal)', () => {
expect(isSafeId('..')).toBe(false);
});
it('rejects three or more dots', () => {
expect(isSafeId('...')).toBe(false);
expect(isSafeId('....')).toBe(false);
});
it('rejects characters outside [A-Za-z0-9._-]', () => {
expect(isSafeId('bad!id')).toBe(false);
expect(isSafeId('a/b')).toBe(false);
expect(isSafeId('a\\b')).toBe(false);
expect(isSafeId(' leading-space')).toBe(false);
});
it('rejects empty string and >128 chars', () => {
expect(isSafeId('')).toBe(false);
expect(isSafeId('a'.repeat(129))).toBe(false);
});
it('rejects non-string inputs', () => {
expect(isSafeId(null as any)).toBe(false);
expect(isSafeId(undefined as any)).toBe(false);
expect(isSafeId(42 as any)).toBe(false);
});
it('accepts valid ids including dots in the middle', () => {
expect(isSafeId('project-1')).toBe(true);
expect(isSafeId('my-project.v2')).toBe(true);
expect(isSafeId('818cf7a8-8399-4220-a507-07802d8842a8')).toBe(true);
expect(isSafeId('a')).toBe(true);
expect(isSafeId('a.b.c')).toBe(true); // mixed-content with dots is fine
});
});

View file

@ -3,7 +3,13 @@ import { build } from "esbuild";
await build({
bundle: true,
entryNames: "[dir]/[name]",
entryPoints: ["./src/index.ts", "./src/critique.ts", "./src/api/connectionTest.ts", "./src/api/research.ts"],
entryPoints: [
"./src/index.ts",
"./src/critique.ts",
"./src/api/connectionTest.ts",
"./src/api/finalize.ts",
"./src/api/research.ts",
],
format: "esm",
outbase: "./src",
outdir: "./dist",

View file

@ -18,6 +18,10 @@
"types": "./dist/api/connectionTest.d.ts",
"default": "./dist/api/connectionTest.mjs"
},
"./api/finalize": {
"types": "./dist/api/finalize.d.ts",
"default": "./dist/api/finalize.mjs"
},
"./api/research": {
"types": "./dist/api/research.d.ts",
"default": "./dist/api/research.mjs"

View file

@ -0,0 +1,57 @@
// Shared DTOs for the `/api/projects/:id/finalize/<provider>` family of
// synthesis endpoints. The first endpoint is `/finalize/anthropic`
// (introduced in PR #832); future provider-namespaced siblings
// (`/finalize/openai` etc.) can reuse the request/response shape.
/**
* Bumped when the finalize request/response shape changes incompatibly.
* Also serves as a real runtime export so esbuild emits a `.mjs` for
* this module (without it, the file is type-only and NodeNext-resolved
* consumers cannot resolve the re-export from the package root).
*/
export const FINALIZE_SCHEMA_VERSION = 1;
/**
* Request body for `POST /api/projects/:id/finalize/anthropic`.
*
* Field names mirror `ProxyStreamRequest` (./proxy.ts) so a caller that
* already has provider credentials assembled for chat can reuse the
* same shape. `baseUrl` is optional here (intentional divergence from
* the proxy, which requires it) standard Anthropic users do not need
* to set it; Bedrock / self-hosted-proxy users still can.
*/
export interface FinalizeAnthropicRequest {
apiKey: string;
baseUrl?: string;
model: string;
maxTokens?: number;
}
/**
* Reference to the artifact that participated in the finalize call, if
* any. Synthesis prompts pass the artifact body verbatim; this response
* field lets the caller name which file was chosen and when it was
* last touched.
*/
export interface FinalizeArtifactRef {
name: string;
/** ISO 8601 from the artifact's manifest, or `null` for legacy artifacts. */
updatedAt: string | null;
}
/**
* Response body for a successful finalize call. The synthesized
* `DESIGN.md` was written atomically to `designMdPath`; `bytesWritten`
* is the exact UTF-8 byte length on disk. Token counts are echoed
* straight from the provider's `usage` block.
*/
export interface FinalizeAnthropicResponse {
designMdPath: string;
bytesWritten: number;
model: string;
inputTokens: number;
outputTokens: number;
artifact: FinalizeArtifactRef | null;
transcriptMessageCount: number;
designSystemId: string | null;
}

View file

@ -8,6 +8,7 @@ export * from './api/connectors';
export * from './api/comments';
export * from './api/connectionTest';
export * from './api/files';
export * from './api/finalize';
export * from './api/live-artifacts';
export * from './api/mcp';
export * from './api/projects';