create-ailk 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -14,6 +14,7 @@ AILK_PUBLIC_DOMAIN=ailaunchkit.ai
14
14
  # 3. <meta name="robots" content="noindex,nofollow"> tag (apps/web/app/[locale]/layout.tsx)
15
15
  # At launch, set STEALTH=false on the hosting platform's env vars and redeploy.
16
16
  # See README.md § "Going live" for the full flip checklist.
17
+ # Accepted (any case): true, 1, yes, on hide the site; false, 0, no, off or empty allow indexing. Any other value fails the build.
17
18
  STEALTH=true
18
19
 
19
20
  # Blog-OFF launch gate (#3413) — SITE-ARCHITECTURE §4.1 parks the blog at
@@ -520,6 +521,18 @@ CONTENT_ADAPTER=mdx
520
521
  # it from one egress address. A whole number from 1 to 999999. Default: 120.
521
522
  # AILK_RATE_LIMIT_DIRECTORY_PER_MINUTE=120
522
523
  #
524
+ # apps/api. Which store rateLimitMiddleware's per-IP/per-key counters use
525
+ # (#5040). `database` counts in the deployment's own Postgres (DATABASE_URL),
526
+ # so the limit holds across every concurrent isolate; `memory` keeps the
527
+ # pre-#5040 in-process Map, which does not. Default: `database` when
528
+ # DATABASE_URL is set, else `memory`. A database failure or a 1s-slow call
529
+ # falls back to the in-process Map for that request rather than letting it
530
+ # through uncounted. Run `pnpm --filter @working-theory/api rate-limit:probe
531
+ # --origin <https origin>` against a live deployment to check which store it
532
+ # selected and whether a burst holds — see
533
+ # docs/runbooks/site-configuration.md.
534
+ # AILK_RATE_LIMIT_STORE=
535
+ #
523
536
  # SPOKE (apps/web, server-only — never NEXT_PUBLIC_). This spoke's key: one
524
537
  # entry of the hub's AILK_VISITOR_FORWARD_KEYS. Sent only when AILK_API_URL
525
538
  # is https (or loopback). Issue a key only to a spoke whose platform
@@ -64,7 +64,7 @@ your module set; `pnpm architecture` enforces it, and a hook in
64
64
  | Change the look | `create-ailk apply-design <name\|path>` (keeps `brand:`); at scaffold, `--design <folder>` |
65
65
  | Logos, photos, brand reference | `brand_assets/` |
66
66
  | Repo identity, labels, hard floors | `project.yaml` |
67
- | Diff hygiene when editing existing files | `.claude/rules/preserve-existing-style.md` |
67
+ | Diff hygiene when editing existing files | `CONVENTIONS.md` § Commit + diff hygiene |
68
68
  | Code style, testing, security | `CONVENTIONS.md` |
69
69
  | Environment variables and what they unlock | `.env.example` |
70
70
  | Deploy configuration | `apps/api/vercel.json`; the `/ailk:setup-deploy` skill |
@@ -196,8 +196,9 @@ move.
196
196
  cleanup goes in its own follow-up PR.
197
197
  - **Diff is the contract.** A 20-line behavioral change should produce
198
198
  a ~20-line diff. If your editor reformatted unrelated lines, revert
199
- the formatting before committing. See
200
- `.claude/rules/preserve-existing-style.md`.
199
+ the formatting before committing. Match the file's existing quotes,
200
+ indentation and import order, and check `git diff --stat` before you
201
+ commit.
201
202
  - **Commit message format:** `feat(area): description (#N)`. Include
202
203
  the issue number when one exists.
203
204
  - **Squash merge only.** Multiple commits on the branch are fine; the
@@ -0,0 +1,162 @@
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build output
5
+ dist/
6
+ build/
7
+ .next/
8
+ *.tsbuildinfo
9
+
10
+ # Turborepo local task cache
11
+ .turbo/
12
+
13
+ # Fumadocs MDX codegen output (generated by fumadocs-mdx/next at build time)
14
+ apps/web/.source/
15
+ apps-paid/marketing-web/.source/
16
+
17
+ # Prisma generated client (regenerated via `pnpm db:generate`)
18
+ database/generated/
19
+ apps-paid/license-issuer/src/generated/
20
+
21
+ # api-client generated types (regenerated via `pnpm codegen`, run at build time)
22
+ packages/api-client/src/generated/
23
+
24
+ # Environment files
25
+ .env
26
+ .env.local
27
+ .env.*.local
28
+
29
+ # Logs
30
+ *.log
31
+ npm-debug.log*
32
+ pnpm-debug.log*
33
+
34
+ # Editor / OS
35
+ .DS_Store
36
+ .vscode/
37
+ .idea/
38
+
39
+ # Test coverage
40
+ coverage/
41
+
42
+ # Coverage audit baseline output (tools/audit-coverage.ts; on-demand only)
43
+ coverage-baseline-*.json
44
+
45
+ # Stryker mutation testing — temp sandbox + per-run report output
46
+ .stryker-tmp/
47
+ **/reports/mutation/
48
+
49
+ # Caches
50
+ .eslintcache
51
+ .cache/
52
+
53
+ # Claude Code session state (worktree manager)
54
+ .claude/worktrees/
55
+
56
+ # AILK file-fallback inbox (OSS standalone mode; leads written here when DB unavailable)
57
+ .ailk/
58
+
59
+ # AILK license refresh-cached JWT (default storage path for @working-theory/license)
60
+ .ailk-license.jwt
61
+
62
+ # Lead capture JSONL store (contains customer contact data — never commit)
63
+ apps/api/leads.jsonl
64
+ apps/api/leads.jsonl.*
65
+
66
+ # Build-time content manifest (generated by pnpm content:compile; not committed)
67
+ content/.compiled/
68
+
69
+ # Preflight success marker (written by pnpm preflight; read by require-preflight-on-push hook)
70
+ .claude/.preflight-ok
71
+
72
+ # Maintainer-only orchestration (Sam's /loop, dispatch, autonomy, babysit/cron skills).
73
+ # These live on the maintainer's filesystem and are not part of the public template.
74
+ .claude/rules/dispatch-defaults.md
75
+ .claude/rules/parallel-agent-discipline.md
76
+ .claude/rules/autonomy-ailk.md
77
+ .claude/skills/babysit/
78
+ .claude/skills/post-merge-sweeper/
79
+ .claude/skills/pr-pruner/
80
+
81
+ # Other ephemeral Claude Code state
82
+ .claude/.continue-after-simplify-attempts
83
+ .claude/.simplify-last-run
84
+ .claude/.enforce-checkpoint-before-stop-attempts
85
+ .claude/.worktree-root
86
+ .claude/scheduled_tasks.lock
87
+ .claude/logs/
88
+ .claude/settings.local.json
89
+ .claude/review-findings/
90
+
91
+ # db:generate advisory lock dir (runtime-only; created/removed by database/scripts/db-generate-locked.sh)
92
+ .claude/.db-generate.lock
93
+
94
+ # Playwright MCP session artifacts (accessibility snapshots auto-created during browser_* runs)
95
+ .playwright-mcp/
96
+
97
+ # Saved Playwright login sessions the extract-*-from-reference skills write
98
+ # (cookies for an auth-gated reference site). This file is also every
99
+ # scaffold's .gitignore (build-templates.mjs SNAPSHOT_FILES), so a customer
100
+ # never commits one either.
101
+ brand_assets/.playwright-auth/
102
+
103
+ # Visual-iteration scratch — directory tracked via .gitkeep, contents ignored
104
+ screenshots/*
105
+ !screenshots/.gitkeep
106
+
107
+ # Content pipeline per-repo scratch (PAGE-COPY, site architecture files, etc.)
108
+ # Collocated with repo for /copywriting and /compile-page skills. See claude-setup#423.
109
+ strategy/
110
+ # Allow versioned strategy docs in docs/internal/strategy/ (claude-setup#486)
111
+ !docs/internal/strategy/
112
+ # Allow the flat versioned strategy docs in docs/strategy/ (claude-setup#3424).
113
+ # Kept out of the public OSS repo via project.yaml strip_for_oss.exclude_dirs.
114
+ !docs/strategy/
115
+
116
+ # Storybook build output
117
+ storybook-static/
118
+
119
+ # Playwright test-run bookkeeping (per-run status + failure artifacts) — never
120
+ # committed; a local `pnpm visual:test` run previously left `.last-run.json`
121
+ # and a per-failure `error-context.md` tracked in the repo (#6577).
122
+ test-results/
123
+
124
+ # Static export output — @working-theory/design-reference-gallery's build
125
+ # artifact (the ailk-storybook hosting shape: a static tree served from a
126
+ # protected origin). Rebuilt by `pnpm --filter @working-theory/design-reference-gallery build`.
127
+ apps-paid/design-reference-gallery/out/
128
+
129
+ # Vercel CLI local state — `vercel pull` writes project settings + a pulled
130
+ # .env.<env>.local here, and `vercel build` writes the Build Output API tree.
131
+ # The deploy workflow creates all of it at the repo root at run time (#4482).
132
+ .vercel/
133
+
134
+ # impeccable detect CLI pilot (epic #4501, issue #4511) — per-developer
135
+ # override + ephemeral working state. .impeccable/config.json itself IS
136
+ # committed (e.g. packages/ui/.impeccable/config.json) — only these are not.
137
+ # Patterns are `**/`-prefixed since .impeccable/ lives under packages/ui, not
138
+ # the repo root.
139
+ **/.impeccable/config.local.json
140
+ **/.impeccable/*.ndjson
141
+ **/.impeccable/cache/
142
+ **/.impeccable/hook.cache.json
143
+
144
+ # Root-anchored scratch-artifact guard (#5179 / PUB-902, PUB-903, PUB-910).
145
+ # Design-iteration screenshots and profiling dumps have no directory to live
146
+ # under at the repository root, so nothing else stops them from recurring
147
+ # there. The leading `/` anchors each pattern to the repo root only — images
148
+ # tracked elsewhere (brand_assets/**, package public/ assets, visual-regression
149
+ # baselines) are untouched. Bytecode caches are unwanted anywhere, so those two
150
+ # patterns are unanchored.
151
+ /*.png
152
+ /*.jpeg
153
+ /*.jpg
154
+ __pycache__/
155
+ *.pyc
156
+
157
+ # e2e journey tests (.claude/skills/e2e): the running stack's state, logs and secrets, and each run's raw results
158
+ tests/e2e/.stack/
159
+ tests/e2e/.results/
160
+
161
+ # create-ailk licence key file (`--license`, `ailk license set`) — owner-only, never committed
162
+ ailk.key
@@ -32,8 +32,14 @@ DATABASE_URL="postgresql://user:password@localhost:5432/customer_template"
32
32
  # Stripe (Optional)
33
33
  # =============================================================================
34
34
  # Consumed by apps/api/src/lib/stripe.ts via @working-theory/stripe-client wrapper.
35
- # Use sandbox keys only — live-mode keys are rejected at runtime.
35
+ # Use sandbox keys only — live-mode keys are rejected at runtime, unless
36
+ # STRIPE_MODE=live below admits a restricted live key (#8450).
36
37
  STRIPE_SECRET_KEY=sk_test_xxx
38
+ # test (the default when unset) or live. `live` is admitted only beside a
39
+ # restricted live key, on a production build — VERCEL_ENV=production, or,
40
+ # off Vercel, NODE_ENV=production, so a staging host must not set the latter.
41
+ # Every other live key stays refused at boot and on every route.
42
+ STRIPE_MODE=test
37
43
  STRIPE_WEBHOOK_SECRET=whsec_xxx
38
44
 
39
45
  # =============================================================================
@@ -46,6 +52,16 @@ STRIPE_WEBHOOK_SECRET=whsec_xxx
46
52
  # Generate with: openssl rand -hex 32
47
53
  # OSS_API_INTERNAL_SECRET=your-shared-secret-here
48
54
 
55
+ # =============================================================================
56
+ # Consent — the signed unsubscribe link (#8438)
57
+ # =============================================================================
58
+ # Keys the HMAC on the unsubscribe token an email's link carries
59
+ # (POST /v1/consent/unsubscribe). Optional: unset, no link can be minted and the
60
+ # route answers 503 link_secret_not_configured. When set it must be at least
61
+ # 32 bytes — a shorter value logs an error at boot and is treated as unset.
62
+ # Generate with: openssl rand -hex 32
63
+ # AILK_CONSENT_LINK_SECRET=
64
+
49
65
  # =============================================================================
50
66
  # Scheduling — the `booker` section's provider (#4416)
51
67
  # =============================================================================
@@ -44,14 +44,14 @@ Routes are versioned under `/v1/*`:
44
44
  - `/v1/leads/capture` (GET) — `{ ok: true, data: { contact, newsletter } }`, two booleans saying whether a submit / a subscribe would be accepted (#6730). The web contact page reads it to show the form or the site's contact email. Never names the delivery mode, an address or an env var. Registered at the top level, outside `leadsScope`'s strict per-IP limit. No MCP twin — a recorded exception on the same footing as the anonymous form-lifecycle routes: an agent needs no preflight, because the three lead-write tools already return the 503 when capture is not accepted.
45
45
  - `/v1/leads` (GET, `?cursor=&limit=`) — the leads INBOX read (#5678): the caller's OWN sites' leads, newest first, cursor-paginated (`limit` defaults to 50, max 200; `(createdAt desc, id desc)`; `cursor` is the previous page's last row id, and one that does not resolve within the caller's scope is a 400, never a silent page 1 — the API keeps that contract, and the web inbox deliberately softens it on its own side by re-reading page one when a URL-supplied cursor 400s, which is a rendering choice and not a change to this response). Five fields per row and nothing more — `id`, `name` (lifted from `payload.name` when the site's form captured one), `email`, `source`, `createdAt`; `payload`'s other keys, `siteId`, `promotedUserId` and `promotedAt` are never selected (`src/services/leads-inbox.ts` is the only read path). Registered in `authScope` only (auth, no tenant guard) and — alone in that scope — **unconditionally**, with no `isModuleEnabled()` gate: lead capture is a never-gate surface at every level, so reading a lead back cannot be gated either. Identity is `request.userId`; there is no site, org or workspace parameter, so the scope cannot be widened from the wire. It is resolved from the caller's own memberships by `src/services/owner-workspaces.ts`'s `resolveOwnerWorkspaces` — the ONE site→workspace resolution in this app, shared with `lead-promotion.ts` rather than written a second time — and every read filters `siteId ∈ workspaceIds`. Role floor: the `inbox-read` permission in `@working-theory/validation`'s permission table (#7636 D2) — every role, a member included (the operator's 2026-10-04 decision; contact PII is the inbox's job); the scope is the UNION of every workspace in the caller's organizations whose role holds `inbox-read`, not one active tenant, because this route registers outside the tenant scope. **Two deployment preconditions decide whether it returns anything, and both fail EMPTY rather than wide:** on a build with no `multi-tenant` module `resolveOwnerWorkspaces` resolves none (a deliberate scope limit since #5677), so there the scope is the default workspace, granted only through `services/default-tenant.ts`'s `resolveDefaultTenantForOperator` — `AILK_OPERATOR_EMAIL` set, the caller's verified email equal to it, a one-workspace deployment, a role holding `inbox-read` — and every other caller gets the empty page (#6730); and the `siteId` the leads were WRITTEN under must equal a workspace id. That `siteId` has TWO sources, and `currentSiteId()` is not the one on the primary path: the three POST capture routes read it from the REQUEST BODY (`routes/leads/index.ts`), which the capture sections declare in content and default to `"default"` (`ContactForm`/`Newsletter`/`StepFlow`, `ext.siteId ?? "default"`), while the recommender's capture path (`routes/flows/recommender.ts`) is the one that uses `currentSiteId()` (`lib/site.ts`, `AILK_SITE_ID ?? "default"`). A workspace id is `wksp_<uuidv7>`, so `siteId ∈ workspaceIds` matches only when the content-declared value — and, for recommender leads, `AILK_SITE_ID` — is set to it (the same convention `waitlist-dashboard.ts` already relies on). The rendered `name` is lifted from `payload.name` and truncated at 200 characters: the write side bounds `payload` at 64 keys and no per-value length, and `POST /v1/leads/submit` is anonymous with no ownership check on `siteId`. No MCP twin — identity-bound (#3591 exception, the same shape as `/v1/workspaces/*` and `/v1/billing/*`).
46
46
  - `/v1/flows/:slug` (GET), `/v1/flows/:slug/steps` (POST) — serve a flow config + submit a step's answers; server-side per-step partial-state persistence (#3956). The GET serves the stored config **minus `completionEmail`** (#7519 D5, SEC-7519-I1): `serveFlow` projects the owner's completion-email copy out, while completion reads the stored snapshot and sends it (`sendCompletionEmail`, for a flow with no `deliverables`). Module: `signal-collectors` (D-48, #5366) — registers only when the module is enabled; MCP twins `get_flow`/`submit_flow_step` carry the same `module` tag (D7).
47
- - `/v1/flows/:slug/definition` (PUT) — a registered spoke seeds one of its declared `flows/<slug>.json` into its OWN workspace (#7876, `src/routes/flows/index.ts` over `services/flow-engine.ts`'s `seedDeclaredFlow`). No MCP twin — recorded exception: a seed is a deploy-time write by the site's owner, and a spoke's `apps/mcp` acts through the same key for every anonymous agent, so a twin would give each of them write access to the site's flows. Off until the hub's operator sets `AILK_SPOKE_FLOW_SEED` (`403 flow_seed_disabled`, answered before the key or the database is read). The workspace comes from the `x-ailk-spoke-key` alone, through `resolveCallerSite`: a keyless call is `403 spoke_key_required` on any deployment, and a refused key `503 tenant_unresolved`. The body is a flow config of `slug`, `defaultLocale`, `steps`, `thankYou` and `flags` only — every block the hub acts on outside the spoke's own pages (emails, deliverables, pricing, registers, listings) is `400 field_not_seedable`; a slug a listing owns, or a stored config the Admin extended with one of those blocks, is `409 slug_owned`, so a seed never deletes them. A config equal to the stored one (keys sorted) answers `unchanged` and writes nothing; otherwise `created` or `updated` through `upsertFlowDefinition`, so `kind`, `title` and `status` are written on create only. Route-level `bodyLimit` 256 KiB; the config is never echoed or logged. Module: `signal-collectors`.
47
+ - `/v1/flows/:slug/definition` (PUT) — a registered spoke seeds one of its declared `flows/<slug>.json` into its OWN workspace (#7876, `src/routes/flows/index.ts` over `services/flow-engine.ts`'s `seedDeclaredFlow`). No MCP twin — recorded exception: a seed is a deploy-time write by the site's owner, and a spoke's `apps/mcp` acts through the same key for every anonymous agent, so a twin would give each of them write access to the site's flows. Off until the hub's operator sets `AILK_SPOKE_FLOW_SEED` (`403 flow_seed_disabled`, answered before the key or the database is read). The workspace comes from the `x-ailk-spoke-key` alone, through `resolveCallerSite`: a keyless call is `403 spoke_key_required` on any deployment, and a refused key `503 tenant_unresolved`. The body is a flow config of `slug`, `defaultLocale`, `steps`, `thankYou` and `flags` only — every block the hub acts on outside the spoke's own pages (emails, deliverables, pricing, registers, listings) is `400 field_not_seedable`; a slug a listing owns, a stored config the Admin extended with one of those blocks, or the standard `onboarding` slug (#8515 D15, before any read) is `409 slug_owned`, so a seed never deletes them or replaces the owner's onboarding questions. A config equal to the stored one (keys sorted) answers `unchanged` and writes nothing; otherwise `created` or `updated` through `upsertFlowDefinition`, so `kind`, `title` and `status` are written on create only. Route-level `bodyLimit` 256 KiB; the config is never echoed or logged. Module: `signal-collectors`.
48
48
  - `/v1/flows/:slug/aggregate` (GET) — anonymous, site-scoped per-question response counts across all completed respondents for the results reveal (#4771): respondents are completed `FlowSession` rows (not `Lead` rows — a recommending-mode respondent has no Lead until the email moment), question metadata + the chart flag come from the `WaitlistExperiment` register, and only `resultsReveal`-flagged `single`/`multi` questions are returned (`src/services/flow-aggregate.ts`). No individual answer, email, name, session id, or score in the response; no auth. Module: `signal-collectors`. No MCP twin — recorded exception (an agent walking a flow already gets everything it needs from `submit_flow_step`'s own response; "how everyone else answered" changes none of it).
49
49
  - `/v1/deliverables/:grantId` (GET) — the capability-token download route for gated PDFs and reports, including client deliverables (#3961 D5); the token itself is the sole gate, no auth preHandler applies (`src/routes/deliverables/index.ts`). Module: `signal-collectors` (D-48, #5366). No MCP twin — identity-bound capability-link download (#3591 exception, the same shape as `/v1/waitlist-signups/me`). **Three grant classes since #6310**, keyed on `grant.assetId`'s ADR-0031 prefix: `playbook:` (unchanged, the FEAT-074 U4b virtual asset); `asset_` — a workspace `Asset`, resolved through the named boundary `src/lib/deliverable-asset-store.ts`, scoped by `grant.workspaceId` (a serve-time tenancy re-check restored for this class — D15) — served `attachment` with an extension pinned to the stored content type, `nosniff`, a non-raster `Content-Security-Policy` (for EVERY content type, including raster — never `inline`), and `Cache-Control: private, no-store` so a deleted asset stops downloading immediately; `dasset_` — the LEGACY `DeliverableAsset` store (unchanged, tried only when a magnet's `assetKey` does not resolve against `Asset`). A client's file — tagged `private`, attached to a member-area message, or (#8171 D3) named by a delivered piece version — is never minted or served as a deliverable. Every miss on the `asset_` class — a NULL `workspaceId`, no row, a foreign-workspace row — folds into the SAME uniform 404 as every other class; there is deliberately no redirect to the public `GET /v1/assets/:assetId` (that route's year-long `immutable` cache would defeat deletion as a revocation lever, and would collapse every lead's capability onto one workspace-canonical id — spec #6310 §9 SEC-001/SEC-002).
50
50
  - `/v1/flows/:slug/save` (POST), `/v1/flows/:slug/checkout` (POST) — the recommender's email moment (#4841): `save` creates the FlowSession + Lead (+ the T+0 DeliverableGrant) for the permalink's answers; `checkout` does the same, records the `FlowCheckout` buyer row at a SERVER-recomputed `price()` (the body carries no amount — `flowCheckoutInputSchema` is strict), and opens a Stripe TEST-mode Checkout Session whose metadata carries the permalink + offer key (`src/routes/flows/recommender.ts`, `src/services/recommender-capture.ts`, `packages/stripe-client`'s `createQuotedCheckoutSession`). Anonymous, rate-limited; a non-`sk_test_` key is refused before any side effect. No MCP twin — recorded exception: an agent walking a flow has no email to give and no card to pay with; the anonymous MCP surface is identity-blind.
51
- - `/v1/workspaces/:workspaceId/magnets` (GET, POST), `/v1/workspaces/:workspaceId/magnets/:id` (GET, PATCH), `/v1/workspaces/:workspaceId/magnets/:id/signups` (GET), `/v1/workspaces/:workspaceId/magnets/:id/subscribers` (GET), `/v1/workspaces/:workspaceId/magnets/:id/messages` (GET) — the tenant-scoped magnet Admin authoring surface over the flow engine's `FlowDefinition` rows (#6157 S5c, `src/routes/magnets/index.ts`): list (cursor-paginated, fixed 50-row page), create, get, update, and the three per-magnet captured-lead reads (#6246 S6h). Reads and writes only through `tenantDb(tenant).flows` — never `prisma.flowDefinition`, `currentSiteId()`, or `upsertFlowDefinition` (the PUBLIC/seed path below, which the admin path never calls). No DELETE — the reversible alternative is `status: "draft"` (#6156), which stops a NEW walk from starting while an in-flight walk finishes on its pinned snapshot. `slug` lives only inside `config.slug` and is immutable through this surface after create (`400 slug_immutable` on a mismatched PATCH). The two operator-writable config URLs (`abandonEmail.resumeUrl`, `followUps[].cta.href`) pass an exact-origin gate against `FRONTEND_URL` before any write, the same discipline the recommender's checkout redirect already applies (#4912). Role floors from `@working-theory/validation`'s permission table (#7636 D2) (D5): list and get serve the full row to `magnets-manage` (owner, admin) and, to a caller holding only `magnets-read` (a member), a LIVE-only summary of exactly `{ id, slug, kind, title, landingPage }` — a member's list is read to completion (at most 20 pages of 50, a `warn` at the cap) and answers `nextCursor: null`, a member's `?cursor=` is a 400, and a draft's get is the same 404 as an absent id — because a magnet's `config` carries `scoring` thresholds, `offers`/`rateTable` and `deliverables` asset keys and no draft is a member's; POST and PATCH need `magnets-manage`; the per-magnet captured-people lists need `inbox-read` (every role), and for a caller without `magnets-manage` a magnet that is not live is the same 404. Both writes declare a route-level `bodyLimit` (256 KiB) and each records one `recordAgentAction` audit row. **`customersEnabled` (#8171 D6)** — the member-area customer switch — is served on every full row and written by the PATCH (a PATCH of it alone skips the plan check); the member summary row never carries it. Module: `multi-tenant || signal-collectors`, the same gate the review-surface dashboard (`/v1/waitlist-experiments/*`, `/v1/project-listings/*`) already registers under. No MCP twin on any of the seven — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes); the public `/v1/flows/*` routes and their two MCP tools are unchanged, pinned by `src/routes/flows/__tests__/parity.test.ts`. **The three new list routes (#6246 S6h)** join every magnet through `Lead.source = 'flow:' + <the resolved magnet's slug>` — the literal string `flow-engine.ts`'s `completeSubmission` stamps on every flow-engine `Lead` row regardless of kind, including `newsletter`/`waitlist` (`Subscriber` is written only by the separate, pre-flow-engine `subscribe_newsletter` path, unrelated to a magnet) — via ONE new `tenantDb(tenant).leads.listBySource(source, { limit })` accessor, an exact-source-equality sibling of #6204's allowlist-scoped `listUnhandledMessages`, served by a new `(workspaceId, source)` composite index on `Lead`. `/signups` expects `kind: "waitlist"`, `/subscribers` expects `"newsletter"`, `/messages` expects `"contact"`; a magnet id absent from the caller's workspace OR resolved to the WRONG kind for the called route is `404 not_found`, indistinguishably (mirrors the existing GET's absent-or-foreign fold). `/signups` and `/subscribers` serve four keys (`id`, `name`, `email`, `createdAt`); `/messages` serves those four plus `handledAt`. Bounded `?limit=` (default 50, max 200), newest-first — mirrors `workspaces/messages.ts`'s own query schema. Applications (Listicle) is deliberately OUT of this surface — `ProjectListing` carries no reverse link to the listicle-kind `FlowDefinition` that captured it, so "scoped to that magnet" cannot be honored without a schema change (split to #6314).
51
+ - `/v1/workspaces/:workspaceId/magnets` (GET, POST), `/v1/workspaces/:workspaceId/magnets/:id` (GET, PATCH), `/v1/workspaces/:workspaceId/magnets/:id/signups` (GET), `/v1/workspaces/:workspaceId/magnets/:id/subscribers` (GET), `/v1/workspaces/:workspaceId/magnets/:id/messages` (GET) — the tenant-scoped magnet Admin authoring surface over the flow engine's `FlowDefinition` rows (#6157 S5c, `src/routes/magnets/index.ts`): list (cursor-paginated, fixed 50-row page), create, get, update, and the three per-magnet captured-lead reads (#6246 S6h). Reads and writes only through `tenantDb(tenant).flows` — never `prisma.flowDefinition`, `currentSiteId()`, or `upsertFlowDefinition` (the PUBLIC/seed path below, which the admin path never calls). No DELETE — the reversible alternative is `status: "draft"` (#6156), which stops a NEW walk from starting while an in-flight walk finishes on its pinned snapshot. `slug` lives only inside `config.slug` and is immutable through this surface after create (`400 slug_immutable` on a mismatched PATCH). The two operator-writable config URLs (`abandonEmail.resumeUrl`, `followUps[].cta.href`) pass an exact-origin gate against `FRONTEND_URL` before any write, the same discipline the recommender's checkout redirect already applies (#4912). Role floors from `@working-theory/validation`'s permission table (#7636 D2) (D5): list and get serve the full row to `magnets-manage` (owner, admin) and, to a caller holding only `magnets-read` (a member), a LIVE-only summary of exactly `{ id, slug, kind, title, landingPage }` — a member's list is read to completion (at most 20 pages of 50, a `warn` at the cap) and answers `nextCursor: null`, a member's `?cursor=` is a 400, and a draft's get is the same 404 as an absent id — because a magnet's `config` carries `scoring` thresholds, `offers`/`rateTable` and `deliverables` asset keys and no draft is a member's; POST and PATCH need `magnets-manage`; the per-magnet captured-people lists need `inbox-read` (every role), and for a caller without `magnets-manage` a magnet that is not live is the same 404. Both writes declare a route-level `bodyLimit` (256 KiB) and each records one `recordAgentAction` audit row. **`customersEnabled` (#8171 D6)** — the member-area customer switch — is served on every full row and written by the PATCH (a PATCH of it alone skips the plan check); the member summary row never carries it. **The standard onboarding magnet (#8515 D2–D4):** every workspace holds one `diagnostic` magnet at slug `onboarding` (`ONBOARDING_FLOW_SLUG`, `onboardingFlowConfig` in `@working-theory/validation`), `draft` and `customersEnabled: true`, written only by `lib/tenant-db.ts`'s insert-only seed (`createMany` with `skipDuplicates`, so a re-seed creates nothing and never overwrites the owner's edits or an owner's own magnet at that slug): `POST /v1/workspaces` seeds the workspace it just created, after its transaction commits (a failed seed is one `error` line and still a `201`), and the seed-presets CLI (`node dist/bin/seed-presets.js`, run once after deploy) seeds every existing workspace and prints counts only. Its two bare-id exports have one production caller each, pinned by `tests/architecture/onboarding-seed-callers.test.ts`. Module: `multi-tenant || signal-collectors`, the same gate the review-surface dashboard (`/v1/waitlist-experiments/*`, `/v1/project-listings/*`) already registers under. No MCP twin on any of the seven — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes); the public `/v1/flows/*` routes and their two MCP tools are unchanged, pinned by `src/routes/flows/__tests__/parity.test.ts`. **The three new list routes (#6246 S6h)** join every magnet through `Lead.source = 'flow:' + <the resolved magnet's slug>` — the literal string `flow-engine.ts`'s `completeSubmission` stamps on every flow-engine `Lead` row regardless of kind, including `newsletter`/`waitlist` (`Subscriber` is written only by the separate, pre-flow-engine `subscribe_newsletter` path, unrelated to a magnet) — via ONE new `tenantDb(tenant).leads.listBySource(source, { limit })` accessor, an exact-source-equality sibling of #6204's allowlist-scoped `listUnhandledMessages`, served by a new `(workspaceId, source)` composite index on `Lead`. `/signups` expects `kind: "waitlist"`, `/subscribers` expects `"newsletter"`, `/messages` expects `"contact"`; a magnet id absent from the caller's workspace OR resolved to the WRONG kind for the called route is `404 not_found`, indistinguishably (mirrors the existing GET's absent-or-foreign fold). `/signups` and `/subscribers` serve four keys (`id`, `name`, `email`, `createdAt`); `/messages` serves those four plus `handledAt`. Bounded `?limit=` (default 50, max 200), newest-first — mirrors `workspaces/messages.ts`'s own query schema. Applications (Listicle) is deliberately OUT of this surface — `ProjectListing` carries no reverse link to the listicle-kind `FlowDefinition` that captured it, so "scoped to that magnet" cannot be honored without a schema change (split to #6314).
52
52
  - `/v1/content/{types,:type,:type/:slug}` — public content reads
53
53
  - `/v1/auth/tokens`, `/v1/user/{profile,data/:key}` — local auth + user data
54
- - `/v1/hub-sign-in/codes` (POST), `/v1/hub-sign-in/token` (POST), `/v1/hub-sign-in/revoke` (POST), `/v1/hub-sign-in/me` (GET) — the hub signs in its spokes (#7894, `src/routes/auth/hub-sign-in.ts` over `src/services/hub-sign-in.ts`; spec `docs/specs/7894-the-hub-signs-in-its-spec.md`): an authorization code with PKCE between a spoke (a confidential client whose `OAuthClient.clientId` IS its `Spoke.id`) and this hub. `codes` is in `authScope` and answers a COOKIE session only (a personal `ApiToken` is 403, an agent key 403 at the preHandler, a hub credential 404): it mints a single-use 60 s code only for an email-verified person and a callback in the client's registered `redirectUris` (exact match), 30 a user per 10 minutes. `token` and `revoke` are in their OWN scope outside `authScope` (per-IP `rateLimitMiddleware`, scope `hubsignin`, 60/min; plus a per-client bucket of 120 exchanges a minute keyed on the VERIFIED client): the caller authenticates with `Authorization: Basic clientId:secret` AND `x-ailk-spoke-key`, which must resolve to the unrevoked spoke whose id is that client — either alone is `401 invalid_client`. `token` consumes the code first and exactly once, then checks the client, the callback and the PKCE verifier (`400 invalid_grant` for any failure, the code gone either way) and issues `ailk_hst_…`, a 24 h credential stored only as a SHA-256 digest in `OAuthAuthorization` (one row per person and spoke, so a new sign-in rotates it); it writes no `Session` row and sets no cookie. `revoke` ends a credential or burns an unexpired code of that client (`{ revoked }`, never an error). `me` answers the credential's person, read live, and is reachable only by that credential. **The credential is admitted by one branch of `middleware/auth.ts`** — only beside the same spoke's key, only while its grant is unrevoked and unexpired, its client enabled and its person's email verified, and only on `HUB_SESSION_ROUTES`, an exact list of 18 method + registered-URL strings, never a prefix (#8220): `GET /v1/hub-sign-in/me`, #7877's three member reads, #8169's eight member reads and #8170's six member writes; every other route answers `404` with no handler run (the status the account area's `read()` loads as "nothing for this caller"), and the list is extended only by a reviewed edit. **The per-person budget (#8220 D2, SEC-8169-4):** each person at each spoke gets 60 requests and 10 writes a minute and 30 writes an hour (a fixed hour window from the first admitted write, so up to twice that across a window edge), keyed on the VERIFIED spoke and person and charged only after admission (a refused credential or an off-list route never gets a bucket); over budget is `429 rate_limited` with `Retry-After`. The write limits refuse only writes, and a refused request is not counted. Every line the request logger writes for an admitted request after admission carries `spokeId` (never the key or the credential); a helper logging through the module-level `logger` carries no request binding. The server-wide per-IP limiter still counts all of one spoke's members together — a named residual whose follow-up (the visitor forward key on member calls) must land before Phase 3 (#8163) ships to a live spoke. A bearer write skips the origin check, so the cross-site-request protection for the member writes is the spoke's: its server actions that send a sealed credential carry it (Phase 3, #8163; SEC-8220-11). A completed password reset (`apps/web/auth.ts` `onPasswordReset`) revokes every credential of that person. The operator registers a spoke's client with `spokes sign-in-client --id <spoke_id> --redirect-uri <url>` (`bin/spokes.ts`; `--disable` switches it off) — `lib/hub-sign-in-store.ts` is the only file that names `prisma.oAuthClient` and `prisma.oAuthAuthorization`. No MCP twin for any of the four — a credential exchange between a site and its hub is not an agent-facing call (#3591 exception, the `/v1/auth/tokens` footing). The browser step lives on the hub's web app: `apps/web/app/api/auth/hub/authorize/route.ts`.
54
+ - `/v1/hub-sign-in/codes` (POST), `/v1/hub-sign-in/token` (POST), `/v1/hub-sign-in/revoke` (POST), `/v1/hub-sign-in/me` (GET) — the hub signs in its spokes (#7894, `src/routes/auth/hub-sign-in.ts` over `src/services/hub-sign-in.ts`; spec `docs/specs/7894-the-hub-signs-in-its-spec.md`): an authorization code with PKCE between a spoke (a confidential client whose `OAuthClient.clientId` IS its `Spoke.id`) and this hub. `codes` is in `authScope` and answers a COOKIE session only (a personal `ApiToken` is 403, an agent key 403 at the preHandler, a hub credential 404): it mints a single-use 60 s code only for an email-verified person and a callback in the client's registered `redirectUris` (exact match), 30 a user per 10 minutes. `token` and `revoke` are in their OWN scope outside `authScope` (per-IP `rateLimitMiddleware`, scope `hubsignin`, 60/min; plus a per-client bucket of 120 exchanges a minute keyed on the VERIFIED client): the caller authenticates with `Authorization: Basic clientId:secret` AND `x-ailk-spoke-key`, which must resolve to the unrevoked spoke whose id is that client — either alone is `401 invalid_client`. `token` consumes the code first and exactly once, then checks the client, the callback and the PKCE verifier (`400 invalid_grant` for any failure, the code gone either way) and issues `ailk_hst_…`, a 24 h credential stored only as a SHA-256 digest in `OAuthAuthorization` (one row per person and spoke, so a new sign-in rotates it); it writes no `Session` row and sets no cookie. `revoke` ends a credential or burns an unexpired code of that client (`{ revoked }`, never an error). `me` answers the credential's person, read live, and is reachable only by that credential. **The credential is admitted by one branch of `middleware/auth.ts`** — only beside the same spoke's key, only while its grant is unrevoked and unexpired, its client enabled and its person's email verified, and only on `HUB_SESSION_ROUTES`, an exact list of 24 method + registered-URL strings, never a prefix (#8220): `GET /v1/hub-sign-in/me`, #7877's three member reads, #8169's eight member reads, #8170's six member writes, #8435's two contact routes, #8438's two consent routes and #8515's onboarding read and write; every other route answers `404` with no handler run (the status the account area's `read()` loads as "nothing for this caller"), and the list is extended only by a reviewed edit. **The per-person budget (#8220 D2, SEC-8169-4):** each person at each spoke gets 60 requests and 10 writes a minute and 30 writes an hour (a fixed hour window from the first admitted write, so up to twice that across a window edge), keyed on the VERIFIED spoke and person and charged only after admission (a refused credential or an off-list route never gets a bucket); over budget is `429 rate_limited` with `Retry-After`. The write limits refuse only writes, and a refused request is not counted. Every line the request logger writes for an admitted request after admission carries `spokeId` (never the key or the credential); a helper logging through the module-level `logger` carries no request binding. The server-wide per-IP limiter still counts all of one spoke's members together — a named residual whose follow-up (the visitor forward key on member calls) must land before Phase 3 (#8163) ships to a live spoke. A bearer write skips the origin check, so the cross-site-request protection for the member writes is the spoke's: its server actions that send a sealed credential carry it (Phase 3, #8163; SEC-8220-11). A completed password reset (`apps/web/auth.ts` `onPasswordReset`) revokes every credential of that person. The operator registers a spoke's client with `spokes sign-in-client --id <spoke_id> --redirect-uri <url>` (`bin/spokes.ts`; `--disable` switches it off) — `lib/hub-sign-in-store.ts` is the only file that names `prisma.oAuthClient` and `prisma.oAuthAuthorization`. No MCP twin for any of the four — a credential exchange between a site and its hub is not an agent-facing call (#3591 exception, the `/v1/auth/tokens` footing). The browser step lives on the hub's web app: `apps/web/app/api/auth/hub/authorize/route.ts`.
55
55
  - `/v1/checkout/sessions` — start a Stripe checkout
56
56
  - `/v1/workspaces/:workspaceId/{theme,identity,seo}` (GET/PUT) — per-workspace site config (multi-tenant module). PUT needs `workspace-settings` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)), checked before the body parse; GET stays open to every member.
57
57
  - `/v1/workspaces/:workspaceId/meetings` (GET/PUT) — the operator's enabled-slug gate for meeting types (#6183 S9b, `src/routes/workspaces/meetings.ts`). Beside the three siblings above in URL shape and tenant-isolation posture, but registered in its OWN scope, gated on `isModuleEnabled("scheduling")` ALONE — not the `workspace`/`multi-tenant` scope its siblings share, because its GET reaches `services/scheduling/` (see below), which a scaffold without that module does not carry (`tools/lint-slice-registration.ts`, #5701). PUT needs `meeting-types-manage` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)) because this blob decides which pages the public site serves. GET answers `meeting-types-manage` with the full join and `meeting-types-read` (every role, a member included) with the ENABLED rows only (#7636 D6) — since it does NOT just return the stored blob, it echoes the deployment's whole provider catalog, including the rows the operator chose not to publish (security review, 2026-09-19), which a member never sees. GET is the one segment in this file's family that does NOT just return the stored blob: it ALSO lists the scheduling provider's own bookable event types (`listEventTypes`, #6182), joined with `enabled: boolean`; an unconfigured/unreachable provider answers the same `SchedulingErrorKind` outcome `/v1/schedule/*` uses, distinct from an empty list. **`customerSlugs` (#8171 D5)** — who may book from the member area, independent of `enabledSlugs`: `GET ?include=customers` (exactly that value) adds `customer: boolean` per row, and without it the body is unchanged; the PUT carries `customerSlugs`, and a PUT without it keeps the stored list (a read then a write, last-writer-wins), so the public toggle never erases it. `enabledSlugs` keeps its whole-list meaning, so a writer of `customerSlugs` sends the `enabledSlugs` it read. An agent's PUT is audited `meetings.set_customers` when it carries `customerSlugs`, else `meetings.set_enabled`. No MCP twin — identity-bound tenant route (#3591 exception).
@@ -60,11 +60,12 @@ Routes are versioned under `/v1/*`:
60
60
  - `/v1/workspaces/:workspaceId/messages` (GET, `?limit=`), `/v1/workspaces/:workspaceId/messages/:leadId` (GET, PATCH) — the Inbox message API (#6216 S7e, `src/routes/workspaces/messages.ts`): the workspace's unhandled messages newest first, the item read (resolves a message handled or not, serving `handledAt`), and the one-way idempotent handled stamp over #6204's `markMessageHandled`. Registered under `isModuleEnabled("workspace") || isModuleEnabled("multi-tenant")`, the same scope as the two routes above. A message is a `Lead` whose `source` is in #6204's closed `MESSAGE_LEAD_SOURCE_LIST` allowlist — checked only in `tenantDb(tenant).leads`, never in this file; a non-message lead is a 404 on the item read and the PATCH and absent from the list, the SAME 404 body as an absent id, another workspace's id, or an unbackfilled `workspaceId`. **One served row across all three routes** — `id`, `name`, `email`, `source`, `createdAt`, `handledAt` — and nothing else: no `payload`, no message body (operator ruling, 2026-09-19; `leads-inbox.ts`'s PII boundary is unchanged, not widened). PATCH takes `{ "handled": true }` only, `.strict()`; there is no un-handle path (#6204 D4), so `{ "handled": false }` is a 400. The stamp records the actor via `tenant.userId` in `Lead.handledBy` — written, not served. Role floor: `inbox-read` on the GETs and `inbox-act` on PATCH and accept (`@working-theory/validation`'s permission table (#7636 D2) — every role, a member included), the SAME rows `services/inbox-messages.ts` re-checks, checked before any read or write. The role is not self-servable: since #7523 `multi-tenant`'s null-binding fallback grants the default tenant only to the pinned, verified operator, the same check the base `workspace`-only guard applies (see the `AILK_OPERATOR_EMAIL` paragraph below). No anonymous MCP twin — identity-bound tenant routes, and the anonymous `/mcp` carries no caller identity. Each route's twin is a signed-in tool on `/mcp/workspace` (#3591 D13), which forwards the caller's agent key here: `list_inbox_messages` (list), `get_inbox_message` (item read), `mark_inbox_message_done` (PATCH) and `accept_inbox_message` (`POST …/:leadId/accept`).
61
61
  - `/v1/workspaces/:workspaceId/submissions` (GET, `?limit=&state=unhandled&magnetId=`), `/v1/workspaces/:workspaceId/submissions/:leadId` (GET, PATCH) — the Inbox submissions API (#7535, `src/routes/workspaces/submissions.ts` over `src/services/inbox-submissions.ts`): every flow completion and waitlist signup of the caller's active workspace, newest first and bounded (`?limit=` 1–200, default 50), the item read (handled or not), and the one-way idempotent handled stamp (`{ "handled": true }` only, `.strict()`; a second PATCH returns the first `handledAt`). Same registration, scope and tenant guard as the messages bullet above. A submission is a `Lead` whose `source` is a fixed submission source (`SUBMISSION_FIXED_SOURCE_LIST` in `@working-theory/validation`) or `flow:<slug>` for a NON-newsletter magnet of THIS workspace — decided only in `tenantDb(tenant).leads`, never in the route; a `flow:` row naming no magnet of the workspace never lists. The list row is seven keys — the message row's six plus `magnet: { slug, title } | null` — and never `payload`. `?magnetId=` narrows to one magnet (limit 1–50) and adds `answers`, `answersTruncated` and `deliverableSentAt` per row; the item read serves the same three. `answers` is `payload` narrowed by `toAnswers`: server-owned keys dropped, control and bidi characters stripped, every key, value and the entry count bounded. `?state=unhandled` filters `handledAt IS NULL` in the query (the Admin's For-you read); any other `state` is a 400. Every miss on the item read and the PATCH — a malformed, absent or foreign id, a message, an application, a newsletter-magnet lead — is the SAME `404 No such submission`; a `?magnetId=` that is absent, foreign or a newsletter magnet is `404 No such magnet`. Every success body is re-parsed against its own `.strict()` schema before it is sent. Role floor: `inbox-read` on the GETs and `inbox-act` on PATCH (`@working-theory/validation`'s permission table (#7636 D2) — every role, a member included), checked before any read or write. The handlers and the service never log a lead id, email, name or answer. No anonymous MCP twin — identity-bound tenant routes. Each route's twin is a signed-in tool on `/mcp/workspace` (#3591 D13): `list_inbox_submissions` (list), `get_inbox_submission` (item read) and `mark_inbox_submission_done` (PATCH).
62
62
  - `/v1/workspaces/:workspaceId/subscribers` (GET, `?limit=`) — the workspace's newsletter signups, read-only (#7535, `src/routes/workspaces/subscribers.ts`): `{ count, items }`, where `count` is every signup and `items` the newest (1–200, default 50), each `{ id, email, source, subscribedAt, magnet: { slug, title } | null }`. A signup is a `Subscriber` row of this workspace or the `Lead` a newsletter-kind magnet of this workspace wrote, both from `tenantDb(tenant).subscribers`, called directly. No item route and no write: a subscriber is never an item to open or mark handled. Same registration, scope, tenant guard and `inbox-read` floor as the submissions bullet above. No MCP twin — identity-bound tenant route (#3591 exception).
63
- - `/v1/workspaces/:workspaceId/people` (GET, `?limit=&cursor=`), `/v1/workspaces/:workspaceId/people/:personId` (GET) — the workspace's clients (#852, `src/routes/workspaces/people.ts` over `tenantDb(tenant).people`; the Person writer is `src/services/person.ts`). A **Person** is one client of one workspace, keyed on a lowercased, trimmed email unique per workspace and created only by `findOrCreatePerson` — called by lead capture, the hub's own Stripe checkout webhook (a live-mode event's `session.customer` only), appointment creation and the inbox accept; `Person.userId` is stamped only on a verified-email match (`stampPersonUser`), never overwritten. The list serves `{ items, nextCursor }` — five keys per row (`id`, `email`, `name`, `planOffering`, `createdAt`), newest first, `limit` 1–200 (default 50); a cursor that does not resolve to a Person of this workspace is `400 invalid_cursor`. The record serves `{ person, timeline, timelineTruncated }`: the Person (`userId` projected to `hasAccount`, never served; the billing pair served) over a timeline derived at read time — leads and appointments by `personId`, flow sessions, checkouts, deliverable grants and project listings by the Person's newest 200 lead ids, plus an exact-normalized-email fallback for lead-less flow sessions and listings older than the Person backfill, (since #7877) the Person's member-area messages as `message` entries carrying the subject, `hasFile` and `readAt` only, and (since #8171 D4) the Person's inbound member rows as `request` entries (`requestKind`, `subject`) and the versions delivered to them as `version` entries (`pieceId`, `version`, `review`; `at` is `deliveredAt`) — keyed by `personId`, never the email grouping, and never a body, link, quote answer, note or review comment — newest first, at most 50 entries; a malformed, absent or foreign id is one `404 not_found`. Never selected or served: a grant id, a flow-session id, a checkout id, a lead `payload`, a permalink, a Stripe session id, an attendee email, a founder email. Role floor: `people-read` (owner, admin — `@working-theory/validation`'s permission table) via `allows`, checked before any read, so an agent key granted `people-read` (the sixth `AGENT_GRANTABLE_PERMISSIONS` entry, clamped to its person's role) reads both in its one workspace. An operator read only: a Person groups rows by an unverified email, so no client-facing read may key on that grouping (#7877's feed keys on a verified link). Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). No MCP twin, by decision — People MCP tools are a later phase of the CRM nucleus (#852 AC 6), recorded in `tests/parity/README.md`.
64
- - `/v1/workspaces/:workspaceId/people/:personId/pieces` (GET, POST), `…/people/:personId/pieces/:pieceId` (PATCH), `/v1/workspaces/:workspaceId/piece-stages` (GET, PUT), `…/people/:personId/plan` (PUT), `…/people/:personId/messages` (GET, POST) — the member area as an operator keeps it (#7877, `src/routes/workspaces/{pieces,plan,messages-out}.ts` over `tenantDb(tenant).memberArea`; spec `docs/specs/7877-the-member-area-contract-a-spec.md`). A **Piece** is one deliverable for one client: a title (1–200), a **stage** drawn from the workspace's OWN declared list (`WorkspacePieceStages`, one row per workspace; no row is the product default `requested, in-progress, in-review, delivered`, which lives only in `lib/tenant-db.ts`'s `DEFAULT_PIECE_STAGES` — there is no stage enum anywhere), a `needsClient` flag and `updatedBy`. The **plan** is `Person.planOffering` (#852's field — never a Plan model), written only by `services/person.ts`'s `setPersonPlanOffering`. A **message** is an `Interaction` on the `member` channel, direction `outbound`: a plain-text subject (1–200) and body (1–10000), both stripped of control and bidi characters (the body keeps `\n`, `\t`), and an optional attachment that must be an `Asset` of THIS workspace tagged `private` with a raster-image or PDF type (never SVG) — `400 asset_not_found` / `400 asset_not_attachable` otherwise. Both records reference a Person of the same workspace through a composite `(person_id, workspace_id)` foreign key. Role floors from `@working-theory/validation`'s permission table: the two list reads need `people-read`, the stage PUT `workspace-settings`, the stage GET the guard alone, each checked with `can(tenant.role, …)` before any read, so an agent's `people-read` grant does not reach pieces or message bodies; the four writes — piece POST and PATCH, plan PUT, message POST — need `member-pieces-manage` (owner, admin), checked with `allows(tenant, "member-pieces-manage")` before any read, so an agent key granted it (#7878, the seventh `AGENT_GRANTABLE_PERMISSIONS` entry) keeps them in its one workspace, attaching only an asset the message's own checks admit. `people-write` (owner, admin) is never agent-grantable and is no longer read by these routes — the Admin person page reads it to render its forms. A malformed, absent or foreign Person or piece is one `404 not_found`; a stage outside the declared list `400 invalid_stage`; a stage-list PUT that drops a stage a piece still holds `409 stage_in_use` with nothing written — piece writes and stage changes take one per-workspace advisory lock first, in one transaction. Lists hold at most 50 rows, newest first. Every write that reaches the store records one `recordAuditAction` row naming the actor (`people.piece.create`, `people.piece.update`, `people.stages.set`, `people.plan.set`, `people.message.create`); no audit summary or log line carries a title, subject, body or offering. The operator's message read never stamps `readAt`, and serves an attachment by its sanitized download name, never its asset id. **The checkout plan rule:** a `POST /v1/leads/submit` from a registered spoke (`viaSpoke`) whose `Idempotency-Key` is a LIVE Checkout Session id (`cs_live_…`), with `source: "web"`, `payload.page: "checkout"`, a string `payload.offering` and `payload.site`, and an `amountTotal` that is absent, `null` or positive, sets its Person's plan to the offering minus a leading `<site>_` (`services/lead-routing.ts`) — never on a replay, never refusing or changing the lead, and with no spoke change. Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). No MCP twin, by decision — member-area MCP tools are a later phase of the CRM nucleus, recorded in `tests/parity/README.md`.
63
+ - `/v1/workspaces/:workspaceId/people` (GET, `?limit=&cursor=`), `/v1/workspaces/:workspaceId/people/:personId` (GET), `/v1/workspaces/:workspaceId/people/:personId/contact` (PATCH) — the workspace's clients (#852, `src/routes/workspaces/people.ts` over `tenantDb(tenant).people`; the Person writer is `src/services/person.ts`). A **Person** is one client of one workspace, keyed on a lowercased, trimmed email unique per workspace and created only by `findOrCreatePerson` — called by lead capture, the hub's own Stripe checkout webhook (a live-mode event's `session.customer` only), appointment creation and the inbox accept; `Person.userId` is stamped only on a verified-email match (`stampPersonUser`), never overwritten. The list serves `{ items, nextCursor }` — five keys per row (`id`, `email`, `name`, `planOffering`, `createdAt`), newest first, `limit` 1–200 (default 50); a cursor that does not resolve to a Person of this workspace is `400 invalid_cursor`. The record serves `{ person, timeline, timelineTruncated }`: the Person (`userId` projected to `hasAccount`, never served; the billing pair served) over a timeline derived at read time — leads and appointments by `personId`, flow sessions, checkouts, deliverable grants and project listings by the Person's newest 200 lead ids, plus an exact-normalized-email fallback for lead-less flow sessions and listings older than the Person backfill, (since #7877) the Person's member-area messages as `message` entries carrying the subject, `hasFile` and `readAt` only, and (since #8171 D4) the Person's inbound member rows as `request` entries (`requestKind`, `subject`) and the versions delivered to them as `version` entries (`pieceId`, `version`, `review`; `at` is `deliveredAt`) — keyed by `personId`, never the email grouping, and never a body, link, quote answer, note or review comment — newest first, at most 50 entries; a malformed, absent or foreign id is one `404 not_found`. Never selected or served: a grant id, a flow-session id, a checkout id, a lead `payload`, a permalink, a Stripe session id, an attendee email, a founder email. Role floor: `people-read` (owner, admin and, since #8514, member — `@working-theory/validation`'s permission table) via `allows`, checked before any read, so an agent key granted `people-read` (the sixth `AGENT_GRANTABLE_PERMISSIONS` entry, clamped to its person's role) reads both in its one workspace. **Agents are withheld (#8514 D3, D4):** `people-read`, `people-write` and `member-pieces-manage` are `AGENT_WITHHELD_PERMISSIONS` — a member holds them by role, an agent (every agent acts at member) never does: `roleAllows` and `allows` read the `agent: true` marker `agentTenant` sets on every agent tenant, so an agent holds the grantable two only by an explicit grant and `people-write` never, and both tenant guards refuse an agent request whose tenant lacks the marker (403, one `agent-tenant-unmarked` error line — fail closed). An operator read only: a Person groups rows by an unverified email, so no client-facing read may key on that grouping (#7877's feed keys on a verified link). **The contact details (#8435, spec `docs/specs/8435-contact-details-on-the-client-spec.md`):** the Person also carries a given name, a family name, an E.164 telephone, a six-part address (the magnet's `addressLine1`/`addressLine2`/`city`/`region`/`postalCode`/`country`, stored whole or not at all), a company name and a job title, plus `contactConfirmedAt`/`contactConfirmedBy` — who last saved the contact by hand. The record serves them as `person.contact` (six keys, `null` where blank) and `person.contactConfirmed` (`{ by: "operator" | "client", at }`, `null` while only captures have written it). **A capture fills a BLANK field of an UNCURATED Person only** (`findOrCreatePerson`'s `contact`: no linked user and never saved by hand; every guarded `updateMany` repeats that condition): a stored value is never overwritten and a cleared field is never refilled. A magnet field's `mapsTo` tag now names the contact field it fills (`email`, `name`, `givenName`, `familyName`, `phone`, `address`, `company`, `jobTitle`); the flow engine hands the visible steps' tagged answers to `routeLead` as `options.contact` and the lead's `payload` keeps every raw answer. A subscriber and a callback with a storable email (a callback's is `payload.email`; at confirm, `recordPaymentLink` links the buyer email when capture had none) now carry a `personId` — a composite foreign key onto `people(id, workspace_id)`, `RESTRICT`. `PATCH …/people/:personId/contact` needs `people-write` (owner, admin, member — by role; never an agent) and takes any of the six keys (`null` clears; an address is whole or `null`; `400 telephone_invalid` for a telephone that is not E.164, `400 validation_failed` otherwise, an 8 KiB body bound), stamps `contactConfirmedBy: "operator"`, answers `{ contact }` and writes one `people.contact.update` audit row naming the actor and the Person, never a value. **The private notes (#8514, spec `docs/specs/8514-redesign-the-person-record-page-spec.md` D5–D8):** `GET`/`PUT …/people/:personId/notes` — the team's one note on a client (`notes`, ≤ 5000 code points), who last saved it and when, and which agent when one did; never selected by any other read and never served to the client (`lib/member-store.ts` names no notes column). The GET needs a person's `people-read` role or an agent's `member-pieces-manage` grant (never a `people-read` grant alone); the PUT needs `member-pieces-manage` through `allows`, takes `{ notes, ifUpdatedAt }` (both required; `null` clears; `ifUpdatedAt` is the `updatedAt` read, a stale one `409 notes_changed` with nothing written; `400 notes_too_long`), and writes one `people.notes.set` audit row whose summary is `{ ok, cleared }`, never the text. Both answer `{ notes: { text, updatedAt, updatedBy: { name, viaAgent } | null } }` with `Cache-Control: private, no-store`, through `tenantDb(tenant).people.notes`/`setNotes`; a malformed, absent or foreign id is one `404 not_found`. Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). The notes pair has MCP twins on `/mcp/workspace`: `get_person_notes` and `set_person_notes` (#8514 D8). The other People routes have no MCP twin, by decision — People MCP tools are a later phase of the CRM nucleus (#852 AC 6), recorded in `tests/parity/README.md`.
64
+ - `/v1/workspaces/:workspaceId/people/:personId/pieces` (GET, POST), `…/people/:personId/pieces/:pieceId` (PATCH), `/v1/workspaces/:workspaceId/piece-stages` (GET, PUT), `…/people/:personId/plan` (PUT), `…/people/:personId/messages` (GET, POST) — the member area as an operator keeps it (#7877, `src/routes/workspaces/{pieces,plan,messages-out}.ts` over `tenantDb(tenant).memberArea`; spec `docs/specs/7877-the-member-area-contract-a-spec.md`). A **Piece** is one deliverable for one client: a title (1–200), a **stage** drawn from the workspace's OWN declared list (`WorkspacePieceStages`, one row per workspace; no row is the product default `requested, in-progress, in-review, delivered`, which lives only in `lib/tenant-db.ts`'s `DEFAULT_PIECE_STAGES` — there is no stage enum anywhere), a `needsClient` flag and `updatedBy`. The **plan** is `Person.planOffering` (#852's field — never a Plan model), written only by `services/person.ts`'s `setPersonPlanOffering`. A **message** is an `Interaction` on the `member` channel, direction `outbound`: a plain-text subject (1–200) and body (1–10000), both stripped of control and bidi characters (the body keeps `\n`, `\t`), and an optional attachment that must be an `Asset` of THIS workspace tagged `private` with a raster-image or PDF type (never SVG) — `400 asset_not_found` / `400 asset_not_attachable` otherwise. Both records reference a Person of the same workspace through a composite `(person_id, workspace_id)` foreign key. Role floors from `@working-theory/validation`'s permission table: the two list reads need `people-read`, the stage PUT `workspace-settings`, the stage GET the guard alone, each checked by role (`roleAllows(tenant, …)`) before any read, so an agent's `people-read` grant does not reach pieces or message bodies; the four writes — piece POST and PATCH, plan PUT, message POST — need `member-pieces-manage` (owner, admin, member since #8514), checked with `allows(tenant, "member-pieces-manage")` before any read, so an agent key granted it (#7878, the seventh `AGENT_GRANTABLE_PERMISSIONS` entry) keeps them in its one workspace, attaching only an asset the message's own checks admit; attaching a file to a message or delivering one as a version also needs `assets-write` (#8514 D19), so a member sends text and link versions, never a private file. `GET …/plan-offerings` (#8514 D9, `people-read`) lists the workspace's own Service then Product pages as plan keys for the Admin's picker (`services/public-site-resolver.ts`'s `workspaceContentAdapter`: off the database adapter only the default workspace has content, every other workspace `[]`). `people-write` (owner, admin, member since #8514) is never agent-grantable and is no longer read by these routes — the Admin person page reads it to render its forms. A malformed, absent or foreign Person or piece is one `404 not_found`; a stage outside the declared list `400 invalid_stage`; a stage-list PUT that drops a stage a piece still holds `409 stage_in_use` with nothing written — piece writes and stage changes take one per-workspace advisory lock first, in one transaction. Lists hold at most 50 rows, newest first. Every write that reaches the store records one `recordAuditAction` row naming the actor (`people.piece.create`, `people.piece.update`, `people.stages.set`, `people.plan.set`, `people.message.create`); no audit summary or log line carries a title, subject, body or offering. The operator's message read never stamps `readAt`, and serves an attachment by its sanitized download name, never its asset id. **The checkout plan rule:** a `POST /v1/leads/submit` from a registered spoke (`viaSpoke`) whose `Idempotency-Key` is a LIVE Checkout Session id (`cs_live_…`), with `source: "web"`, `payload.page: "checkout"`, a string `payload.offering` and `payload.site`, and an `amountTotal` that is absent, `null` or positive, sets its Person's plan to the offering minus a leading `<site>_` (`services/lead-routing.ts`) — never on a replay, never refusing or changing the lead, and with no spoke change. Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). **Proposals (#8516, spec `docs/specs/8516-proposals-as-an-artifact-kind-spec.md`):** a piece now carries a `kind` — `deliverable` (every piece before #8516, the column default) or `proposal` — served on every piece row and never set or changed through the piece routes (a body naming it is `400 validation_failed`), plus a proposal's optional price (minor units of `usd`/`eur`/`gbp`/`cad`/`aud`). `GET …/people/:personId/proposals` (a person's `people-read` role or an agent's `member-pieces-manage` grant) lists the Person's proposals with their price, approval, paid stamp and latest version, and `payments` (`on-approval` or `off`); `POST …/people/:personId/proposals` (`member-pieces-manage`; a file version also `assets-write`) is Send proposal — the proposal (`needsClient: true`, the first declared stage unless named) and its version 1 in one transaction under the piece lock, one `people.proposal.create` audit row whose summary is `{ priced }` alone. **Only a person prices (C4, the money gate):** an agent's create with a price, and an agent's PATCH or Deliver a version on a priced proposal, are `403 proposal_needs_person` with nothing written. Both read and write through `tenantDb(tenant).memberArea`. The proposals pair has MCP twins on `/mcp/workspace`: `list_proposals` and `create_proposal` (#8516 D7). **Start onboarding (#8515 D5, D6):** `POST …/people/:personId/onboarding` (`member-pieces-manage` through `allows`) takes `{ subject, body }` (the message bounds, no attachment — a body naming `assetId` is `400 validation_failed`) and writes one outbound member message whose `payload` is `{ action: "onboarding" }` — the one outbound payload `interactions_outbound_shape` admits — which the client's own site renders as a link to its `/account/onboarding`; it reads the workspace's onboarding magnet through `tenantDb(tenant).flows.bySlug` and never seeds one: no `diagnostic` row at that slug with `customersEnabled` is `409 onboarding_off` with nothing written. A malformed, absent or foreign Person is the same `404 not_found`; one `people.onboarding.start` audit row, no summary. The message POST never carries the action. The other member-area routes have no MCP twin, by decision — member-area MCP tools are a later phase of the CRM nucleus, recorded in `tests/parity/README.md`.
65
65
  - `/v1/member/pieces` (GET), `/v1/member/messages` (GET), `/v1/member/messages/:id/file` (GET) — the member reads: a client's OWN pieces and messages, served to the spoke that signed them in (#7877, `src/routes/member/{pieces,messages}.ts`). Registered in `authScope` only (auth, NOT tenant-guarded — a client holds no membership; an agent key is 403 at the preHandler) under the same `workspace || multi-tenant` predicate, at the exact URLs `HUB_SESSION_ROUTES` admits a hub credential on. Who and where come only from `services/member-area.ts`'s `resolveMember`, before any member read: the caller through `middleware/auth.ts`'s `memberCaller` — the hub's own cookie session or a hub credential, never a personal `ApiToken` (`401 { error: "unauthorized" }`); the workspace from the calling spoke's `x-ailk-spoke-key` (a keyless call only on a one-workspace deployment that is not a hub), and a hub credential must name that same workspace, else `503 tenant_unresolved`; an unverified email `403 email_unverified`. The Person is the one linked by `Person.userId`, else the one whose email is the caller's verified email, linked on that first read by `stampPersonUser`'s rule; no Person is `items: []`. Every read goes through the named boundary `src/lib/member-store.ts`, scoped by that resolved workspace AND Person: at most 50 rows, newest first; a message serves `{ id, subject, body, file: { filename, contentType } | null, createdAt, readAt }` — never an asset, creator, person or workspace id — and the first read stamps `readAt` ("shown to the client's spoke", not "read"; served as it stood before the stamp). The file read streams the attachment only when the message is the caller's own and its stored type is attachable — any other case is one `404 not_found` — `attachment` with a sanitized, extension-pinned name, `nosniff`, the sandbox `Content-Security-Policy` and `Cache-Control: private, no-store`. `@working-theory/api-client`'s `member.*` is the spoke's caller; a spoke that re-serves the bytes sends those five headers unchanged. No MCP twin, by decision — identity-bound client reads behind a hub credential; the anonymous MCP surface is identity-blind, recorded in `tests/parity/README.md`.
66
- - `/v1/member/purchases`, `/v1/member/appointments`, `/v1/member/artifacts`, `/v1/member/artifacts/:pieceId/versions/:version/file`, `/v1/member/meeting-types`, `/v1/member/availability`, `/v1/member/request-form`, `/v1/member/billing` (all GET) — the Phase 1 member reads (#8169, `src/routes/member/{purchases,appointments,artifacts,meeting-types,availability,request-form,billing}.ts`; spec `docs/specs/8169-member-reads-spec.md`). Same caller seam, refusals and registration as the bullet above, each read through one `src/lib/member-store.ts` function, each success body re-parsed against its `@working-theory/validation` `member.ts` schema. All eight are in `HUB_SESSION_ROUTES` (#8220). Appointments, artifacts and the file read are scoped by the resolved workspace AND Person (≤ 50, never an attendee's name, email or time zone; never a version or asset id; the file read is one `404 not_found` for every miss, a version above the 32-bit column included, and sends `messages.ts`'s five headers). **Purchases and billing read at organization grain** (the operator's #8140 ruling): only Persons whose `userId` is ALREADY the caller, inside the calling workspace's organization — they link nothing, so a caller with no Person here still sees a linked Person elsewhere. That grain rests on one precondition nothing enforces (SEC-8169-2, stated at `lib/member-store.ts`'s `linkedPersonsInOrganization` and pinned by `src/__tests__/member-isolation.test.ts`): every workspace of one organization belongs to one owner, so a deployment must never put two businesses' workspaces in one organization. A purchase is a completed hub `FlowCheckout` whose lead carries its Person's own email, or (#8185) a `Lead` the hub marked `spokeCheckout` when it was written — a registered spoke's unrelayed (`x-ailk-relayed` absent), LIVE paid checkout, the #7877 plan rule's own predicate evaluated in `services/lead-routing.ts` before the create and never updated, served with the same Person-workspace and Person-email rules, its offering kept only if it is a plan-offering key (SEC-8169-6); an anonymous, relayed, test-mode or pre-#8185 lead is never marked and never served (no backfill), and `tests/architecture/idempotency-key-producers.test.ts` fails if any spoke-side file, MCP tool or client wrapper starts sending an `Idempotency-Key`. **Billing answers a hub credential only**: any other caller gets `404 not_found` before `resolveMember` and before any read, and no log line ever names a `cus_` id; a hub credential reads it at the organization grain, under that precondition. Meeting types and availability join the workspace's `customerSlugs` (independent of `enabledSlugs`) to the scheduling provider — a provider failure is its `SCHEDULING_ERROR_STATUS`, never an empty list; a slug outside `customerSlugs` is `404 meeting_type_not_offered` with no provider call — and register under `scheduling && (workspace || multi-tenant)`, because only the `scheduling` module delivers `services/scheduling/`. The request form is the `request-quote` contact row while `customersEnabled`, whatever its status, else `{ form: null }`. No MCP twin, by decision — the same reason, recorded in `tests/parity/README.md`.
67
- - `/v1/member/requests` (POST), `/v1/member/messages` (POST), `/v1/member/bookings` (POST), `/v1/member/artifacts/:pieceId/review` (POST), `/v1/member/profile` (PATCH), `/v1/member/deletion-request` (POST) — the Phase 1 member writes (#8170, `src/routes/member/{requests,messages,bookings,artifact-review,profile,deletion-request}.ts`; spec `docs/specs/8170-member-writes-spec.md`). Same caller seam and refusals as the reads, and a cookie write must carry a trusted `Origin` (the preHandler's 401). Nothing in a body, path or query names a person, user or workspace; every body parses its `@working-theory/validation` `member.ts` schema (`400 invalid_input`, or `400 link_not_https` when a link is why) and every success body is re-parsed before it is sent. **Requests, messages and reviews are each ONE inbound `Interaction` on the caller's own Person** — channel `member`, direction `inbound`, `createdBy` the caller — and never a `Lead`: a request is checked against the `request-quote` magnet's CURRENT field steps (`400 answers_invalid` for a key the form does not ask, a missing required answer, an array answer or a failed field rule; `409 request_form_off` when the form is off or the caller has no Person) and stores `{ flowSlug, items }` with each label as the form shows it; a message stores `{ link }` when one is sent (`404 not_found` with no Person). **The cap:** every inbound write runs in one transaction whose first statement is the Person's advisory lock (`ailk:member-inbound:<personId>`), then counts the Person's inbound rows of the last hour — 30 or more is `429 too_many_requests` with nothing written. **The review** lands only on the piece's LATEST version (`409 version_not_latest`), only once per version and never after `Piece.approvedAt` is set (`409 review_closed`); a missing, foreign or approved piece is refused from one read before any lock, otherwise the Person lock then the workspace's piece lock (`lib/tenant-db.ts`'s `pieceStagesLockKey`, the key the operator's piece writes take), and it writes the version's `review`, `reviewComment`, `reviewedAt`, the piece's `approvedAt` on an approval and the `review` Interaction in that one transaction — never a title, stage, link, asset or note; asking for changes stays open on every new latest version (#8140 disposition 3). **The booking** books an offered `customerSlugs` meeting type at `<handle>/<slug>` as the caller (verified email, own name), refuses a Person holding 10 upcoming non-cancelled appointments `429` before any provider call, answers a provider failure through `SCHEDULING_ERROR_STATUS`, and binds a spoke caller's booking to its workspace as `/v1/schedule/book` does; it registers under `scheduling && (workspace || multi-tenant)`. **The profile** writes only the caller's own `User.name` / `User.image` (https only) and answers `{ name, image }`. **The deletion request** is filed through `routeLead` as a `web` message lead (kind `deletion`, the caller's verified email, no idempotency key) into `resolveSiteWorkspaceId()` — the deployment's own site workspace, so a spoke caller's request reaches the hub operator (`sentTo: "hub-operator"`), never the spoke's. Every data write goes through `src/lib/member-store.ts`; every write logs only `{ event, outcome }` and a `ref` of at most 8 characters (`services/member-area.ts`'s `memberWriteLog`), never a subject, body, answer, link, note, name, email or full id. All six are in `HUB_SESSION_ROUTES` (#8220): a spoke's deletion request reaches the hub operator, and a spoke's profile write changes the name and photo the hub's `me` answers. No MCP twin, by decision — identity-bound client writes, recorded in `tests/parity/README.md`.
66
+ - `/v1/member/purchases`, `/v1/member/appointments`, `/v1/member/artifacts`, `/v1/member/artifacts/:pieceId/versions/:version/file`, `/v1/member/meeting-types`, `/v1/member/availability`, `/v1/member/request-form`, `/v1/member/billing` (all GET) — the Phase 1 member reads (#8169, `src/routes/member/{purchases,appointments,artifacts,meeting-types,availability,request-form,billing}.ts`; spec `docs/specs/8169-member-reads-spec.md`). Same caller seam, refusals and registration as the bullet above, each read through one `src/lib/member-store.ts` function, each success body re-parsed against its `@working-theory/validation` `member.ts` schema. All eight are in `HUB_SESSION_ROUTES` (#8220). Appointments, artifacts and the file read are scoped by the resolved workspace AND Person (≤ 50, never an attendee's name, email or time zone; never a version or asset id; the file read is one `404 not_found` for every miss, a version above the 32-bit column included, and sends `messages.ts`'s five headers). **Purchases and billing read at organization grain** (the operator's #8140 ruling): only Persons whose `userId` is ALREADY the caller, inside the calling workspace's organization — they link nothing, so a caller with no Person here still sees a linked Person elsewhere. That grain rests on one precondition nothing enforces (SEC-8169-2, stated at `lib/member-store.ts`'s `linkedPersonsInOrganization` and pinned by `src/__tests__/member-isolation.test.ts`): every workspace of one organization belongs to one owner, so a deployment must never put two businesses' workspaces in one organization. A purchase is a completed hub `FlowCheckout` whose lead carries its Person's own email, or (#8185) a `Lead` the hub marked `spokeCheckout` when it was written — a registered spoke's unrelayed (`x-ailk-relayed` absent), LIVE paid checkout, the #7877 plan rule's own predicate evaluated in `services/lead-routing.ts` before the create and never updated, served with the same Person-workspace and Person-email rules, its offering kept only if it is a plan-offering key (SEC-8169-6); an anonymous, relayed, test-mode or pre-#8185 lead is never marked and never served (no backfill), and `tests/architecture/idempotency-key-producers.test.ts` fails if any spoke-side file, MCP tool or client wrapper starts sending an `Idempotency-Key`. **Billing answers a hub credential only**: any other caller gets `404 not_found` before `resolveMember` and before any read, and no log line ever names a `cus_` id; a hub credential reads it at the organization grain, under that precondition. Meeting types and availability join the workspace's `customerSlugs` (independent of `enabledSlugs`) to the scheduling provider — a provider failure is its `SCHEDULING_ERROR_STATUS`, never an empty list; a slug outside `customerSlugs` is `404 meeting_type_not_offered` with no provider call — and register under `scheduling && (workspace || multi-tenant)`, because only the `scheduling` module delivers `services/scheduling/`. The request form is the `request-quote` contact row while `customersEnabled`, whatever its status, else `{ form: null }`. **`GET /v1/member/onboarding` (#8515 D7)** serves the standard onboarding magnet — the `diagnostic` row at slug `onboarding` while `customersEnabled`, whatever its status — reduced to display keys (a field's name, input type, `required`, select options and label/help/placeholder; a choice question's name, options, `multi` and `exclusiveValues`; never a `showIf`, `mapsTo`, `purposes`, field rule, `segments`, `whenHidden` or `resultsReveal`), else `{ form: null }`. `GET /v1/member/messages` items carry `action: "onboarding" | null` (#8515 D6). No MCP twin, by decision — the same reason, recorded in `tests/parity/README.md`.
67
+ - `/v1/member/requests` (POST), `/v1/member/messages` (POST), `/v1/member/bookings` (POST), `/v1/member/artifacts/:pieceId/review` (POST), `/v1/member/profile` (PATCH), `/v1/member/profile/contact` (GET, PATCH), `/v1/member/deletion-request` (POST) — the Phase 1 member writes (#8170, the contact pair #8435, `src/routes/member/{requests,messages,bookings,artifact-review,profile,deletion-request}.ts`; spec `docs/specs/8170-member-writes-spec.md`). Same caller seam and refusals as the reads, and a cookie write must carry a trusted `Origin` (the preHandler's 401). Nothing in a body, path or query names a person, user or workspace; every body parses its `@working-theory/validation` `member.ts` schema (`400 invalid_input`, or `400 link_not_https` when a link is why) and every success body is re-parsed before it is sent. **Requests, messages and reviews are each ONE inbound `Interaction` on the caller's own Person** — channel `member`, direction `inbound`, `createdBy` the caller — and never a `Lead`: a request is checked against the `request-quote` magnet's CURRENT field steps (`400 answers_invalid` for a key the form does not ask, a missing required answer, an array answer or a failed field rule; `409 request_form_off` when the form is off or the caller has no Person) and stores `{ flowSlug, items }` with each label as the form shows it; a message stores `{ link }` when one is sent (`404 not_found` with no Person). **The cap:** every inbound write runs in one transaction whose first statement is the Person's advisory lock (`ailk:member-inbound:<personId>`), then counts the Person's inbound rows of the last hour — 30 or more is `429 too_many_requests` with nothing written. **The review** lands only on the piece's LATEST version (`409 version_not_latest`), only once per version and never after `Piece.approvedAt` is set (`409 review_closed`); a missing, foreign or approved piece is refused from one read before any lock, otherwise the Person lock then the workspace's piece lock (`lib/tenant-db.ts`'s `pieceStagesLockKey`, the key the operator's piece writes take), and it writes the version's `review`, `reviewComment`, `reviewedAt`, the piece's `approvedAt` on an approval and the `review` Interaction in that one transaction — never a title, stage, link, asset or note; asking for changes stays open on every new latest version (#8140 disposition 3). **The booking** books an offered `customerSlugs` meeting type at `<handle>/<slug>` as the caller (verified email, own name), refuses a Person holding 10 upcoming non-cancelled appointments `429` before any provider call, answers a provider failure through `SCHEDULING_ERROR_STATUS`, and binds a spoke caller's booking to its workspace as `/v1/schedule/book` does; it registers under `scheduling && (workspace || multi-tenant)`. **The profile** writes only the caller's own `User.name` / `User.image` (https only) and answers `{ name, image }`. **The contact** (`profile/contact`, #8435) is the caller's contact details on THIS site, never on the hub `User` and never synced to another site: the GET serves the contact of the caller's linked Person only once a human saved it (`contactConfirmedAt` set — a value only an anonymous capture wrote is `{ contact: null }`, SEC-8435-B); the PATCH takes all six keys, each nullable and required, and REPLACES the whole contact (so no value the client never saw survives), stamping `contactConfirmedBy: "client"`. With no Person yet it finds or creates the Person for the caller's VERIFIED email and links it through `stampPersonUser`'s rule; any outcome but linked is `409 contact_unavailable`, nothing written. Only the caller's own linked Person (`{ id, workspaceId, userId }`) is ever written; `400 invalid_input` or `400 telephone_invalid` for a refused body, an 8 KiB body bound, and `{ event: "member-contact", outcome }` as its one log line. **The deletion request** is filed through `routeLead` as a `web` message lead (kind `deletion`, the caller's verified email, no idempotency key) into `resolveSiteWorkspaceId()` — the deployment's own site workspace, so a spoke caller's request reaches the hub operator (`sentTo: "hub-operator"`), never the spoke's. Every data write goes through `src/lib/member-store.ts`; every write logs only `{ event, outcome }` and a `ref` of at most 8 characters (`services/member-area.ts`'s `memberWriteLog`), never a subject, body, answer, link, note, name, email or full id. All six (and the two contact routes) are in `HUB_SESSION_ROUTES` (#8220): a spoke's deletion request reaches the hub operator, and a spoke's profile write changes the name and photo the hub's `me` answers. **The onboarding submission (#8515 D8–D10):** `POST /v1/member/onboarding` takes the request body's `{ answers }` (`plainText`, ≤ 64), checks them against the served onboarding form (every key a served field or question; a required field non-blank; `email`, https `url`, `select` options and `minLength` as the request checker applies them; a single choice one option value; a `multi` choice 1…options distinct option values, an exclusive value only alone — `400 answers_invalid` / `link_not_https`; `409 onboarding_off` with no Person or no form) and writes ONE `flow:onboarding` `Lead` — never an `Interaction` — on the caller's own Person with the Person's email, the answers, `flowSlug`, `locale` and the server-written `submittedBy` (the caller's user id, a server-owned key the Inbox never shows as an answer), so it is an Inbox submission and a `lead` Activity entry with no reader change. Under the Person's inbound lock, at most five of the caller's OWN submissions an hour (`429 too_many_requests`) — only leads carrying the caller's `submittedBy` count, so an anonymous `POST /v1/leads/submit` with that source and the client's email never fills the allowance. Both onboarding routes are in `HUB_SESSION_ROUTES`. **A proposal (#8516):** the artifacts read and the review answer carry `kind`, `price` and `payment` (`{ status: "due" }` for an approved, priced, unpaid proposal where payment runs; `{ status: "paid", paidAt }` once paid; else `null`), and a proposal's review — the same write, which is how a proposal is accepted or sent back — also writes one `people.proposal.client_review` audit row as the client (`recordWorkspaceAuditAction`). **`POST /v1/member/artifacts/:pieceId/payment`** opens the client's Stripe Checkout page for their own approved, priced, unpaid proposal and answers `{ url }` — a `checkout.stripe.com` URL only (`isStripeCheckoutUrl`, checked before anything is stored), `Cache-Control: private, no-store`. It sits behind the commerce claim (`requireCommerceClaim`, the module-disabled 404 before any read) and its own strict per-IP limiter (`proposalpay`); it is NOT on `HUB_SESSION_ROUTES`. **Same site, sandbox only:** `services/proposal-payments.ts`'s `proposalPaymentsFor` is `on-approval` only with the claim, on a one-workspace deployment whose workspace is the proposal's (the deployment's one Stripe account is provably that seller's), with a key `assertSandboxKey` admits and `FRONTEND_URL` set — and never for a call carrying a spoke key or a hub credential; anywhere else approval records acceptance only and the pay route is `404 not_found`. The session is card only, expires in an hour, carries a fixed product name and the stored amount (never a request value), and is reused while it has more than five minutes left; a concurrent second session is expired. `409 proposal_not_approved` / `409 proposal_paid` / `502 payment_unavailable`; each opened session writes one `people.proposal.checkout` audit row. No MCP twin, by decision — identity-bound client writes, recorded in `tests/parity/README.md`.
68
+ - `/v1/consent/cookie` (POST), `/v1/consent/unsubscribe` (POST), `/v1/consent/platform` (GET, POST), `/v1/member/consents` (GET, POST), `/v1/workspaces/:workspaceId/people/:personId/consents` (GET) — the consent ledger's routes (#8438, spec `docs/specs/8438-consent-registry-and-ledger-step-spec.md`). `consent_grants` is THE ledger: append-only (the database refuses any change to a stored row, and any truncation, on it and on `consent_visitor_links`), every row naming its `source`, its `policyVersion` and, when known, its Person; every writer is `src/services/consent.ts` (`recordConsent`, `recordPersonConsent`, `recordCookieChoice`, `linkConsentVisitor`) and every reader is `src/lib/consent-store.ts` (`DISTINCT ON` current-state reads, never a window). **Two readings of a Person's rows:** the OWN reading (`sign_up`, `account_settings`, `email_unsubscribe` — a verified caller or a signed link) is what the member's screens and any send read; the ANY-SOURCE reading (adds `magnet`, `api`, `cookie_banner`, and linked visitors' cookie rows) is the operator's, marked `unverified` — an anonymous form's row never changes a member's own answer. A write appends only when the answer changes the reading it is checked against, under an advisory lock on the (subject, purpose) pair. `POST /v1/consent/cookie` takes `{ visitorId, choice, locale }` and never a workspace (it comes from `resolvePublicWorkspace`); `POST /v1/consent/unsubscribe` takes a token signed with `AILK_CONSENT_LINK_SECRET` (≥ 32 bytes, optional; unset ⇒ `503 link_secret_not_configured`) and answers every defect as one `400 invalid_link`. Both are top-level, outside every module gate, in their own rate-limit scopes (`consent`, `unsubscribe`) and on `FORWARDED_VISITOR_ROUTES`. The member pair is in `HUB_SESSION_ROUTES` (24 entries since #8515); `/v1/consent/platform` is NOT — it is the deployment owner's own consent to Working Theory, a cookie session holding `platform-consents`, under `multi-tenant`. The banner's visitor id travels in `x-ailk-consent-visitor` (only a v4 UUID counts) on the lead and flow routes and the member write, linking the visitor to the Person; the header is never echoed or logged. A `consent` flow field now stores `{ [purposeId]: boolean }` and writes the ledger at completion (source `magnet`, `sourceRef` the flow slug). The operator's `…/consents` answers `{ own, anySource, history, nextCursor }` (history 500 a page, `Cache-Control: private, no-store`, never a subject id). No MCP twin for any of them, recorded in `tests/parity/README.md`.
68
69
  - `/v1/workspaces/:workspaceId/requests` (GET, `?limit=`), `/v1/workspaces/:workspaceId/people/:personId/pieces/:pieceId/versions` (POST) — the provider routes (#8171, `src/routes/workspaces/{requests,pieces}.ts` over `tenantDb(tenant).memberArea`; spec `docs/specs/8171-provider-routes-the-workspace-requests-spec.md`). **The Requests read** lists the workspace's inbound member rows (#8170's messages, quote requests and reviews), newest first, `limit` 1–50 (default 50, no cursor), behind `inbox-read` (every role) checked with `can(tenant.role, …)`. It is tiered: a role holding `people-read` (owner, admin) gets the full row — the subject, body and the kind's own fields (a message's `link`, a quote's `items`, a review's `pieceId`, `version`, `review`); every other caller, every agent key included, gets `{ id, kind, person, createdAt }` and the content is never selected. A row whose payload fails its kind's schema, or whose Person is gone, is left out and counted in one `warn`. **Deliver a version** numbers version n+1 under the piece lock (`withPieceStagesLock`), behind `member-pieces-manage` through `allows` (an agent granted it delivers); the body is exactly one of an `https` link (`isHttpsLink`, else `400 link_not_https`) and a private attachable asset of this workspace (`400 asset_not_found` / `asset_not_attachable`), plus an optional note; `version`, `deliveredBy` and `deliveredAt` are the server's (a body naming one is `400 validation_failed`); a piece with `approvedAt` set is `409 piece_approved`; a malformed, absent or foreign Person or piece is `404 not_found`. There is no round count, limit or remaining count (#8140 disposition 3). It answers `201 { version: { id, pieceId, version, file, link, note, deliveredAt } }` — the file's download name, never an asset id or `deliveredBy` — and every call that reaches the store writes one `people.version.create` audit row. Same registration, scope and tenant guard as the routes above (`workspace || multi-tenant`). No MCP twin, by decision — member-area MCP tools are a later phase of the CRM nucleus, recorded in `tests/parity/README.md`.
69
70
  - `/v1/workspaces/:workspaceId/members` (GET) — the org's member roster (`userId`, `name`, `email`, `role`), the Pro `workspace` module's Team tab (#5371). Read-only by design: granting a Membership is the invite flow, which is its own spec with a mandatory security review (§2.3), so no POST/PUT/DELETE is routed at this path. Scoped to `request.tenant.organizationId` — Membership is org-level — server-resolved from the validated session. Role floor: `members-read` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)): the roster carries every member's email, and who the other members are is not a member's job. No MCP twin — identity-bound tenant route (#3591 exception).
70
71
  - `/v1/workspaces/:workspaceId/agents` (GET `?userId=`, POST), `/v1/workspaces/:workspaceId/agents/:agentId/key` (POST), `/v1/workspaces/:workspaceId/agents/:agentId` (PATCH `{ enabled }`), `PUT /v1/workspaces/:workspaceId/agents/:agentId/grants` (`{ grants }`, #7864), and `/v1/agent/context` (GET) — the agent seat (#3591, `src/routes/workspaces/agents.ts` over `src/lib/agent-store.ts`, `// oss: false`). An agent is a stored delegate of one person, bound to one workspace. The five management routes refuse an agent caller outright (403), check `agents-manage` (owner, admin) before any read, and scope every read and write to `tenant.organizationId`, never a body or query value; an agent of another organization is the same 404 as a missing one. Create returns 201 with the key shown once; re-key returns the new key once. Each successful create, re-key, switch and grant change writes an `agents.*` audit record. **Grants (#7864, `docs/specs/7864-agent-key-grants-spec.md`).** An agent acts at `member`, plus any of seven permissions an owner or admin granted it one by one — `content-edit`, `content-publish`, `assets-write`, `magnets-manage`, `meeting-types-manage`, `people-read` since #852, and `member-pieces-manage` since #7878 (`@working-theory/validation`'s `AGENT_GRANTABLE_PERMISSIONS`, held to that set by the `agents_grants_allowed` CHECK). The grant PUT replaces the whole set (an empty list removes every grant) and refuses a grant the agent's person's own role does not hold, `409 grant_exceeds_person`. The clamp is one function both tenant guards call (`services/default-tenant.ts`'s `agentTenant`): an agent holds a stored grant only while its person's role holds it, so a demoted person's agent keeps the row but not the permission. The routes for the seven check `allows(tenant, "<permission>")`, which reads the grants; every other check keeps `can(tenant.role, …)`, so no other owner or admin permission is reachable. Every agent write on content, assets and meeting types writes one `recordAgentWrite` row naming the agent and its person (magnets already did); a person's writes there stay unaudited. The member-area writes audit every caller through `recordAuditAction`, whose actor names an agent as the agent. No grant raises the ceiling. `/v1/agent/context` is the agent's own read, unchanged by grants, and needs no permission beyond the workspace guard. Same registration scope as the routes above (`workspace || multi-tenant`).
@@ -165,6 +166,7 @@ sweep's own DB constraint (never a caller-side lock).
165
166
  - `@working-theory/stripe-client` wraps Stripe. Credentials come from customer env vars.
166
167
  - Don't assume Stripe is configured. Read at startup; if missing, disable commerce endpoints and log a warning.
167
168
  - Never log secret keys, even at debug.
169
+ - **A restricted live key can run live (#8450, D-68's apps/api leg).** `build()` admits the key once at boot through `lib/stripe.ts`'s `admitApiStripeKey()` — the one apps/api file `architecture.yaml` lets name the SDK's admission call. It admits only a restricted live key beside `STRIPE_MODE=live` on a production build (`VERCEL_ENV=production`, or `NODE_ENV=production` off Vercel); `STRIPE_MODE` unset or `test` is the old sandbox-only boot, and every other combination stops the server. At request time the member portal session and the checkout start and read call `assertAdmittedStripeKey`, which passes the admitted restricted key and still refuses an unrestricted or publishable live key; the Organization portal, the recommender checkout and request-and-confirm stay on `assertSandboxKey`, sandbox-only until their own issues. The Billing page's plan read and webhook signature checks have no route guard and go live with the process. A live process gives both checkout routes their own per-IP `checkout` rate-limit bucket (`AILK_RATE_LIMIT_CHECKOUT_PER_MINUTE`). The webhook drops, with a 200 and a `livemode_mismatch` warn, any signed event whose `livemode` is not the admitted key class (`stripeKeyMode()`), before any write and without marking it seen. **The proposal paid stamp (#8516 D13):** a `checkout.session.completed` carrying the server-written `metadata.ailk_proposal` and `ailk_workspace` stamps that proposal's `paid_at` and `paid_session_id` once through the base-tree boundary `lib/proposal-paid-store.ts` (the webhook may import no slice-owned file), and writes one `people.proposal.paid` audit row as the `stripe` system actor keyed by the event id — `succeeded`, or `failed` with `already_paid` (a second charge) or `not_paid`; a same-session replay writes none. Proposal payment stays on `assertSandboxKey` until its own issue moves it live.
168
170
 
169
171
  ## What NOT to do
170
172
 
@@ -175,7 +177,15 @@ sweep's own DB constraint (never a caller-side lock).
175
177
  - Don't put session state in `apps/api`. It's stateless.
176
178
  - Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
177
179
 
178
- Last updated: 2026-10-11 — the spoke member admission (#8220): the hub-sign-in bullet names `HUB_SESSION_ROUTES`' 18 entries by group, the per-(person, spoke) budget, the `spokeId` log binding, the per-IP residual and the spoke's cross-site-request obligation; the #8169 and #8170 bullets say their routes are admitted, and the #8169 bullet states the one-owner-per-organization precondition — verified against `apps/api/src/middleware/auth.ts`, `apps/api/src/lib/member-store.ts`, `apps/api/src/routes/member/billing.ts` and `apps/api/src/__tests__/member-isolation.test.ts`.
180
+ Last updated: 2026-10-14 — the standard client onboarding diagnostic (#8515): the magnets bullet names the standard onboarding seed and its two callers; the flow-definition seed refuses the `onboarding` slug; the member-area bullet names Start onboarding and the outbound action; the member reads and writes bullets name the onboarding pair and the message `action`; `HUB_SESSION_ROUTES` is 24 entries — verified against `apps/api/src/lib/{tenant-db,member-store}.ts`, `apps/api/src/routes/{member/onboarding,workspaces/messages-out,workspaces/index}.ts`, `apps/api/src/services/flow-engine.ts`, `apps/api/src/bin/seed-presets.ts` and `apps/api/src/middleware/auth.ts`.
181
+
182
+ Prior: 2026-10-14 — proposals as an artifact kind (#8516): the member-area bullet names the piece `kind`, the proposals pair, its two MCP twins and the money gate (C4); the member-writes bullet names a proposal's artifact keys, the client review audit row and the pay route with its same-site and sandbox rule; the BYO Stripe bullet names the webhook's proposal stamp — verified against `apps/api/src/routes/workspaces/pieces.ts`, `apps/api/src/routes/member/{artifacts,artifact-review}.ts`, `apps/api/src/services/proposal-payments.ts`, `apps/api/src/lib/{tenant-db,member-store,proposal-paid-store}.ts` and `apps/api/src/routes/webhooks/stripe.ts`.
183
+
184
+ Prior: 2026-10-13 — the Person record redesign (#8514): the People bullet names members on the three client rows, the agent-withheld rule and its fail-closed marker, and the private notes pair with its two MCP twins; the member-area bullet's floors name members — verified against `packages/validation/src/schemas/permissions.ts`, `apps/api/src/services/default-tenant.ts`, `apps/api/src/middleware/{tenant,default-tenant}.ts`, `apps/api/src/routes/workspaces/{people,plan}.ts` and `apps/api/src/lib/tenant-db.ts`. Before that, 2026-10-13 — the contact details on the client record (#8435): the People bullet names the contact columns, the capture fill rule, the operator PATCH and the subscriber/callback links; the member-writes bullet names the two contact routes; `HUB_SESSION_ROUTES` is 20 entries. Before that, 2026-10-11 — the spoke member admission (#8220): the hub-sign-in bullet names `HUB_SESSION_ROUTES`' 18 entries by group, the per-(person, spoke) budget, the `spokeId` log binding, the per-IP residual and the spoke's cross-site-request obligation; the #8169 and #8170 bullets say their routes are admitted, and the #8169 bullet states the one-owner-per-organization precondition — verified against `apps/api/src/middleware/auth.ts`, `apps/api/src/lib/member-store.ts`, `apps/api/src/routes/member/billing.ts` and `apps/api/src/__tests__/member-isolation.test.ts`.
185
+
186
+ Prior: 2026-10-12 — the hub's restricted live Stripe key (#8450): the BYO Stripe bullet on the boot admission, the admitted-key guard on the portal session and the checkout start and read, the three routes that stay sandbox-only, the live-only checkout bucket and the webhook's livemode drop — verified against `apps/api/src/lib/stripe.ts`, `apps/api/src/server.ts`, `apps/api/src/routes/{subscription/portal,checkout/sessions,checkout/session,webhooks/stripe}.ts` and `architecture.yaml`.
187
+
188
+ Prior: 2026-10-11 — the spoke member admission (#8220): the hub-sign-in bullet names `HUB_SESSION_ROUTES`' 18 entries by group, the per-(person, spoke) budget, the `spokeId` log binding, the per-IP residual and the spoke's cross-site-request obligation; the #8169 and #8170 bullets say their routes are admitted, and the #8169 bullet states the one-owner-per-organization precondition — verified against `apps/api/src/middleware/auth.ts`, `apps/api/src/lib/member-store.ts`, `apps/api/src/routes/member/billing.ts` and `apps/api/src/__tests__/member-isolation.test.ts`.
179
189
 
180
190
  Prior: 2026-10-11 — the provider routes (#8171): added the bullet for the Requests read and Deliver a version, and extended the People (`request` and `version` timeline kinds), meetings (`customerSlugs`), magnets (`customersEnabled`), deliverables and asset-store (a version's file) bullets and added a `/v1/subscription/me` note (`?include=billing`) — verified against `apps/api/src/routes/workspaces/{requests,pieces,meetings}.ts`, `apps/api/src/routes/magnets/index.ts`, `apps/api/src/routes/subscription/me.ts`, `apps/api/src/services/subscription.ts` and `apps/api/src/lib/{tenant-db,asset-store,deliverable-asset-store,stripe}.ts`.
181
191
 
@@ -17,8 +17,9 @@
17
17
  "seed:presets": "tsx src/bin/seed-presets.ts",
18
18
  "spokes": "tsx src/bin/spokes.ts",
19
19
  "flows:copy": "tsx src/bin/flows-copy.ts",
20
+ "rate-limit:probe": "tsx src/bin/rate-limit-probe.ts",
20
21
  "build": "tsc",
21
- "build:vercel": "cd ../../database && pnpm exec prisma generate && cd ../.. && pnpm --filter @working-theory/validation build && pnpm --filter @working-theory/api build && mkdir -p apps/api/public && echo '{}' > apps/api/public/.vercel",
22
+ "build:vercel": "cd ../../database && pnpm exec prisma generate && cd .. && if [ -d packages/validation ]; then pnpm --filter @working-theory/validation build; fi && pnpm --filter @working-theory/api build && mkdir -p apps/api/public && echo '{}' > apps/api/public/.vercel",
22
23
  "start": "node dist/cli.js",
23
24
  "smoke:dev": "node -e \"const{spawn}=require('child_process');const http=require('http');const PORT=process.env.PORT||'3002';const SLUG=process.env.SMOKE_FLOW_SLUG||'smoke-check';const child=spawn('pnpm',['exec','tsx','watch','src/cli.ts'],{env:process.env,stdio:'ignore'});let done=false;function finish(code){if(done)return;done=true;try{child.kill();}catch(e){}process.exit(code);}function poll(attempt){if(attempt>40){console.error('smoke:dev - timeout waiting for server');finish(1);return;}const req=http.get({host:'127.0.0.1',port:PORT,path:'/v1/flows/'+SLUG},(res)=>{console.log('smoke:dev - GET /v1/flows/'+SLUG+' -> HTTP '+res.statusCode);res.resume();finish(0);});req.on('error',()=>{setTimeout(()=>poll(attempt+1),500);});}child.on('exit',(code)=>{if(!done){console.error('smoke:dev - child exited early with code '+code);finish(1);}});setTimeout(()=>poll(0),500);\"",
24
25
  "smoke:build": "node -e \"const{spawn}=require('child_process');const http=require('http');const PORT=process.env.PORT||'3002';const SLUG=process.env.SMOKE_FLOW_SLUG||'smoke-check';const child=spawn('node',['dist/cli.js'],{env:process.env,stdio:'ignore'});let done=false;function finish(code){if(done)return;done=true;try{child.kill();}catch(e){}process.exit(code);}function poll(attempt){if(attempt>40){console.error('smoke:build - timeout waiting for server');finish(1);return;}const req=http.get({host:'127.0.0.1',port:PORT,path:'/v1/flows/'+SLUG},(res)=>{console.log('smoke:build - GET /v1/flows/'+SLUG+' -> HTTP '+res.statusCode);res.resume();finish(0);});req.on('error',()=>{setTimeout(()=>poll(attempt+1),500);});}child.on('exit',(code)=>{if(!done){console.error('smoke:build - child exited early with code '+code);finish(1);}});setTimeout(()=>poll(0),500);\"",
@@ -15,7 +15,9 @@
15
15
  * declared compound keys the way Postgres does (NULLs distinct): `createMany`
16
16
  * with `skipDuplicates` skips a conflicting row (`ON CONFLICT DO NOTHING`),
17
17
  * and a `create`, a `createMany` without it, or an `updateMany` that would
18
- * give two rows one key throws `{ code: "P2002" }`.
18
+ * give two rows one key throws `{ code: "P2002" }`. `flowDefinition` declares
19
+ * its `(siteId, slug)` key (#8515); a suite that pushes rows onto `__db`
20
+ * directly never reaches the check.
19
21
  *
20
22
  * Member area (#7877 D17). `piece`, `interaction` and `workspacePieceStages`
21
23
  * are ordinary delegates here — `where` on `workspaceId`, `personId`, `id`,
@@ -79,15 +81,52 @@ export function createInMemoryPrisma(): { prisma: Record<string, unknown> } {
79
81
  ["workspaceId", "userId"],
80
82
  ],
81
83
  workspacePieceStages: [["workspaceId"]],
84
+ // #8515 D2 — `@@unique([siteId, slug])`, so the onboarding seed's
85
+ // `createMany({ skipDuplicates })` is insert-only here as in Postgres.
86
+ flowDefinition: [["siteId", "slug"]],
82
87
  };
83
88
  /** Column defaults a create omits, as the schema declares them. */
84
89
  const DEFAULTS: Record<string, Row> = {
85
- piece: { needsClient: false },
90
+ // #8435 — a created Person carries every nullable column the way Postgres
91
+ // would read it back: `null`, not absent. The contact fill reads `null` as
92
+ // "blank" and `undefined` as "not mine to touch".
93
+ person: {
94
+ userId: null,
95
+ givenName: null,
96
+ familyName: null,
97
+ telephone: null,
98
+ addressLine1: null,
99
+ addressLine2: null,
100
+ addressCity: null,
101
+ addressRegion: null,
102
+ addressPostalCode: null,
103
+ addressCountry: null,
104
+ companyName: null,
105
+ jobTitle: null,
106
+ contactConfirmedAt: null,
107
+ contactConfirmedBy: null,
108
+ },
109
+ // #8516 D1 — the column default and the seven nullable proposal columns.
110
+ piece: {
111
+ needsClient: false,
112
+ kind: "deliverable",
113
+ approvedAt: null,
114
+ priceAmount: null,
115
+ priceCurrency: null,
116
+ checkoutSessionId: null,
117
+ checkoutUrl: null,
118
+ checkoutExpiresAt: null,
119
+ paidAt: null,
120
+ paidSessionId: null,
121
+ },
86
122
  interaction: { assetId: null, readAt: null },
87
123
  };
88
124
  /** A model whose registry prefix is not its delegate name (`packages/ids`). */
89
125
  const ID_PREFIX: Record<string, string> = {
90
126
  interaction: "intx",
127
+ // #8515 — a created workspace is a `wksp_` id, so the onboarding seed's
128
+ // id check accepts it.
129
+ workspace: "wksp",
91
130
  workspacePieceStages: "pstages",
92
131
  };
93
132
  /** Whether `row` would share a declared unique key with another row. */
@@ -7,6 +7,13 @@
7
7
  process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgresql://test:test@localhost:5432/test';
8
8
  process.env.DATABASE_URL_NON_POOLED = process.env.DATABASE_URL_NON_POOLED || process.env.DATABASE_URL;
9
9
 
10
+ // Rate limiter (#5040 D4): every suite always sees a DATABASE_URL (above,
11
+ // real or placeholder), which would otherwise send every rate-limit hook
12
+ // into the mocked $queryRaw that other suites arm and assert on. Tests that
13
+ // need the database store pass `store` explicitly, or set this inside
14
+ // jest.isolateModules.
15
+ process.env.AILK_RATE_LIMIT_STORE = process.env.AILK_RATE_LIMIT_STORE || 'memory';
16
+
10
17
  // Auth — Better Auth (post-#587). NEXTAUTH_* removed.
11
18
  process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET || 'test-secret-key';
12
19
  process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret-min-32-characters-required!!';