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,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);
@@ -0,0 +1,335 @@
1
+ /**
2
+ * The consent ledger's read boundary (#8438, spec
3
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md D4).
4
+ *
5
+ * `consent_grants` is append-only and a Person's current state is the newest
6
+ * row per purpose, so every reader here asks the database for exactly that.
7
+ * Nothing is read through a window: `DISTINCT ON (purpose_id) … ORDER BY
8
+ * purpose_id, created_at DESC, id DESC` is one indexed pass over ALL the
9
+ * matching rows, so no number of other rows can push an answer out
10
+ * (SEC-8438-2).
11
+ *
12
+ * TWO READINGS of a Person's rows (SEC-8438-1). A Person is keyed on an email
13
+ * anyone can type into a form, so a row an anonymous writer attached to it
14
+ * proves nothing about what the person themself chose:
15
+ *
16
+ * - OWN rows: `person_id = P` and a source only a verified caller or a
17
+ * signed link sent to the person can write — `sign_up`,
18
+ * `account_settings`, `email_unsubscribe` (`OWN_CONSENT_SOURCES`). The
19
+ * member's screens, the sign-up prompt, and every future send read only
20
+ * these.
21
+ * - ANY-SOURCE rows: every row with `person_id = P`, plus the cookie rows
22
+ * of every visitor linked to P. The operator's Person page reads these,
23
+ * marked `unverified` when the source proves nothing.
24
+ *
25
+ * Every query takes an explicit `workspaceId`, and a join to a linked
26
+ * visitor's rows matches `workspace_id` on both tables (SEC-8438-11) — a
27
+ * visitor id is client-chosen and not namespaced, so another tenant's rows
28
+ * under the same id must never join.
29
+ *
30
+ * This file is the ONLY non-test reader of `consent_grants` and
31
+ * `consent_visitor_links` (`architecture.yaml` names it as a data-access
32
+ * boundary, the kind `member-store.ts` is). The writes are
33
+ * `services/consent.ts`. Logs carry nothing: no email, visitor id, token or
34
+ * statement.
35
+ */
36
+ import type { Prisma } from "@working-theory/database";
37
+ import {
38
+ consentPurposeKind,
39
+ type ConsentPurposeKind,
40
+ type ConsentSource,
41
+ type SiteConsentConfig,
42
+ } from "@working-theory/validation";
43
+
44
+ import { prisma } from "./prisma.js";
45
+
46
+ /** What every reader needs of a client: raw reads only. */
47
+ export type ConsentReadClient = Pick<Prisma.TransactionClient, "$queryRaw">;
48
+
49
+ /**
50
+ * The sources only a verified caller or a signed link can write (D4,
51
+ * SEC-8438-1). The SQL below names the same three literals; the store's test
52
+ * asserts the two agree.
53
+ */
54
+ export const OWN_CONSENT_SOURCES = [
55
+ "sign_up",
56
+ "account_settings",
57
+ "email_unsubscribe",
58
+ ] as const satisfies readonly ConsentSource[];
59
+
60
+ /** The sources whose rows prove nothing the client confirmed themself. */
61
+ const UNVERIFIED_SOURCES: ReadonlySet<ConsentSource> = new Set([
62
+ "magnet",
63
+ "api",
64
+ "cookie_banner",
65
+ "legacy",
66
+ ]);
67
+
68
+ /** Whether a row from `source` is unconfirmed by the client (D4). */
69
+ export function isUnverifiedSource(source: ConsentSource): boolean {
70
+ return UNVERIFIED_SOURCES.has(source);
71
+ }
72
+
73
+ /** A history page's size (D7). */
74
+ export const CONSENT_HISTORY_PAGE_SIZE = 500;
75
+
76
+ /** The most platform rows one user can have in view (D4). */
77
+ const PLATFORM_ROWS_MAX = 100;
78
+
79
+ /** The newest ledger row for one purpose, as the readers serve it. */
80
+ export interface ConsentStateRow {
81
+ id: string;
82
+ purposeId: string;
83
+ granted: boolean;
84
+ at: Date;
85
+ source: ConsentSource;
86
+ sourceRef: string | null;
87
+ policyVersion: number | null;
88
+ }
89
+
90
+ export interface ConsentHistoryRow extends ConsentStateRow {
91
+ locale: string;
92
+ statement: string;
93
+ subjectType: string;
94
+ }
95
+
96
+ /** A registry purpose with the newest row read for it and what that means now. */
97
+ export interface PurposeConsentState {
98
+ purposeId: string;
99
+ kind: ConsentPurposeKind;
100
+ row: ConsentStateRow | null;
101
+ /** Granted now: the row's answer, else on for a preference and off otherwise. */
102
+ effective: boolean;
103
+ }
104
+
105
+ /** The effective answer for a purpose given its newest row (D4). */
106
+ export function effectiveConsent(
107
+ kind: ConsentPurposeKind,
108
+ row: { granted: boolean } | null,
109
+ ): boolean {
110
+ if (row !== null) return row.granted;
111
+ return kind === "preference";
112
+ }
113
+
114
+ /** Fold the newest row per purpose id into the registry's purposes. */
115
+ export function foldRowsIntoPurposes(
116
+ config: SiteConsentConfig,
117
+ rows: readonly ConsentStateRow[],
118
+ ): PurposeConsentState[] {
119
+ const byPurpose = new Map(rows.map((row) => [row.purposeId, row]));
120
+ return config.purposes.map((purpose) => {
121
+ const kind = consentPurposeKind(purpose);
122
+ const row = byPurpose.get(purpose.id) ?? null;
123
+ return {
124
+ purposeId: purpose.id,
125
+ kind,
126
+ row,
127
+ effective: effectiveConsent(kind, row),
128
+ };
129
+ });
130
+ }
131
+
132
+ /**
133
+ * The newest OWN row per purpose id for a Person, unfolded (D4): one indexed
134
+ * `DISTINCT ON` pass over all own rows, never a window.
135
+ */
136
+ export async function ownConsentRows(
137
+ workspaceId: string,
138
+ personId: string,
139
+ db: ConsentReadClient = prisma,
140
+ ): Promise<ConsentStateRow[]> {
141
+ return db.$queryRaw<ConsentStateRow[]>`
142
+ SELECT DISTINCT ON (g."purpose_id")
143
+ g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
144
+ g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
145
+ FROM "consent_grants" g
146
+ WHERE g."workspace_id" = ${workspaceId}
147
+ AND g."person_id" = ${personId}
148
+ AND g."source" IN ('sign_up', 'account_settings', 'email_unsubscribe')
149
+ ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC`;
150
+ }
151
+
152
+ /**
153
+ * The newest ANY-SOURCE row per purpose id for a Person: `person_id = P` or a
154
+ * cookie row of a visitor linked to P, in this workspace on both tables.
155
+ * Includes purposes the registry no longer declares — the operator still sees
156
+ * what was recorded.
157
+ */
158
+ export async function anySourceConsentRows(
159
+ workspaceId: string,
160
+ personId: string,
161
+ db: ConsentReadClient = prisma,
162
+ ): Promise<ConsentStateRow[]> {
163
+ return db.$queryRaw<ConsentStateRow[]>`
164
+ SELECT DISTINCT ON (g."purpose_id")
165
+ g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
166
+ g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
167
+ FROM "consent_grants" g
168
+ WHERE g."workspace_id" = ${workspaceId}
169
+ AND (
170
+ g."person_id" = ${personId}
171
+ OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
172
+ SELECT l."visitor_id" FROM "consent_visitor_links" l
173
+ WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
174
+ ))
175
+ )
176
+ ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC`;
177
+ }
178
+
179
+ /**
180
+ * A Person's OWN reading, per registry purpose (D4). The member's screens and
181
+ * the sign-up prompt read only this.
182
+ */
183
+ export async function ownConsentState(
184
+ workspaceId: string,
185
+ personId: string,
186
+ config: SiteConsentConfig,
187
+ db: ConsentReadClient = prisma,
188
+ ): Promise<PurposeConsentState[]> {
189
+ return foldRowsIntoPurposes(
190
+ config,
191
+ await ownConsentRows(workspaceId, personId, db),
192
+ );
193
+ }
194
+
195
+ /**
196
+ * A Person's ANY-SOURCE reading, per registry purpose. The operator's Person
197
+ * page and the unsubscribe dedupe read it; a row here is never a member's own
198
+ * answer (D4).
199
+ */
200
+ export async function anySourceConsentState(
201
+ workspaceId: string,
202
+ personId: string,
203
+ config: SiteConsentConfig,
204
+ db: ConsentReadClient = prisma,
205
+ ): Promise<PurposeConsentState[]> {
206
+ return foldRowsIntoPurposes(
207
+ config,
208
+ await anySourceConsentRows(workspaceId, personId, db),
209
+ );
210
+ }
211
+
212
+ /** One page of a Person's any-source history, newest first. */
213
+ export interface ConsentHistoryPage {
214
+ rows: ConsentHistoryRow[];
215
+ /** The id of the page's last row when another page follows, else `null`. */
216
+ nextCursor: string | null;
217
+ }
218
+
219
+ /**
220
+ * A Person's any-source history, 500 rows a page, newest first, keyed by
221
+ * `(created_at, id)`. Every page is complete: nothing is truncated (D7).
222
+ * Returns `"invalid_cursor"` for a cursor that is not one of this Person's
223
+ * rows — checked inside the Person's own scope, so a row id of another
224
+ * Person or workspace reads exactly like an unknown one.
225
+ */
226
+ export async function personConsentHistory(
227
+ workspaceId: string,
228
+ personId: string,
229
+ cursor: string | undefined,
230
+ db: ConsentReadClient = prisma,
231
+ ): Promise<ConsentHistoryPage | "invalid_cursor"> {
232
+ if (cursor !== undefined) {
233
+ const found = await db.$queryRaw<Array<{ id: string }>>`
234
+ SELECT g."id" FROM "consent_grants" g
235
+ WHERE g."workspace_id" = ${workspaceId}
236
+ AND g."id" = ${cursor}
237
+ AND (
238
+ g."person_id" = ${personId}
239
+ OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
240
+ SELECT l."visitor_id" FROM "consent_visitor_links" l
241
+ WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
242
+ ))
243
+ )
244
+ LIMIT 1`;
245
+ if (found.length === 0) return "invalid_cursor";
246
+ }
247
+ const cursorId = cursor ?? null;
248
+ const rows = await db.$queryRaw<ConsentHistoryRow[]>`
249
+ SELECT g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
250
+ g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion",
251
+ g."locale", g."statement", g."subject_type" AS "subjectType"
252
+ FROM "consent_grants" g
253
+ WHERE g."workspace_id" = ${workspaceId}
254
+ AND (
255
+ g."person_id" = ${personId}
256
+ OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
257
+ SELECT l."visitor_id" FROM "consent_visitor_links" l
258
+ WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
259
+ ))
260
+ )
261
+ AND (${cursorId}::text IS NULL OR (g."created_at", g."id") < (
262
+ SELECT c."created_at", c."id" FROM "consent_grants" c WHERE c."id" = ${cursorId}::text
263
+ ))
264
+ ORDER BY g."created_at" DESC, g."id" DESC
265
+ LIMIT ${CONSENT_HISTORY_PAGE_SIZE + 1}`;
266
+ const page = rows.slice(0, CONSENT_HISTORY_PAGE_SIZE);
267
+ const last = page[page.length - 1];
268
+ return {
269
+ rows: page,
270
+ nextCursor:
271
+ rows.length > CONSENT_HISTORY_PAGE_SIZE && last ? last.id : null,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * The newest row recording a visitor's choice for one purpose — the cookie
277
+ * writer's dedupe: each CHANGE of choice is logged, a replay adds nothing
278
+ * (D3, SEC-8438-2).
279
+ */
280
+ export async function latestVisitorChoice(
281
+ workspaceId: string,
282
+ visitorId: string,
283
+ purposeId: string,
284
+ db: ConsentReadClient = prisma,
285
+ ): Promise<{ granted: boolean; policyVersion: number | null } | null> {
286
+ const rows = await db.$queryRaw<
287
+ Array<{ granted: boolean; policyVersion: number | null }>
288
+ >`
289
+ SELECT g."granted", g."policy_version" AS "policyVersion"
290
+ FROM "consent_grants" g
291
+ WHERE g."workspace_id" = ${workspaceId}
292
+ AND g."subject_type" = 'visitor'
293
+ AND g."subject_id" = ${visitorId}
294
+ AND g."purpose_id" = ${purposeId}
295
+ ORDER BY g."created_at" DESC, g."id" DESC
296
+ LIMIT 1`;
297
+ return rows[0] ?? null;
298
+ }
299
+
300
+ /**
301
+ * When the caller's own `User` row was created — the sign-up window's only
302
+ * input (SEC-8438-5: `via: "sign_up"` is honored for a user created within
303
+ * the last 24 hours). `null` when no such user exists.
304
+ */
305
+ export async function userCreatedAt(
306
+ userId: string,
307
+ db: Pick<Prisma.TransactionClient, "user"> = prisma,
308
+ ): Promise<Date | null> {
309
+ const user = await db.user.findUnique({
310
+ where: { id: userId },
311
+ select: { createdAt: true },
312
+ });
313
+ return user?.createdAt ?? null;
314
+ }
315
+
316
+ /**
317
+ * The owner's rows for the Working Theory platform purposes (C8): subject
318
+ * `user` / `userId`, newest per purpose, at most 100.
319
+ */
320
+ export async function userPlatformRows(
321
+ workspaceId: string,
322
+ userId: string,
323
+ db: ConsentReadClient = prisma,
324
+ ): Promise<ConsentStateRow[]> {
325
+ return db.$queryRaw<ConsentStateRow[]>`
326
+ SELECT DISTINCT ON (g."purpose_id")
327
+ g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
328
+ g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
329
+ FROM "consent_grants" g
330
+ WHERE g."workspace_id" = ${workspaceId}
331
+ AND g."subject_type" = 'user'
332
+ AND g."subject_id" = ${userId}
333
+ ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC
334
+ LIMIT ${PLATFORM_ROWS_MAX}`;
335
+ }
@@ -24,8 +24,8 @@
24
24
  * workspace's bytes.
25
25
  *
26
26
  * Neither ever resolves a client file (SEC-7877-A, #7877): an asset tagged
27
- * `private`, or one a member-area message attaches, is the same `null` a
28
- * miss is — so a magnet pointed at a client file's key mints nothing and a
27
+ * `private`, one a member-area message attaches, or one a delivered piece
28
+ * version names (#8171 D3), is the same `null` a miss is — so a magnet pointed at a client file's key mints nothing and a
29
29
  * grant minted before the file became one serves nothing.
30
30
  *
31
31
  * No write, no `findMany`, no list, no unscoped read — both functions take
@@ -36,14 +36,15 @@ import { PRIVATE_ASSET_TAG } from "@working-theory/validation";
36
36
 
37
37
  import { prisma } from "./prisma.js";
38
38
 
39
- /** A client file (SEC-7877-A) — `private`-tagged, or attached to a message. */
39
+ /** A client file (SEC-7877-A) — `private`-tagged, attached to a message, or a version's file (#8171 D3). */
40
40
  async function isClientFile(
41
41
  assetId: string,
42
42
  workspaceId: string,
43
43
  tags: string[],
44
44
  ): Promise<boolean> {
45
45
  if (tags.includes(PRIVATE_ASSET_TAG)) return true;
46
- return (await prisma.interaction.count({ where: { assetId, workspaceId } })) > 0;
46
+ if ((await prisma.interaction.count({ where: { assetId, workspaceId } })) > 0) return true;
47
+ return (await prisma.pieceVersion.count({ where: { assetId, workspaceId } })) > 0;
47
48
  }
48
49
 
49
50
  /** What the mint-time resolution needs, and nothing more. */