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
@@ -27,7 +27,7 @@
27
27
  * 400 validation-error — `:id` fails the shape check
28
28
  * 404 unknown-session — Stripe raised `resource_missing`
29
29
  * 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty
30
- * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
30
+ * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
31
31
  * 500 config-error — STRIPE_SECRET_KEY invalid configuration
32
32
  * 502 checkout-unavailable — Stripe SDK threw an unexpected error
33
33
  */
@@ -35,7 +35,7 @@ import type { FastifyReply, FastifyRequest } from "fastify";
35
35
  import { z } from "zod";
36
36
 
37
37
  import {
38
- assertSandboxKey,
38
+ assertAdmittedStripeKey,
39
39
  retrieveCheckoutSession,
40
40
  StripeConfigError,
41
41
  UnknownSessionError,
@@ -80,7 +80,7 @@ export async function checkoutSessionReadHandler(
80
80
  return reply.status(500).send({ error: "stripe-not-configured" });
81
81
  }
82
82
  try {
83
- assertSandboxKey(stripeSecretKey);
83
+ assertAdmittedStripeKey(stripeSecretKey);
84
84
  } catch {
85
85
  return reply.status(500).send({ error: "live-mode-forbidden" });
86
86
  }
@@ -7,7 +7,9 @@
7
7
  * price opens a subscription session), so this route serves both (#4398).
8
8
  *
9
9
  * Security invariants:
10
- * - Sandbox-only: live Stripe keys are rejected at runtime before any Stripe call.
10
+ * - Admitted key only (#8450): before any Stripe call, a test-mode key or
11
+ * the restricted live key the process admitted at boot passes
12
+ * (`assertAdmittedStripeKey`); every other live key is refused.
11
13
  * - No PII in logs: successUrl/cancelUrl and full session IDs are never logged.
12
14
  * Only the first 8 chars of the session ID appear in log lines.
13
15
  * - Error responses never echo user input (productCode, URLs).
@@ -48,7 +50,7 @@
48
50
  * unauthenticated redirect is off the site origin (#6877)
49
51
  * 404 unknown-product-code — no active Stripe price for the given productCode
50
52
  * 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty (no Stripe call)
51
- * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key
53
+ * 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
52
54
  * 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper),
53
55
  * or no trusted browser origin is configured when the origin
54
56
  * lock applies (#6877, #7395)
@@ -62,7 +64,7 @@ import { z } from "zod";
62
64
  import { config } from "../../config/index.js";
63
65
  import { siteStamp } from "../../lib/site.js";
64
66
  import {
65
- assertSandboxKey,
67
+ assertAdmittedStripeKey,
66
68
  createCheckoutSession,
67
69
  StripeConfigError,
68
70
  UnknownCustomerError,
@@ -248,7 +250,7 @@ export async function checkoutSessionsHandler(
248
250
  return reply.status(500).send({ error: "stripe-not-configured" });
249
251
  }
250
252
  try {
251
- assertSandboxKey(stripeSecretKey);
253
+ assertAdmittedStripeKey(stripeSecretKey);
252
254
  } catch {
253
255
  return reply.status(500).send({ error: "live-mode-forbidden" });
254
256
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The cookie banner's log (#8438, spec
3
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md D5).
4
+ *
5
+ * POST /v1/consent/cookie
6
+ * `{ visitorId, choice: "accepted" | "declined", locale }` — one row
7
+ * in the consent ledger against the visitor id, source
8
+ * `cookie_banner`. Answers `201 { recorded: true }`, or
9
+ * `200 { recorded: false }` when the site declares no cookie purpose
10
+ * or the visitor's newest row already records the same choice.
11
+ *
12
+ * The workspace comes from `resolvePublicWorkspace(request)` — the spoke
13
+ * key, else the one-workspace deployment — and NEVER from the body, which is
14
+ * strict and names no site, workspace, person or user. An unresolved
15
+ * workspace is `503 tenant_unresolved`; no database `503
16
+ * consent_store_unavailable`; a failed write the same.
17
+ *
18
+ * Registered at the top level in its own rate-limited scope, outside every
19
+ * module gate: consent logging is part of the never-gated privacy floor.
20
+ * The browser cookie `ailk-consent` stays the operative switch (C3) — this
21
+ * route only records the choice.
22
+ *
23
+ * Logs carry `{ event, outcome }` only: never a visitor id or a statement.
24
+ *
25
+ * MCP parity note: no MCP twin, a recorded exception — it logs a browser's
26
+ * banner choice against a browser-held id, and an agent holds neither
27
+ * (tests/parity/README.md).
28
+ */
29
+ import { consentCookieBodySchema } from "@working-theory/validation";
30
+ import type { FastifyPluginAsync } from "fastify";
31
+
32
+ import { recordCookieChoice } from "../../services/consent.js";
33
+ import { resolvePublicWorkspace } from "../../services/public-site-resolver.js";
34
+
35
+ const EVENT = "consent-cookie";
36
+
37
+ export const consentCookieRoute: FastifyPluginAsync = async (fastify) => {
38
+ fastify.post("/v1/consent/cookie", async (request, reply) => {
39
+ const body = consentCookieBodySchema.safeParse(request.body);
40
+ if (!body.success) {
41
+ return reply.status(400).send({
42
+ error: "invalid_input",
43
+ message: "The request was not valid.",
44
+ });
45
+ }
46
+ if (!process.env["DATABASE_URL"]) {
47
+ return reply.status(503).send({
48
+ error: "consent_store_unavailable",
49
+ message: "Consent cannot be recorded on this site.",
50
+ });
51
+ }
52
+
53
+ const site = await resolvePublicWorkspace(request);
54
+ if (site.kind !== "resolved") {
55
+ request.log.warn(
56
+ { event: EVENT, outcome: "tenant_unresolved" },
57
+ "[consent] refused",
58
+ );
59
+ return reply.status(503).send({
60
+ error: "tenant_unresolved",
61
+ message: "This site is not configured to record consent.",
62
+ });
63
+ }
64
+
65
+ try {
66
+ const result = await recordCookieChoice({
67
+ workspaceId: site.workspaceId,
68
+ visitorId: body.data.visitorId,
69
+ choice: body.data.choice,
70
+ locale: body.data.locale,
71
+ });
72
+ request.log.info(
73
+ { event: EVENT, outcome: result.recorded ? "recorded" : "unchanged" },
74
+ "[consent] cookie choice",
75
+ );
76
+ return reply
77
+ .status(result.recorded ? 201 : 200)
78
+ .send({ recorded: result.recorded });
79
+ } catch {
80
+ request.log.error(
81
+ { event: EVENT, outcome: "failed" },
82
+ "[consent] cookie choice failed",
83
+ );
84
+ return reply.status(503).send({
85
+ error: "consent_store_unavailable",
86
+ message: "Consent cannot be recorded right now.",
87
+ });
88
+ }
89
+ });
90
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The consent routes (#8438, spec
3
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md D5, D8, D9).
4
+ *
5
+ * cookie.ts POST /v1/consent/cookie — the banner's log
6
+ * unsubscribe.ts POST /v1/consent/unsubscribe — a signed link's landing
7
+ * platform.ts GET/POST /v1/consent/platform — the Admin owner's
8
+ * consents to Working Theory (multi-tenant module only)
9
+ *
10
+ * The first two are the never-gated privacy floor: registered at the top
11
+ * level, each in its OWN rate-limited scope (`consent`, `unsubscribe`) so
12
+ * filling one never blocks the other, and both on the forwarded-visitor
13
+ * route list (`middleware/rate-limit.ts`). This barrel re-exports exactly
14
+ * those two; the platform routes belong to the multi-tenant slice and are
15
+ * loaded only through `server.ts`'s slice table.
16
+ */
17
+ export { consentCookieRoute } from "./cookie.js";
18
+ export { consentUnsubscribeRoute } from "./unsubscribe.js";
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The one unsubscribe landing every email link will call (#8438, spec
3
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md D9, SEC-8438-9).
4
+ *
5
+ * POST /v1/consent/unsubscribe
6
+ * `{ token }` — a signed token minted by `consentUnsubscribeToken`.
7
+ * Withdraws the communications purpose it names for the Person it
8
+ * names, source `email_unsubscribe`, unless the Person's own and
9
+ * any-source readings both already say "not granted". Answers `200 { purpose: { id, description },
10
+ * changed }`; a second click is `changed: false` with nothing written.
11
+ *
12
+ * The token is the only authority: it names no email, and a forged one fails
13
+ * its HMAC. Any defect — a flipped character, another workspace's Person, a
14
+ * purpose that is not a communications consent or preference, a token older
15
+ * than 400 days, a malformed body — is ONE uniform `400 invalid_link`. With
16
+ * `AILK_CONSENT_LINK_SECRET` unset or under 32 bytes the route answers `503
17
+ * link_secret_not_configured` (the 503/400 split tells an outsider only
18
+ * whether the feature is configured, which the absence of links in mail
19
+ * already shows — accepted in SEC-8438-9).
20
+ *
21
+ * Registered at the top level in its own rate-limited scope (`unsubscribe`),
22
+ * outside every module gate, beside the cookie route. The page that calls it
23
+ * (apps/web `/unsubscribe`) performs no write on GET, so a mail scanner's
24
+ * prefetch changes nothing.
25
+ *
26
+ * Logs carry `{ event, outcome }` only: never a token, an id or a statement.
27
+ *
28
+ * MCP parity note: no MCP twin — the route is a capability link's own
29
+ * landing, the `/v1/deliverables/:grantId` footing (tests/parity/README.md).
30
+ */
31
+ import {
32
+ consentPurposeById,
33
+ consentPurposeKind,
34
+ consentUnsubscribeBodySchema,
35
+ resolveLocalizedString,
36
+ } from "@working-theory/validation";
37
+ import type { FastifyPluginAsync } from "fastify";
38
+
39
+ import {
40
+ consentConfigForSite,
41
+ consentLinkSecretConfigured,
42
+ consentPersonExists,
43
+ recordPersonConsent,
44
+ verifyConsentUnsubscribeToken,
45
+ } from "../../services/consent.js";
46
+
47
+ const EVENT = "consent-unsubscribe";
48
+
49
+ const INVALID_LINK = {
50
+ error: "invalid_link",
51
+ message: "This link isn't valid.",
52
+ } as const;
53
+
54
+ export const consentUnsubscribeRoute: FastifyPluginAsync = async (fastify) => {
55
+ fastify.post("/v1/consent/unsubscribe", async (request, reply) => {
56
+ if (!consentLinkSecretConfigured()) {
57
+ request.log.warn(
58
+ { event: EVENT, outcome: "secret_missing" },
59
+ "[consent] refused",
60
+ );
61
+ return reply.status(503).send({
62
+ error: "link_secret_not_configured",
63
+ message: "Unsubscribing isn't available on this site.",
64
+ });
65
+ }
66
+ const refuse = (outcome: string) => {
67
+ request.log.warn({ event: EVENT, outcome }, "[consent] refused");
68
+ return reply.status(400).send(INVALID_LINK);
69
+ };
70
+
71
+ const body = consentUnsubscribeBodySchema.safeParse(request.body);
72
+ if (!body.success) return refuse("invalid_body");
73
+ const claims = verifyConsentUnsubscribeToken(body.data.token);
74
+ if (claims === null) return refuse("invalid_token");
75
+
76
+ try {
77
+ const config = await consentConfigForSite(claims.workspaceId);
78
+ const purpose = consentPurposeById(config, claims.purposeId);
79
+ if (
80
+ !purpose ||
81
+ purpose.area !== "communications" ||
82
+ consentPurposeKind(purpose) === "agreement"
83
+ ) {
84
+ return refuse("purpose_not_unsubscribable");
85
+ }
86
+ if (!(await consentPersonExists(claims.workspaceId, claims.personId))) {
87
+ return refuse("person_unknown");
88
+ }
89
+
90
+ const result = await recordPersonConsent({
91
+ workspaceId: claims.workspaceId,
92
+ personId: claims.personId,
93
+ purposeId: purpose.id,
94
+ granted: false,
95
+ source: "email_unsubscribe",
96
+ locale: "en",
97
+ config,
98
+ subject: { subjectType: "person", subjectId: claims.personId },
99
+ });
100
+ if (!result.ok) return refuse(result.code);
101
+
102
+ request.log.info(
103
+ { event: EVENT, outcome: result.changed ? "withdrawn" : "unchanged" },
104
+ "[consent] unsubscribe",
105
+ );
106
+ return reply.send({
107
+ purpose: {
108
+ id: purpose.id,
109
+ description:
110
+ purpose.description === undefined
111
+ ? purpose.id
112
+ : resolveLocalizedString(purpose.description, "en", "en"),
113
+ },
114
+ changed: result.changed,
115
+ });
116
+ } catch {
117
+ request.log.error(
118
+ { event: EVENT, outcome: "failed" },
119
+ "[consent] unsubscribe failed",
120
+ );
121
+ return reply.status(503).send({
122
+ error: "consent_store_unavailable",
123
+ message: "Unsubscribing isn't available right now.",
124
+ });
125
+ }
126
+ });
127
+ };
@@ -71,6 +71,10 @@ import type { FastifyPluginAsync } from "fastify";
71
71
 
72
72
  import { acceptsKind, resolveLeadDelivery } from "../../lib/lead-delivery.js";
73
73
  import { currentSiteId, resolveSiteWorkspaceId } from "../../lib/site.js";
74
+ import {
75
+ CONSENT_VISITOR_HEADER,
76
+ consentVisitorFromHeader,
77
+ } from "../../services/consent.js";
74
78
  import {
75
79
  type LeadKind,
76
80
  type RouteLeadInput,
@@ -249,6 +253,17 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
249
253
  // The mark can only remove the spoke-checkout marker, never grant it.
250
254
  sourceMeta.relayed = request.headers["x-ailk-relayed"] !== undefined;
251
255
 
256
+ // #8438 D11 — the cookie banner's visitor id, forwarded by the site's
257
+ // own server from its first-party cookie. Only a v4 UUID counts; the
258
+ // header is never echoed or logged. It links the visitor to the
259
+ // lead's Person after the write, and changes nothing about the answer.
260
+ const consentVisitorId = consentVisitorFromHeader(
261
+ request.headers[CONSENT_VISITOR_HEADER],
262
+ );
263
+ if (consentVisitorId !== undefined) {
264
+ sourceMeta.consentVisitorId = consentVisitorId;
265
+ }
266
+
252
267
  const result = await routeLead(
253
268
  buildInput(kind, body, workspaceId),
254
269
  sourceMeta,
@@ -516,6 +516,10 @@ describe("the draft lifecycle (#5036 D1)", () => {
516
516
  locale: "en",
517
517
  statement,
518
518
  statementHash: sha256(statement),
519
+ // #8438 D3 — a listing names no magnet slug, so the source is `api`.
520
+ source: "api",
521
+ personId: null,
522
+ policyVersion: 1,
519
523
  });
520
524
  expect(data[0]!["statement"]).not.toBe(UNRECOVERABLE_CONSENT_STATEMENT);
521
525
  // The row's own consentAt is untouched by the grant write (spec §2.3).
@@ -539,6 +543,7 @@ describe("the draft lifecycle (#5036 D1)", () => {
539
543
  locale: "und",
540
544
  statement: UNRECOVERABLE_CONSENT_STATEMENT,
541
545
  statementHash: sha256(UNRECOVERABLE_CONSENT_STATEMENT),
546
+ source: "api",
542
547
  });
543
548
  // Never today's wording for a legacy call — that would fabricate evidence (D11).
544
549
  expect(data[0]!["statement"]).not.toContain("You agree to being contacted");
@@ -163,13 +163,41 @@ through `metadata.site`.
163
163
  open (a session lives up to 24 hours). A session whose stamp no longer
164
164
  matches is acked as foreign and never retried, so its hub side effects are
165
165
  lost; the `stamp` / `ownSite` pair in the log line is how to spot that drift.
166
- - **Not filtered.** `customer.*`, `invoice.*` and subscription events carry no
167
- session metadata and are handled as before. Filtering them needs a stamp on
168
- those objects, which the spoke-checkout package writes (spoke pattern
169
- Phase 3).
166
+ A subscription keeps its stamp for its whole life and each finalized
167
+ invoice keeps an immutable copy of it, so a slug collision or a mis-stamp
168
+ on those lasts as long as the subscription — not the 24 hours a session
169
+ lives.
170
+ - **Billing events (#8024).** One gate before the event switch applies the
171
+ same test to every `customer.*`, `customer.subscription.*` and `invoice.*`
172
+ event, handled type or not. It reads the stamp from the event object's own
173
+ metadata — the Subscription's (where spoke-checkout writes `site` through
174
+ `subscription_data.metadata`) or the Customer's — and, for an invoice, from
175
+ its own metadata, then the subscription snapshot under
176
+ `parent.subscription_details.metadata` (the pinned API version), then the
177
+ top-level `subscription_details.metadata` (an endpoint on a pre-`basil`
178
+ version). Any foreign source makes the event foreign; the first is logged.
179
+ A source is read only when `data.object.object` is the family's own type
180
+ (`subscription`, `customer`, `invoice`), so a `customer.source.*` card or a
181
+ `customer.tax_id.*` object is never read. A foreign event answers 200,
182
+ logs `outcome: "foreign_site"` (the same five fields), writes nothing — no
183
+ billing bind, request-paid stamp or dunning record — and is marked seen.
184
+ The decision uses the payload alone: no Stripe call.
185
+ - **What stays unstamped.** The filter cannot see a stamp nobody wrote, so
186
+ these are still handled as the hub's own, exactly as before: spoke
187
+ customers (Checkout cannot put metadata on a customer it creates); quoted
188
+ spoke subscriptions and their invoices (`createQuotedCheckoutSession`
189
+ sends no `subscription_data` yet); a fixed spoke subscription whose `site`
190
+ was later removed; spoke subscriptions created before the stamp shipped,
191
+ or through a Payment Link or the Dashboard; and the billing app's sessions
192
+ and subscriptions on the shared account. Where such a subscription sits on
193
+ a hub User's own Stripe customer (a known buyer reused by a spoke), its
194
+ events keep reaching that User's dunning record.
170
195
  - **A label, not a boundary.** The stamp is written by whoever holds the
171
196
  account's secret key. It separates cooperating sites on one account, not
172
- tenants.
197
+ tenants. Whoever holds a write key on the account can also make the hub
198
+ skip one of its own objects' billing events by stamping it with another
199
+ site, which silences the hub's dunning record for that subscription — the
200
+ same key can already cancel or re-price it outright.
173
201
 
174
202
  ## Dunning (#6034)
175
203
 
@@ -52,6 +52,15 @@ jest.mock("@working-theory/stripe-client", () => ({
52
52
  WEBHOOK_TOLERANCE_SECONDS: 300,
53
53
  }));
54
54
 
55
+ // #8450 D10 — the handler drops an event whose livemode is not the process's,
56
+ // so the Person cases below run the process in their event's own mode.
57
+ let mockKeyMode: "test" | "live" = "test";
58
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
59
+ jest.mock("../../../lib/stripe.js", () => ({
60
+ ...jest.requireActual("../../../lib/stripe.js"),
61
+ stripeKeyMode: () => mockKeyMode,
62
+ }));
63
+
55
64
  import * as configModule from "../../../config/index.js";
56
65
  import { stripeWebhookHandler } from "../stripe.js";
57
66
  import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
@@ -115,6 +124,7 @@ let server: FastifyInstance;
115
124
 
116
125
  beforeEach(async () => {
117
126
  jest.clearAllMocks();
127
+ mockKeyMode = "test";
118
128
  logCalls.length = 0;
119
129
  __resetIdempotencyStore();
120
130
  jest.replaceProperty(configModule, "config", {
@@ -349,10 +359,13 @@ describe("the hub checkout's Person (#852 D6, AC-3)", () => {
349
359
  logCalls.filter(
350
360
  (c) => typeof c[0] === "object" && c[0] !== null && String((c[0] as { outcome?: string }).outcome).startsWith("person_"),
351
361
  );
352
- const liveEvent = (livemode: boolean, object: Record<string, unknown> = {}) => ({
353
- ...sessionEvent("checkout.session.completed", { customer: "cus_9", ...object }),
354
- livemode,
355
- });
362
+ const liveEvent = (livemode: boolean, object: Record<string, unknown> = {}) => {
363
+ mockKeyMode = livemode ? "live" : "test";
364
+ return {
365
+ ...sessionEvent("checkout.session.completed", { customer: "cus_9", ...object }),
366
+ livemode,
367
+ };
368
+ };
356
369
 
357
370
  beforeEach(() => {
358
371
  (prisma.flowCheckout.findFirst as jest.Mock).mockResolvedValue({ leadId: LEAD, workspaceId: WORKSPACE });
@@ -57,6 +57,8 @@ jest.mock("../../../lib/stripe.js", () => ({
57
57
  verifyWebhookSignature: (...args: unknown[]) =>
58
58
  mockVerifyWebhookSignature(...args),
59
59
  StripeWebhookSignatureError: MockStripeWebhookSignatureError,
60
+ // #8450 D10 — the handler compares each event's livemode with this.
61
+ stripeKeyMode: () => "test",
60
62
  }));
61
63
 
62
64
  import { stripeWebhookHandler } from "../stripe.js";
@@ -0,0 +1,159 @@
1
+ /**
2
+ * POST /api/webhooks/stripe — the livemode guard, live-mode process (#8450
3
+ * D10; #6852 SEC-03). The mirror of `stripe-livemode.test.ts`.
4
+ *
5
+ * `lib/stripe.ts` runs as itself except `stripeKeyMode`, which answers
6
+ * `"live"` — the state `build()` leaves a process in after admitting a
7
+ * restricted live key. A signed event whose `livemode` is `false` (a
8
+ * test-mode event, forgeable by anyone holding the test key) is answered
9
+ * 200, writes nothing, is not marked seen and logs one `livemode_mismatch`
10
+ * warn; the same event with `livemode: true` reaches its arm.
11
+ *
12
+ * The stripe-site-filter.test.ts harness: the commerce claim mocked present,
13
+ * the prisma manual mock, the idempotency store reset per test.
14
+ */
15
+ import type { FastifyInstance } from "fastify";
16
+ import Fastify from "fastify";
17
+
18
+ jest.mock("@working-theory/license", () => ({
19
+ checkLicense: jest.fn().mockResolvedValue({
20
+ tier: "pro",
21
+ plan: "pro-monthly",
22
+ features: ["commerce"],
23
+ expiresAt: null,
24
+ revoked: false,
25
+ }),
26
+ }));
27
+
28
+ jest.mock("@working-theory/analytics/server", () => ({
29
+ track: jest.fn().mockResolvedValue(undefined),
30
+ }));
31
+
32
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
33
+ jest.mock("../../../lib/prisma.js");
34
+
35
+ const mockVerifyWebhookSignature = jest.fn();
36
+ jest.mock("@working-theory/stripe-client", () => ({
37
+ ...jest.requireActual("@working-theory/stripe-client"),
38
+ verifyWebhookSignature: (...args: unknown[]) => mockVerifyWebhookSignature(...args),
39
+ }));
40
+
41
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
42
+ jest.mock("../../../lib/stripe.js", () => ({
43
+ ...jest.requireActual("../../../lib/stripe.js"),
44
+ stripeKeyMode: () => "live",
45
+ }));
46
+
47
+ import * as configModule from "../../../config/index.js";
48
+ import { prisma } from "../../../lib/prisma.js";
49
+ import { __resetIdempotencyStore, hasSeen } from "../../../lib/webhook-idempotency.js";
50
+ import { stripeWebhookHandler } from "../stripe.js";
51
+
52
+ const EVENT_ID = "evt_livemode_12345678";
53
+
54
+ function customerCreated(livemode: boolean) {
55
+ return {
56
+ id: EVENT_ID,
57
+ type: "customer.created",
58
+ livemode,
59
+ data: {
60
+ object: {
61
+ id: "cus_livemode_fixture",
62
+ email: "client@example.com",
63
+ metadata: { ailk_user_id: "usr_livemode" },
64
+ },
65
+ },
66
+ };
67
+ }
68
+
69
+ type LogCall = [unknown, string?];
70
+ const logCalls: LogCall[] = [];
71
+
72
+ async function buildTestServer(): Promise<FastifyInstance> {
73
+ const server = Fastify({ logger: false });
74
+ await server.register(async (webhooksScope) => {
75
+ webhooksScope.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
76
+ done(null, body);
77
+ });
78
+ webhooksScope.addHook("onRequest", async (request) => {
79
+ for (const level of ["info", "warn", "error"] as const) {
80
+ const original = request.log[level].bind(request.log);
81
+ request.log[level] = ((...args: unknown[]) => {
82
+ logCalls.push(args as LogCall);
83
+ return (original as (...a: unknown[]) => void)(...args);
84
+ }) as typeof request.log.info;
85
+ }
86
+ });
87
+ webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
88
+ });
89
+ await server.ready();
90
+ return server;
91
+ }
92
+
93
+ function mismatchLogs(): LogCall[] {
94
+ return logCalls.filter(
95
+ ([obj]) =>
96
+ typeof obj === "object" && obj !== null && (obj as Record<string, unknown>)["outcome"] === "livemode_mismatch",
97
+ );
98
+ }
99
+
100
+ let server: FastifyInstance;
101
+
102
+ beforeEach(async () => {
103
+ jest.clearAllMocks();
104
+ logCalls.length = 0;
105
+ __resetIdempotencyStore();
106
+ jest.replaceProperty(configModule, "config", {
107
+ ...configModule.config,
108
+ STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
109
+ });
110
+ (prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
111
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
112
+ server = await buildTestServer();
113
+ });
114
+
115
+ afterEach(async () => {
116
+ await server.close();
117
+ jest.restoreAllMocks();
118
+ });
119
+
120
+ async function post() {
121
+ return server.inject({
122
+ method: "POST",
123
+ url: "/v1/webhooks/stripe",
124
+ payload: Buffer.from(JSON.stringify({ type: "test" })),
125
+ headers: { "content-type": "application/json", "stripe-signature": "t=1234,v1=abc" },
126
+ });
127
+ }
128
+
129
+ describe("stripe webhook — livemode guard in a live-mode process (#8450 D10)", () => {
130
+ it("drops a test-mode event: 200, no write, not marked seen, one warn", async () => {
131
+ mockVerifyWebhookSignature.mockReturnValue(customerCreated(false));
132
+
133
+ const res = await post();
134
+
135
+ expect(res.statusCode).toBe(200);
136
+ expect(res.json()).toEqual({ received: true });
137
+ expect(prisma.user.findFirst).not.toHaveBeenCalled();
138
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
139
+ expect(prisma.user.update).not.toHaveBeenCalled();
140
+ expect(hasSeen(EVENT_ID)).toBe(false);
141
+ expect(mismatchLogs()).toHaveLength(1);
142
+ expect(mismatchLogs()[0]?.[0]).toEqual({
143
+ eventType: "customer.created",
144
+ eventIdPrefix: EVENT_ID.substring(0, 8),
145
+ outcome: "livemode_mismatch",
146
+ });
147
+ });
148
+
149
+ it("handles a live-mode event: it reaches its arm and is marked seen", async () => {
150
+ mockVerifyWebhookSignature.mockReturnValue(customerCreated(true));
151
+
152
+ const res = await post();
153
+
154
+ expect(res.statusCode).toBe(200);
155
+ expect(prisma.user.findFirst).toHaveBeenCalled();
156
+ expect(hasSeen(EVENT_ID)).toBe(true);
157
+ expect(mismatchLogs()).toHaveLength(0);
158
+ });
159
+ });