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,237 @@
1
+ /**
2
+ * @file consent-ledger-double.ts
3
+ * @description An in-memory model of the consent ledger for suites that run
4
+ * the REAL `services/consent.ts` and `lib/consent-store.ts` over the mocked
5
+ * Prisma client (`lib/__mocks__/prisma.ts`) — #8438.
6
+ *
7
+ * `armConsentLedger(prisma)` wires the mocked client so that:
8
+ * - `$transaction(fn)` runs `fn` on the same client;
9
+ * - `consentGrant.createMany` appends to the returned `ledger`;
10
+ * - `consentVisitorLink.createMany` appends to `links` (skipping a repeat);
11
+ * - `$queryRaw` answers the way the store's SQL would — newest row per
12
+ * purpose for the own / any-source / platform readings, the visitor's
13
+ * newest choice, and keyset history pages — by recognising each query's
14
+ * SQL text.
15
+ *
16
+ * This proves the writers' and routes' decisions ("write only when the answer
17
+ * changes", "own rows only", "a cursor of another Person is invalid") against
18
+ * a model with the same semantics as the SQL. The SQL itself is proved
19
+ * against a real database in `database/__tests__/consent-ledger.db.test.ts`.
20
+ */
21
+
22
+ export type LedgerRow = {
23
+ id: string;
24
+ workspaceId: string;
25
+ subjectType: string;
26
+ subjectId: string;
27
+ personId: string | null;
28
+ purposeId: string;
29
+ granted: boolean;
30
+ source: string;
31
+ sourceRef: string | null;
32
+ policyVersion: number | null;
33
+ locale: string;
34
+ statement: string;
35
+ createdAt: Date;
36
+ };
37
+
38
+ export type LinkRow = {
39
+ workspaceId: string;
40
+ visitorId: string;
41
+ personId: string;
42
+ source: string;
43
+ };
44
+
45
+ const OWN_SOURCES = ["sign_up", "account_settings", "email_unsubscribe"];
46
+
47
+ type Mockable = {
48
+ mockImplementation: (fn: (...args: never[]) => unknown) => unknown;
49
+ mockResolvedValue: (v: unknown) => unknown;
50
+ };
51
+
52
+ interface ArmablePrisma {
53
+ $transaction: unknown;
54
+ $executeRaw: unknown;
55
+ $queryRaw: unknown;
56
+ consentGrant: { createMany: unknown };
57
+ consentVisitorLink: { createMany: unknown };
58
+ }
59
+
60
+ export interface ConsentLedgerDouble {
61
+ ledger: LedgerRow[];
62
+ links: LinkRow[];
63
+ }
64
+
65
+ let clock = 0;
66
+
67
+ export function armConsentLedger(prisma: unknown): ConsentLedgerDouble {
68
+ const p = prisma as ArmablePrisma;
69
+ const double: ConsentLedgerDouble = { ledger: [], links: [] };
70
+ clock = 0;
71
+
72
+ (p.$transaction as Mockable).mockImplementation((async (
73
+ fn: (tx: unknown) => unknown,
74
+ ) => fn(prisma)) as never);
75
+ (p.$executeRaw as Mockable).mockResolvedValue(1);
76
+
77
+ (p.consentGrant.createMany as Mockable).mockImplementation(((args: {
78
+ data: Array<Omit<LedgerRow, "id" | "createdAt">>;
79
+ }) => {
80
+ for (const row of args.data) {
81
+ clock += 1;
82
+ double.ledger.push({
83
+ ...row,
84
+ id: `cnsnt_00000000-0000-7000-8000-${String(clock).padStart(12, "0")}`,
85
+ createdAt: new Date(Date.UTC(2026, 9, 12, 0, 0, clock)),
86
+ });
87
+ }
88
+ return Promise.resolve({ count: args.data.length });
89
+ }) as never);
90
+
91
+ (p.consentVisitorLink.createMany as Mockable).mockImplementation(((args: {
92
+ data: LinkRow[];
93
+ }) => {
94
+ let count = 0;
95
+ for (const row of args.data) {
96
+ const seen = double.links.some(
97
+ (l) =>
98
+ l.workspaceId === row.workspaceId &&
99
+ l.visitorId === row.visitorId &&
100
+ l.personId === row.personId,
101
+ );
102
+ if (!seen) {
103
+ double.links.push(row);
104
+ count += 1;
105
+ }
106
+ }
107
+ return Promise.resolve({ count });
108
+ }) as never);
109
+
110
+ (p.$queryRaw as Mockable).mockImplementation(((
111
+ strings: TemplateStringsArray,
112
+ ...values: unknown[]
113
+ ) => {
114
+ const sql = strings.join("?");
115
+ const [workspaceId] = values as [string];
116
+
117
+ const linkedVisitors = (personId: string) =>
118
+ new Set(
119
+ double.links
120
+ .filter(
121
+ (l) => l.workspaceId === workspaceId && l.personId === personId,
122
+ )
123
+ .map((l) => l.visitorId),
124
+ );
125
+ const anySource = (personId: string) =>
126
+ double.ledger.filter(
127
+ (r) =>
128
+ r.workspaceId === workspaceId &&
129
+ (r.personId === personId ||
130
+ (r.subjectType === "visitor" &&
131
+ linkedVisitors(personId).has(r.subjectId))),
132
+ );
133
+ const newestPerPurpose = (rows: LedgerRow[]) => {
134
+ const byPurpose = new Map<string, LedgerRow>();
135
+ for (const row of rows) byPurpose.set(row.purposeId, row);
136
+ return [...byPurpose.values()];
137
+ };
138
+ const state = (r: LedgerRow) => ({
139
+ id: r.id,
140
+ purposeId: r.purposeId,
141
+ granted: r.granted,
142
+ at: r.createdAt,
143
+ source: r.source,
144
+ sourceRef: r.sourceRef,
145
+ policyVersion: r.policyVersion,
146
+ });
147
+
148
+ // The visitor's newest choice for one purpose (the cookie writer's dedupe).
149
+ if (
150
+ sql.includes(`"subject_type" = 'visitor'`) &&
151
+ sql.includes("LIMIT 1") &&
152
+ !sql.includes("person_id")
153
+ ) {
154
+ const [, visitorId, purposeId] = values as [string, string, string];
155
+ const rows = double.ledger.filter(
156
+ (r) =>
157
+ r.workspaceId === workspaceId &&
158
+ r.subjectType === "visitor" &&
159
+ r.subjectId === visitorId &&
160
+ r.purposeId === purposeId,
161
+ );
162
+ const last = rows[rows.length - 1];
163
+ return Promise.resolve(
164
+ last
165
+ ? [{ granted: last.granted, policyVersion: last.policyVersion }]
166
+ : [],
167
+ );
168
+ }
169
+ // The platform reading: subject `user`.
170
+ if (sql.includes(`"subject_type" = 'user'`)) {
171
+ const [, userId] = values as [string, string];
172
+ return Promise.resolve(
173
+ newestPerPurpose(
174
+ double.ledger.filter(
175
+ (r) =>
176
+ r.workspaceId === workspaceId &&
177
+ r.subjectType === "user" &&
178
+ r.subjectId === userId,
179
+ ),
180
+ ).map(state),
181
+ );
182
+ }
183
+ // A history cursor check: does this row id belong to this Person's scope?
184
+ if (sql.includes('SELECT g."id" FROM "consent_grants" g')) {
185
+ const [, cursor, personId] = values as [string, string, string];
186
+ return Promise.resolve(
187
+ anySource(personId)
188
+ .filter((r) => r.id === cursor)
189
+ .map((r) => ({ id: r.id })),
190
+ );
191
+ }
192
+ // A history page: newest first, keyset on (created_at, id).
193
+ if (
194
+ sql.includes('ORDER BY g."created_at" DESC, g."id" DESC') &&
195
+ sql.includes('g."locale"')
196
+ ) {
197
+ const personId = values[1] as string;
198
+ const cursor = values[values.length - 3] as string | null;
199
+ let rows = anySource(personId).slice().reverse();
200
+ if (cursor) {
201
+ const at = rows.findIndex((r) => r.id === cursor);
202
+ rows = rows.slice(at + 1);
203
+ }
204
+ const limit = values[values.length - 1] as number;
205
+ return Promise.resolve(
206
+ rows.slice(0, limit).map((r) => ({
207
+ ...state(r),
208
+ locale: r.locale,
209
+ statement: r.statement,
210
+ subjectType: r.subjectType,
211
+ })),
212
+ );
213
+ }
214
+ // The own reading (a Person's own-source rows).
215
+ if (sql.includes(`'sign_up', 'account_settings', 'email_unsubscribe'`)) {
216
+ const [, personId] = values as [string, string];
217
+ return Promise.resolve(
218
+ newestPerPurpose(
219
+ double.ledger.filter(
220
+ (r) =>
221
+ r.workspaceId === workspaceId &&
222
+ r.personId === personId &&
223
+ OWN_SOURCES.includes(r.source),
224
+ ),
225
+ ).map(state),
226
+ );
227
+ }
228
+ // The any-source reading.
229
+ if (sql.includes("consent_visitor_links")) {
230
+ const [, personId] = values as [string, string];
231
+ return Promise.resolve(newestPerPurpose(anySource(personId)).map(state));
232
+ }
233
+ return Promise.resolve([]);
234
+ }) as never);
235
+
236
+ return double;
237
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * @file consent-store.test.ts
3
+ * @description The consent ledger's read boundary (#8438 D4, AC-2).
4
+ *
5
+ * External dependency boundaries mocked:
6
+ * - @working-theory/database (via moduleNameMapper -> prisma-client.js mock;
7
+ * prisma singleton via __mocks__/prisma.ts)
8
+ *
9
+ * The readers run over `consent-ledger-double.ts`, an in-memory model with
10
+ * the same semantics as their SQL; the SQL text itself is run against a real
11
+ * database by `database/__tests__/consent-ledger.db.test.ts`. What this file
12
+ * proves: the two readings never mix (a row an anonymous writer attached to a
13
+ * Person never changes the Person's OWN answer), a flood cannot push an
14
+ * answer out, history pages are complete, and a cursor of another Person is
15
+ * refused.
16
+ */
17
+
18
+ // 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.
19
+ jest.mock("../prisma.js");
20
+
21
+ import { readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+
24
+ import { siteConsentConfigSchema } from "@working-theory/validation";
25
+
26
+ import {
27
+ CONSENT_HISTORY_PAGE_SIZE,
28
+ anySourceConsentState,
29
+ effectiveConsent,
30
+ isUnverifiedSource,
31
+ latestVisitorChoice,
32
+ OWN_CONSENT_SOURCES,
33
+ ownConsentState,
34
+ personConsentHistory,
35
+ userPlatformRows,
36
+ } from "../consent-store.js";
37
+ import { prisma } from "../prisma.js";
38
+
39
+ import {
40
+ armConsentLedger,
41
+ type ConsentLedgerDouble,
42
+ type LedgerRow,
43
+ } from "./consent-ledger-double.js";
44
+
45
+ const WS = "wksp_01920000-0000-7000-8000-0000000000a1";
46
+ const OTHER_WS = "wksp_01920000-0000-7000-8000-0000000000a2";
47
+ const PERSON = "person_01920000-0000-7000-8000-0000000000a1";
48
+ const OTHER_PERSON = "person_01920000-0000-7000-8000-0000000000a2";
49
+ const VISITOR = "0192a000-0000-4000-8000-0000000000a1";
50
+
51
+ const CONFIG = siteConsentConfigSchema.parse({
52
+ policies: {},
53
+ purposes: [
54
+ {
55
+ id: "news",
56
+ kind: "preference",
57
+ area: "communications",
58
+ label: { en: "News." },
59
+ },
60
+ { id: "data", kind: "consent", area: "data", label: { en: "Data." } },
61
+ ],
62
+ });
63
+
64
+ let double: ConsentLedgerDouble;
65
+ let counter = 0;
66
+
67
+ function row(over: Partial<LedgerRow>): LedgerRow {
68
+ counter += 1;
69
+ return {
70
+ id: `cnsnt_00000000-0000-7000-8000-${String(900000 + counter).padStart(12, "0")}`,
71
+ workspaceId: WS,
72
+ subjectType: "person",
73
+ subjectId: PERSON,
74
+ personId: PERSON,
75
+ purposeId: "news",
76
+ granted: true,
77
+ source: "account_settings",
78
+ sourceRef: null,
79
+ policyVersion: 1,
80
+ locale: "en",
81
+ statement: "News.",
82
+ createdAt: new Date(Date.UTC(2026, 9, 12, 0, 0, 0, counter)),
83
+ ...over,
84
+ };
85
+ }
86
+
87
+ beforeEach(() => {
88
+ jest.clearAllMocks();
89
+ counter = 0;
90
+ double = armConsentLedger(prisma);
91
+ });
92
+
93
+ describe("the two readings (SEC-8438-1)", () => {
94
+ it("a magnet grant an anonymous form attached never changes the Person's OWN answer", async () => {
95
+ double.ledger.push(
96
+ row({ purposeId: "data", granted: false, source: "account_settings" }),
97
+ row({
98
+ purposeId: "data",
99
+ granted: true,
100
+ source: "magnet",
101
+ sourceRef: "guide",
102
+ }),
103
+ );
104
+ const own = await ownConsentState(WS, PERSON, CONFIG);
105
+ const any = await anySourceConsentState(WS, PERSON, CONFIG);
106
+ expect(own.find((s) => s.purposeId === "data")?.effective).toBe(false);
107
+ expect(any.find((s) => s.purposeId === "data")?.effective).toBe(true);
108
+ expect(any.find((s) => s.purposeId === "data")?.row?.source).toBe("magnet");
109
+ });
110
+
111
+ it("a preference is on until withdrawn, a consent off until granted, with no row", async () => {
112
+ const own = await ownConsentState(WS, PERSON, CONFIG);
113
+ expect(own.map((s) => [s.purposeId, s.kind, s.row, s.effective])).toEqual([
114
+ ["news", "preference", null, true],
115
+ ["data", "consent", null, false],
116
+ ]);
117
+ expect(effectiveConsent("preference", null)).toBe(true);
118
+ expect(effectiveConsent("agreement", null)).toBe(false);
119
+ expect(effectiveConsent("consent", { granted: false })).toBe(false);
120
+ });
121
+
122
+ it("the OWN sources are exactly the three the SQL names, and the unverified set is the rest", () => {
123
+ const source = readFileSync(join(__dirname, "../consent-store.ts"), "utf8");
124
+ const literal = `IN ('${OWN_CONSENT_SOURCES.join("', '")}')`;
125
+ expect(source.split(literal).length - 1).toBe(1);
126
+ expect(OWN_CONSENT_SOURCES.map(isUnverifiedSource)).toEqual([
127
+ false,
128
+ false,
129
+ false,
130
+ ]);
131
+ expect(
132
+ (["magnet", "api", "cookie_banner", "legacy"] as const).every(
133
+ isUnverifiedSource,
134
+ ),
135
+ ).toBe(true);
136
+ });
137
+
138
+ it("another workspace's rows and another Person's rows never show", async () => {
139
+ double.ledger.push(
140
+ row({ workspaceId: OTHER_WS, purposeId: "data", granted: true }),
141
+ row({
142
+ personId: OTHER_PERSON,
143
+ subjectId: OTHER_PERSON,
144
+ purposeId: "data",
145
+ granted: true,
146
+ }),
147
+ );
148
+ const own = await ownConsentState(WS, PERSON, CONFIG);
149
+ expect(own.find((s) => s.purposeId === "data")?.row).toBeNull();
150
+ });
151
+
152
+ it("a linked visitor's cookie rows reach the any-source reading only, and only in the same workspace", async () => {
153
+ double.ledger.push(
154
+ row({
155
+ subjectType: "visitor",
156
+ subjectId: VISITOR,
157
+ personId: null,
158
+ purposeId: "data",
159
+ granted: true,
160
+ source: "cookie_banner",
161
+ }),
162
+ );
163
+ double.links.push({
164
+ workspaceId: WS,
165
+ visitorId: VISITOR,
166
+ personId: PERSON,
167
+ source: "magnet",
168
+ });
169
+ double.links.push({
170
+ workspaceId: OTHER_WS,
171
+ visitorId: VISITOR,
172
+ personId: OTHER_PERSON,
173
+ source: "magnet",
174
+ });
175
+ const any = await anySourceConsentState(WS, PERSON, CONFIG);
176
+ expect(any.find((s) => s.purposeId === "data")?.row?.source).toBe(
177
+ "cookie_banner",
178
+ );
179
+ expect(
180
+ (await anySourceConsentState(WS, OTHER_PERSON, CONFIG)).every(
181
+ (s) => s.row === null,
182
+ ),
183
+ ).toBe(true);
184
+ expect(
185
+ (await ownConsentState(WS, PERSON, CONFIG)).every((s) => s.row === null),
186
+ ).toBe(true);
187
+ });
188
+ });
189
+
190
+ describe("the flood case (SEC-8438-2, AC-2)", () => {
191
+ it("600 magnet grants and 600 linked-visitor cookie rows cannot push out a withdrawal", async () => {
192
+ double.ledger.push(
193
+ row({ purposeId: "data", granted: false, source: "account_settings" }),
194
+ );
195
+ double.links.push({
196
+ workspaceId: WS,
197
+ visitorId: VISITOR,
198
+ personId: PERSON,
199
+ source: "magnet",
200
+ });
201
+ for (let i = 0; i < 600; i += 1) {
202
+ double.ledger.push(
203
+ row({ purposeId: "data", granted: true, source: "magnet" }),
204
+ );
205
+ }
206
+ for (let i = 0; i < 600; i += 1) {
207
+ double.ledger.push(
208
+ row({
209
+ subjectType: "visitor",
210
+ subjectId: VISITOR,
211
+ personId: null,
212
+ purposeId: "data",
213
+ granted: true,
214
+ source: "cookie_banner",
215
+ }),
216
+ );
217
+ }
218
+ const own = await ownConsentState(WS, PERSON, CONFIG);
219
+ const any = await anySourceConsentState(WS, PERSON, CONFIG);
220
+ expect(own.find((s) => s.purposeId === "data")).toMatchObject({
221
+ effective: false,
222
+ });
223
+ expect(any.find((s) => s.purposeId === "data")).toMatchObject({
224
+ effective: true,
225
+ });
226
+
227
+ // Following nextCursor returns all 1,201 rows exactly once.
228
+ const seen: string[] = [];
229
+ let cursor: string | undefined;
230
+ let pages = 0;
231
+ for (;;) {
232
+ const page = await personConsentHistory(WS, PERSON, cursor);
233
+ if (page === "invalid_cursor")
234
+ throw new Error("unexpected invalid cursor");
235
+ expect(page.rows.length).toBeLessThanOrEqual(CONSENT_HISTORY_PAGE_SIZE);
236
+ seen.push(...page.rows.map((r) => r.id));
237
+ pages += 1;
238
+ if (page.nextCursor === null) break;
239
+ cursor = page.nextCursor;
240
+ }
241
+ expect(pages).toBe(3);
242
+ expect(seen).toHaveLength(1201);
243
+ expect(new Set(seen).size).toBe(1201);
244
+ });
245
+ });
246
+
247
+ describe("personConsentHistory", () => {
248
+ it("is newest first and carries the wording, never a subject id", async () => {
249
+ double.ledger.push(row({ granted: true }), row({ granted: false }));
250
+ const page = await personConsentHistory(WS, PERSON, undefined);
251
+ if (page === "invalid_cursor") throw new Error("unexpected");
252
+ expect(page.rows.map((r) => r.granted)).toEqual([false, true]);
253
+ expect(page.rows[0]).toHaveProperty("statement", "News.");
254
+ expect(page.rows[0]).not.toHaveProperty("subjectId");
255
+ expect(page.nextCursor).toBeNull();
256
+ });
257
+
258
+ it("refuses a cursor that is another Person's row, another workspace's row, or unknown", async () => {
259
+ double.ledger.push(
260
+ row({ granted: true }),
261
+ row({ personId: OTHER_PERSON, subjectId: OTHER_PERSON }),
262
+ row({ workspaceId: OTHER_WS }),
263
+ );
264
+ const [, theirs, elsewhere] = double.ledger;
265
+ expect(await personConsentHistory(WS, PERSON, theirs!.id)).toBe(
266
+ "invalid_cursor",
267
+ );
268
+ expect(await personConsentHistory(WS, PERSON, elsewhere!.id)).toBe(
269
+ "invalid_cursor",
270
+ );
271
+ expect(await personConsentHistory(WS, PERSON, "cnsnt_nope")).toBe(
272
+ "invalid_cursor",
273
+ );
274
+ });
275
+ });
276
+
277
+ describe("the cookie dedupe read and the platform read", () => {
278
+ it("latestVisitorChoice answers the newest row for the visitor and purpose", async () => {
279
+ double.ledger.push(
280
+ row({
281
+ subjectType: "visitor",
282
+ subjectId: VISITOR,
283
+ personId: null,
284
+ granted: true,
285
+ purposeId: "data",
286
+ source: "cookie_banner",
287
+ }),
288
+ row({
289
+ subjectType: "visitor",
290
+ subjectId: VISITOR,
291
+ personId: null,
292
+ granted: false,
293
+ purposeId: "data",
294
+ source: "cookie_banner",
295
+ }),
296
+ );
297
+ expect(await latestVisitorChoice(WS, VISITOR, "data")).toEqual({
298
+ granted: false,
299
+ policyVersion: 1,
300
+ });
301
+ expect(await latestVisitorChoice(WS, VISITOR, "news")).toBeNull();
302
+ expect(await latestVisitorChoice(OTHER_WS, VISITOR, "data")).toBeNull();
303
+ });
304
+
305
+ it("userPlatformRows reads the user's own rows, newest per purpose", async () => {
306
+ double.ledger.push(
307
+ row({
308
+ subjectType: "user",
309
+ subjectId: "user_1",
310
+ personId: null,
311
+ purposeId: "wt_marketing",
312
+ granted: true,
313
+ }),
314
+ row({
315
+ subjectType: "user",
316
+ subjectId: "user_1",
317
+ personId: null,
318
+ purposeId: "wt_marketing",
319
+ granted: false,
320
+ }),
321
+ row({
322
+ subjectType: "user",
323
+ subjectId: "user_2",
324
+ personId: null,
325
+ purposeId: "wt_marketing",
326
+ granted: true,
327
+ }),
328
+ );
329
+ const rows = await userPlatformRows(WS, "user_1");
330
+ expect(rows.map((r) => [r.purposeId, r.granted])).toEqual([
331
+ ["wt_marketing", false],
332
+ ]);
333
+ });
334
+ });
@@ -184,3 +184,41 @@ describe("module boundary contract (§1.14 / D16)", () => {
184
184
  );
185
185
  });
186
186
  });
187
+
188
+ // #8171 D3 (AC-6): a delivered version's file is never a magnet deliverable —
189
+ // untagged, attached to no message, and still `null` from both reads.
190
+ describe("a delivered version's file (#8171 D3)", () => {
191
+ beforeEach(() => {
192
+ jest.clearAllMocks();
193
+ (prisma.interaction.count as jest.Mock).mockResolvedValue(0);
194
+ });
195
+
196
+ it("findDeliverableAssetIdByKey returns null, counting within the workspace", async () => {
197
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: ASSET_ID, tags: [] });
198
+ (prisma.pieceVersion.count as jest.Mock).mockResolvedValueOnce(1);
199
+
200
+ const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "draft-2.pdf");
201
+
202
+ expect(prisma.pieceVersion.count).toHaveBeenCalledWith({
203
+ where: { assetId: ASSET_ID, workspaceId: WORKSPACE_ID },
204
+ });
205
+ expect(result).toBeNull();
206
+ });
207
+
208
+ it("readDeliverableAsset returns null, counting within the workspace", async () => {
209
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({
210
+ filename: "draft-2.pdf",
211
+ contentType: "application/pdf",
212
+ bytes: new Uint8Array([1, 2, 3]),
213
+ tags: [],
214
+ });
215
+ (prisma.pieceVersion.count as jest.Mock).mockResolvedValueOnce(1);
216
+
217
+ const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
218
+
219
+ expect(prisma.pieceVersion.count).toHaveBeenCalledWith({
220
+ where: { assetId: ASSET_ID, workspaceId: WORKSPACE_ID },
221
+ });
222
+ expect(result).toBeNull();
223
+ });
224
+ });