create-ailk 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -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
+ });
@@ -0,0 +1,217 @@
1
+ // oss: false
2
+ /**
3
+ * apps/api's live-key admission (#8450 D2, D3) over the REAL
4
+ * `@working-theory/stripe-client` — no mock, so the process-wide flag the
5
+ * package keeps is the one these cases arm or leave alone.
6
+ *
7
+ * Admission is one-way per module registry, so every case loads
8
+ * `lib/stripe.ts` inside its own `jest.isolateModules` with `process.env`
9
+ * set before the `require` and restored after.
10
+ *
11
+ * Every live-shaped key is composed at runtime from fragments, so this file
12
+ * never spells a live prefix (architecture.yaml's `(sk|pk|rk)_live_` pattern)
13
+ * and never names the SDK's admission call.
14
+ */
15
+ import { execFileSync } from "node:child_process";
16
+ import path from "node:path";
17
+
18
+ type Shim = typeof import("../stripe");
19
+
20
+ const RESTRICTED_LIVE = ["rk", "live", ""].join("_");
21
+ const SECRET_LIVE = ["sk", "live", ""].join("_");
22
+ const PUBLISHABLE_LIVE = ["pk", "live", ""].join("_");
23
+ // A distinctive tail: no refusal message may carry any of it.
24
+ const TAIL = "FixtureOnlyQv7Zq9X";
25
+
26
+ const restrictedLiveKey = RESTRICTED_LIVE + TAIL;
27
+ const secretLiveKey = SECRET_LIVE + TAIL;
28
+ const publishableLiveKey = PUBLISHABLE_LIVE + TAIL;
29
+ const testKey = "sk_test_" + TAIL;
30
+
31
+ const ENV_KEYS = ["STRIPE_SECRET_KEY", "STRIPE_MODE", "VERCEL_ENV", "VERCEL", "NODE_ENV"] as const;
32
+ type EnvKey = (typeof ENV_KEYS)[number];
33
+
34
+ /** Load a fresh `lib/stripe.ts` under `env`, run `fn`, then restore the four variables. */
35
+ function withShim(env: Partial<Record<EnvKey, string>>, fn: (shim: Shim) => void): void {
36
+ const saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
37
+ try {
38
+ for (const k of ENV_KEYS) {
39
+ if (env[k] === undefined) delete process.env[k];
40
+ else process.env[k] = env[k];
41
+ }
42
+ jest.isolateModules(() => {
43
+ fn(require("../stripe") as Shim);
44
+ });
45
+ } finally {
46
+ for (const k of ENV_KEYS) {
47
+ if (saved[k] === undefined) delete process.env[k];
48
+ else process.env[k] = saved[k];
49
+ }
50
+ }
51
+ }
52
+
53
+ /** The thrown error, asserted to be the shim's `StripeConfigError` and to carry no key material. */
54
+ function refusal(shim: Shim, fn: () => unknown): Error {
55
+ let caught: unknown;
56
+ try {
57
+ fn();
58
+ } catch (err) {
59
+ caught = err;
60
+ }
61
+ expect(caught).toBeInstanceOf(shim.StripeConfigError);
62
+ const message = (caught as Error).message;
63
+ expect(message).not.toContain(TAIL);
64
+ expect(message).not.toContain(TAIL.slice(-4));
65
+ return caught as Error;
66
+ }
67
+
68
+ const LIVE_PRODUCTION = {
69
+ STRIPE_SECRET_KEY: restrictedLiveKey,
70
+ STRIPE_MODE: "live",
71
+ VERCEL_ENV: "production",
72
+ } as const;
73
+
74
+ describe("admitApiStripeKey — D2's table", () => {
75
+ it("takes no parameter, so nothing can arm it from a synthetic object", () => {
76
+ withShim({ STRIPE_SECRET_KEY: testKey }, (shim) => {
77
+ expect(shim.admitApiStripeKey.length).toBe(0);
78
+ });
79
+ });
80
+
81
+ it("admits a restricted live key beside STRIPE_MODE=live on a Vercel production build", () => {
82
+ withShim(LIVE_PRODUCTION, (shim) => {
83
+ expect(shim.stripeKeyMode()).toBe("test");
84
+ expect(shim.admitApiStripeKey()).toBe("live");
85
+ expect(shim.stripeKeyMode()).toBe("live");
86
+
87
+ // F2/F3 — the admitted class passes; the other two live classes do not.
88
+ expect(() => shim.assertAdmittedStripeKey(restrictedLiveKey)).not.toThrow();
89
+ refusal(shim, () => shim.assertAdmittedStripeKey(secretLiveKey));
90
+ refusal(shim, () => shim.assertAdmittedStripeKey(publishableLiveKey));
91
+ // A test key still passes after admission.
92
+ expect(() => shim.assertAdmittedStripeKey(testKey)).not.toThrow();
93
+
94
+ // The client the route then calls accepts the same key.
95
+ expect(shim.getStripeClient()).toBeDefined();
96
+ });
97
+ });
98
+
99
+ it("off Vercel, reads NODE_ENV=production as the production build", () => {
100
+ withShim(
101
+ { STRIPE_SECRET_KEY: restrictedLiveKey, STRIPE_MODE: "live", NODE_ENV: "production" },
102
+ (shim) => {
103
+ expect(shim.admitApiStripeKey()).toBe("live");
104
+ expect(shim.stripeKeyMode()).toBe("live");
105
+ },
106
+ );
107
+ });
108
+
109
+ it.each([
110
+ ["a Vercel preview build", { VERCEL_ENV: "preview", NODE_ENV: "production" }],
111
+ ["a non-production build off Vercel", { NODE_ENV: "test" }],
112
+ // vercel.json sets NODE_ENV=production everywhere, Preview included.
113
+ ["Vercel with VERCEL_ENV missing, even beside NODE_ENV=production", { VERCEL: "1", NODE_ENV: "production" }],
114
+ ])("refuses a restricted live key with STRIPE_MODE=live on %s", (_label, build) => {
115
+ withShim({ STRIPE_SECRET_KEY: restrictedLiveKey, STRIPE_MODE: "live", ...build }, (shim) => {
116
+ const err = refusal(shim, () => shim.admitApiStripeKey());
117
+ expect(err.message).toMatch(/production/);
118
+ expect(shim.stripeKeyMode()).toBe("test");
119
+ refusal(shim, () => shim.assertAdmittedStripeKey(restrictedLiveKey));
120
+ refusal(shim, () => shim.getStripeClient());
121
+ });
122
+ });
123
+
124
+ it.each([
125
+ ["an unrestricted live key", secretLiveKey],
126
+ ["a publishable live key", publishableLiveKey],
127
+ ["a test key", testKey],
128
+ ])("refuses STRIPE_MODE=live beside %s", (_label, key) => {
129
+ withShim({ STRIPE_SECRET_KEY: key, STRIPE_MODE: "live", VERCEL_ENV: "production" }, (shim) => {
130
+ const err = refusal(shim, () => shim.admitApiStripeKey());
131
+ expect(err.message).toMatch(/STRIPE_MODE=live/);
132
+ expect(shim.stripeKeyMode()).toBe("test");
133
+ refusal(shim, () => shim.assertAdmittedStripeKey(restrictedLiveKey));
134
+ });
135
+ });
136
+
137
+ it.each(["production", "LIVE", ""])("refuses STRIPE_MODE=%j, whatever the key", (mode) => {
138
+ withShim({ ...LIVE_PRODUCTION, STRIPE_MODE: mode }, (shim) => {
139
+ const err = refusal(shim, () => shim.admitApiStripeKey());
140
+ expect(err.message).toMatch(/STRIPE_MODE must be "test" or "live"/);
141
+ expect(shim.stripeKeyMode()).toBe("test");
142
+ refusal(shim, () => shim.assertAdmittedStripeKey(restrictedLiveKey));
143
+ refusal(shim, () => shim.getStripeClient());
144
+ });
145
+ });
146
+
147
+ it.each([undefined, "test"])("with STRIPE_MODE=%s, runs today's sandbox check on a test key", (mode) => {
148
+ withShim({ STRIPE_SECRET_KEY: testKey, STRIPE_MODE: mode }, (shim) => {
149
+ expect(shim.admitApiStripeKey()).toBe("test");
150
+ expect(shim.stripeKeyMode()).toBe("test");
151
+ expect(() => shim.assertAdmittedStripeKey(testKey)).not.toThrow();
152
+ });
153
+ });
154
+
155
+ it.each([undefined, "test"])(
156
+ "with STRIPE_MODE=%s, refuses a restricted live key with today's sandbox message, even on production",
157
+ (mode) => {
158
+ withShim({ ...LIVE_PRODUCTION, STRIPE_MODE: mode }, (shim) => {
159
+ const err = refusal(shim, () => shim.admitApiStripeKey());
160
+ expect(err.message).toMatch(/Stripe live mode is blocked — sandbox only/);
161
+ expect(shim.stripeKeyMode()).toBe("test");
162
+ refusal(shim, () => shim.assertAdmittedStripeKey(restrictedLiveKey));
163
+ refusal(shim, () => shim.getStripeClient());
164
+ });
165
+ },
166
+ );
167
+ });
168
+
169
+ describe("assertAdmittedStripeKey — without admission", () => {
170
+ it("is today's sandbox allow-list: every live class refused, a test key accepted", () => {
171
+ withShim({ STRIPE_SECRET_KEY: testKey }, (shim) => {
172
+ expect(() => shim.assertAdmittedStripeKey(testKey)).not.toThrow();
173
+ for (const key of [restrictedLiveKey, secretLiveKey, publishableLiveKey]) {
174
+ const err = refusal(shim, () => shim.assertAdmittedStripeKey(key));
175
+ expect(err.message).toMatch(/Stripe live mode is blocked — sandbox only/);
176
+ }
177
+ });
178
+ });
179
+ });
180
+
181
+ describe("admitApiStripeKey — call sites (#8450 § 9 SEC-5)", () => {
182
+ // The sweep is a MONOREPO invariant: it reads the whole git tree. A
183
+ // scaffolded site (`create-ailk` verify-scaffold, which copies apps/api into
184
+ // a site that is not a git repository) has no tree to sweep and cannot grow
185
+ // a new call site of its own, so the sweep is skipped there (#8479/#8478).
186
+ const repoRoot = path.resolve(__dirname, "../../../../..");
187
+ const inGitTree = (() => {
188
+ try {
189
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
190
+ cwd: repoRoot,
191
+ stdio: "ignore",
192
+ });
193
+ return true;
194
+ } catch {
195
+ return false;
196
+ }
197
+ })();
198
+ const itInRepo = inGitTree ? it : it.skip;
199
+
200
+ itInRepo("is named only by lib/stripe.ts, server.ts and #8450's own tests", () => {
201
+ const files = execFileSync("git", ["grep", "-l", "admitApiStripeKey", "--", ":!*.md"], {
202
+ cwd: repoRoot,
203
+ encoding: "utf8",
204
+ })
205
+ .split("\n")
206
+ .filter(Boolean)
207
+ .sort();
208
+ expect(files).toEqual([
209
+ "apps/api/src/lib/__tests__/stripe-admission.test.ts",
210
+ "apps/api/src/lib/__tests__/stripe.test.ts",
211
+ "apps/api/src/lib/stripe.ts",
212
+ "apps/api/src/routes/checkout/__tests__/live-key.test.ts",
213
+ "apps/api/src/routes/subscription/__tests__/portal-live-key.test.ts",
214
+ "apps/api/src/server.ts",
215
+ ]);
216
+ });
217
+ });
@@ -62,6 +62,13 @@ describe("apps/api/src/lib/stripe — surface re-exports", () => {
62
62
  expect(new stripeShim.UnknownProductCodeError()).toBeInstanceOf(Error);
63
63
  });
64
64
 
65
+ // #8450 D2, D3 — apps/api's own live-key admission and its request-time guard.
66
+ it("exports admitApiStripeKey, assertAdmittedStripeKey and stripeKeyMode as functions", () => {
67
+ expect(typeof stripeShim.admitApiStripeKey).toBe("function");
68
+ expect(typeof stripeShim.assertAdmittedStripeKey).toBe("function");
69
+ expect(typeof stripeShim.stripeKeyMode).toBe("function");
70
+ });
71
+
65
72
  it("shim functions are the same references as @working-theory/stripe-client exports", () => {
66
73
  expect(stripeShim.getStripeClient).toBe(stripeClient.getStripeClient);
67
74
  expect(stripeShim.assertSandboxKey).toBe(stripeClient.assertSandboxKey);