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,430 @@
1
+ /**
2
+ * @file rate-limit-store.test.ts
3
+ *
4
+ * Unit tests for the rate limiter's database store seam (#5040 AC-2 to
5
+ * AC-5). The SQL double below is a `RateLimitSqlClient` whose `$queryRaw` /
6
+ * `$executeRaw` are `jest.fn()`s modeling the same upsert/sweep semantics as
7
+ * the real SQL (`apps/api/src/lib/rate-limit-store.ts`'s `createDatabaseRateLimitStore`);
8
+ * the SQL text itself is proved against a real Postgres by
9
+ * `rate-limit-cross-isolate.db.test.ts` (AC-1, AC-7, AC-8). AC-6's own claim
10
+ * ("the existing rate-limit.test.ts passes unedited") is checked by running
11
+ * that file, not re-asserted here.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+
15
+ import Fastify, { type FastifyInstance } from "fastify";
16
+
17
+ import {
18
+ createDatabaseRateLimitStore,
19
+ resolveRateLimitStoreKind,
20
+ type RateLimitSqlClient,
21
+ } from "../../lib/rate-limit-store.js";
22
+ import {
23
+ buildTrustedKeyDigests,
24
+ rateLimitMiddleware,
25
+ type RateLimitOptions,
26
+ } from "../rate-limit.js";
27
+
28
+ function sha256Hex(value: string): string {
29
+ return createHash("sha256").update(value).digest("hex");
30
+ }
31
+
32
+ type Row = { count: number; windowStartMs: number; windowMs: number };
33
+
34
+ /** A `RateLimitSqlClient` double over an in-memory `Map<keyHash, Row>`. */
35
+ function armSqlDouble(): {
36
+ client: RateLimitSqlClient;
37
+ queryRaw: jest.Mock;
38
+ executeRaw: jest.Mock;
39
+ rows: Map<string, Row>;
40
+ } {
41
+ const rows = new Map<string, Row>();
42
+
43
+ const queryRaw = jest.fn(
44
+ (strings: TemplateStringsArray, ...values: unknown[]) => {
45
+ const sql = strings.join("?");
46
+ const hash = values[0] as string;
47
+ const now = values[1] as number;
48
+ const windowMs = values[2] as number;
49
+
50
+ if (sql.includes("INSERT INTO")) {
51
+ const existing = rows.get(hash);
52
+ const fresh =
53
+ !existing || now - existing.windowStartMs >= windowMs;
54
+ const row: Row = fresh
55
+ ? { count: 1, windowStartMs: now, windowMs }
56
+ : { count: existing.count + 1, windowStartMs: existing.windowStartMs, windowMs };
57
+ rows.set(hash, row);
58
+ return Promise.resolve([
59
+ { count: row.count, window_start_ms: row.windowStartMs },
60
+ ]);
61
+ }
62
+ if (sql.includes("SELECT 1")) {
63
+ const row = rows.get(hash);
64
+ const open = row !== undefined && now - row.windowStartMs < windowMs;
65
+ return Promise.resolve(open ? [{ one: 1 }] : []);
66
+ }
67
+ return Promise.resolve([]);
68
+ },
69
+ );
70
+
71
+ const executeRaw = jest.fn(
72
+ (strings: TemplateStringsArray, ...values: unknown[]) => {
73
+ const sql = strings.join("?");
74
+ if (sql.includes("DELETE FROM")) {
75
+ const now = values[0] as number;
76
+ for (const [key, row] of rows) {
77
+ if (row.windowStartMs + row.windowMs <= now) rows.delete(key);
78
+ }
79
+ }
80
+ return Promise.resolve(0);
81
+ },
82
+ );
83
+
84
+ // eslint-disable-next-line no-restricted-syntax -- RateLimitSqlClient's methods are Prisma's overloaded tagged-template signature, which a jest.fn() double cannot structurally match; this double is the SQL boundary, same footing as lib/prisma.ts's own repo-standard test double.
85
+ const client = {
86
+ $queryRaw: queryRaw,
87
+ $executeRaw: executeRaw,
88
+ } as unknown as RateLimitSqlClient;
89
+
90
+ return { client, queryRaw, executeRaw, rows };
91
+ }
92
+
93
+ describe("AC-2 — the #7394 R-1 rule holds over the database store", () => {
94
+ const KEY = "a".repeat(32);
95
+ const noWarn = () => undefined;
96
+ const SCOPE = "leads";
97
+ const KEY_BUCKET = `${SCOPE}|key,0`;
98
+
99
+ function keyed(visitorIp: string): Record<string, string> {
100
+ return {
101
+ "x-forwarded-for": "198.51.100.1",
102
+ "x-ailk-visitor-forward-key": KEY,
103
+ "x-ailk-visitor-ip": visitorIp,
104
+ };
105
+ }
106
+
107
+ function buildApp(double: ReturnType<typeof armSqlDouble>): FastifyInstance {
108
+ const app = Fastify({ trustProxy: true });
109
+ app.register(rateLimitMiddleware, {
110
+ scope: SCOPE,
111
+ perIpPerMinute: 10,
112
+ perKeyPerMinute: 5,
113
+ trustedKeyDigests: buildTrustedKeyDigests(KEY, noWarn),
114
+ store: createDatabaseRateLimitStore(double.client),
115
+ } satisfies RateLimitOptions);
116
+ app.post("/v1/leads/submit", async () => ({ ok: true }));
117
+ return app;
118
+ }
119
+
120
+ async function post(app: FastifyInstance, visitorIp: string): Promise<number> {
121
+ const res = await app.inject({
122
+ method: "POST",
123
+ url: "/v1/leads/submit",
124
+ headers: keyed(visitorIp),
125
+ });
126
+ return res.statusCode;
127
+ }
128
+
129
+ it("a key over ceiling for a visitor with no open window: hitKey + hasOpenWindow run, hit does not", async () => {
130
+ const double = armSqlDouble();
131
+ const app = buildApp(double);
132
+ await app.ready();
133
+ try {
134
+ for (let i = 1; i <= 5; i++) expect(await post(app, `203.0.113.${i}`)).toBe(200);
135
+
136
+ const fresh = "203.0.113.50";
137
+ const freshHash = sha256Hex(`${SCOPE}|v0,${fresh}`);
138
+ const sixth = await app.inject({
139
+ method: "POST",
140
+ url: "/v1/leads/submit",
141
+ headers: keyed(fresh),
142
+ });
143
+ expect(sixth.statusCode).toBe(429);
144
+
145
+ const keyHash = sha256Hex(KEY_BUCKET);
146
+ const insertCalls = double.queryRaw.mock.calls.filter(
147
+ (call) =>
148
+ String((call[0] as TemplateStringsArray).join("?")).includes(
149
+ "INSERT INTO",
150
+ ),
151
+ );
152
+ expect(insertCalls.some((call) => call[1] === keyHash)).toBe(true);
153
+ // hasOpenWindow ran for the fresh visitor...
154
+ const selectCalls = double.queryRaw.mock.calls.filter(
155
+ (call) =>
156
+ String((call[0] as TemplateStringsArray).join("?")).includes(
157
+ "SELECT 1",
158
+ ),
159
+ );
160
+ expect(selectCalls.some((call) => call[1] === freshHash)).toBe(true);
161
+ // ...but no `hit` (INSERT) ran for the fresh visitor's own bucket.
162
+ expect(insertCalls.some((call) => call[1] === freshHash)).toBe(false);
163
+ } finally {
164
+ await app.close();
165
+ }
166
+ });
167
+
168
+ it("a key over ceiling for a visitor with an OPEN window still issues the visitor hit", async () => {
169
+ const double = armSqlDouble();
170
+ const app = buildApp(double);
171
+ await app.ready();
172
+ try {
173
+ // Visitor 1 opens its own window first (count 1, well under perIpPerMinute).
174
+ expect(await post(app, "203.0.113.1")).toBe(200);
175
+ for (let i = 2; i <= 5; i++) expect(await post(app, `203.0.113.${i}`)).toBe(200);
176
+
177
+ const visitor1Hash = sha256Hex(`${SCOPE}|v0,203.0.113.1`);
178
+ const before = double.queryRaw.mock.calls.filter(
179
+ (call) =>
180
+ String((call[0] as TemplateStringsArray).join("?")).includes(
181
+ "INSERT INTO",
182
+ ) && call[1] === visitor1Hash,
183
+ ).length;
184
+
185
+ // The key is now over ceiling; visitor 1 still has an open window.
186
+ await post(app, "203.0.113.1");
187
+
188
+ const after = double.queryRaw.mock.calls.filter(
189
+ (call) =>
190
+ String((call[0] as TemplateStringsArray).join("?")).includes(
191
+ "INSERT INTO",
192
+ ) && call[1] === visitor1Hash,
193
+ ).length;
194
+ expect(after).toBe(before + 1);
195
+ } finally {
196
+ await app.close();
197
+ }
198
+ });
199
+ });
200
+
201
+ describe("AC-3 — bounded, fail-open to the memory store, never to no limit (#5040 D5)", () => {
202
+ const IP = "203.0.113.9";
203
+
204
+ // Each half runs inside its own jest.isolateModules instance so D5's
205
+ // once-per-60-s fallback-warn throttle starts fresh for it — a shared
206
+ // module instance would let test (a)'s warn suppress test (b)'s.
207
+ function runIsolated(
208
+ build: (mod: typeof import("../rate-limit")) => Promise<void>,
209
+ ): Promise<void> {
210
+ return new Promise<void>((resolve, reject) => {
211
+ jest.isolateModules(() => {
212
+ const mod: typeof import("../rate-limit") = require("../rate-limit");
213
+ build(mod).then(resolve, reject);
214
+ });
215
+ });
216
+ }
217
+
218
+ it("(a) every SQL call rejecting: 10 succeed, the 11th is 429, exactly one fallback line with reason: error", async () => {
219
+ await runIsolated(async (mod) => {
220
+ // eslint-disable-next-line no-restricted-syntax -- see the SQL double above.
221
+ const client = {
222
+ $queryRaw: jest.fn().mockRejectedValue(new Error("db down")),
223
+ $executeRaw: jest.fn().mockRejectedValue(new Error("db down")),
224
+ } as unknown as RateLimitSqlClient;
225
+
226
+ const logLines: Array<Record<string, unknown>> = [];
227
+ const app = Fastify({
228
+ trustProxy: true,
229
+ logger: {
230
+ level: "warn",
231
+ stream: { write: (msg: string) => logLines.push(JSON.parse(msg)) },
232
+ },
233
+ });
234
+ app.register(mod.rateLimitMiddleware, {
235
+ perIpPerMinute: 10,
236
+ store: createDatabaseRateLimitStore(client),
237
+ } satisfies RateLimitOptions);
238
+ app.get("/test", async () => ({ ok: true }));
239
+ await app.ready();
240
+
241
+ try {
242
+ for (let i = 0; i < 10; i++) {
243
+ const res = await app.inject({
244
+ method: "GET",
245
+ url: "/test",
246
+ headers: { "x-forwarded-for": IP },
247
+ });
248
+ expect(res.statusCode).toBe(200);
249
+ }
250
+ const eleventh = await app.inject({
251
+ method: "GET",
252
+ url: "/test",
253
+ headers: { "x-forwarded-for": IP },
254
+ });
255
+ expect(eleventh.statusCode).toBe(429);
256
+
257
+ const fallbackLines = logLines.filter(
258
+ (l) => l["event"] === "rate_limit_store_fallback",
259
+ );
260
+ expect(fallbackLines).toHaveLength(1);
261
+ expect(fallbackLines[0]?.["reason"]).toBe("error");
262
+ // No key, IP, hash or error message on the fallback line.
263
+ expect(JSON.stringify(fallbackLines)).not.toContain(IP);
264
+ } finally {
265
+ await app.close();
266
+ }
267
+ });
268
+ });
269
+
270
+ it("(b) a never-settling SQL call with storeTimeoutMs:50: each request answers within 1s, the 11th is 429, one fallback line with reason: timeout", async () => {
271
+ await runIsolated(async (mod) => {
272
+ // eslint-disable-next-line no-restricted-syntax -- see the SQL double above.
273
+ const client = {
274
+ $queryRaw: jest.fn(() => new Promise(() => undefined)),
275
+ $executeRaw: jest.fn(() => new Promise(() => undefined)),
276
+ } as unknown as RateLimitSqlClient;
277
+
278
+ const logLines: Array<Record<string, unknown>> = [];
279
+ const app = Fastify({
280
+ trustProxy: true,
281
+ logger: {
282
+ level: "warn",
283
+ stream: { write: (msg: string) => logLines.push(JSON.parse(msg)) },
284
+ },
285
+ });
286
+ app.register(mod.rateLimitMiddleware, {
287
+ perIpPerMinute: 10,
288
+ storeTimeoutMs: 50,
289
+ store: createDatabaseRateLimitStore(client),
290
+ } satisfies RateLimitOptions);
291
+ app.get("/test", async () => ({ ok: true }));
292
+ await app.ready();
293
+
294
+ try {
295
+ for (let i = 0; i < 10; i++) {
296
+ const res = await app.inject({
297
+ method: "GET",
298
+ url: "/test",
299
+ headers: { "x-forwarded-for": IP },
300
+ });
301
+ expect(res.statusCode).toBe(200);
302
+ }
303
+ const eleventh = await app.inject({
304
+ method: "GET",
305
+ url: "/test",
306
+ headers: { "x-forwarded-for": IP },
307
+ });
308
+ expect(eleventh.statusCode).toBe(429);
309
+
310
+ const fallbackLines = logLines.filter(
311
+ (l) => l["event"] === "rate_limit_store_fallback",
312
+ );
313
+ expect(fallbackLines).toHaveLength(1);
314
+ expect(fallbackLines[0]?.["reason"]).toBe("timeout");
315
+ } finally {
316
+ await app.close();
317
+ }
318
+ });
319
+ }, 15_000);
320
+ });
321
+
322
+ describe("AC-4 — resolveRateLimitStoreKind (D4's table, all seven rows)", () => {
323
+ it.each<
324
+ [string, string | undefined, string | undefined, "memory" | "database", boolean]
325
+ >([
326
+ ["set + unset", "postgresql://x", undefined, "database", false],
327
+ ["set + database", "postgresql://x", "database", "database", false],
328
+ ["set + memory", "postgresql://x", "memory", "memory", false],
329
+ ["unset + memory", undefined, "memory", "memory", false],
330
+ ["unset + unset", undefined, undefined, "memory", false],
331
+ ["unset + database", undefined, "database", "memory", true],
332
+ ["set + garbage", "postgresql://x", "garbage", "database", true],
333
+ ])(
334
+ "%s -> %s (warns: %s)",
335
+ (_label, databaseUrl, store, expected, warns) => {
336
+ const warn = jest.fn();
337
+ const result = resolveRateLimitStoreKind(
338
+ { DATABASE_URL: databaseUrl, AILK_RATE_LIMIT_STORE: store },
339
+ warn,
340
+ );
341
+ expect(result).toBe(expected);
342
+ expect(warn).toHaveBeenCalledTimes(warns ? 1 : 0);
343
+ },
344
+ );
345
+
346
+ it("unset + garbage -> memory, with the same warning", () => {
347
+ const warn = jest.fn();
348
+ const result = resolveRateLimitStoreKind(
349
+ { DATABASE_URL: undefined, AILK_RATE_LIMIT_STORE: "garbage" },
350
+ warn,
351
+ );
352
+ expect(result).toBe("memory");
353
+ expect(warn).toHaveBeenCalledTimes(1);
354
+ expect(warn.mock.calls[0]?.[0]).toContain(
355
+ "AILK_RATE_LIMIT_STORE is not memory or database",
356
+ );
357
+ });
358
+ });
359
+
360
+ describe("AC-5 — the process default store, resolved and logged once per process", () => {
361
+ const prevDatabaseUrl = process.env.DATABASE_URL;
362
+ const prevStore = process.env.AILK_RATE_LIMIT_STORE;
363
+
364
+ afterEach(() => {
365
+ if (prevDatabaseUrl === undefined) delete process.env.DATABASE_URL;
366
+ else process.env.DATABASE_URL = prevDatabaseUrl;
367
+ if (prevStore === undefined) delete process.env.AILK_RATE_LIMIT_STORE;
368
+ else process.env.AILK_RATE_LIMIT_STORE = prevStore;
369
+ });
370
+
371
+ it("defaults to the database store when DATABASE_URL is set and AILK_RATE_LIMIT_STORE is unset, logging once", async () => {
372
+ let queryRawMock!: jest.Mock;
373
+
374
+ await new Promise<void>((resolve, reject) => {
375
+ jest.isolateModules(() => {
376
+ process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test";
377
+ delete process.env.AILK_RATE_LIMIT_STORE;
378
+
379
+ queryRawMock = jest.fn().mockResolvedValue([
380
+ { count: 1, window_start_ms: Date.now() },
381
+ ]);
382
+ const executeRawMock = jest.fn().mockResolvedValue(0);
383
+
384
+ jest.doMock("../../lib/prisma.js", () => ({
385
+ prisma: { $queryRaw: queryRawMock, $executeRaw: executeRawMock },
386
+ }));
387
+
388
+ const mod: typeof import("../rate-limit") = require("../rate-limit");
389
+
390
+ const logLines: Array<Record<string, unknown>> = [];
391
+ const app = Fastify({
392
+ trustProxy: true,
393
+ logger: {
394
+ level: "info",
395
+ stream: { write: (msg: string) => logLines.push(JSON.parse(msg)) },
396
+ },
397
+ });
398
+ app.register(mod.rateLimitMiddleware, { perIpPerMinute: 10 });
399
+ app.register(async (child) => {
400
+ child.register(mod.rateLimitMiddleware, {
401
+ perIpPerMinute: 10,
402
+ scope: "leads",
403
+ });
404
+ child.get("/other", async () => ({ ok: true }));
405
+ });
406
+ app.get("/test", async () => ({ ok: true }));
407
+
408
+ app
409
+ .ready()
410
+ .then(() =>
411
+ app.inject({
412
+ method: "GET",
413
+ url: "/test",
414
+ headers: { "x-forwarded-for": "203.0.113.77" },
415
+ }),
416
+ )
417
+ .then((res) => {
418
+ expect(res.statusCode).toBe(200);
419
+ expect(queryRawMock).toHaveBeenCalledTimes(1);
420
+ const infoLines = logLines.filter(
421
+ (l) => l["rateLimitStore"] === "database",
422
+ );
423
+ expect(infoLines).toHaveLength(1);
424
+ return app.close();
425
+ })
426
+ .then(resolve, reject);
427
+ });
428
+ });
429
+ });
430
+ });
@@ -404,22 +404,21 @@ describe("per-IP rate limit middleware", () => {
404
404
  * load with an empty Map), reproduced deterministically in-process.
405
405
  *
406
406
  * This demonstrates — rather than merely asserts, per AC-1's own wording —
407
- * that the per-IP limit, AS CURRENTLY DESIGNED, does NOT hold across
407
+ * that the per-IP limit, UNDER THE MEMORY STORE, does NOT hold across
408
408
  * separate execution contexts: the same IP, already rate-limited to
409
409
  * exhaustion on one instance, is granted a fresh budget on another. This
410
- * is the accepted, documented trade-off recorded in
411
- * docs/specs/5111-limiter-surface-spec.md § D4 (no new store for OSS v1;
412
- * MAX_BUCKETS bounds memory, not the cross-isolate aggregate rate) — this
413
- * test makes that trade-off provable rather than only claimed in a
414
- * comment, and will fail (correctly) the day a cross-isolate store
415
- * replaces the in-process Map.
410
+ * pins the memory store's own documented trade-off (the SINGLE-REPLICA
411
+ * CAVEAT) now that a database deployment defaults to the shared store
412
+ * instead (#5040 D1/D4) — this test makes that trade-off provable rather
413
+ * than only claimed in a comment, and will fail (correctly) if the memory
414
+ * store's isolation is ever accidentally narrowed.
416
415
  *
417
416
  * What this test does NOT demonstrate: whether the LIVE deployment
418
- * actually runs more than one concurrent isolate for a given route today.
419
- * That is a platform fact only a reachable live deployment can answer
420
- * (#5040 AC-1's deployment-level half, recorded open in the issue).
417
+ * actually runs more than one concurrent isolate for a given route today,
418
+ * nor the database store's own cross-isolate behavior — that is
419
+ * `rate-limit-cross-isolate.db.test.ts`'s job (#5040 AC-1).
421
420
  */
422
- describe("cross-isolate limit — does NOT hold by design (#5040, SINGLE-REPLICA CAVEAT)", () => {
421
+ describe("cross-isolate limit — the memory store does not hold by design (#5040, SINGLE-REPLICA CAVEAT)", () => {
423
422
  it("the same IP, exhausted on one module instance, is freshly allowed on an independently-loaded instance", async () => {
424
423
  const perIpPerMinute = 5;
425
424
  const ip = "198.51.100.7";
@@ -753,13 +752,18 @@ describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394
753
752
  expect(codes[10]).toBe(429);
754
753
  });
755
754
 
756
- it("(v) the valid key on POST /v1/project-listings in the same scope: the eleventh request is 429", async () => {
755
+ // `POST /v1/project-listings` joined the list with #7479; the resume
756
+ // link's verification write shares its scope and stays off the list.
757
+ it("(v) the valid key on POST /v1/project-listings/drafts/:id/verify-email in the same scope: the eleventh request is 429", async () => {
757
758
  app = forwardApp({ ...base, trustedKeyDigests: digests() }, [
758
759
  LEADS_SUBMIT,
759
- "/v1/project-listings",
760
+ "/v1/project-listings/drafts/:id/verify-email",
760
761
  ]);
761
762
  await app.ready();
762
- const codes = await elevenDistinctVisitors((ip) => keyed(ip), "/v1/project-listings");
763
+ const codes = await elevenDistinctVisitors(
764
+ (ip) => keyed(ip),
765
+ "/v1/project-listings/drafts/listing_abc/verify-email",
766
+ );
763
767
  expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
764
768
  expect(codes[10]).toBe(429);
765
769
  });
@@ -836,10 +840,64 @@ describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394
836
840
  { method: "POST", url: "/v1/leads/callback" },
837
841
  { method: "GET", url: AVAILABILITY },
838
842
  { method: "POST", url: BOOK },
843
+ { method: "POST", url: "/v1/consent/cookie" },
844
+ { method: "POST", url: "/v1/consent/unsubscribe" },
845
+ { method: "POST", url: "/v1/project-listings" },
846
+ { method: "PATCH", url: "/v1/project-listings/drafts/:id" },
847
+ { method: "POST", url: "/v1/project-listings/drafts/:id/submit" },
839
848
  ]);
840
849
  });
841
850
  });
842
851
 
852
+ // #8438 D5/D9 — the cookie banner's log and the unsubscribe landing. Each
853
+ // is registered in its OWN scope (`consent`, `unsubscribe`), exactly as
854
+ // server.ts does, and both are on the forwarded-visitor list.
855
+ describe("the consent routes count each keyed visitor separately, in scopes of their own (#8438)", () => {
856
+ const COOKIE = "/v1/consent/cookie";
857
+ const UNSUBSCRIBE = "/v1/consent/unsubscribe";
858
+
859
+ function consentApp(): FastifyInstance {
860
+ const instance = Fastify({ trustProxy: true });
861
+ for (const [scope, url] of [
862
+ ["consent", COOKIE],
863
+ ["unsubscribe", UNSUBSCRIBE],
864
+ ] as const) {
865
+ instance.register(async (scoped) => {
866
+ scoped.register(rateLimitMiddleware, {
867
+ scope,
868
+ perIpPerMinute: 10,
869
+ trustedKeyDigests: digests(),
870
+ });
871
+ scoped.post(url, async () => ({ ok: true }));
872
+ });
873
+ }
874
+ return instance;
875
+ }
876
+
877
+ async function post(url: string, headers: Record<string, string>): Promise<number> {
878
+ if (!app) throw new Error("no app");
879
+ return (await app.inject({ method: "POST", url, headers })).statusCode;
880
+ }
881
+
882
+ it.each([COOKIE, UNSUBSCRIBE])("%s: two visitors behind one site key are counted separately", async (url) => {
883
+ app = consentApp();
884
+ await app.ready();
885
+ for (let i = 0; i < 10; i++) {
886
+ expect(await post(url, keyed("203.0.113.1"))).toBe(200);
887
+ }
888
+ expect(await post(url, keyed("203.0.113.1"))).toBe(429);
889
+ expect(await post(url, keyed("203.0.113.2"))).toBe(200);
890
+ });
891
+
892
+ it("an exhausted consent bucket leaves unsubscribe answering", async () => {
893
+ app = consentApp();
894
+ await app.ready();
895
+ for (let i = 0; i < 10; i++) await post(COOKIE, keyed("203.0.113.1"));
896
+ expect(await post(COOKIE, keyed("203.0.113.1"))).toBe(429);
897
+ expect(await post(UNSUBSCRIBE, keyed("203.0.113.1"))).toBe(200);
898
+ });
899
+ });
900
+
843
901
  describe("AC-5 — visitor IP validation and normalisation", () => {
844
902
  function identityApp(): FastifyInstance {
845
903
  const instance = Fastify({ trustProxy: true });
@@ -34,8 +34,8 @@
34
34
  * spoke's key (`x-ailk-spoke-key`), only while its grant is unrevoked and
35
35
  * unexpired, its client enabled and its person's email verified, and only
36
36
  * on `HUB_SESSION_ROUTES` — an exact list of METHOD + registered URL
37
- * pairs — the credential's own `me` and the member routes, 18 in all
38
- * (#8220); any other route is 404 (never 403, so the account area's
37
+ * pairs — the credential's own `me` and the member routes, 24 in all
38
+ * (#8220, #8515); any other route is 404 (never 403, so the account area's
39
39
  * `read()` loads "nothing for this caller"). It acts as its person
40
40
  * (`request.userId`) and carries `request.hubSession` — never
41
41
  * `request.sessionTenant` — and its request logger names the spoke
@@ -153,9 +153,12 @@ export type HubSession = {
153
153
  * The only routes a hub-issued credential reaches (#7894 D6, #8220 D1): an
154
154
  * EXACT list of method + registered URL, never a prefix and never computed,
155
155
  * so a route added later joins only by a reviewed edit of this list
156
- * (SEC-7894-3). Three groups, in this order: the credential's own `me` and
156
+ * (SEC-7894-3). Five groups, in this order: the credential's own `me` and
157
157
  * #7877's three member reads; #8169's eight member reads; #8170's six member
158
- * writes (roadmap §7.2 row order). The member profile write IS admitted — it
158
+ * writes (roadmap §7.2 row order); #8438's member consents read and write;
159
+ * #8515's onboarding read and write — 24 in all.
160
+ * `GET /v1/consent/platform` is not admitted — it is the Admin owner's own
161
+ * consent to Working Theory, on the hub's cookie session. The member profile write IS admitted — it
159
162
  * is the person's own account, which roadmap D9 saves through it. Not
160
163
  * admitted, deliberately: `GET /v1/waitlist-signups/me`,
161
164
  * `GET /v1/flow-checkouts/me`, `GET /v1/subscription/me` and
@@ -181,7 +184,16 @@ export const HUB_SESSION_ROUTES: readonly string[] = Object.freeze([
181
184
  "POST /v1/member/requests",
182
185
  "POST /v1/member/messages",
183
186
  "PATCH /v1/member/profile",
187
+ "GET /v1/member/profile/contact",
188
+ "PATCH /v1/member/profile/contact",
184
189
  "POST /v1/member/deletion-request",
190
+ // #8438 D6 — the member's own consents. `GET /v1/consent/platform` is NOT
191
+ // admitted: those are the Admin owner's, on the hub's own cookie session.
192
+ "GET /v1/member/consents",
193
+ "POST /v1/member/consents",
194
+ // #8515 D13 — the client's onboarding read and write.
195
+ "GET /v1/member/onboarding",
196
+ "POST /v1/member/onboarding",
185
197
  ]);
186
198
 
187
199
  /**
@@ -122,6 +122,15 @@ export async function defaultTenantPreHandler(
122
122
  return;
123
123
  }
124
124
 
125
+ // #8514 D4 (SEC-8514-A) — fail closed: an agent's tenant must carry the
126
+ // marker `agentTenant` sets, or the agent would hold a member's client rows
127
+ // by role. A path that built one without it refuses every request.
128
+ if (request.agent && tenant.agent !== true) {
129
+ request.log.error({ event: "agent-tenant-unmarked" }, "agent tenant without its marker");
130
+ reply.code(403).send(FORBIDDEN);
131
+ return;
132
+ }
133
+
125
134
  request.tenant = tenant;
126
135
  }
127
136