create-ailk 0.19.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 (91) hide show
  1. package/dist/add-module.js +7 -2
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  5. package/dist/lib/scaffold-render-sweep.js +52 -0
  6. package/dist/module-architecture.js +9 -3
  7. package/dist/programmatic.js +8 -1
  8. package/dist/site-roster.js +21 -4
  9. package/dist/surfaces.d.ts +14 -0
  10. package/dist/surfaces.js +31 -1
  11. package/dist/topology.js +29 -1
  12. package/package.json +3 -3
  13. package/templates/apps/api/.env.example +17 -1
  14. package/templates/apps/api/CLAUDE.md +10 -4
  15. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  16. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  17. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  18. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  19. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  20. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  21. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  22. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  23. package/templates/apps/api/src/lib/stripe.ts +97 -5
  24. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +10 -4
  25. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  26. package/templates/apps/api/src/middleware/auth.ts +11 -3
  27. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  28. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  29. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  30. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  31. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  32. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  33. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  34. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  35. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  36. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  37. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  38. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  39. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  40. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  41. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  42. package/templates/apps/api/src/server.ts +111 -16
  43. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  44. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  45. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  46. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  47. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  48. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  49. package/templates/apps/api/src/services/consent.ts +488 -3
  50. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  51. package/templates/apps/api/src/services/person.ts +501 -1
  52. package/templates/apps/api/src/services/project-listings.ts +7 -2
  53. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  54. package/templates/apps/api/src/services/revalidation.ts +11 -1
  55. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  57. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +156 -0
  58. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +89 -14
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +122 -18
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +15 -1
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +55 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  65. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +4 -7
  66. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  67. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +66 -4
  69. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  70. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +2 -2
  71. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  72. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +41 -33
  75. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  76. package/templates/apps/web/auth.ts +20 -1
  77. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  78. package/templates/apps/web/lib/auth-email.ts +45 -10
  79. package/templates/apps/web/lib/topology.generated.ts +9 -0
  80. package/templates/apps/web/messages/en.json +54 -14
  81. package/templates/database/CHANGELOG.md +16 -0
  82. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  83. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  84. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  85. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  86. package/templates/database/__tests__/person.db.test.ts +3 -1
  87. package/templates/database/inbox/schema.prisma +76 -10
  88. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  89. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  90. package/templates/database/package.json +1 -1
  91. package/versions.json +35 -35
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @file live-key.test.ts
3
+ *
4
+ * The hub checkout with a live key (#8450 AC 1, AC 2): `POST
5
+ * /v1/checkout/sessions` and the return page's `GET
6
+ * /v1/checkout/sessions/:id`. Unlike `sessions.test.ts` and
7
+ * `session.test.ts`, BOTH guards here are real: `lib/stripe.ts` runs as
8
+ * itself (only the two Stripe calls are mocked, so nothing leaves the
9
+ * process), and `@working-theory/stripe-client` runs as itself with
10
+ * `assertSandboxKey` wrapped in a spy, so the test can see whether a route
11
+ * still calls the sandbox-only guard.
12
+ *
13
+ * The process admits a restricted live key once, in `beforeAll`, exactly as
14
+ * `build()` does at boot. Every live-shaped key is composed at runtime from
15
+ * fragments — never a literal (architecture.yaml's live-key pattern).
16
+ */
17
+ import type { FastifyInstance } from "fastify";
18
+
19
+ const mockCreateCheckoutSession = jest.fn();
20
+ const mockRetrieveCheckoutSession = jest.fn();
21
+
22
+ jest.mock("@working-theory/stripe-client", () => {
23
+ const actual = jest.requireActual("@working-theory/stripe-client");
24
+ return { ...actual, assertSandboxKey: jest.fn(actual.assertSandboxKey) };
25
+ });
26
+
27
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
28
+ jest.mock("../../../lib/stripe.js", () => ({
29
+ ...jest.requireActual("../../../lib/stripe.js"),
30
+ createCheckoutSession: (...args: unknown[]) => mockCreateCheckoutSession(...args),
31
+ retrieveCheckoutSession: (...args: unknown[]) => mockRetrieveCheckoutSession(...args),
32
+ }));
33
+
34
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted; the DB boundary, mirrors sessions.test.ts
35
+ jest.mock("../../../lib/prisma.js", () => ({
36
+ prisma: {
37
+ session: { findUnique: jest.fn() },
38
+ user: { findUnique: jest.fn() },
39
+ apiToken: { findUnique: jest.fn(), update: jest.fn() },
40
+ },
41
+ }));
42
+
43
+ import { assertSandboxKey } from "@working-theory/stripe-client";
44
+
45
+ import { config } from "../../../config/index.js";
46
+ import { admitApiStripeKey, stripeKeyMode } from "../../../lib/stripe.js";
47
+ import { __resetBuckets } from "../../../middleware/rate-limit.js";
48
+ import { checkoutRoutes } from "../index";
49
+
50
+ config.FRONTEND_URL = "https://example.com";
51
+ config.TRUSTED_ORIGINS = ["https://example.com"];
52
+
53
+ const TAIL = "FixtureOnlyCheckout";
54
+ const restrictedLiveKey = ["rk", "live", ""].join("_") + TAIL;
55
+ const secretLiveKey = ["sk", "live", ""].join("_") + TAIL;
56
+ const publishableLiveKey = ["pk", "live", ""].join("_") + TAIL;
57
+
58
+ const sandboxSpy = assertSandboxKey as jest.MockedFunction<typeof assertSandboxKey>;
59
+
60
+ const ENV_KEYS = ["STRIPE_SECRET_KEY", "STRIPE_MODE", "VERCEL_ENV", "OSS_API_INTERNAL_SECRET"] as const;
61
+ const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
62
+
63
+ const VALID_BODY = {
64
+ productCode: "ailk-pro",
65
+ successUrl: "https://example.com/success",
66
+ cancelUrl: "https://example.com/cancel",
67
+ };
68
+
69
+ const SESSION_ID = "cs_live_fixture12345";
70
+
71
+ function buildApp(): FastifyInstance {
72
+ const fastify = require("fastify")({ logger: false });
73
+ fastify.register(checkoutRoutes);
74
+ return fastify;
75
+ }
76
+
77
+ describe("the hub checkout — an admitted live key (#8450)", () => {
78
+ let app: FastifyInstance;
79
+
80
+ beforeAll(() => {
81
+ delete process.env["OSS_API_INTERNAL_SECRET"];
82
+ process.env["STRIPE_SECRET_KEY"] = restrictedLiveKey;
83
+ process.env["STRIPE_MODE"] = "live";
84
+ process.env["VERCEL_ENV"] = "production";
85
+ expect(admitApiStripeKey()).toBe("live");
86
+ expect(stripeKeyMode()).toBe("live");
87
+ });
88
+
89
+ afterAll(() => {
90
+ for (const k of ENV_KEYS) {
91
+ if (savedEnv[k] === undefined) delete process.env[k];
92
+ else process.env[k] = savedEnv[k];
93
+ }
94
+ });
95
+
96
+ beforeEach(async () => {
97
+ jest.clearAllMocks();
98
+ __resetBuckets();
99
+ process.env["STRIPE_SECRET_KEY"] = restrictedLiveKey;
100
+ mockCreateCheckoutSession.mockResolvedValue({
101
+ sessionId: "cs_live_fixture12345",
102
+ url: "https://checkout.stripe.com/c/pay/fixture",
103
+ });
104
+ mockRetrieveCheckoutSession.mockResolvedValue({
105
+ id: SESSION_ID,
106
+ status: "complete",
107
+ paymentStatus: "paid",
108
+ currency: "usd",
109
+ amountTotal: 4800,
110
+ lineItems: [],
111
+ });
112
+ app = buildApp();
113
+ await app.ready();
114
+ });
115
+
116
+ afterEach(async () => {
117
+ await app.close();
118
+ });
119
+
120
+ describe("POST /v1/checkout/sessions", () => {
121
+ it("gets past the key check on the restricted live key, without the sandbox-only guard", async () => {
122
+ const res = await app.inject({ method: "POST", url: "/v1/checkout/sessions", payload: VALID_BODY });
123
+
124
+ expect(res.statusCode).toBe(201);
125
+ expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
126
+ expect(sandboxSpy).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it.each([
130
+ ["an unrestricted live key", secretLiveKey],
131
+ ["a publishable live key", publishableLiveKey],
132
+ ])("still refuses %s with 500 live-mode-forbidden and no Stripe call", async (_label, key) => {
133
+ process.env["STRIPE_SECRET_KEY"] = key;
134
+
135
+ const res = await app.inject({ method: "POST", url: "/v1/checkout/sessions", payload: VALID_BODY });
136
+
137
+ expect(res.statusCode).toBe(500);
138
+ expect(res.json()).toEqual({ error: "live-mode-forbidden" });
139
+ expect(res.body).not.toContain(TAIL);
140
+ expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
141
+ });
142
+ });
143
+
144
+ describe("GET /v1/checkout/sessions/:id", () => {
145
+ it("gets past the key check on the restricted live key, without the sandbox-only guard", async () => {
146
+ const res = await app.inject({ method: "GET", url: `/v1/checkout/sessions/${SESSION_ID}` });
147
+
148
+ expect(res.statusCode).toBe(200);
149
+ expect(mockRetrieveCheckoutSession).toHaveBeenCalledTimes(1);
150
+ expect(sandboxSpy).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it.each([
154
+ ["an unrestricted live key", secretLiveKey],
155
+ ["a publishable live key", publishableLiveKey],
156
+ ])("still refuses %s with 500 live-mode-forbidden and no Stripe call", async (_label, key) => {
157
+ process.env["STRIPE_SECRET_KEY"] = key;
158
+
159
+ const res = await app.inject({ method: "GET", url: `/v1/checkout/sessions/${SESSION_ID}` });
160
+
161
+ expect(res.statusCode).toBe(500);
162
+ expect(res.json()).toEqual({ error: "live-mode-forbidden" });
163
+ expect(res.body).not.toContain(TAIL);
164
+ expect(mockRetrieveCheckoutSession).not.toHaveBeenCalled();
165
+ });
166
+ });
167
+ });
@@ -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");
@@ -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";