{
  "slug": "contract-signing-page",
  "spec_file": "docs/specs/2026-05-31-contract-signing-page.md",
  "findings": [
    {
      "id": "contract-signing-page-001",
      "severity": "P0",
      "type": "security",
      "summary": "POST /api/contracts/:id/sign signs by contract UUID — bypasses per-signatory token capability",
      "spec_ref": "Overview — token uniquely identifies one signatory; public access MUST be token-scoped only",
      "code_ref": "apps/zync-api/src/routes/contracts/index.ts:277",
      "evidence": "Handler comment: 'PUBLIC, no auth — contractId is the signer token'. No signatory token lookup. getContractPublic(db, contractId) resolves by contracts.id UUID (packages/db/src/queries/contracts.ts:76-87) then signContract sets status='signed' (contracts.ts:243-251). Not mounted on /api/sign token router.",
      "repro": "POST /api/contracts/{sent-contract-uuid}/sign {\"signatureData\":{}} with no signatory token → signs entire contract by UUID.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-002",
      "severity": "P0",
      "type": "security",
      "summary": "GET /api/sign/:token returns 200 for expired and voided tokens — no 410 Gone",
      "spec_ref": "Page States 6–7 Expired/Voided; API reference — token_expires_at < now() and voided contracts must reject access",
      "code_ref": "apps/zync-api/src/routes/sign.ts:58",
      "evidence": "GET handler resolves token (:66-67), logs view (:70-77), returns 200 SignPageData (:108). No row.tokenExpiresAt < now() check (POST checks at :131-133). No row.contract.status voided check. grep 'voided' in sign.ts → 0 matches.",
      "repro": "Void or expire a signatory token → GET /api/sign/{token} → 200 with full contract content.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-003",
      "severity": "P0",
      "type": "security",
      "summary": "POST /api/sign/:token and /decline do not reject voided contracts",
      "spec_ref": "Page State 7 Voided — signature CTAs hidden; voided links invalidated",
      "code_ref": "apps/zync-api/src/routes/sign.ts:113",
      "evidence": "POST sign checks token expiry only (:131-133). No guard on row.contract.status. decline handler (:153-188) likewise checks expiry only (:170-172). voidContract sets status='voided' (packages/db/src/queries/contracts.ts:293-295).",
      "repro": "Void contract via staff API → POST /api/sign/{token} or /decline → 200 success.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-004",
      "severity": "P0",
      "type": "security",
      "summary": "POST /api/sign/:token does not enforce sequential signing order",
      "spec_ref": "Page State 2 Waiting for Prior Signatory; signing order enforced (spec 48)",
      "code_ref": "apps/zync-api/src/routes/sign.ts:135",
      "evidence": "isSignatoryTurn/recipientsForDispatch implemented (apps/zync-api/src/contracts/signing-order.ts:47-65) and used in staff serializeSignatory (routes/contracts/index.ts:151-164). grep isSignatoryTurn in apps/zync-api/src/routes/sign.ts → 0 matches. GET never populates waitingFor (:85-106).",
      "repro": "Sequential 2-signatory contract → second signatory POST /api/sign/{token2} before first signs → 200 ok:true.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-005",
      "severity": "P0",
      "type": "broken",
      "summary": "First signatory signature marks entire contract SIGNED — no wait-for-all completion",
      "spec_ref": "Active state requires contracts.status SENT; completion only when all signatories signed (spec 48)",
      "code_ref": "packages/db/src/queries/contract-signing.ts:143",
      "evidence": "signContract (token path) unconditionally UPDATE contracts SET status='signed', signed_at=now() after one signatory (:143-147). No query of sibling signatories. grep completeIfAllSigned in packages/db → 0 matches.",
      "repro": "2-signatory contract → first POST /api/sign/{token1} → contracts.status='signed' while second signatory still unsigned; second visit may show active signing UI.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-006",
      "severity": "P0",
      "type": "broken",
      "summary": "signature_type wire values 'drawn'/'typed' do not match DB CHECK 'draw'/'type' — sign submit fails",
      "spec_ref": "Submit body signature_type 'drawn'|'typed'; spec 48 CHECK constraint",
      "code_ref": "apps/zync-api/src/routes/sign.ts:138",
      "evidence": "signSubmitSchema z.enum(['drawn','typed']) at :32. Handler passes parsed.data.signature_type with TS cast only at :138 (runtime value unchanged). DB CHECK: IN ('draw','type') (packages/db/src/schema/contracts.ts:120-122). Insert uses input.signatureType verbatim (contract-signing.ts:117). Island sends 'drawn'/'typed' (ContractSigningIsland.tsx:271).",
      "repro": "POST /api/sign/{valid-token} with signature_type:'drawn' → DB CHECK violation → 500 Failed to sign.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-007",
      "severity": "P0",
      "type": "broken",
      "summary": "Voided page state never triggers — UI checks 'VOIDED' but API/DB use lowercase 'voided'",
      "spec_ref": "Page State 7 Voided — contract.status VOIDED",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:43",
      "evidence": "resolvePageState checks contract.status === 'VOIDED' (:43). GET returns row.contract.status as stored (sign.ts:90). DB status values lowercase (packages/db/src/schema/contracts.ts:75). voidContract sets 'voided' (contracts.ts:294). Falls through to 'active' for unsigned signatory on voided contract.",
      "repro": "Void contract → open /sign/{token} → signing form shown instead of voided message.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-008",
      "severity": "P1",
      "type": "missing",
      "summary": "GET /api/sign/:token never returns waitingFor — Waiting state unreachable",
      "spec_ref": "Page State 2; GET response waitingFor?: { name }",
      "code_ref": "apps/zync-api/src/routes/sign.ts:85",
      "evidence": "SignPageData built at :85-106 has no waitingFor field. signatory.order hardcoded 0 with comment 'not surfaced' (:95). Island resolvePageState checks waitingFor (:46) but API never sets it. Staff route computes waitingFor via isSequential/currentTurnOrder (contracts/index.ts:151-164) — not reused.",
      "repro": "Sequential contract, second signatory opens /sign/{token2} → active signing UI instead of waiting state.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-009",
      "severity": "P1",
      "type": "missing",
      "summary": "Signed state lacks Download signed PDF link to GET /api/sign/:token/pdf",
      "spec_ref": "Page State 4 Already Signed; Post-Completion PDF Download",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:363",
      "evidence": "already_signed block (:363-369) shows confirmation only — no link. grep '/pdf' in apps/zync-www → 0 matches. API route GET /:token/pdf exists (sign.ts:193-227). SignPageData has no signedPdfR2Key field (packages/types/src/sign.ts:17-33).",
      "repro": "Fully signed contract with signed_pdf_r2_key set → /sign/{token} signed view has no download CTA.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-010",
      "severity": "P1",
      "type": "missing",
      "summary": "Contract content not rendered via generateHTML + shared renderContractHTML helper",
      "spec_ref": "Plan Task 3 contract-render.ts; HTML rendering security — Tiptap JSONB → generateHTML → DOMPurify",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:226",
      "evidence": "grep renderContractHTML|generateHTML in apps/zync-www → 0 matches. Island treats contract.content as raw string (:227-232) with inline DOMPurify only. Plan files apps/zync-www/src/lib/contract-render.ts and sign-locale.ts absent (glob → 0 files).",
      "repro": "Contract stored as Tiptap JSON → signing page shows raw JSON or empty instead of rendered HTML.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-011",
      "severity": "P1",
      "type": "missing",
      "summary": "Planned page-state components and resolver not implemented — monolithic island only",
      "spec_ref": "Plan Task 4–5 — resolve-state.ts, Invalid/Waiting/Signed/Declined/Expired/Voided state components, ContractBody.astro",
      "code_ref": "apps/zync-www/src/pages/sign/[token].astro:101",
      "evidence": "glob apps/zync-www/src/components/sign/** → 0 files. glob apps/zync-www/src/islands/** → 0 files. Single ContractSigningIsland at components/signing/ handles all states inline. Astro page mounts island for all non-error responses (:101-108) without server-side state selection.",
      "repro": "N/A — structural gap vs plan; SSR does not render terminal states without hydrating full island.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-012",
      "severity": "P1",
      "type": "missing",
      "summary": "tenantBranding.locale always null — locale fallback chain broken at API",
      "spec_ref": "Locale resolution order — tenantBranding.locale as fallback before 'he'",
      "code_ref": "apps/zync-api/src/routes/sign.ts:104",
      "evidence": "tenantBranding.locale hardcoded null at :104. SignTenantBranding.locale typed 'he'|'en'|null (packages/types/src/sign.ts:14). Astro resolveLocale falls back to signingData?.tenantBranding.locale (:59-61) which is always null — only Accept-Language or default 'he' apply.",
      "repro": "Tenant default locale 'en', browser sends no Accept-Language → page renders Hebrew not tenant English.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-013",
      "severity": "P2",
      "type": "diverges",
      "summary": "signature_pad library not used — raw Canvas pointer events instead",
      "spec_ref": "Signature UI Draw tab — uses signature_pad library (MIT)",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:74",
      "evidence": "grep signature_pad in apps/zync-www → 0 matches. Custom CanvasSignature component with pointerdown/move/up (:74-165). apps/zync-www/package.json has no signature_pad dependency.",
      "repro": "N/A — alternate implementation; touch mapping untested vs spec library.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-014",
      "severity": "P2",
      "type": "diverges",
      "summary": "@fontsource/dancing-script not self-hosted — Type tab uses generic cursive",
      "spec_ref": "Type tab — Dancing Script via @fontsource/dancing-script, no Google Fonts CDN",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:251",
      "evidence": "grep fontsource|dancing-script in apps/zync-www → 0 matches. Typed signature ctx.font = '36px cursive' (:251). Input preview fontFamily: 'cursive' (:416).",
      "repro": "Type tab renders system cursive, not Dancing Script.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-015",
      "severity": "P2",
      "type": "diverges",
      "summary": "All signing page copy hardcoded English — no he/en localization",
      "spec_ref": "Plan Task 5/9 — all copy localized he/en; dates IL DD/MM/YYYY for he",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:461",
      "evidence": "locale prop accepted (:26) but only used in formatDate (:51-59). UI strings literal English: 'Sign Document' (:477), 'I have read and agree...' (:461), 'Decline to sign' (:485), waiting/expired/voided blocks (:338-376). No i18n dictionary.",
      "repro": "Set locale='he' via Accept-Language → Hebrew dir on html but English UI strings.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-016",
      "severity": "P2",
      "type": "diverges",
      "summary": "Island path components/signing/ not spec islands/ContractSigningIsland.tsx",
      "spec_ref": "Signature UI — React island apps/zync-www/src/islands/ContractSigningIsland.tsx",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:1",
      "evidence": "glob apps/zync-www/src/islands/** → 0 files. Actual path components/signing/ContractSigningIsland.tsx imported from sign/[token].astro:13.",
      "repro": "N/A — path divergence from spec/plan.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-017",
      "severity": "P2",
      "type": "diverges",
      "summary": "astro.config output server not hybrid as spec requires",
      "spec_ref": "Foundation Deltas — output: hybrid for dynamic /sign/{token}",
      "code_ref": "apps/zync-www/astro.config.mjs:6",
      "evidence": "output: 'server' at astro.config.mjs:6. Page uses export const prerender = false ([token].astro:10) for SSR. Spec/plan explicitly require output: 'hybrid'.",
      "repro": "N/A — config divergence; SSR may still work on server mode.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-018",
      "severity": "P2",
      "type": "diverges",
      "summary": "SSR treats API 410 as Invalid Token not Expired state",
      "spec_ref": "Page State 1 vs 6 — expired shows deadline message with readable content",
      "code_ref": "apps/zync-www/src/pages/sign/[token].astro:33",
      "evidence": "res.status === 404 || res.status === 410 both set notFound=true (:33-34). Renders invalid/revoked copy (:92-97) not expired copy. Island expired state only reachable client-side after POST 410.",
      "repro": "Expired token → server-render shows 'invalid or revoked' not 'signing deadline has passed'.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-019",
      "severity": "P2",
      "type": "diverges",
      "summary": "Declined state missing formatted date and optional reason",
      "spec_ref": "Page State 5 Declined — date and reason if provided; contact tenantName",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:372",
      "evidence": "declined block (:372-376) shows generic 'You declined to sign' + 'sender notified'. No signatory.declinedAt formatting. declineContract does not persist reason — no decline_reason column (grep decline_reason in packages/db → 0). POST decline accepts reason (sign.ts:37-38) but metadata not returned to page.",
      "repro": "Decline with reason → page shows no date or reason text.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-020",
      "severity": "P2",
      "type": "security",
      "summary": "RATE_LIMITER_SIGN errors fail open — unlimited requests when binding throws",
      "spec_ref": "Rate limiting 20 req/min per IP per token; Plan Task 1 middleware",
      "code_ref": "apps/zync-api/src/routes/sign.ts:48",
      "evidence": "checkSignRateLimit catch returns { success: true } (:48). Optional chaining RATE_LIMITER_SIGN?.limit also defaults success (:48). Plan file apps/zync-api/src/middleware/rate-limit-sign.ts absent — logic inlined. Binding exists in wrangler.toml:128.",
      "repro": "Simulate RateLimiter binding failure → all /api/sign/* requests pass without 429.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-021",
      "severity": "P2",
      "type": "diverges",
      "summary": "Active signing UI shown without verifying contract.status is SENT/VIEWED",
      "spec_ref": "Page State 3 Active — contracts.status is SENT and signatory unsigned",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:41",
      "evidence": "resolvePageState falls through to 'active' (:48) without contract.status check. DB uses lowercase 'sent' (schema/contracts.ts:75). Combined with premature SIGNED (:143 contract-signing.ts), second signatory may see active form after contract marked signed.",
      "repro": "After first of two signs (contract status signed) → second token page shows full signing form.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-022",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Signature tabs and error banner lack spec a11y roles",
      "spec_ref": "Plan Task 10 — role=tablist/tab/tabpanel, role=alert, role=img on canvas",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:388",
      "evidence": "Tab buttons (:390-404) have no role=tablist/tab/aria-selected. Error div (:381-384) has no role=alert. Canvas (:148) has no role=img or aria-label. DeclineModal has role=dialog aria-modal (:179-180) only.",
      "repro": "Screen reader audit — tabs announced as buttons not tab interface.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-023",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "prefers-reduced-motion not honored on state transitions",
      "spec_ref": "Plan Task 10 — disable animations when prefers-reduced-motion: reduce",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:207",
      "evidence": "grep prefers-reduced-motion in apps/zync-www/src/components/signing → 0 matches. State transitions via setPageState with no motion media query.",
      "repro": "N/A — no animations present; spec compliance gap for future transitions.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-024",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Canvas height fixed 150px — no 120px reduction below 375px viewport",
      "spec_ref": "Signature UI — height 150px; small screens <375px height 120px",
      "code_ref": "apps/zync-www/src/components/signing/ContractSigningIsland.tsx:151",
      "evidence": "Canvas inline style height: '150px' (:151). No viewport width media query or resize listener.",
      "repro": "Viewport 320px wide → canvas still 150px tall.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "contract-signing-page-025",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Invalid-token SSR may reference branding when signingData null — copy only, logo guard OK",
      "spec_ref": "Page State 1 — no tenant branding if token cannot be resolved",
      "code_ref": "apps/zync-www/src/pages/sign/[token].astro:85",
      "evidence": "notFound branch uses branding?.logoR2Key (:85) where branding = signingData?.tenantBranding (:64) — null on 404 so logo skipped. Message correct. Minor: 410 grouped with 404 as notFound loses expired-specific SSR copy (see finding 018).",
      "repro": "404 token → no logo (correct). Message matches invalid state.",
      "fix_direction": "code_to_spec"
    }
  ],
  "summary": {
    "P0": 7,
    "P1": 5,
    "P2": 9,
    "P3": 4,
    "total": 25
  }
}
