create-ailk 0.19.1 → 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 (132) hide show
  1. package/README.md +64 -7
  2. package/dist/cli.js +35 -0
  3. package/dist/copy.d.ts +41 -0
  4. package/dist/copy.js +87 -21
  5. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  6. package/dist/lib/fetch-template-snapshot.js +205 -0
  7. package/dist/lib/merge-three-way.d.ts +31 -0
  8. package/dist/lib/merge-three-way.js +65 -0
  9. package/dist/module-architecture.js +55 -2
  10. package/dist/programmatic.d.ts +7 -1
  11. package/dist/programmatic.js +32 -2
  12. package/dist/spoke-checkout.d.ts +7 -0
  13. package/dist/spoke-checkout.js +8 -1
  14. package/dist/surfaces.js +67 -2
  15. package/dist/template-record.d.ts +59 -0
  16. package/dist/template-record.js +183 -0
  17. package/dist/update.d.ts +48 -0
  18. package/dist/update.js +504 -0
  19. package/package.json +3 -3
  20. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  21. package/templates/.claude/hooks/architecture-check.sh +8 -0
  22. package/templates/.claude/rules/loop-contract.md +129 -0
  23. package/templates/.claude/settings.json +3 -2
  24. package/templates/.env.example +13 -0
  25. package/templates/CLAUDE.md +1 -1
  26. package/templates/CONVENTIONS.md +3 -2
  27. package/templates/_gitignore +162 -0
  28. package/templates/apps/api/CLAUDE.md +14 -10
  29. package/templates/apps/api/package.json +2 -1
  30. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  31. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  32. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  33. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  34. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  35. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  36. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  37. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  38. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  39. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  40. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  41. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  42. package/templates/apps/api/src/middleware/auth.ts +8 -4
  43. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  44. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  45. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  46. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  47. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  48. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  49. package/templates/apps/api/src/server.ts +14 -2
  50. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  51. package/templates/apps/api/src/services/audit-log.ts +72 -10
  52. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  53. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  54. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  55. package/templates/apps/api/vercel.json +1 -1
  56. package/templates/apps/mcp/CLAUDE.md +19 -7
  57. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  58. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  59. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  60. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  61. package/templates/apps/web/_gitignore +6 -0
  62. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  63. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  64. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  65. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  75. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  79. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  87. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  88. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  89. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  93. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  96. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  97. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  98. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  100. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  101. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  102. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  103. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  104. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  105. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  106. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  107. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  108. package/templates/apps/web/jest.config.cjs +3 -0
  109. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  110. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  111. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  112. package/templates/apps/web/lib/account-config.ts +31 -16
  113. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  114. package/templates/apps/web/lib/relay.ts +20 -0
  115. package/templates/apps/web/messages/en.json +67 -4
  116. package/templates/apps/web/next.config.mjs +8 -2
  117. package/templates/database/CHANGELOG.md +39 -0
  118. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  119. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  120. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  121. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  122. package/templates/database/_gitignore +8 -0
  123. package/templates/database/inbox/schema.prisma +38 -12
  124. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  125. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  126. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  127. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  128. package/templates/database/ops/schema.prisma +16 -0
  129. package/templates/database/package.json +1 -1
  130. package/templates/project.yaml +6 -3
  131. package/versions.json +35 -35
  132. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @file rate-limit-cross-isolate.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the shared store (#5040 D8: AC-1, AC-7, AC-8). Runs
5
+ * only when `AILK_TEST_LIVE_DATABASE === "1"` — `apps/api/src/__tests__/setup.ts`
6
+ * always sets a placeholder `DATABASE_URL`, so the usual
7
+ * `DATABASE_URL ? describe : describe.skip` gate can't distinguish a real
8
+ * database from the placeholder; `.github/workflows/api-postgres-compat.yml`'s
9
+ * full-suite step sets this variable beside its real `DATABASE_URL`.
10
+ *
11
+ * Uses the `pg` driver directly (raw SQL), the same reason
12
+ * `database/__tests__/prefixed-ids.db.test.ts` does: the generated Prisma
13
+ * client is ESM-only and cannot load under this package's CJS ts-jest
14
+ * transform. AC-1 drives the store's SQL through a `pg.Client` shim that
15
+ * turns a tagged template's strings and values into `$1…$n` text, not
16
+ * through the Prisma binding production uses — that binding is exercised by
17
+ * `rate-limit-store.test.ts`'s mocked-SQL-client unit tests.
18
+ */
19
+ import { createHash, randomUUID } from "node:crypto";
20
+
21
+ import Fastify, { type FastifyInstance } from "fastify";
22
+ import { Client } from "pg";
23
+
24
+ import {
25
+ createDatabaseRateLimitStore,
26
+ type RateLimitSqlClient,
27
+ } from "../../lib/rate-limit-store.js";
28
+
29
+ const describeDb =
30
+ process.env.AILK_TEST_LIVE_DATABASE === "1" ? describe : describe.skip;
31
+
32
+ function sha256Hex(value: string): string {
33
+ return createHash("sha256").update(value).digest("hex");
34
+ }
35
+
36
+ /** Turns a tagged template's strings + values into `$1…$n` text for `pg`. */
37
+ function sqlClient(pgClient: Client): RateLimitSqlClient {
38
+ function toParamText(
39
+ strings: TemplateStringsArray,
40
+ values: readonly unknown[],
41
+ ): string {
42
+ let text = strings[0] ?? "";
43
+ for (let i = 0; i < values.length; i++) {
44
+ text += `$${i + 1}${strings[i + 1] ?? ""}`;
45
+ }
46
+ return text;
47
+ }
48
+
49
+ async function queryRaw(
50
+ strings: TemplateStringsArray,
51
+ ...values: unknown[]
52
+ ): Promise<unknown[]> {
53
+ const res = await pgClient.query(toParamText(strings, values), values);
54
+ return res.rows;
55
+ }
56
+
57
+ async function executeRaw(
58
+ strings: TemplateStringsArray,
59
+ ...values: unknown[]
60
+ ): Promise<number> {
61
+ const res = await pgClient.query(toParamText(strings, values), values);
62
+ return res.rowCount ?? 0;
63
+ }
64
+
65
+ return {
66
+ $queryRaw: queryRaw as RateLimitSqlClient["$queryRaw"],
67
+ $executeRaw: executeRaw as RateLimitSqlClient["$executeRaw"],
68
+ };
69
+ }
70
+
71
+ describeDb("rate limiter — the shared database store (live, #5040)", () => {
72
+ let db: Client;
73
+
74
+ beforeAll(async () => {
75
+ db = new Client({ connectionString: process.env.DATABASE_URL });
76
+ await db.connect();
77
+ });
78
+
79
+ afterAll(async () => {
80
+ await db.end();
81
+ });
82
+
83
+ it("AC-1: 20 concurrent requests spread 10/10 over two isolated module instances, one identity, perIpPerMinute 5 — exactly 5×200, 15×429, stored count 20", async () => {
84
+ const identity = `rl-demo-${randomUUID()}`;
85
+ const clientA = new Client({ connectionString: process.env.DATABASE_URL });
86
+ const clientB = new Client({ connectionString: process.env.DATABASE_URL });
87
+ await clientA.connect();
88
+ await clientB.connect();
89
+
90
+ let appA: FastifyInstance | undefined;
91
+ let appB: FastifyInstance | undefined;
92
+
93
+ // Each isolateModules block gives a fresh require cache: requiring
94
+ // rate-limit-store AND rate-limit inside it yields module instances
95
+ // distinct from every other test file's top-level import, with their
96
+ // own process-default-store memoization — exactly the D8 pattern PR
97
+ // #5544 already used for the memory-store proof.
98
+ jest.isolateModules(() => {
99
+ const storeMod: typeof import("../../lib/rate-limit-store") = require("../../lib/rate-limit-store");
100
+ const rateLimitMod: typeof import("../rate-limit") = require("../rate-limit");
101
+ const app = Fastify({ trustProxy: true });
102
+ app.register(rateLimitMod.rateLimitMiddleware, {
103
+ perIpPerMinute: 5,
104
+ store: storeMod.createDatabaseRateLimitStore(sqlClient(clientA)),
105
+ });
106
+ app.get("/test", async () => ({ ok: true }));
107
+ appA = app;
108
+ });
109
+
110
+ jest.isolateModules(() => {
111
+ const storeMod: typeof import("../../lib/rate-limit-store") = require("../../lib/rate-limit-store");
112
+ const rateLimitMod: typeof import("../rate-limit") = require("../rate-limit");
113
+ const app = Fastify({ trustProxy: true });
114
+ app.register(rateLimitMod.rateLimitMiddleware, {
115
+ perIpPerMinute: 5,
116
+ store: storeMod.createDatabaseRateLimitStore(sqlClient(clientB)),
117
+ });
118
+ app.get("/test", async () => ({ ok: true }));
119
+ appB = app;
120
+ });
121
+
122
+ if (!appA || !appB) {
123
+ throw new Error("isolateModules did not synchronously assign both apps");
124
+ }
125
+ await appA.ready();
126
+ await appB.ready();
127
+
128
+ try {
129
+ const inject = (app: FastifyInstance) =>
130
+ app.inject({
131
+ method: "GET",
132
+ url: "/test",
133
+ headers: { "x-forwarded-for": identity },
134
+ });
135
+ const results = await Promise.all([
136
+ ...Array.from({ length: 10 }, () => inject(appA!)),
137
+ ...Array.from({ length: 10 }, () => inject(appB!)),
138
+ ]);
139
+ const statuses = results.map((r) => r.statusCode);
140
+ expect(statuses.filter((s) => s === 200)).toHaveLength(5);
141
+ expect(statuses.filter((s) => s === 429)).toHaveLength(15);
142
+
143
+ const hash = sha256Hex(identity);
144
+ const row = await db.query(
145
+ `SELECT "count" FROM "rate_limit_counters" WHERE "key_hash" = $1`,
146
+ [hash],
147
+ );
148
+ expect(row.rows[0]?.count).toBe(20);
149
+ } finally {
150
+ await appA.close();
151
+ await appB.close();
152
+ await clientA.end();
153
+ await clientB.end();
154
+ }
155
+ });
156
+
157
+ it("AC-7: sweep deletes an expired row only on the interval trigger, never on 'limited'", async () => {
158
+ const store = createDatabaseRateLimitStore(sqlClient(db));
159
+ const now = Date.now();
160
+ const staleHash = sha256Hex(`rl-stale-${randomUUID()}`);
161
+ const openHash = sha256Hex(`rl-open-${randomUUID()}`);
162
+
163
+ await db.query(
164
+ `INSERT INTO "rate_limit_counters" ("key_hash", "window_start_ms", "window_ms", "count") VALUES ($1, $2, $3, 1)`,
165
+ [staleHash, now - 120_000, 60_000],
166
+ );
167
+ await db.query(
168
+ `INSERT INTO "rate_limit_counters" ("key_hash", "window_start_ms", "window_ms", "count") VALUES ($1, $2, $3, 1)`,
169
+ [openHash, now, 60_000],
170
+ );
171
+
172
+ await store.sweep(now, 60_000, "limited");
173
+ let rows = await db.query(
174
+ `SELECT "key_hash" FROM "rate_limit_counters" WHERE "key_hash" IN ($1, $2)`,
175
+ [staleHash, openHash],
176
+ );
177
+ expect(rows.rows).toHaveLength(2);
178
+
179
+ await store.sweep(now, 60_000, "interval");
180
+ rows = await db.query(
181
+ `SELECT "key_hash" FROM "rate_limit_counters" WHERE "key_hash" IN ($1, $2)`,
182
+ [staleHash, openHash],
183
+ );
184
+ expect(rows.rows.map((r: { key_hash: string }) => r.key_hash)).toEqual([
185
+ openHash,
186
+ ]);
187
+ });
188
+
189
+ it("AC-8: key_hash is always a sha256 digest, never the raw identity, and both format CHECKs hold", async () => {
190
+ const store = createDatabaseRateLimitStore(sqlClient(db));
191
+ const identity = `203.0.113.${1 + Math.floor(Math.random() * 254)}`;
192
+ const now = Date.now();
193
+ await store.hit(identity, now, 60_000);
194
+
195
+ const allRows = await db.query(`SELECT "key_hash" FROM "rate_limit_counters"`);
196
+ for (const row of allRows.rows as Array<{ key_hash: string }>) {
197
+ expect(row.key_hash).not.toContain("203.0.113");
198
+ }
199
+ const hash = sha256Hex(identity);
200
+ const matched = await db.query(
201
+ `SELECT 1 FROM "rate_limit_counters" WHERE "key_hash" = $1`,
202
+ [hash],
203
+ );
204
+ expect(matched.rows).toHaveLength(1);
205
+
206
+ await expect(
207
+ db.query(
208
+ `INSERT INTO "rate_limit_counters" ("key_hash", "window_start_ms", "window_ms", "count") VALUES ('not-hex', $1, 60000, 1)`,
209
+ [now],
210
+ ),
211
+ ).rejects.toThrow(/rate_limit_counters_key_hash_hex/);
212
+
213
+ await expect(
214
+ db.query(
215
+ `INSERT INTO "rate_limit_counters" ("id", "key_hash", "window_start_ms", "window_ms", "count") VALUES ('bad', $1, $2, 60000, 1)`,
216
+ [sha256Hex(`rl-bad-id-${randomUUID()}`), now],
217
+ ),
218
+ ).rejects.toThrow(/rate_limit_counters_id_format/);
219
+ });
220
+ });
@@ -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
+ });