Conversational Form Engine v2 Target: Claude Code • Cursor • Windsurf • Devin 469 Lines • Markdown + DDL

Typeform & Tally Replacement v2 — Entire Agent Prompt Specification

This is the verbatim production plan (typeform-logic-forms.plan.md). Copy and paste this directly into your coding agent's Planning Mode prompt, or attach it as a workspace file before starting execution.

Download Raw .plan.md ← Back to Blueprint Overview
typeform-logic-forms.plan.md
# Blueprint: Conversational Form Builder with Logic Branching (Typeform-style MVP)

**Plan version:** 2.0.0

> **For humans:** paste this entire document into a coding agent (Claude Code, Cursor, Windsurf, Devin) in an empty directory.
> **Prerequisites on the machine:** Node.js 22+, npm, Docker (with Compose), git. No cloud accounts are needed.
> **Result:** a local, tested app you can run with `npm run db:up && npm run db:migrate && npm run db:seed && npm run dev`.

---

## 0. Operating Rules for the Agent

Read this whole document before writing any code. Then follow these rules:

1. **Don't ask clarifying questions.** Where this document is silent, choose the simplest option consistent with it and record the choice in `DECISIONS.md` (one line per decision).
2. **Work phase by phase (Section 7).** At the end of each phase, run that phase's Definition of Done commands and fix failures until they pass. Don't start the next phase while any command fails.
3. **Never weaken a test to make it pass.** Don't skip, delete, or loosen assertions, and don't add `@ts-ignore`, `eslint-disable`, or `any`, to get green. Fix the code.
4. **Commit after each phase** with a message like `phase 2: database and auth`.
5. **If the environment blocks you** (e.g. Docker isn't running, a port is taken), stop and tell the user exactly what they need to do. Don't work around it by mocking the database.
6. **Never commit secrets.** `.env` is gitignored; `.env.example` holds placeholders only.
7. **Add no runtime dependencies beyond Section 1** without a line in `DECISIONS.md` explaining why.
8. **Hard limit: 200 lines per source file.** Split by responsibility when you approach it.

---

## 1. Locked Decisions

| Concern | Choice |
|---|---|
| Framework | Next.js, latest stable, App Router, scaffolded with `create-next-app@latest`. Lockfile committed. |
| Language | TypeScript, `strict: true`, `noUncheckedIndexedAccess: true` |
| UI | React (version bundled with Next), plain CSS Modules + CSS variables. No CSS framework. |
| Animation | CSS `transform` / `opacity` transitions only. No animation library. |
| Validation | Zod (latest), shared between client and server |
| Database | PostgreSQL 16 in Docker Compose (`app` db for dev, `app_test` db for tests) |
| ORM / migrations | Drizzle ORM + drizzle-kit. Generated SQL migrations committed under `src/db/migrations/`. |
| Auth | Built in: email + password, argon2id via `@node-rs/argon2`, DB-backed sessions. No auth provider. |
| Drag and drop | `@dnd-kit/core` + `@dnd-kit/sortable` |
| SSRF guard | `ipaddr.js` + `undici` (custom `Agent` with a validating DNS lookup) |
| Unit / integration tests | Vitest |
| E2E tests | Playwright (Chromium) + `@axe-core/playwright` |
| Lint | ESLint (Next config) with `react/no-danger: error` |
| CI | GitHub Actions running `npm run verify` against a Postgres service container |

**Environment variables** (all listed in `.env.example`):

```
DATABASE_URL=postgres://app:app@localhost:5432/app
TEST_DATABASE_URL=postgres://app:app@localhost:5432/app_test
APP_ORIGIN=http://localhost:3000
IP_HASH_SECRET=change-me-32-bytes-min
TRUST_PROXY=false                 # true only behind a proxy that sets X-Forwarded-For
WEBHOOK_ALLOW_INSECURE=false      # true only in test: allows http:// and private IPs for the local receiver
```

The app must fail fast at startup (Zod-validated `src/lib/server/env.ts`) if a required variable is missing or `IP_HASH_SECRET` is shorter than 32 characters.

---

## 2. Form Schema Contract

This is the central contract: the builder writes it, the player runs it, and the server validates against it. Implement it in Zod in `src/lib/form/schema.ts` and derive the TS types with `z.infer`.

```ts
type Id = string; // /^[a-z0-9_]{1,40}$/, unique within a form

type Choice = { id: Id; label: string /* 1..200 */ };

type Target = Id | "end";

type Operator =
  | "equals" | "not_equals"          // all types; for choices, compares choice id
  | "greater_than" | "less_than"     // number only
  | "contains"                       // text: case-insensitive substring; multi-choice: includes choice id
  | "is_answered";                   // all types; `value` ignored

type Condition = { questionId: Id; op: Operator; value?: string | number };

type Rule = { if: Condition[] /* 1..5, ALL must match */; goto: Target };

type BaseQuestion = {
  id: Id;
  title: string;        // 1..500, rendered as plain text
  description?: string; // 0..1000, rendered as plain text
  required: boolean;
  rules: Rule[];        // 0..20, evaluated in order
  next?: Target;        // default destination when no rule matches
};

type Question = BaseQuestion & (
  | { type: "short_text"; maxLength?: number /* <= 500, default 500 */ }
  | { type: "long_text";  maxLength?: number /* <= 5000, default 5000 */ }
  | { type: "email" }
  | { type: "number"; min?: number; max?: number; integer?: boolean }
  | { type: "multiple_choice"; choices: Choice[] /* 2..26 */; allowMultiple: boolean }
);

type FormSchema = { questions: Question[] /* 1..100 */ };

type Theme = { bg: string; accent: string }; // both /^#[0-9a-fA-F]{6}$/

type AnswerValue = string | number | string[]; // string[] only for multi-choice
type Answers = Record<Id, AnswerValue>;
```

### 2.1 Logic Semantics (normative)

- **When rules run:** when the respondent leaves question `Q`. `Q.rules` are checked in order and the **first** rule whose conditions all match wins.
- **Fallback:** if no rule matches, go to `Q.next` if set. Otherwise go to the next question in array order, or `"end"` if `Q` is last.
- **Forward-only jumps:** every `goto` and `next` must point to a question **later in array order**, or to `"end"`. This makes the flow a DAG by construction, so cycles are impossible.
- **Condition scope:** a condition may reference `Q` itself or any question **earlier** in array order.
- **Unanswered questions:** every condition on an unanswered question evaluates to `false`. That includes `is_answered` (as expected) and `not_equals` (which is easy to get wrong).
- **Operator/type matrix:**

  | Type | Allowed operators |
  |---|---|
  | `short_text`, `long_text`, `email` | `equals`, `not_equals`, `contains`, `is_answered` |
  | `number` | `equals`, `not_equals`, `greater_than`, `less_than`, `is_answered` |
  | single-choice | `equals`, `not_equals`, `is_answered` |
  | multi-choice | `contains`, `is_answered` |

  Text `equals` is case-sensitive after trimming. Choice values must be an existing choice id.

### 2.2 Pure Functions (`src/lib/form/`, no I/O, no React)

| Function | Contract |
|---|---|
| `getNext(schema, answers, questionId): Target` | Applies 2.1 for one step. |
| `resolvePath(schema, answers): Id[]` | Walks from the first question using `getNext` until `"end"`. Returns the visited ids. |
| `validateAnswers(schema, answers): Result` | Computes `resolvePath`. Fails if a key in `answers` is **not on the path**, a required question on the path is unanswered, a value has the wrong type, a choice id is unknown, a length/min/max/integer limit is exceeded, or an email is invalid (max 254 chars). Returns per-question errors. |
| `validateFormLogic(schema): LogicError[]` | Reports duplicate question ids, duplicate choice ids, backward or self targets, unknown targets, conditions referencing a later or unknown question, operators not allowed for the type, and choice values that don't exist. |
| `maxRemainingSteps(schema, fromId): number` | Longest possible path length from `fromId` to `"end"` (DP over the DAG). |
| `progress(historyLength, maxRemaining): number` | `historyLength / (historyLength + maxRemaining)`. It only increases or stays flat as you advance. |

**Draft vs publish validation:** `PATCH` accepts any schema that passes the Zod shape check, so a draft may contain broken logic while being edited. **Publish** additionally requires `validateFormLogic` to return no errors. The builder shows those errors next to the offending question.

---

## 3. Database Schema

This DDL is the spec. Write it as `src/db/schema.ts` in Drizzle and generate the migration. Use `DEFAULT now()`, never `timezone('utc', now())`: the latter returns a time-zone-less value that's cast back using the session time zone and is wrong when the session isn't UTC.

```sql
CREATE TABLE users (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email         TEXT NOT NULL UNIQUE CHECK (email = lower(email)),
  password_hash TEXT NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE sessions (
  id         CHAR(64) PRIMARY KEY,            -- sha256 hex of the cookie token
  user_id    UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sessions_user ON sessions(user_id);

CREATE TABLE forms (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  owner_id          UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  title             VARCHAR(255) NOT NULL,
  slug              VARCHAR(64) NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$'),
  draft_schema      JSONB NOT NULL,
  theme             JSONB NOT NULL DEFAULT '{"bg":"#000000","accent":"#e53539"}',
  published_version INT,                      -- NULL = not live
  webhook_url       TEXT,
  webhook_secret    TEXT,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_forms_owner ON forms(owner_id, updated_at DESC);

CREATE TABLE form_versions (                  -- immutable snapshot taken on publish
  form_id      UUID NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
  version      INT NOT NULL,
  schema       JSONB NOT NULL,
  theme        JSONB NOT NULL,
  published_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (form_id, version)
);

CREATE TABLE form_submissions (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  form_id      UUID NOT NULL,
  form_version INT NOT NULL,
  answers      JSONB NOT NULL,
  ip_hmac      CHAR(64),                      -- HMAC-SHA256(IP_HASH_SECRET, ip), never a plain hash
  completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  FOREIGN KEY (form_id, form_version) REFERENCES form_versions(form_id, version) ON DELETE CASCADE
);
CREATE INDEX idx_submissions_form ON form_submissions(form_id, completed_at DESC);

CREATE TABLE webhook_deliveries (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  submission_id UUID NOT NULL REFERENCES form_submissions(id) ON DELETE CASCADE,
  status_code   INT,
  error         TEXT,
  attempted_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

- **Publishing:** in one transaction, insert `form_versions` with `version = coalesce(max(version), 0) + 1`, copying `draft_schema` and `theme`, then set `forms.published_version` to it. Editing the draft never affects the live form or the meaning of past submissions.
- **Unpublishing:** set `published_version = NULL`. Versions and submissions are kept.
- **Timestamps:** the data access layer sets `updated_at = now()` on every update.

---

## 4. API Contract

All bodies are JSON. Errors use `{ "error": { "code": string, "message": string, "fields"?: Record<string, string> } }` and never include stack traces. Route handlers parse input with Zod before doing anything else.

| Method & path | Auth | Behavior |
|---|---|---|
| `POST /api/auth/signup` | none | `{email, password}` (password 10..128 chars). Creates the user and a session. Returns 201. Duplicate email returns 409. |
| `POST /api/auth/login` | none | Returns 200 and sets the session cookie. Any failure returns 401 with the same generic message. |
| `POST /api/auth/logout` | session | Deletes the session row and clears the cookie. Returns 204. |
| `GET /api/forms` | session | Lists the caller's forms. |
| `POST /api/forms` | session | `{title}`. Creates a draft with one `short_text` question and slug `slugify(title)-<6 random [a-z0-9]>`. Returns 201 `{id, slug}`. |
| `GET /api/forms/:id` | owner | The form including draft schema, theme, published version, and webhook URL. **Never returns `webhook_secret`.** |
| `PATCH /api/forms/:id` | owner | Any of `{title, schema, theme, webhookUrl}`. A `schema` must pass the Zod shape. Setting a non-null `webhookUrl` validates it (S9), generates a new 32-byte hex secret, and returns the secret **once** in the response. |
| `POST /api/forms/:id/publish` | owner | Returns 422 with `LogicError[]` if `validateFormLogic` fails; otherwise snapshots and returns `{version}`. |
| `POST /api/forms/:id/unpublish` | owner | Returns 204. |
| `DELETE /api/forms/:id` | owner | Returns 204. |
| `GET /api/forms/:id/submissions?cursor=` | owner | Newest first, 50 per page, each submission with its `form_version` schema's question titles. |
| `POST /api/submissions` | none | `{slug, version, answers, website}`. See below. |

**`POST /api/submissions` behavior:**

- 404 if the form doesn't exist or isn't published.
- 409 `stale_form` if `version !== published_version`.
- 422 with per-field errors if `validateAnswers` fails against that version's snapshot.
- 429 when rate limited; 413 when the body is over 64 KB.
- `website` is a honeypot. If it's non-empty, return 201 with a fake id and store nothing.
- On success, insert the row and return 201 `{id}`. Then dispatch the webhook with Next's `after()` so the response doesn't wait on it.

**Authorization lives in the data access layer.** Every function in `forms-dal.ts` and `submissions-dal.ts` takes `userId` and filters by `owner_id`. A form the caller doesn't own is indistinguishable from a missing one: both return 404. The request-interception file (`proxy.ts`, or `middleware.ts` on older Next) may redirect unauthenticated users from `/dashboard` to `/login` for UX, but it is **never** the authorization boundary.

---

## 5. Security Requirements

Each requirement must have at least one test, named with its id (e.g. `it("S3: ...")`).

| Id | Requirement |
|---|---|
| S1 | Passwords are hashed with argon2id. Plain passwords and tokens never appear in logs or the DB. |
| S2 | The session token is 32 random bytes in an `HttpOnly`, `SameSite=Lax`, `Path=/` cookie (`Secure` when `NODE_ENV=production`), valid for 30 days. Only its SHA-256 is stored. Logout and expiry invalidate it server side. |
| S3 | Every form/submission read or write by a non-owner returns 404. This is tested for every owner route in Section 4. |
| S4 | Every non-GET `/api/*` request must have an `Origin` header equal to `APP_ORIGIN` and `Content-Type: application/json`, or it gets 403 / 415. |
| S5 | Rate limits: login 5/min per IP+email, signup 3/min per IP, submissions 10/min per IP+form. Exceeding a limit returns 429. |
| S6 | Body size limits: submissions 64 KB, form PATCH 256 KB. Larger bodies get 413 before parsing. |
| S7 | Submissions are validated server side by replaying logic (`validateAnswers`): off-path answers, missing required answers, and wrong types are rejected with 422. |
| S8 | User content (titles, descriptions, choice labels) is rendered as React text only. `dangerouslySetInnerHTML` is banned by lint. Theme colors are applied as CSS custom properties only after the hex regex passes. |
| S9 | Webhook URLs must parse, use `https:` (or `http:` when `WEBHOOK_ALLOW_INSECURE=true`), and contain no credentials. URLs whose host resolves to a non-unicast address are rejected (`ipaddr.js` `range() !== "unicast"`, with IPv4-mapped IPv6 unwrapped first). That covers loopback, private, link-local/metadata, CGNAT, multicast, and unspecified addresses. The check runs at save time **and** at connect time through an undici `Agent` whose `connect.lookup` applies the same check, which blocks DNS rebinding. |
| S10 | Webhook requests use `redirect: "manual"` (a 3xx counts as failure) and a 5-second timeout. The response body is not read. Every attempt is recorded in `webhook_deliveries`. |
| S11 | Webhook body: `{event:"submission.created", formId, formVersion, submissionId, completedAt, answers}`. Headers: `X-Webhook-Timestamp: <unix seconds>` and `X-Webhook-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>`. |
| S12 | `ip_hmac` = HMAC-SHA256(`IP_HASH_SECRET`, ip). The client IP comes from the first `X-Forwarded-For` entry only when `TRUST_PROXY=true`; otherwise it's `null`, and rate limiting falls back to a single shared bucket per key. |
| S13 | Security headers on all responses: CSP with a per-request nonce (per the Next.js CSP guide) including `default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'`, plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`. |
| S14 | Server modules import `server-only`. Unexpected errors return a generic 500 and are logged without request bodies. |
| S15 | The seed script refuses to run when `NODE_ENV=production`. |

`NOTE:` the in-memory rate limiter is per-process, which is fine for a single instance. Swap in Redis or Postgres behind the same interface before running multiple instances. Webhook retries and encrypting `webhook_secret` at rest are deferred.

---

## 6. File Layout

```
docker-compose.yml          # postgres:16, creates app + app_test databases
.env.example  DECISIONS.md  README.md
drizzle.config.ts  vitest.config.ts  playwright.config.ts
.github/workflows/ci.yml
scripts/seed.ts
src/
  proxy.ts                              # or middleware.ts; nonce CSP + /dashboard redirect only
  app/
    layout.tsx  page.tsx
    (auth)/login/page.tsx  (auth)/signup/page.tsx
    dashboard/page.tsx                  # form list + create
    dashboard/forms/[id]/edit/page.tsx
    dashboard/forms/[id]/responses/page.tsx
    f/[slug]/page.tsx                   # server: loads published version, renders player
    api/auth/{signup,login,logout}/route.ts
    api/forms/route.ts
    api/forms/[id]/route.ts
    api/forms/[id]/{publish,unpublish}/route.ts
    api/forms/[id]/submissions/route.ts
    api/submissions/route.ts
  components/
    player/  form-player.tsx  question-stage.tsx  question-input.tsx
             question-choice.tsx  progress-bar.tsx  nav-footer.tsx
    builder/ builder-shell.tsx  question-list.tsx  question-editor.tsx
             logic-editor.tsx  form-settings.tsx  publish-bar.tsx
    ui/      button.tsx  modal.tsx  field.tsx
  hooks/     use-form-runner.ts  use-player-keyboard.ts  use-draft-storage.ts
  lib/
    form/    schema.ts  logic.ts  validate-answers.ts  validate-logic.ts  runner-reducer.ts
    server/  env.ts  db.ts  auth.ts  session.ts  forms-dal.ts  submissions-dal.ts
             http.ts  rate-limit.ts  client-ip.ts
    webhooks/ ssrf-guard.ts  sign.ts  dispatch.ts
  db/        schema.ts  migrations/
tests/
  unit/  integration/  e2e/  helpers/   # helpers: test DB reset, user/session factory, webhook receiver
```

**npm scripts:** `dev`, `build`, `db:up` (`docker compose up -d --wait`), `db:migrate`, `db:seed`, `typecheck`, `lint`, `test` (unit + integration), `test:e2e`, and `verify`.

`verify` runs typecheck, lint, `test`, build, then `test:e2e`, in that order, failing fast. Integration and e2e tests use `TEST_DATABASE_URL`, run migrations first, and truncate tables between tests.

---

## 7. Phases

Each phase ends with its **Definition of Done (DoD)**. Every listed command must exit 0 before you move on.

### Phase 0: Scaffold and tooling

Scaffold Next.js, then add every dependency in Section 1, Docker Compose, the Drizzle config, `env.ts`, the Vitest/Playwright configs, the npm scripts, the CI workflow, `.env.example`, `DECISIONS.md`, and a README with setup steps. Add one trivial unit test and one Playwright test that loads `/`.

**DoD:** `npm run db:up && npm run verify`

### Phase 1: Schema contract and logic engine (pure, test-first)

Implement Section 2 in `src/lib/form/`, plus `runner-reducer.ts`.

The reducer state is `{history: Id[], answers: Answers, status: "answering" | "submitting" | "done" | "error"}`. It has these actions:

- `answer(id, value)`
- `next()`: validates the current question and pushes `getNext`. Reaching `"end"` sets `submitting`.
- `back()`: pops history. Answers stay in state so going forward again prefills them.
- `restore(state)`

Any submit must send `answers` filtered to `resolvePath`, so answers from abandoned branches are dropped.

Write the tests first. Cover:

- every cell of the operator/type matrix
- first-match ordering and AND conditions
- unanswered semantics
- the `next` fallback, array-order fallback, and `"end"`
- every `validateFormLogic` error kind
- every `validateAnswers` rejection kind
- `maxRemainingSteps` on a branching form
- both paths of the seed form (Phase 2)
- reducer back-then-change-branch, with a test that the submitted answers exclude the abandoned branch

**DoD:** `npm run typecheck && npm run lint && npm test`

### Phase 2: Database, auth, data access, seed

Do the migrations from Section 3, then `auth.ts` / `session.ts` (S1, S2), the auth routes, `http.ts` (S4, S6, the error shape), `rate-limit.ts` (S5), and the DALs with owner scoping. Then write the seed script.

The seed script (S15) creates `[email protected]` / `demo-password-123` and publishes the form `customer-feedback`:

1. `name`: `short_text`, required
2. `satisfaction`: `multiple_choice`, single, choices `very_satisfied`, `satisfied`, `unsatisfied`, required. Rule: `satisfaction equals unsatisfied` → `what_went_wrong`.
3. `recommend`: `number`, 0..10, integer, required, `next: "email"`
4. `what_went_wrong`: `long_text`, required
5. `email`: `email`, optional

Path A is name → satisfaction → recommend → email. Path B is name → satisfaction(unsatisfied) → what_went_wrong → email.

Integration tests call the route handlers directly against the test DB. They cover S1–S6, plus signup/login/logout flows and session expiry.

**DoD:** `npm run db:migrate && npm run db:seed && npm test`

### Phase 3: Forms API and builder UI

Build the form routes from Section 4 and the dashboard pages.

The builder has:

- a sortable question list (dnd-kit)
- an editor for the selected question (title, description, required, type-specific fields)
- a logic editor modal (add/remove rules, each with conditions and a target; the target dropdown lists only later questions plus "End")
- theme color inputs
- the webhook URL with a show-secret-once dialog
- debounced autosave (PATCH)
- inline `LogicError`s, a Publish/Unpublish button, and a link to `/f/<slug>`

Reordering that makes a rule point backward must surface as a `LogicError` and block publish. It must not silently rewrite rules.

Integration tests cover S3 for every owner route, publish snapshotting and version increments, the 422 on invalid logic, and the webhook secret never appearing in a GET.

**DoD:** `npm test && npm run build`

### Phase 4: Respondent player

`/f/[slug]` loads the published version server-side (404 page if it isn't live) and renders a full-screen player themed by CSS variables.

**Keyboard contract:**

| Key | Behavior |
|---|---|
| `Enter` | Commits and advances on `short_text`, `email`, `number`, and choice questions. Shows an inline error if the answer is invalid. |
| `Shift+Enter` | Newline in `long_text`. Plain `Enter` advances there too. |
| `A`–`Z` | On choice questions, select the matching choice: single-choice selects and advances after 250 ms (0 ms with reduced motion); multi-choice toggles. Bound only when focus is not in a text-entry element. Choices show their letter badge. |
| `ArrowUp` / `ArrowDown` | Previous / next, only when focus is not in a text-entry or number element. |
| `Tab` / `Shift+Tab` | Never intercepted. Native focus order. |

The footer always has visible, focusable Previous and OK buttons, so nothing requires the keyboard shortcuts.

**Accessibility:**

- On each question change, move focus to the question's input (or the first choice) and announce `Question N: <title>` in an `aria-live="polite"` region.
- Errors use `aria-invalid` and `aria-describedby`.
- Choices are a `radiogroup` or a group of checkboxes with proper roles.
- Under `prefers-reduced-motion: reduce`, all transitions are disabled.

**Drafts:** `sessionStorage` key `draft:<formId>:<version>` stores `{history, answers}`. It's restored on load, ignored if the version differs, and cleared on successful submit or on a 409.

**Submit:** POST to `/api/submissions`. On 409, show "This form was updated" with a reload button. On 422, jump to the first question with an error. On success, show a thank-you screen.

Progress uses `progress()` from Section 2.2.

**DoD:** `npm run typecheck && npm run lint && npm test && npm run build`

### Phase 5: Submissions pipeline, webhooks, responses view

Finish `POST /api/submissions` (S7, S12, honeypot, 409), then `ssrf-guard.ts` (S9), `sign.ts` and `dispatch.ts` (S10, S11), and the responses page (paginated table, columns from each submission's version).

Unit tests:

- an SSRF table covering 127.0.0.1, ::1, 10.x, 172.16.x, 192.168.x, 169.254.169.254, 100.64.x, 0.0.0.0, ::ffff:127.0.0.1, fc00::1, fe80::1, a public IP, `http:` with the flag off, and a URL containing credentials
- a signature test against a fixed vector
- an `ip_hmac` test showing it's deterministic and secret-dependent

Integration tests:

- S7 rejections
- 404 when unpublished, 409 when stale
- the honeypot stores nothing
- delivery to a local receiver (with `WEBHOOK_ALLOW_INSECURE=true`) arrives with a verifiable signature
- a redirecting receiver is recorded as a failure

**DoD:** `npm test && npm run build`

### Phase 6: End-to-end and acceptance

Playwright tests against the seeded test DB:

1. **Path A, keyboard only:** type name + Enter, `A`, type 9 + Enter, type email + Enter. The thank-you screen shows, and the stored submission has exactly those four keys.
2. **Path B, keyboard only:** choose `C` (unsatisfied), and `what_went_wrong` appears instead of `recommend`.
3. **Branch change:** go down path B, then use the Previous button (reached with Shift+Tab, activated with Enter) to get back to `satisfaction`, and pick `A`. Path A follows, and the stored submission has no `what_went_wrong` key.
4. **Draft restore:** answer two questions, reload, and both answers and position are restored.
5. **Reduced motion:** emulate `reduce` and verify the computed `transition-duration` is `0s`.
6. **Accessibility:** run an axe scan on the player and on the builder. No `serious` or `critical` violations.
7. **Builder flow:** sign up, create a form, add a multiple-choice question and a text question, add a rule, publish, open `/f/<slug>` in a new context, submit, and see the response in the responses view.
8. **Auth:** unauthenticated `/dashboard` redirects to `/login`. User B opening user A's builder URL sees a 404.
9. **Headers:** the player response has the CSP header and `X-Content-Type-Options`.

**DoD:** `npm run verify` passes from a clean clone (`rm -rf node_modules .next && npm ci && npm run db:up && npm run verify`).

---

## 8. Final Acceptance Checklist

Report this checklist to the user with each item checked, along with the output of the final `npm run verify`.

- [ ] `npm run verify` passes from a clean clone
- [ ] Every S-requirement has a test whose name starts with its id (`grep -r "S[0-9]*:" tests` lists S1–S15)
- [ ] No file in `src/` exceeds 200 lines
- [ ] No `any`, `@ts-ignore`, `eslint-disable`, or `dangerouslySetInnerHTML` in `src/`
- [ ] `.env` is gitignored; `.env.example` has every variable from Section 1
- [ ] README documents setup, scripts, the demo login, and deployment notes (any Node host + managed Postgres; set env vars; run `db:migrate`; set `TRUST_PROXY=true` behind a proxy)
- [ ] `DECISIONS.md` lists every choice made where this plan was silent

**Out of scope for this MVP:** partial-response analytics, file uploads, payments, custom domains, embedding in third-party sites, CSV export, email notifications, team accounts.
End of specification • 469 lines
Full prompt copied to clipboard!