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,132 @@
1
+ // oss: false
2
+ /**
3
+ * @file stripe-boot.test.ts
4
+ *
5
+ * The real `build()` admits apps/api's Stripe key at boot (#8450 D4): an
6
+ * unset `STRIPE_MODE` is today's sandbox-only boot, and only a restricted
7
+ * live key beside `STRIPE_MODE=live` on a production build boots live. Each
8
+ * case runs in its own module registry (`jest.isolateModulesAsync`), because
9
+ * admission is one-way per process. Live-shaped keys are composed at runtime
10
+ * from fragments, never written out.
11
+ *
12
+ * External boundaries are mocked exactly as `server.test.ts` mocks them.
13
+ */
14
+ import type { FastifyInstance } from "fastify";
15
+
16
+ jest.mock("@fastify/cors", () => {
17
+ return jest.fn().mockResolvedValue(undefined);
18
+ });
19
+
20
+ jest.mock("@fastify/rate-limit", () => {
21
+ return jest.fn().mockResolvedValue(undefined);
22
+ });
23
+
24
+ jest.mock("@working-theory/email", () => ({
25
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
26
+ }));
27
+
28
+ type ServerModule = typeof import("../server");
29
+ type StripeShim = typeof import("../lib/stripe");
30
+
31
+ const restrictedLiveKey = ["rk", "live", ""].join("_") + "FixtureOnlyBoot";
32
+
33
+ const ENV_KEYS = ["STRIPE_SECRET_KEY", "STRIPE_MODE", "VERCEL_ENV"] as const;
34
+ type EnvKey = (typeof ENV_KEYS)[number];
35
+
36
+ /** Boot `build()` in a fresh registry under `env`; hand `fn` the app (or the rejection) and the shim. */
37
+ async function boot(
38
+ env: Partial<Record<EnvKey, string>>,
39
+ fn: (result: { app?: FastifyInstance; error?: unknown; shim: StripeShim }) => Promise<void>,
40
+ ): Promise<void> {
41
+ const saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
42
+ try {
43
+ for (const k of ENV_KEYS) {
44
+ if (env[k] === undefined) delete process.env[k];
45
+ else process.env[k] = env[k];
46
+ }
47
+ await jest.isolateModulesAsync(async () => {
48
+ const { build } = require("../server") as ServerModule;
49
+ const shim = require("../lib/stripe") as StripeShim;
50
+ let app: FastifyInstance | undefined;
51
+ let error: unknown;
52
+ try {
53
+ app = await build();
54
+ } catch (err) {
55
+ error = err;
56
+ }
57
+ try {
58
+ await fn({ app, error, shim });
59
+ } finally {
60
+ await app?.close();
61
+ }
62
+ });
63
+ } finally {
64
+ for (const k of ENV_KEYS) {
65
+ if (saved[k] === undefined) delete process.env[k];
66
+ else process.env[k] = saved[k];
67
+ }
68
+ }
69
+ }
70
+
71
+ /** POST the checkout route `n` times from one IP; return the status codes. */
72
+ async function postCheckout(app: FastifyInstance, n: number): Promise<number[]> {
73
+ const codes: number[] = [];
74
+ for (let i = 0; i < n; i += 1) {
75
+ const res = await app.inject({
76
+ method: "POST",
77
+ url: "/v1/checkout/sessions",
78
+ remoteAddress: "203.0.113.7",
79
+ payload: {},
80
+ });
81
+ codes.push(res.statusCode);
82
+ }
83
+ return codes;
84
+ }
85
+
86
+ describe("build() — Stripe key admission at boot (#8450 D4)", () => {
87
+ it("refuses a restricted live key when STRIPE_MODE is unset (unchanged by default)", async () => {
88
+ await boot({ STRIPE_SECRET_KEY: restrictedLiveKey, VERCEL_ENV: "production" }, async ({ app, error, shim }) => {
89
+ expect(app).toBeUndefined();
90
+ expect(error).toBeInstanceOf(shim.StripeConfigError);
91
+ expect((error as Error).message).toMatch(/sandbox only/);
92
+ expect(shim.stripeKeyMode()).toBe("test");
93
+ });
94
+ });
95
+
96
+ it("boots in test mode on a test key with STRIPE_MODE unset, with no checkout bucket", async () => {
97
+ await boot({ STRIPE_SECRET_KEY: "sk_test_boot_fixture" }, async ({ app, error, shim }) => {
98
+ expect(error).toBeUndefined();
99
+ expect(app).toBeDefined();
100
+ expect(shim.stripeKeyMode()).toBe("test");
101
+ // No commerce claim, so every POST is the gate's own answer — never a 429.
102
+ const codes = await postCheckout(app!, 12);
103
+ expect(codes).not.toContain(429);
104
+ });
105
+ });
106
+
107
+ it("boots live on a restricted live key beside STRIPE_MODE=live on a production build", async () => {
108
+ await boot(
109
+ { STRIPE_SECRET_KEY: restrictedLiveKey, STRIPE_MODE: "live", VERCEL_ENV: "production" },
110
+ async ({ app, error, shim }) => {
111
+ expect(error).toBeUndefined();
112
+ expect(app).toBeDefined();
113
+ expect(shim.stripeKeyMode()).toBe("live");
114
+ expect(() => shim.assertAdmittedStripeKey(restrictedLiveKey)).not.toThrow();
115
+ },
116
+ );
117
+ });
118
+
119
+ it("gives a live process's checkout routes their own per-IP bucket (§ 9 SEC-14)", async () => {
120
+ await boot(
121
+ { STRIPE_SECRET_KEY: restrictedLiveKey, STRIPE_MODE: "live", VERCEL_ENV: "production" },
122
+ async ({ app }) => {
123
+ const { __resetBuckets, DEFAULT_PER_IP_PER_MINUTE_STRICT } =
124
+ require("../middleware/rate-limit") as typeof import("../middleware/rate-limit");
125
+ __resetBuckets();
126
+ const codes = await postCheckout(app!, DEFAULT_PER_IP_PER_MINUTE_STRICT + 1);
127
+ expect(codes.slice(0, DEFAULT_PER_IP_PER_MINUTE_STRICT)).not.toContain(429);
128
+ expect(codes[DEFAULT_PER_IP_PER_MINUTE_STRICT]).toBe(429);
129
+ },
130
+ );
131
+ });
132
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @file rate-limit-probe.test.ts
3
+ *
4
+ * Unit tests for `runRateLimitProbe` (#5040 AC-10), with a stubbed `fetch`
5
+ * and a no-op `sleep`. Each stub tracks per-identity call counts; the probe
6
+ * itself picks its TEST-NET-3 addresses at random, so the stubs key their
7
+ * behavior off "the first identity used" and "every other identity" rather
8
+ * than a literal address.
9
+ */
10
+ import { runRateLimitProbe, type ProbeFetch } from "../../lib/rate-limit-probe.js";
11
+
12
+ const ORIGIN = "https://example-deployment.vercel.app";
13
+ const noopSleep = async () => undefined;
14
+
15
+ function headerIdentity(init: { headers: Record<string, string> }): string {
16
+ return init.headers["x-forwarded-for"] ?? "unknown";
17
+ }
18
+
19
+ /** Limits every distinct identity independently to `limit` per window. */
20
+ function perIdentityLimiter(limit: number): ProbeFetch {
21
+ const counts = new Map<string, number>();
22
+ return async (_url, init) => {
23
+ const id = headerIdentity(init);
24
+ const count = (counts.get(id) ?? 0) + 1;
25
+ counts.set(id, count);
26
+ return { status: count <= limit ? 200 : 429 };
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Limits ONLY the first identity used; every other identity always
32
+ * succeeds (#5040 AC-10(d): "answers 200 to every burst request").
33
+ */
34
+ function firstIdentityOnlyLimiter(limit: number): ProbeFetch {
35
+ let firstId: string | undefined;
36
+ const counts = new Map<string, number>();
37
+ return async (_url, init) => {
38
+ const id = headerIdentity(init);
39
+ if (firstId === undefined) firstId = id;
40
+ if (id !== firstId) return { status: 200 };
41
+ const count = (counts.get(id) ?? 0) + 1;
42
+ counts.set(id, count);
43
+ return { status: count <= limit ? 200 : 429 };
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Limits the first identity and never lets ANY other identity through
49
+ * either (#5040 AC-10(f): "answers 429 to every burst request").
50
+ */
51
+ function alwaysOverLimiter(limit: number): ProbeFetch {
52
+ let firstId: string | undefined;
53
+ const counts = new Map<string, number>();
54
+ return async (_url, init) => {
55
+ const id = headerIdentity(init);
56
+ if (firstId === undefined) firstId = id;
57
+ const count = (counts.get(id) ?? 0) + 1;
58
+ counts.set(id, count);
59
+ if (id === firstId) return { status: count <= limit ? 200 : 429 };
60
+ return { status: 429 };
61
+ };
62
+ }
63
+
64
+ describe("runRateLimitProbe (#5040 AC-10)", () => {
65
+ it("(a) a per-identity limiter gives xff: survives, burst: holds (10/30), exit 0", async () => {
66
+ const log = jest.fn();
67
+ const code = await runRateLimitProbe({
68
+ origin: ORIGIN,
69
+ fetch: perIdentityLimiter(10),
70
+ sleep: noopSleep,
71
+ log,
72
+ });
73
+ expect(code).toBe(0);
74
+ expect(log).toHaveBeenCalledWith("xff: survives");
75
+ expect(log).toHaveBeenCalledWith("burst: holds (10/30)");
76
+ });
77
+
78
+ it("(b) a shared, header-blind identity gives xff: overwritten, sleeps exactly once with 62000, exit 0", async () => {
79
+ let count = 0;
80
+ const fetch: ProbeFetch = async () => {
81
+ count += 1;
82
+ return { status: count <= 10 ? 200 : 429 };
83
+ };
84
+ const sleep = jest.fn(async (ms: number) => {
85
+ // Simulate the real window rolling over once the probe waits it out.
86
+ void ms;
87
+ count = 0;
88
+ });
89
+
90
+ const code = await runRateLimitProbe({
91
+ origin: ORIGIN,
92
+ fetch,
93
+ sleep,
94
+ log: jest.fn(),
95
+ });
96
+
97
+ expect(code).toBe(0);
98
+ expect(sleep).toHaveBeenCalledTimes(1);
99
+ expect(sleep).toHaveBeenCalledWith(62_000);
100
+ });
101
+
102
+ it("(c) a limiter that never answers 429 gives limiter: not-observed, exit 1", async () => {
103
+ const log = jest.fn();
104
+ const fetchAlways200: ProbeFetch = async () => ({ status: 200 });
105
+ const code = await runRateLimitProbe({
106
+ origin: ORIGIN,
107
+ fetch: fetchAlways200,
108
+ sleep: noopSleep,
109
+ log,
110
+ });
111
+ expect(code).toBe(1);
112
+ expect(log).toHaveBeenCalledWith("limiter: not-observed");
113
+ });
114
+
115
+ it("(d) only the first identity is limited, every other answers 200: burst: exceeded (30/30), exit 1", async () => {
116
+ const log = jest.fn();
117
+ const code = await runRateLimitProbe({
118
+ origin: ORIGIN,
119
+ fetch: firstIdentityOnlyLimiter(10),
120
+ sleep: noopSleep,
121
+ log,
122
+ });
123
+ expect(code).toBe(1);
124
+ expect(log).toHaveBeenCalledWith("xff: survives");
125
+ expect(log).toHaveBeenCalledWith("burst: exceeded (30/30)");
126
+ });
127
+
128
+ it("(e) --origin http://example.com is refused before any fetch call", async () => {
129
+ const fetch = jest.fn();
130
+ const log = jest.fn();
131
+ const code = await runRateLimitProbe({
132
+ origin: "http://example.com",
133
+ fetch,
134
+ sleep: noopSleep,
135
+ log,
136
+ });
137
+ expect(code).toBe(2);
138
+ expect(fetch).not.toHaveBeenCalled();
139
+ expect(log).toHaveBeenCalledWith("probe: error invalid-origin");
140
+ });
141
+
142
+ it("(f) every burst request answers 429: burst: inconclusive (0/30), exit 1", async () => {
143
+ const log = jest.fn();
144
+ const code = await runRateLimitProbe({
145
+ origin: ORIGIN,
146
+ fetch: alwaysOverLimiter(10),
147
+ sleep: noopSleep,
148
+ log,
149
+ });
150
+ expect(code).toBe(1);
151
+ expect(log).toHaveBeenCalledWith("burst: inconclusive (0/30)");
152
+ });
153
+
154
+ it("a loopback http origin is accepted", async () => {
155
+ const code = await runRateLimitProbe({
156
+ origin: "http://localhost:3002",
157
+ fetch: perIdentityLimiter(10),
158
+ sleep: noopSleep,
159
+ log: jest.fn(),
160
+ });
161
+ expect(code).toBe(0);
162
+ });
163
+
164
+ it("a network error during the burst answers probe: error <code>, exit 2", async () => {
165
+ const log = jest.fn();
166
+ let count = 0;
167
+ const fetch: ProbeFetch = async () => {
168
+ count += 1;
169
+ if (count <= 11) return { status: count <= 10 ? 200 : 429 };
170
+ if (count === 12) return { status: 200 }; // the xff probe (survives)
171
+ const err = new Error("boom") as Error & { code: string };
172
+ err.code = "ECONNRESET";
173
+ throw err;
174
+ };
175
+ const code = await runRateLimitProbe({
176
+ origin: ORIGIN,
177
+ fetch,
178
+ sleep: noopSleep,
179
+ log,
180
+ });
181
+ expect(code).toBe(2);
182
+ expect(log).toHaveBeenCalledWith("probe: error ECONNRESET");
183
+ });
184
+ });
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ /* istanbul ignore file -- CLI bootstrap; the logic it wraps
3
+ (runRateLimitProbe) is unit-tested directly in __tests__/rate-limit-probe.test.ts
4
+ — the abandon-sweep.ts / lib/abandon-email.ts split. */
5
+ /**
6
+ * The live rate-limiter probe (#5040 D9, FR-7, AC-10).
7
+ *
8
+ * Dev: `pnpm --filter @working-theory/api rate-limit:probe --origin <https origin>`
9
+ * Production: `node dist/bin/rate-limit-probe.js --origin <https origin>`
10
+ *
11
+ * Answers the two platform facts #5040 and #5111 need a live deployment for:
12
+ *
13
+ * 1. Does a client-chosen `x-forwarded-for` survive to the serverless
14
+ * function, or does the edge overwrite it? (`xff: survives | overwritten`)
15
+ * 2. Does a concurrent burst against ONE identity stay within the
16
+ * configured limit? (`burst: holds | exceeded | inconclusive`)
17
+ *
18
+ * It only ever calls `GET <origin>/v1/aeo/score/example` — a no-fetch,
19
+ * no-write read already behind the aeo scope's strict limiter (#5111) — and
20
+ * refuses an origin that is not `https:` or loopback `http:` before any
21
+ * network call.
22
+ */
23
+ import { runRateLimitProbe } from "../lib/rate-limit-probe.js";
24
+
25
+ function parseOrigin(argv: readonly string[]): string | undefined {
26
+ const index = argv.indexOf("--origin");
27
+ return index === -1 ? undefined : argv[index + 1];
28
+ }
29
+
30
+ async function main(): Promise<number> {
31
+ const origin = parseOrigin(process.argv.slice(2));
32
+ if (!origin) {
33
+ console.log("probe: error missing --origin");
34
+ return 2;
35
+ }
36
+ return runRateLimitProbe({
37
+ origin,
38
+ fetch: (url, init) => globalThis.fetch(url, init),
39
+ });
40
+ }
41
+
42
+ main()
43
+ .then((code) => {
44
+ process.exitCode = code;
45
+ })
46
+ .catch((err: unknown) => {
47
+ // Belt-and-braces, as abandon-sweep.ts: runRateLimitProbe catches its own
48
+ // network/status errors, so this only stops a bare unhandled rejection.
49
+ console.log(
50
+ `probe: error ${err instanceof Error ? err.message : String(err)}`,
51
+ );
52
+ process.exitCode = 2;
53
+ });
@@ -436,6 +436,10 @@ export const prisma = {
436
436
  createMany: jest.fn(),
437
437
  findMany: jest.fn(),
438
438
  },
439
+ // #8438 D2 — a banner visitor linked to a Person (insert-or-ignore).
440
+ consentVisitorLink: {
441
+ createMany: jest.fn(),
442
+ },
439
443
  // ── Inbox namespace (Appointment — #6085, brief H7) ──────────────────────
440
444
  // `updateMany` — the § D7(a) guarded write, one per family (lifecycle /
441
445
  // payment / no-show); `findFirst` — the disambiguating read on a zero-match
@@ -542,6 +546,10 @@ export const prisma = {
542
546
  // `$executeRaw` — the per-booking `pg_advisory_xact_lock` (#7532 D5),
543
547
  // taken inside `$transaction`. Suites on that path arm both.
544
548
  $executeRaw: jest.fn(),
549
+ // `$queryRaw` — the consent ledger's reads (lib/consent-store.ts, #8438 D4):
550
+ // `DISTINCT ON` current-state and keyset history cannot be a Prisma
551
+ // `findMany`. Suites on that path arm it with the rows to serve.
552
+ $queryRaw: jest.fn(),
545
553
  $connect: jest.fn(),
546
554
  $disconnect: jest.fn(),
547
555
  };
@@ -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
+ }