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,503 @@
1
+ /**
2
+ * @file consent-ledger.test.ts
3
+ * @description The consent ledger's writers (#8438 D3) — `recordCookieChoice`,
4
+ * `recordPersonConsent`, `linkConsentVisitor`, the unsubscribe token, and the
5
+ * `source` contract of `recordConsent`.
6
+ *
7
+ * External dependency boundaries mocked:
8
+ * - @working-theory/database (via moduleNameMapper -> prisma-client.js mock;
9
+ * prisma singleton via __mocks__/prisma.ts)
10
+ *
11
+ * The ledger is modelled in memory: `$transaction` runs its callback on the
12
+ * mocked client, `consentGrant.createMany` appends, and `$queryRaw` answers
13
+ * the way the store's `DISTINCT ON` queries would — newest row per purpose —
14
+ * so the "writes only when the answer changes" rule is exercised end to end.
15
+ */
16
+
17
+ // 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.
18
+ jest.mock("../../lib/prisma.js");
19
+
20
+ import {
21
+ PLATFORM_CONSENT_CONFIG,
22
+ resolveConsentStatement,
23
+ SITE_CONSENT_CONFIG_DEFAULTS,
24
+ siteConsentConfigSchema,
25
+ } from "@working-theory/validation";
26
+
27
+ import {
28
+ armConsentLedger,
29
+ type LedgerRow,
30
+ type LinkRow,
31
+ } from "../../lib/__tests__/consent-ledger-double.js";
32
+ import { prisma } from "../../lib/prisma.js";
33
+
34
+ import {
35
+ ConsentLinkSecretMissing,
36
+ consentLinkSecretConfigured,
37
+ consentUnsubscribeToken,
38
+ linkConsentVisitor,
39
+ recordConsent,
40
+ recordCookieChoice,
41
+ recordPersonConsent,
42
+ verifyConsentUnsubscribeToken,
43
+ } from "../consent";
44
+
45
+ const WS = "wksp_01920000-0000-7000-8000-0000000000a1";
46
+ const PERSON = "person_01920000-0000-7000-8000-0000000000a1";
47
+ const VISITOR = "0192a000-0000-4000-8000-0000000000a1";
48
+
49
+ type Row = LedgerRow;
50
+
51
+ const CONFIG = siteConsentConfigSchema.parse({
52
+ policies: { terms: "/terms", privacy: "/privacy" },
53
+ purposes: [
54
+ {
55
+ id: "service_terms",
56
+ required: true,
57
+ kind: "agreement",
58
+ policy: "terms",
59
+ label: { en: "You agree." },
60
+ },
61
+ {
62
+ id: "product_news",
63
+ kind: "preference",
64
+ area: "communications",
65
+ label: { en: "Product news." },
66
+ },
67
+ {
68
+ id: "share_data",
69
+ kind: "consent",
70
+ area: "data",
71
+ label: { en: "Share usage data." },
72
+ },
73
+ {
74
+ id: "analytics_cookies",
75
+ kind: "consent",
76
+ area: "cookies",
77
+ label: { en: "Analytics cookies." },
78
+ },
79
+ ],
80
+ });
81
+
82
+ /** The in-memory ledger every case appends to (`lib/__tests__/consent-ledger-double.ts`). */
83
+ let ledger: Row[] = [];
84
+ let links: LinkRow[] = [];
85
+
86
+ function newest(rows: Row[], purposeId?: string): Row | null {
87
+ const matching = rows.filter((r) => purposeId === undefined || r.purposeId === purposeId);
88
+ return matching.length === 0 ? null : matching[matching.length - 1]!;
89
+ }
90
+
91
+ beforeEach(() => {
92
+ jest.clearAllMocks();
93
+ const double = armConsentLedger(prisma);
94
+ ledger = double.ledger;
95
+ links = double.links;
96
+ });
97
+
98
+ describe("recordConsent carries the ledger fields (#8438 D3)", () => {
99
+ it("stamps personId, source, sourceRef and the purpose's policy version on every row", async () => {
100
+ await recordConsent({
101
+ siteId: WS,
102
+ subjectType: "lead",
103
+ subjectId: "lead_1",
104
+ personId: PERSON,
105
+ source: "magnet",
106
+ sourceRef: "free-guide",
107
+ locale: "en",
108
+ config: CONFIG,
109
+ consents: { service_terms: true, share_data: false },
110
+ });
111
+ expect(ledger).toHaveLength(2);
112
+ expect(ledger[0]).toMatchObject({
113
+ personId: PERSON,
114
+ source: "magnet",
115
+ sourceRef: "free-guide",
116
+ policyVersion: 1,
117
+ });
118
+ });
119
+
120
+ it("is a type error to write a row with no source", async () => {
121
+ await recordConsent(
122
+ // @ts-expect-error -- `source` is required: every writer must say where the answer came from.
123
+ {
124
+ siteId: WS,
125
+ subjectType: "lead",
126
+ subjectId: "lead_1",
127
+ personId: null,
128
+ locale: "en",
129
+ config: CONFIG,
130
+ consents: { service_terms: true },
131
+ },
132
+ );
133
+ });
134
+ });
135
+
136
+ describe("recordPersonConsent (#8438 D3)", () => {
137
+ const base = {
138
+ workspaceId: WS,
139
+ personId: PERSON,
140
+ locale: "en",
141
+ config: CONFIG,
142
+ subject: { subjectType: "person", subjectId: PERSON },
143
+ };
144
+ const settings = (purposeId: string, granted: boolean) =>
145
+ recordPersonConsent({
146
+ ...base,
147
+ purposeId,
148
+ granted,
149
+ source: "account_settings",
150
+ });
151
+
152
+ it("grant -> withdraw -> grant leaves three rows, newest granted", async () => {
153
+ await settings("share_data", true);
154
+ await settings("share_data", false);
155
+ await settings("share_data", true);
156
+ expect(ledger.map((r) => r.granted)).toEqual([true, false, true]);
157
+ expect(newest(ledger, "share_data")?.source).toBe("account_settings");
158
+ });
159
+
160
+ it("writes nothing for a repeat of the same answer", async () => {
161
+ await settings("share_data", true);
162
+ const again = await settings("share_data", true);
163
+ expect(again).toEqual({ ok: true, changed: false });
164
+ expect(ledger).toHaveLength(1);
165
+ });
166
+
167
+ it("treats a preference as on until withdrawn: granting it first writes nothing", async () => {
168
+ expect(await settings("product_news", true)).toEqual({
169
+ ok: true,
170
+ changed: false,
171
+ });
172
+ expect(ledger).toHaveLength(0);
173
+ expect(await settings("product_news", false)).toEqual({
174
+ ok: true,
175
+ changed: true,
176
+ });
177
+ expect(ledger).toHaveLength(1);
178
+ });
179
+
180
+ it("records the statement the registry resolves, never one from the caller", async () => {
181
+ await settings("share_data", true);
182
+ const purpose = CONFIG.purposes.find((p) => p.id === "share_data")!;
183
+ expect(ledger[0]?.["statement"]).toBe(
184
+ resolveConsentStatement(purpose, "en").statement,
185
+ );
186
+ });
187
+
188
+ it("asks an agreement once per policy version and refuses to withdraw it", async () => {
189
+ expect(await settings("service_terms", true)).toEqual({
190
+ ok: true,
191
+ changed: true,
192
+ });
193
+ expect(await settings("service_terms", true)).toEqual({
194
+ ok: true,
195
+ changed: false,
196
+ });
197
+ expect(await settings("service_terms", false)).toEqual({
198
+ ok: false,
199
+ code: "agreement_not_withdrawable",
200
+ });
201
+ expect(ledger).toHaveLength(1);
202
+ });
203
+
204
+ it("re-asks an agreement when its policy version is bumped", async () => {
205
+ await settings("service_terms", true);
206
+ const bumped = siteConsentConfigSchema.parse({
207
+ ...CONFIG,
208
+ purposes: CONFIG.purposes.map((p) =>
209
+ p.id === "service_terms" ? { ...p, policyVersion: 2 } : p,
210
+ ),
211
+ });
212
+ const result = await recordPersonConsent({
213
+ ...base,
214
+ config: bumped,
215
+ purposeId: "service_terms",
216
+ granted: true,
217
+ source: "account_settings",
218
+ });
219
+ expect(result).toEqual({ ok: true, changed: true });
220
+ expect(newest(ledger, "service_terms")?.policyVersion).toBe(2);
221
+ });
222
+
223
+ it("refuses an unknown purpose and the cookie purpose", async () => {
224
+ expect(await settings("nope", true)).toEqual({
225
+ ok: false,
226
+ code: "purpose_unknown",
227
+ });
228
+ expect(await settings("analytics_cookies", true)).toEqual({
229
+ ok: false,
230
+ code: "cookie_purpose",
231
+ });
232
+ expect(ledger).toHaveLength(0);
233
+ });
234
+
235
+ it("takes the (subject, purpose) advisory lock before it reads", async () => {
236
+ await settings("share_data", true);
237
+ const lock = (prisma.$executeRaw as jest.Mock).mock.calls[0]!;
238
+ expect(String(lock[0].join("?"))).toContain("pg_advisory_xact_lock");
239
+ expect(lock[1]).toBe(`ailk:consent:${WS}:person:${PERSON}:share_data`);
240
+ });
241
+
242
+ it("an unsubscribe withdraws a grant an anonymous form made, and only once", async () => {
243
+ // A magnet row the person never confirmed themself: own reading says
244
+ // 'not granted', any-source says granted.
245
+ ledger.push({
246
+ id: "cnsnt_00000000-0000-7000-8000-0000000000f1",
247
+ workspaceId: WS,
248
+ subjectType: "lead",
249
+ subjectId: "lead_1",
250
+ personId: PERSON,
251
+ purposeId: "share_data",
252
+ granted: true,
253
+ source: "magnet",
254
+ sourceRef: "free-guide",
255
+ policyVersion: 1,
256
+ locale: "en",
257
+ statement: "Share usage data.",
258
+ createdAt: new Date("2026-10-12T00:00:00Z"),
259
+ });
260
+ const first = await recordPersonConsent({
261
+ ...base,
262
+ purposeId: "share_data",
263
+ granted: false,
264
+ source: "email_unsubscribe",
265
+ });
266
+ const second = await recordPersonConsent({
267
+ ...base,
268
+ purposeId: "share_data",
269
+ granted: false,
270
+ source: "email_unsubscribe",
271
+ });
272
+ expect(first).toEqual({ ok: true, changed: true });
273
+ expect(second).toEqual({ ok: true, changed: false });
274
+ expect(ledger.filter((r) => r.source === "email_unsubscribe")).toHaveLength(
275
+ 1,
276
+ );
277
+ });
278
+
279
+ it("an unsubscribe still withdraws an own grant that a newer anonymous decline hides", async () => {
280
+ await settings("share_data", true);
281
+ ledger.push({
282
+ id: "cnsnt_00000000-0000-7000-8000-0000000000f2",
283
+ workspaceId: WS,
284
+ subjectType: "lead",
285
+ subjectId: "lead_2",
286
+ personId: PERSON,
287
+ purposeId: "share_data",
288
+ granted: false,
289
+ source: "magnet",
290
+ sourceRef: "free-guide",
291
+ policyVersion: 1,
292
+ locale: "en",
293
+ statement: "Share usage data.",
294
+ createdAt: new Date(Date.now() + 60_000),
295
+ });
296
+ const result = await recordPersonConsent({
297
+ ...base,
298
+ purposeId: "share_data",
299
+ granted: false,
300
+ source: "email_unsubscribe",
301
+ });
302
+ expect(result).toEqual({ ok: true, changed: true });
303
+ expect(ledger.filter((r) => r.source === "email_unsubscribe")).toHaveLength(
304
+ 1,
305
+ );
306
+ });
307
+
308
+ it("writes the owner's platform consents against subject user with no Person", async () => {
309
+ const result = await recordPersonConsent({
310
+ workspaceId: WS,
311
+ personId: null,
312
+ purposeId: "wt_product_data",
313
+ granted: true,
314
+ source: "account_settings",
315
+ locale: "en",
316
+ config: PLATFORM_CONSENT_CONFIG,
317
+ subject: { subjectType: "user", subjectId: "user_1" },
318
+ });
319
+ expect(result).toEqual({ ok: true, changed: true });
320
+ expect(ledger[0]).toMatchObject({ subjectType: "user", personId: null });
321
+ });
322
+ });
323
+
324
+ describe("recordCookieChoice (#8438 D3)", () => {
325
+ const choice = (c: "accepted" | "declined") =>
326
+ recordCookieChoice(
327
+ { workspaceId: WS, visitorId: VISITOR, choice: c, locale: "en" },
328
+ CONFIG,
329
+ );
330
+
331
+ it("writes one visitor row stamped with the banner source and the registry statement", async () => {
332
+ expect(await choice("accepted")).toEqual({ recorded: true });
333
+ expect(ledger).toHaveLength(1);
334
+ expect(ledger[0]).toMatchObject({
335
+ subjectType: "visitor",
336
+ subjectId: VISITOR,
337
+ purposeId: "analytics_cookies",
338
+ granted: true,
339
+ source: "cookie_banner",
340
+ policyVersion: 1,
341
+ personId: null,
342
+ });
343
+ });
344
+
345
+ it("logs a change of choice and not a replay", async () => {
346
+ await choice("accepted");
347
+ expect(await choice("accepted")).toEqual({ recorded: false });
348
+ expect(await choice("declined")).toEqual({ recorded: true });
349
+ expect(ledger.map((r) => r.granted)).toEqual([true, false]);
350
+ });
351
+
352
+ it("writes nothing when the registry declares no cookie purpose", async () => {
353
+ const none = siteConsentConfigSchema.parse({
354
+ policies: {},
355
+ purposes: [{ id: "a", label: { en: "a" } }],
356
+ });
357
+ expect(
358
+ await recordCookieChoice(
359
+ {
360
+ workspaceId: WS,
361
+ visitorId: VISITOR,
362
+ choice: "accepted",
363
+ locale: "en",
364
+ },
365
+ none,
366
+ ),
367
+ ).toEqual({ recorded: false });
368
+ expect(ledger).toHaveLength(0);
369
+ });
370
+
371
+ it("uses the platform default registry when none is passed", async () => {
372
+ expect(
373
+ await recordCookieChoice({
374
+ workspaceId: WS,
375
+ visitorId: VISITOR,
376
+ choice: "declined",
377
+ locale: "en",
378
+ }),
379
+ ).toEqual({ recorded: true });
380
+ expect(ledger[0]?.["purposeId"]).toBe(
381
+ SITE_CONSENT_CONFIG_DEFAULTS.purposes.find((p) => p.area === "cookies")
382
+ ?.id,
383
+ );
384
+ });
385
+ });
386
+
387
+ describe("linkConsentVisitor (#8438 C5)", () => {
388
+ it("inserts and ignores a duplicate", async () => {
389
+ (prisma.consentVisitorLink.createMany as jest.Mock).mockResolvedValue({
390
+ count: 1,
391
+ });
392
+ await linkConsentVisitor({
393
+ workspaceId: WS,
394
+ visitorId: VISITOR,
395
+ personId: PERSON,
396
+ source: "magnet",
397
+ });
398
+ expect(prisma.consentVisitorLink.createMany).toHaveBeenCalledWith({
399
+ data: [
400
+ {
401
+ workspaceId: WS,
402
+ visitorId: VISITOR,
403
+ personId: PERSON,
404
+ source: "magnet",
405
+ },
406
+ ],
407
+ skipDuplicates: true,
408
+ });
409
+ });
410
+
411
+ it("never throws: a failure is swallowed so a link cannot fail a lead", async () => {
412
+ (prisma.consentVisitorLink.createMany as jest.Mock).mockRejectedValue(
413
+ new Error("down"),
414
+ );
415
+ await expect(
416
+ linkConsentVisitor({
417
+ workspaceId: WS,
418
+ visitorId: VISITOR,
419
+ personId: PERSON,
420
+ source: "api",
421
+ }),
422
+ ).resolves.toBeUndefined();
423
+ });
424
+ });
425
+
426
+ describe("the unsubscribe token (#8438 D3, SEC-8438-9)", () => {
427
+ const SECRET = "x".repeat(40);
428
+ const claims = {
429
+ workspaceId: WS,
430
+ personId: PERSON,
431
+ purposeId: "product_news",
432
+ };
433
+ let saved: string | undefined;
434
+
435
+ beforeEach(() => {
436
+ saved = process.env["AILK_CONSENT_LINK_SECRET"];
437
+ process.env["AILK_CONSENT_LINK_SECRET"] = SECRET;
438
+ });
439
+ afterEach(() => {
440
+ if (saved === undefined) delete process.env["AILK_CONSENT_LINK_SECRET"];
441
+ else process.env["AILK_CONSENT_LINK_SECRET"] = saved;
442
+ });
443
+
444
+ it("round-trips and has the v1.<payload>.<mac> shape", () => {
445
+ const token = consentUnsubscribeToken(claims);
446
+ expect(token).toMatch(/^v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
447
+ expect(verifyConsentUnsubscribeToken(token)).toEqual(claims);
448
+ });
449
+
450
+ it("refuses a token with one flipped character", () => {
451
+ const token = consentUnsubscribeToken(claims);
452
+ const flipped = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A");
453
+ expect(verifyConsentUnsubscribeToken(flipped)).toBeNull();
454
+ const body = token.split(".");
455
+ body[1] = body[1]!.slice(0, -1) + (body[1]!.endsWith("A") ? "B" : "A");
456
+ expect(verifyConsentUnsubscribeToken(body.join("."))).toBeNull();
457
+ });
458
+
459
+ it("refuses a token older than 400 days, and a future-dated one", () => {
460
+ const now = new Date("2026-10-12T00:00:00Z");
461
+ const old = consentUnsubscribeToken(
462
+ claims,
463
+ new Date(now.getTime() - 401 * 86_400_000),
464
+ );
465
+ const fresh = consentUnsubscribeToken(
466
+ claims,
467
+ new Date(now.getTime() - 399 * 86_400_000),
468
+ );
469
+ const future = consentUnsubscribeToken(
470
+ claims,
471
+ new Date(now.getTime() + 3_600_000),
472
+ );
473
+ expect(verifyConsentUnsubscribeToken(old, now)).toBeNull();
474
+ expect(verifyConsentUnsubscribeToken(fresh, now)).toEqual(claims);
475
+ expect(verifyConsentUnsubscribeToken(future, now)).toBeNull();
476
+ });
477
+
478
+ it("refuses a wrong version, garbage, and a token signed with another secret", () => {
479
+ const token = consentUnsubscribeToken(claims);
480
+ expect(
481
+ verifyConsentUnsubscribeToken(token.replace(/^v1/, "v2")),
482
+ ).toBeNull();
483
+ expect(verifyConsentUnsubscribeToken("nonsense")).toBeNull();
484
+ expect(verifyConsentUnsubscribeToken("a.b.c")).toBeNull();
485
+ process.env["AILK_CONSENT_LINK_SECRET"] = "y".repeat(40);
486
+ expect(verifyConsentUnsubscribeToken(token)).toBeNull();
487
+ });
488
+
489
+ it("throws on mint, and refuses on verify, with the secret unset or under 32 bytes", () => {
490
+ const token = consentUnsubscribeToken(claims);
491
+ delete process.env["AILK_CONSENT_LINK_SECRET"];
492
+ expect(consentLinkSecretConfigured()).toBe(false);
493
+ expect(() => consentUnsubscribeToken(claims)).toThrow(
494
+ ConsentLinkSecretMissing,
495
+ );
496
+ expect(verifyConsentUnsubscribeToken(token)).toBeNull();
497
+ process.env["AILK_CONSENT_LINK_SECRET"] = "short";
498
+ expect(consentLinkSecretConfigured()).toBe(false);
499
+ expect(() => consentUnsubscribeToken(claims)).toThrow(
500
+ ConsentLinkSecretMissing,
501
+ );
502
+ });
503
+ });
@@ -38,7 +38,12 @@ import {
38
38
  } from "../consent";
39
39
 
40
40
  const SITE_ID = "site-1";
41
- const SUBJECT = { subjectType: "project_listing", subjectId: "prjl_00000000-0000-7000-8000-000000000001" };
41
+ const SUBJECT = {
42
+ subjectType: "project_listing",
43
+ subjectId: "prjl_00000000-0000-7000-8000-000000000001",
44
+ personId: null,
45
+ source: "api" as const,
46
+ };
42
47
 
43
48
  /** Two purposes, one of them bilingual — the fixture every locale case reads from. */
44
49
  const CONFIG = siteConsentConfigSchema.parse({
@@ -220,6 +225,10 @@ describe("recordLegacyConsent (#5275 D11)", () => {
220
225
  locale: UNRECOVERABLE_CONSENT_LOCALE,
221
226
  statement: UNRECOVERABLE_CONSENT_STATEMENT,
222
227
  statementHash: consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT),
228
+ // #8438 D2 — the ledger fields: no Person, no magnet slug, and the
229
+ // registry's policy version in force.
230
+ sourceRef: null,
231
+ policyVersion: 1,
223
232
  });
224
233
  expect(rows[0]!["purposeId"]).toBe("service_terms");
225
234
  expect(rows[0]!["locale"]).toBe("und");
@@ -266,13 +275,19 @@ describe("consentConfigForSite", () => {
266
275
  });
267
276
 
268
277
  describe("requiredConsentUnmet (#5275, security review Medium 1)", () => {
269
- const config = {
278
+ const config = siteConsentConfigSchema.parse({
270
279
  policies: {},
271
280
  purposes: [
272
281
  { id: "service_terms", required: true, label: { en: "a" } },
273
282
  { id: "marketing", required: false, label: { en: "b" } },
274
283
  ],
275
- };
284
+ });
285
+
286
+ it("does not demand a sign-up agreement of a form that cannot show it (#8438 D13)", () => {
287
+ expect(
288
+ requiredConsentUnmet(SITE_CONSENT_CONFIG_DEFAULTS, { service_terms: true }),
289
+ ).toBeUndefined();
290
+ });
276
291
 
277
292
  it("names a required purpose that is declined OR absent, and passes when every required one is true", () => {
278
293
  expect(requiredConsentUnmet(config, { service_terms: false })).toBe("service_terms");