create-ailk 0.18.0 → 0.19.1

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 (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  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/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -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,
@@ -245,6 +249,21 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
245
249
  }
246
250
  }
247
251
 
252
+ // #8185 D3 — a spoke's same-origin relay marks every forwarded call.
253
+ // The mark can only remove the spoke-checkout marker, never grant it.
254
+ sourceMeta.relayed = request.headers["x-ailk-relayed"] !== undefined;
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
+
248
267
  const result = await routeLead(
249
268
  buildInput(kind, body, workspaceId),
250
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");
@@ -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
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * POST /api/webhooks/stripe — the livemode guard, test-mode process (#8450
3
+ * D10; #6852 SEC-03).
4
+ *
5
+ * The real `lib/stripe.ts` runs over the package mocked for
6
+ * `verifyWebhookSignature` only, and nothing admits a live key, so the
7
+ * process is in test mode: a signed event whose `livemode` is `true` is
8
+ * answered 200, writes nothing, is not marked seen and logs one
9
+ * `livemode_mismatch` warn; the same event with `livemode: false` reaches
10
+ * its arm. The live-mode mirror is `stripe-livemode-live.test.ts`.
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
+ import * as configModule from "../../../config/index.js";
42
+ import { prisma } from "../../../lib/prisma.js";
43
+ import { stripeKeyMode } from "../../../lib/stripe.js";
44
+ import { __resetIdempotencyStore, hasSeen } from "../../../lib/webhook-idempotency.js";
45
+ import { stripeWebhookHandler } from "../stripe.js";
46
+
47
+ const EVENT_ID = "evt_livemode_12345678";
48
+
49
+ function customerCreated(livemode: boolean) {
50
+ return {
51
+ id: EVENT_ID,
52
+ type: "customer.created",
53
+ livemode,
54
+ data: {
55
+ object: {
56
+ id: "cus_livemode_fixture",
57
+ email: "client@example.com",
58
+ metadata: { ailk_user_id: "usr_livemode" },
59
+ },
60
+ },
61
+ };
62
+ }
63
+
64
+ type LogCall = [unknown, string?];
65
+ const logCalls: LogCall[] = [];
66
+
67
+ async function buildTestServer(): Promise<FastifyInstance> {
68
+ const server = Fastify({ logger: false });
69
+ await server.register(async (webhooksScope) => {
70
+ webhooksScope.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
71
+ done(null, body);
72
+ });
73
+ webhooksScope.addHook("onRequest", async (request) => {
74
+ for (const level of ["info", "warn", "error"] as const) {
75
+ const original = request.log[level].bind(request.log);
76
+ request.log[level] = ((...args: unknown[]) => {
77
+ logCalls.push(args as LogCall);
78
+ return (original as (...a: unknown[]) => void)(...args);
79
+ }) as typeof request.log.info;
80
+ }
81
+ });
82
+ webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
83
+ });
84
+ await server.ready();
85
+ return server;
86
+ }
87
+
88
+ function mismatchLogs(): LogCall[] {
89
+ return logCalls.filter(
90
+ ([obj]) =>
91
+ typeof obj === "object" && obj !== null && (obj as Record<string, unknown>)["outcome"] === "livemode_mismatch",
92
+ );
93
+ }
94
+
95
+ let server: FastifyInstance;
96
+
97
+ beforeEach(async () => {
98
+ jest.clearAllMocks();
99
+ logCalls.length = 0;
100
+ __resetIdempotencyStore();
101
+ jest.replaceProperty(configModule, "config", {
102
+ ...configModule.config,
103
+ STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
104
+ });
105
+ (prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
106
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
107
+ server = await buildTestServer();
108
+ });
109
+
110
+ afterEach(async () => {
111
+ await server.close();
112
+ jest.restoreAllMocks();
113
+ });
114
+
115
+ async function post() {
116
+ return server.inject({
117
+ method: "POST",
118
+ url: "/v1/webhooks/stripe",
119
+ payload: Buffer.from(JSON.stringify({ type: "test" })),
120
+ headers: { "content-type": "application/json", "stripe-signature": "t=1234,v1=abc" },
121
+ });
122
+ }
123
+
124
+ describe("stripe webhook — livemode guard in a test-mode process (#8450 D10)", () => {
125
+ it("the process is in test mode", () => {
126
+ expect(stripeKeyMode()).toBe("test");
127
+ });
128
+
129
+ it("drops a live-mode event: 200, no write, not marked seen, one warn", async () => {
130
+ mockVerifyWebhookSignature.mockReturnValue(customerCreated(true));
131
+
132
+ const res = await post();
133
+
134
+ expect(res.statusCode).toBe(200);
135
+ expect(res.json()).toEqual({ received: true });
136
+ expect(prisma.user.findFirst).not.toHaveBeenCalled();
137
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
138
+ expect(prisma.user.update).not.toHaveBeenCalled();
139
+ expect(hasSeen(EVENT_ID)).toBe(false);
140
+ expect(mismatchLogs()).toHaveLength(1);
141
+ expect(mismatchLogs()[0]?.[0]).toEqual({
142
+ eventType: "customer.created",
143
+ eventIdPrefix: EVENT_ID.substring(0, 8),
144
+ outcome: "livemode_mismatch",
145
+ });
146
+ });
147
+
148
+ it("handles a test-mode event as before: it reaches its arm and is marked seen", async () => {
149
+ mockVerifyWebhookSignature.mockReturnValue(customerCreated(false));
150
+
151
+ const res = await post();
152
+
153
+ expect(res.statusCode).toBe(200);
154
+ expect(prisma.user.findFirst).toHaveBeenCalled();
155
+ expect(hasSeen(EVENT_ID)).toBe(true);
156
+ expect(mismatchLogs()).toHaveLength(0);
157
+ });
158
+ });
@@ -60,6 +60,8 @@ jest.mock("../../../lib/stripe.js", () => ({
60
60
  verifyWebhookSignature: (...args: unknown[]) =>
61
61
  mockVerifyWebhookSignature(...args),
62
62
  StripeWebhookSignatureError: MockStripeWebhookSignatureError,
63
+ // #8450 D10 — the handler compares each event's livemode with this.
64
+ stripeKeyMode: () => "test",
63
65
  }));
64
66
 
65
67
  import { webhooksRoutes } from "../index.js";
@@ -104,6 +104,7 @@ import {
104
104
  SITE_STAMP_KEY,
105
105
  } from "../../lib/site.js";
106
106
  import {
107
+ stripeKeyMode,
107
108
  StripeWebhookSignatureError,
108
109
  toSubscriptionLifecycleStatus,
109
110
  verifyWebhookSignature,
@@ -274,6 +275,22 @@ export const stripeWebhookHandler = async (
274
275
 
275
276
  const event = settled.event;
276
277
  const eventIdPrefix = event.id.substring(0, 8);
278
+
279
+ // #8450 D10 (#6852 SEC-03) — an event whose livemode is not the key class
280
+ // this process admitted is never one of its own: a live hub must not act on
281
+ // a test-mode event anyone with the test key can forge, nor a test hub on a
282
+ // live one. Dropped before the idempotency check and any write; 200 so
283
+ // Stripe stops retrying, and NOT marked seen, so a corrected deployment
284
+ // still accepts a redelivery.
285
+ if ((event.livemode === true) !== (stripeKeyMode() === "live")) {
286
+ request.log.warn(
287
+ { eventType: event.type, eventIdPrefix, outcome: "livemode_mismatch" },
288
+ "stripe webhook event dropped — livemode differs from the admitted key class",
289
+ );
290
+ await reply.code(200).send({ received: true });
291
+ return;
292
+ }
293
+
277
294
  request.log.info(
278
295
  { eventType: event.type, eventIdPrefix },
279
296
  "stripe webhook event received",