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
@@ -57,6 +57,8 @@ import { logger } from "@working-theory/observability";
57
57
  import { isModuleEnabled } from "../config/modules.js";
58
58
  import { prisma } from "../lib/prisma.js";
59
59
 
60
+ import { findOrCreatePerson, normalizeTelephone } from "./person.js";
61
+
60
62
  // ─── The credential (spec D5) ──────────────────────────────────────────────
61
63
 
62
64
  /**
@@ -580,6 +582,56 @@ export async function recordPaymentLink(
580
582
  where: { id },
581
583
  data: { stripePaymentLinkId: paymentLinkId },
582
584
  });
585
+ // #8435 D5 — only now, with the Payment Link recorded: the Person step can
586
+ // never strand a spent token without its link (SEC-8435-G).
587
+ await linkConfirmedCallbackPerson(id);
588
+ }
589
+
590
+ /**
591
+ * Link a confirmed callback to its Person (#8435 D5): when the capture did not
592
+ * (`personId` null — no `payload.email`) and the tenant typed a buyer email on
593
+ * the confirm form, find or create that email's Person in the callback's own
594
+ * workspace and stamp it, guarded on `personId: null` so a link a capture
595
+ * already made is never replaced. Best-effort like every Person step: it never
596
+ * throws and never changes `recordPaymentLink`'s outcome. Logs the outcome
597
+ * kind only.
598
+ */
599
+ async function linkConfirmedCallbackPerson(id: string): Promise<void> {
600
+ let outcome: "linked" | "skipped" | "failed";
601
+ try {
602
+ // fence: global-key — `Callback.id`, the row `recordPaymentLink` just
603
+ // wrote. The Person it names is found or created in THIS row's own
604
+ // `workspaceId`, so the link can only ever name a Person of that workspace.
605
+ const callback = await prisma.callback.findFirst({
606
+ where: { id },
607
+ select: { workspaceId: true, buyerEmail: true, name: true, phone: true, personId: true },
608
+ });
609
+ if (!callback || callback.personId !== null || callback.buyerEmail === null) {
610
+ outcome = "skipped";
611
+ } else {
612
+ const telephone = normalizeTelephone(callback.phone);
613
+ const person = await findOrCreatePerson(callback.workspaceId, {
614
+ email: callback.buyerEmail,
615
+ name: callback.name,
616
+ contact: telephone === null ? {} : { telephone },
617
+ });
618
+ if (!person.ok) {
619
+ outcome = person.error.kind === "invalid_email" ? "skipped" : "failed";
620
+ } else {
621
+ // fence: global-key — same `Callback.id`; `workspaceId` is the row's
622
+ // own, and `personId: null` keeps a capture's link.
623
+ await prisma.callback.updateMany({
624
+ where: { id, workspaceId: callback.workspaceId, personId: null },
625
+ data: { personId: person.value.personId },
626
+ });
627
+ outcome = "linked";
628
+ }
629
+ }
630
+ } catch {
631
+ outcome = "failed";
632
+ }
633
+ if (outcome === "failed") logger.error({ event: "callback-person", outcome }, "callback person failed");
634
+ else logger.info({ event: "callback-person", outcome }, "callback person");
583
635
  }
584
636
 
585
637
  /** What the buyer is told, once the tenant has confirmed. */
@@ -94,9 +94,18 @@ export interface RevalidateTarget {
94
94
 
95
95
  export interface RevalidateOptions {
96
96
  event: "publish" | "unpublish";
97
+ /**
98
+ * Waits out one backoff. A test seam (#8418): the publish routes never pass
99
+ * it, so production always waits on the real timer. A test passes one that
100
+ * records the delay and resolves at once, so the retry schedule is asserted
101
+ * as scheduled rather than measured against a loaded runner's clock.
102
+ *
103
+ * @internal
104
+ */
105
+ sleep?: (ms: number) => Promise<void>;
97
106
  }
98
107
 
99
- function sleep(ms: number): Promise<void> {
108
+ function realSleep(ms: number): Promise<void> {
100
109
  return new Promise((resolve) => setTimeout(resolve, ms));
101
110
  }
102
111
 
@@ -125,6 +134,7 @@ export async function notifyRevalidate(
125
134
  locale: target.locale,
126
135
  });
127
136
 
137
+ const { sleep = realSleep } = options;
128
138
  const url = config.CONTENT_REVALIDATE_URL;
129
139
  const secret = config.CONTENT_REVALIDATE_SECRET;
130
140
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 2,
3
- "ignoreCommand": "[ -n \"$VERCEL_GIT_PREVIOUS_SHA\" ] && cd ../.. && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- apps/api packages database pnpm-lock.yaml || exit 1",
3
+ "ignoreCommand": "[ -n \"$VERCEL_GIT_PREVIOUS_SHA\" ] && cd ../.. && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- apps/api packages database pnpm-lock.yaml package.json pnpm-workspace.yaml turbo.json 'tsconfig*.json' || exit 1",
4
4
  "buildCommand": "cd ../.. && pnpm run --filter @working-theory/api build:vercel",
5
5
  "installCommand": "cd ../.. && pnpm install --frozen-lockfile",
6
6
  "outputDirectory": "public",
@@ -83,7 +83,7 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
83
83
 
84
84
  - **20 in the `TOOLS` array / parity test**: the `TOOLS` array in
85
85
  `packages/metadata/src/mcp-tool-catalog.ts` defines 20 anonymous tools; the
86
- parity test counts that full array. It also holds the eleven signed-in
86
+ parity test counts that full array. It also holds the fifteen signed-in
87
87
  workspace tools (#3591), which no anonymous view returns — see § The
88
88
  signed-in workspace surface below.
89
89
  - **19 registered on a KEYLESS deployment, 20 on a commerce-claimed one**
@@ -126,16 +126,24 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
126
126
  - **apps/mcp checks only the key's shape.** The key is attached as the SDK's `req.auth` for that
127
127
  request alone and reaches a tool as `extra.authInfo.token`. apps/api is the one place it is
128
128
  checked, and its tenant guard and the caller's clamped role decide every answer.
129
- - **Eleven tools, each a `twin`.** The catalog rows carry `surface: "workspace"`, `tier: "pro"`
129
+ - **Fifteen tools, each a `twin`.** The catalog rows carry `surface: "workspace"`, `tier: "pro"`
130
130
  and `modulesAnyOf` (the modules any one of which registers the tool — its twin route's gate):
131
- `get_agent_context`, the four message tools, the three submission tools and the three
132
- application tools. Each takes its route's params, query and body and returns the route's
131
+ `get_agent_context`, the four message tools, the three submission tools, the three
132
+ application tools and (#8514) the two People notes tools, `get_person_notes` and
133
+ `set_person_notes` — the team's private note on one client, which an agent reads and writes
134
+ only with the `member-pieces-manage` grant; `set_person_notes` requires the `ifUpdatedAt` it
135
+ read and is refused `notes_changed` when someone saved since — and (#8516) the two proposals
136
+ tools, `list_proposals` and `create_proposal`, twinning `GET`/`POST
137
+ /v1/workspaces/:workspaceId/people/:personId/proposals` with the same grant: a proposal is
138
+ private to one client, `payments` says whether approval leads to a Stripe page, and an agent's
139
+ price is refused `proposal_needs_person` (a person prices). Each takes its route's params, query and body and returns the route's
133
140
  `data`. They are in the parity guard like every other tool, and never in
134
141
  `/.well-known/mcp.json`.
135
142
  - **Handlers forward, nothing else.** `workspace-server.ts` resolves each tool to a paid-slice
136
143
  handler file (`tools/agent_context.ts`, `inbox_messages.ts`, `inbox_submissions.ts`,
137
- `inbox_applications.ts`, all `// oss: false`) through a specifier table, as `server.ts` does.
138
- Each calls `@working-theory/api-client`'s `inbox` surface with the key. A refusal from the
144
+ `inbox_applications.ts`, `person_notes.ts`, `proposals.ts`, all `// oss: false`) through a
145
+ specifier table, as `server.ts` does. Each calls `@working-theory/api-client`'s `inbox` surface
146
+ (the notes and proposals tools, its `people` surface) with the key. A refusal from the
139
147
  route is an MCP tool error naming the route's `error.code` and HTTP status only.
140
148
  - **Never log or return the key.** Don't log or serialise `extra`, `extra.authInfo` or
141
149
  `extra.requestInfo` — the SDK puts the raw `authorization` header there.
@@ -211,7 +219,11 @@ Example (lead routing):
211
219
  - Don't add a tool without the matching `apps/api` endpoint.
212
220
  - Don't put business logic in tool handlers — they're adapters.
213
221
 
214
- Last updated: 2026-10-10 — #8071: `create_booking` and `submit_flow_step` join the HTTP write branch — § The free anonymous tools names its six members, the Booking and Flow rows record it, and § What a spoke's MCP host on Vercel enforces lists the two new daily caps — verified against `apps/mcp/src/http-transport.ts` and `apps/mcp/src/abuse/index.ts`.
222
+ Last updated: 2026-10-14 — #8516: the signed-in workspace surface is fifteen tools — `list_proposals` and `create_proposal` (`tools/proposals.ts`) twin `GET`/`POST /v1/workspaces/:workspaceId/people/:personId/proposals` — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `apps/mcp/src/workspace-server.ts` and `tests/parity/api-parity/kinds/site.ts`.
223
+
224
+ Prior: 2026-10-13 — #8514: the signed-in workspace surface is thirteen tools — `get_person_notes` and `set_person_notes` (`tools/person_notes.ts`) twin `GET`/`PUT /v1/workspaces/:workspaceId/people/:personId/notes` — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `apps/mcp/src/workspace-server.ts` and `tests/parity/api-parity/kinds/site.ts`.
225
+
226
+ Prior: 2026-10-10 — #8071: `create_booking` and `submit_flow_step` join the HTTP write branch — § The free anonymous tools names its six members, the Booking and Flow rows record it, and § What a spoke's MCP host on Vercel enforces lists the two new daily caps — verified against `apps/mcp/src/http-transport.ts` and `apps/mcp/src/abuse/index.ts`.
215
227
 
216
228
  Prior: 2026-10-10 — #8070 (amendment 2): the Vercel entry exports a handler, no `vercel.json`; § Two entrypoints, one boot — the Vercel entry `src/app.ts`, the shared `src/boot.ts`, `MCP_HTTP_STATELESS`, `mcpContentRoot()`, `create_project` withheld on a spoke, and what a spoke's MCP host enforces — verified against `apps/mcp/src/{app,boot,serverless-app,index,http-transport,server}.ts` and `lib/content-adapter.ts`.
217
229
 
@@ -41,8 +41,29 @@ import {
41
41
  startMcpClaimRefresh,
42
42
  type McpCommerceClaim,
43
43
  } from "../src/lib/commerce-claim.js";
44
+ import { UNSERVED_MCP_TOOLS } from "../src/lib/topology.generated.js";
44
45
  import { createMcpServer, listRegisteredTools } from "../src/server.js";
45
46
 
47
+ // The topology seam's UNSERVED_MCP_TOOLS (#8070 D7), made switchable the way
48
+ // server.test.ts's AC-8b suite does: a getter returning an override when one
49
+ // is set, else the real module's value, so the 16-state loop below reads
50
+ // this scaffold's own seam ([] in the monorepo and a hub, ["create_project"]
51
+ // in a spoke) and the #8513 D2 describe can force either state regardless.
52
+ const mockSeam: { unserved: readonly string[] | undefined } = {
53
+ unserved: undefined,
54
+ };
55
+ // eslint-disable-next-line no-restricted-syntax -- the generated topology seam IS the boundary under test (#8513 D2); every other export passes through from the real module
56
+ jest.mock("../src/lib/topology.generated.js", () => {
57
+ const actual: { UNSERVED_MCP_TOOLS: readonly string[] } =
58
+ jest.requireActual("../src/lib/topology.generated.js");
59
+ const mocked = { ...actual };
60
+ Object.defineProperty(mocked, "UNSERVED_MCP_TOOLS", {
61
+ enumerable: true,
62
+ get: () => mockSeam.unserved ?? actual.UNSERVED_MCP_TOOLS,
63
+ });
64
+ return mocked;
65
+ });
66
+
46
67
  /** The injected licence reader — same seam `commerce-claim.test.ts` types. */
47
68
  type CheckLicenseSeam = NonNullable<
48
69
  NonNullable<Parameters<typeof initMcpCommerceClaim>[0]>["checkLicense"]
@@ -125,6 +146,17 @@ function catalogOptionsFor(
125
146
  };
126
147
  }
127
148
 
149
+ /**
150
+ * The catalog this topology actually serves — the full catalog minus the
151
+ * seam's own UNSERVED_MCP_TOOLS, read at use (never destructured at module
152
+ * load) so a mocked override above is seen (#8513 D2, C3).
153
+ */
154
+ function servedCatalog(options: McpCatalogOptions): McpToolCatalogEntry[] {
155
+ return getMcpToolCatalog(options).filter(
156
+ (t) => !UNSERVED_MCP_TOOLS.includes(t.name),
157
+ );
158
+ }
159
+
128
160
  describe.each(CLAIM_STATES)(
129
161
  "MCP catalog ↔ server drift — commerce %s, mode %s, held %j",
130
162
  (claim, mode, held) => {
@@ -150,18 +182,18 @@ describe.each(CLAIM_STATES)(
150
182
  }
151
183
  });
152
184
 
153
- it("registered tool names equal catalog tool names", () => {
185
+ it("registered tool names equal the catalog minus this topology's unserved tools", () => {
154
186
  const registered = registeredToolsOf(createMcpServer());
155
187
  const registeredNames = Object.keys(registered).sort();
156
- const catalogNames = getMcpToolCatalog(options)
188
+ const catalogNames = servedCatalog(options)
157
189
  .map((t) => t.name)
158
190
  .sort();
159
191
  expect(registeredNames).toEqual(catalogNames);
160
192
  });
161
193
 
162
- it("registered tool descriptions equal catalog descriptions", () => {
194
+ it("registered tool descriptions equal the served catalog's descriptions", () => {
163
195
  const registered = registeredToolsOf(createMcpServer());
164
- for (const entry of getMcpToolCatalog(options)) {
196
+ for (const entry of servedCatalog(options)) {
165
197
  expect(registered[entry.name]).toBeDefined();
166
198
  expect(registered[entry.name]!.description).toBe(entry.description);
167
199
  }
@@ -193,6 +225,57 @@ describe.each(CLAIM_STATES)(
193
225
  },
194
226
  );
195
227
 
228
+ describe("MCP catalog ↔ server drift — UNSERVED_MCP_TOOLS subtracts from the served catalog (#8513 D2, AC-2)", () => {
229
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
230
+
231
+ beforeEach(() => {
232
+ process.env.AILK_ENABLED_MODULES = PAID_MODULES;
233
+ });
234
+
235
+ afterEach(() => {
236
+ mockSeam.unserved = undefined;
237
+ });
238
+
239
+ afterAll(() => {
240
+ if (priorEnabledModules === undefined) {
241
+ delete process.env.AILK_ENABLED_MODULES;
242
+ } else {
243
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
244
+ }
245
+ });
246
+
247
+ it("a spoke's seam (['create_project']) withholds it from registration and the served catalog", () => {
248
+ mockSeam.unserved = ["create_project"];
249
+ const options = catalogOptionsFor("unclaimed", "warn", []);
250
+
251
+ const registeredNames = Object.keys(
252
+ registeredToolsOf(createMcpServer()),
253
+ ).sort();
254
+ const servedNames = servedCatalog(options)
255
+ .map((t) => t.name)
256
+ .sort();
257
+
258
+ expect(registeredNames).toEqual(servedNames);
259
+ expect(registeredNames).not.toContain("create_project");
260
+ expect(servedNames).not.toContain("create_project");
261
+ });
262
+
263
+ it("a hub's seam ([]) registers the full catalog, create_project included", () => {
264
+ mockSeam.unserved = [];
265
+ const options = catalogOptionsFor("unclaimed", "warn", []);
266
+
267
+ const registeredNames = Object.keys(
268
+ registeredToolsOf(createMcpServer()),
269
+ ).sort();
270
+ const fullCatalogNames = getMcpToolCatalog(options)
271
+ .map((t) => t.name)
272
+ .sort();
273
+
274
+ expect(registeredNames).toEqual(fullCatalogNames);
275
+ expect(registeredNames).toContain("create_project");
276
+ });
277
+ });
278
+
196
279
  describe("MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1)", () => {
197
280
  const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
198
281
 
@@ -227,11 +310,11 @@ describe("MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1)"
227
310
  };
228
311
  const bootNames = Object.keys(registeredToolsOf(createMcpServer())).sort();
229
312
  expect(bootNames).toEqual(
230
- getMcpToolCatalog(bootOptions)
313
+ servedCatalog(bootOptions)
231
314
  .map((t) => t.name)
232
315
  .sort(),
233
316
  );
234
- expect(bootNames).toHaveLength(20);
317
+ expect(bootNames).toHaveLength(20 - UNSERVED_MCP_TOOLS.length);
235
318
 
236
319
  startMcpClaimRefresh({ checkLicense: resolving({ features: [] }) });
237
320
  await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
@@ -246,14 +329,18 @@ describe("MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1)"
246
329
  .sort();
247
330
  expect(catalogNames).toHaveLength(14);
248
331
 
332
+ const servedNames = servedCatalog(refreshedOptions)
333
+ .map((t) => t.name)
334
+ .sort();
335
+
249
336
  const serverNames = Object.keys(
250
337
  registeredToolsOf(createMcpServer()),
251
338
  ).sort();
252
- expect(serverNames).toEqual(catalogNames);
339
+ expect(serverNames).toEqual(servedNames);
253
340
 
254
341
  const listedNames = listRegisteredTools()
255
342
  .map((t) => t.name)
256
343
  .sort();
257
- expect(listedNames).toEqual(catalogNames);
344
+ expect(listedNames).toEqual(servedNames);
258
345
  });
259
346
  });
@@ -0,0 +1,25 @@
1
+ // oss: false
2
+ /**
3
+ * The signed-in workspace surface's People notes tools (#8514 D8) — the twins
4
+ * of `apps/api/src/routes/workspaces/people.ts`'s notes pair:
5
+ *
6
+ * get_person_notes GET /v1/workspaces/:workspaceId/people/:personId/notes
7
+ * set_person_notes PUT /v1/workspaces/:workspaceId/people/:personId/notes
8
+ *
9
+ * Each takes the route's params and body (the SDK has already parsed them
10
+ * against the catalog's input shape, which is the route's own schemas),
11
+ * forwards the caller's agent key through `@working-theory/api-client`, and
12
+ * returns the route's answer untouched. apps/api checks the key, the grant and
13
+ * the workspace; this file only carries the key, and never logs or returns it.
14
+ */
15
+
16
+ import { people, type PersonRef } from "@working-theory/api-client";
17
+ import type { PersonNotesPutInput } from "@working-theory/validation";
18
+
19
+ export function ailkGetPersonNotes(key: string, input: PersonRef) {
20
+ return people.notes(key, input);
21
+ }
22
+
23
+ export function ailkSetPersonNotes(key: string, input: PersonRef & PersonNotesPutInput) {
24
+ return people.setNotes(key, input);
25
+ }
@@ -0,0 +1,27 @@
1
+ // oss: false
2
+ /**
3
+ * The signed-in workspace surface's proposals tools (#8516 D7) — the twins of
4
+ * `apps/api/src/routes/workspaces/pieces.ts`'s proposals pair:
5
+ *
6
+ * list_proposals GET /v1/workspaces/:workspaceId/people/:personId/proposals
7
+ * create_proposal POST /v1/workspaces/:workspaceId/people/:personId/proposals
8
+ *
9
+ * Each takes the route's params and body (the SDK has already parsed them
10
+ * against the catalog's input shape, which is the route's own schemas),
11
+ * forwards the caller's agent key through `@working-theory/api-client`, and
12
+ * returns the route's answer untouched. apps/api checks the key, the grant,
13
+ * the workspace and the money gate (an agent's price is `403
14
+ * proposal_needs_person`); this file only carries the key, and never logs or
15
+ * returns it.
16
+ */
17
+
18
+ import { people, type PersonRef } from "@working-theory/api-client";
19
+ import type { ProposalCreateBody } from "@working-theory/validation";
20
+
21
+ export function ailkListProposals(key: string, input: PersonRef) {
22
+ return people.proposals(key, input);
23
+ }
24
+
25
+ export function ailkCreateProposal(key: string, input: PersonRef & ProposalCreateBody) {
26
+ return people.createProposal(key, input);
27
+ }
@@ -95,6 +95,22 @@ const WORKSPACE_TOOL_ENTRIES: Record<
95
95
  specifier: "./tools/inbox_applications.js",
96
96
  exportName: "ailkDecideInboxApplication",
97
97
  },
98
+ get_person_notes: {
99
+ specifier: "./tools/person_notes.js",
100
+ exportName: "ailkGetPersonNotes",
101
+ },
102
+ set_person_notes: {
103
+ specifier: "./tools/person_notes.js",
104
+ exportName: "ailkSetPersonNotes",
105
+ },
106
+ list_proposals: {
107
+ specifier: "./tools/proposals.js",
108
+ exportName: "ailkListProposals",
109
+ },
110
+ create_proposal: {
111
+ specifier: "./tools/proposals.js",
112
+ exportName: "ailkCreateProposal",
113
+ },
98
114
  };
99
115
 
100
116
  async function resolveWorkspaceHandler(
@@ -0,0 +1,6 @@
1
+ # Next.js build output
2
+ .next/
3
+ next-env.d.ts
4
+
5
+ # Test coverage
6
+ coverage/
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Tests for SignUpCard's sign-up agreements (#8438 D13, AC-3).
3
+ *
4
+ * Every submit — the password form and each OAuth provider — is gated by
5
+ * the site's `signUp` agreements, rendered as a `ConsentGroup` below
6
+ * `AuthCard`. Nothing happens on any entry point until every required
7
+ * agreement is ticked. The password path also mints the carried-agreement
8
+ * cookie (`rememberSignUpAgreementsAction`) before calling Better Auth.
9
+ */
10
+ import { render, screen } from "@testing-library/react";
11
+ import userEvent from "@testing-library/user-event";
12
+
13
+ const mockSignUpEmail = jest.fn();
14
+ const mockSignInSocial = jest.fn();
15
+ jest.mock("better-auth/react", () => ({
16
+ __esModule: true,
17
+ createAuthClient: () => ({
18
+ signUp: { email: (...args: unknown[]) => mockSignUpEmail(...args) },
19
+ signIn: { social: (...args: unknown[]) => mockSignInSocial(...args) },
20
+ }),
21
+ }));
22
+
23
+ const mockRememberAgreements = jest.fn();
24
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the card only passes it through.
25
+ jest.mock("../sign-up.actions", () => ({
26
+ __esModule: true,
27
+ rememberSignUpAgreementsAction: (...args: unknown[]) => mockRememberAgreements(...args),
28
+ }));
29
+
30
+ import { SignUpCard, type SignUpCardProps } from "../sign-up-card";
31
+
32
+ const AGREEMENT = {
33
+ id: "account_terms",
34
+ required: true,
35
+ statement: "You agree to our Terms and Privacy Policy.",
36
+ };
37
+
38
+ function props(overrides: Partial<SignUpCardProps> = {}): SignUpCardProps {
39
+ return {
40
+ providers: ["google"],
41
+ method: "password",
42
+ next: "/account",
43
+ signInHref: "/sign-in",
44
+ labels: {
45
+ signUpHeading: "Create your account",
46
+ signUpSubhead: "",
47
+ signUpSubmit: "Sign up",
48
+ signUpFooterPrompt: "Already have an account?",
49
+ signUpFooterLink: "Sign in",
50
+ ssoPrefix: "Continue with",
51
+ google: "Google",
52
+ github: "GitHub",
53
+ email: "Email",
54
+ password: "Password",
55
+ orContinue: "or continue with",
56
+ },
57
+ notConfiguredSubhead: "Sign-up is not configured.",
58
+ errorLabels: { signUpFailed: "Sign-up failed.", providerFailed: "Provider failed." },
59
+ agreements: [AGREEMENT],
60
+ agreementPolicies: { terms: "/terms", privacy: "/privacy" },
61
+ agreementsLegend: "Agreements",
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ async function fillAndSubmit(): Promise<void> {
67
+ const user = userEvent.setup();
68
+ await user.type(screen.getByTestId("auth-email"), "ada@example.com");
69
+ await user.type(screen.getByTestId("auth-password"), "correct horse battery staple");
70
+ await user.click(screen.getByTestId("auth-submit"));
71
+ }
72
+
73
+ async function tick(purposeId: string): Promise<void> {
74
+ const user = userEvent.setup();
75
+ await user.click(screen.getByTestId(`consent-${purposeId}`));
76
+ }
77
+
78
+ beforeEach(() => {
79
+ mockSignUpEmail.mockReset().mockResolvedValue({ data: { status: true }, error: null });
80
+ mockSignInSocial.mockReset().mockResolvedValue({ data: { status: true }, error: null });
81
+ mockRememberAgreements.mockReset().mockResolvedValue(undefined);
82
+ });
83
+
84
+ describe("the password submit is gated (AC-3)", () => {
85
+ it("renders the agreement, unticked, and the card marked disabled", () => {
86
+ render(<SignUpCard {...props()} />);
87
+ expect(screen.getByTestId("consent-account_terms")).toHaveAttribute("aria-checked", "false");
88
+ expect(screen.getByTestId("sign-up-card-gate")).toHaveAttribute("aria-disabled", "true");
89
+ });
90
+
91
+ it("does nothing on submit while the agreement is unticked", async () => {
92
+ render(<SignUpCard {...props()} />);
93
+ await fillAndSubmit();
94
+ expect(mockSignUpEmail).not.toHaveBeenCalled();
95
+ expect(mockRememberAgreements).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it("mints the carried-agreement cookie, then signs up, once every required agreement is ticked", async () => {
99
+ render(<SignUpCard {...props()} />);
100
+ await tick("account_terms");
101
+ expect(screen.getByTestId("sign-up-card-gate")).toHaveAttribute("aria-disabled", "false");
102
+ await fillAndSubmit();
103
+ expect(mockRememberAgreements).toHaveBeenCalledWith("ada@example.com", ["account_terms"]);
104
+ expect(mockSignUpEmail).toHaveBeenCalledWith({
105
+ email: "ada@example.com",
106
+ password: "correct horse battery staple",
107
+ name: "ada@example.com",
108
+ callbackURL: "/account",
109
+ });
110
+ });
111
+
112
+ it("re-disables after unticking", async () => {
113
+ render(<SignUpCard {...props()} />);
114
+ await tick("account_terms");
115
+ await tick("account_terms");
116
+ expect(screen.getByTestId("sign-up-card-gate")).toHaveAttribute("aria-disabled", "true");
117
+ await fillAndSubmit();
118
+ expect(mockSignUpEmail).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("with two required agreements, both must be ticked", async () => {
122
+ render(
123
+ <SignUpCard
124
+ {...props({
125
+ agreements: [
126
+ AGREEMENT,
127
+ { id: "service_terms", required: true, statement: "You agree to being contacted." },
128
+ ],
129
+ })}
130
+ />,
131
+ );
132
+ await tick("account_terms");
133
+ await fillAndSubmit();
134
+ expect(mockSignUpEmail).not.toHaveBeenCalled();
135
+ await tick("service_terms");
136
+ await fillAndSubmit();
137
+ expect(mockSignUpEmail).toHaveBeenCalledTimes(1);
138
+ });
139
+
140
+ it("a failed cookie mint still submits — convenience only", async () => {
141
+ mockRememberAgreements.mockRejectedValue(new Error("no secret"));
142
+ render(<SignUpCard {...props()} />);
143
+ await tick("account_terms");
144
+ await fillAndSubmit();
145
+ expect(mockSignUpEmail).toHaveBeenCalledTimes(1);
146
+ });
147
+ });
148
+
149
+ describe("provider buttons are gated too (AC-3)", () => {
150
+ it("a click on Google does nothing while unticked", async () => {
151
+ render(<SignUpCard {...props({ method: "sso", providers: ["google"] })} />);
152
+ const user = userEvent.setup();
153
+ await user.click(screen.getByTestId("auth-sso-google"));
154
+ expect(mockSignInSocial).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it("launches the provider once ticked, with no cookie mint", async () => {
158
+ render(<SignUpCard {...props({ method: "sso", providers: ["google"] })} />);
159
+ await tick("account_terms");
160
+ const user = userEvent.setup();
161
+ await user.click(screen.getByTestId("auth-sso-google"));
162
+ expect(mockSignInSocial).toHaveBeenCalledWith({ provider: "google", callbackURL: "/account" });
163
+ expect(mockRememberAgreements).not.toHaveBeenCalled();
164
+ });
165
+ });
@@ -15,6 +15,11 @@
15
15
 
16
16
  import { localeHref } from "@working-theory/ui";
17
17
  import { AuthCard, type AuthCardLabels } from "@working-theory/ui/blocks";
18
+ import {
19
+ resolveConsentStatement,
20
+ SITE_CONSENT_CONFIG_DEFAULTS,
21
+ signUpAgreements,
22
+ } from "@working-theory/validation";
18
23
  import type { Metadata } from "next";
19
24
  import { getTranslations } from "next-intl/server";
20
25
  import type { ReactElement } from "react";
@@ -79,6 +84,17 @@ export default async function SignUpPage({
79
84
  orContinue: t("orContinue"),
80
85
  };
81
86
 
87
+ // #8438 D13 — the site's sign-up agreements, resolved to this locale. No
88
+ // per-site registry row is wired yet (C1), so this always reads the
89
+ // platform default — the same registry the member Privacy tab and the
90
+ // account-settings write both read from.
91
+ const config = SITE_CONSENT_CONFIG_DEFAULTS;
92
+ const agreements = signUpAgreements(config).map((purpose) => ({
93
+ id: purpose.id,
94
+ required: purpose.required,
95
+ statement: resolveConsentStatement(purpose, locale).statement,
96
+ }));
97
+
82
98
  return (
83
99
  <SignUpCard
84
100
  providers={providers}
@@ -91,6 +107,9 @@ export default async function SignUpPage({
91
107
  signUpFailed: t("signUpFailed"),
92
108
  providerFailed: t("providerFailed"),
93
109
  }}
110
+ agreements={agreements}
111
+ agreementPolicies={config.policies}
112
+ agreementsLegend={t("signUpAgreementsLegend")}
94
113
  />
95
114
  );
96
115
  }