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,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
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The proposal paid stamp's data boundary (#8516, spec
3
+ * docs/specs/8516-proposals-as-an-artifact-kind-spec.md D13).
4
+ *
5
+ * The Stripe webhook (`routes/webhooks/stripe.ts`) is a commerce primitive
6
+ * registered on every build, so it may import no slice-owned file —
7
+ * `lib/member-store.ts` is one, and importing it would pull the webhook out of
8
+ * every scaffold without the member slice (the #5677 failure). Its one piece
9
+ * write therefore lives here, in the base tree, importing only `prisma.ts`.
10
+ *
11
+ * Two statements and no others, both scoped by a workspace id AND a piece id
12
+ * taken from a Stripe-signed `checkout.session.completed` event's metadata —
13
+ * written by this server when it opened the session (`ailk_proposal`,
14
+ * `ailk_workspace`; `services/proposal-payments.ts`), never settable by the
15
+ * payer: a guarded `updateMany` that stamps an unpaid proposal paid, and a
16
+ * `findFirst` by the same pair reading back which session paid it. No list,
17
+ * no other model, no read by any other key; never called by a route.
18
+ * `architecture.yaml` names this file as a tenant-scope exclusion with that
19
+ * reason.
20
+ */
21
+ import { prisma } from "./prisma.js";
22
+
23
+ /**
24
+ * - `stamped` — this session paid an unpaid proposal; stamped now.
25
+ * - `replay` — the proposal was already stamped by THIS session.
26
+ * - `already_paid` — a DIFFERENT session already paid it (a second charge).
27
+ * - `not_found` — no proposal of that id in that workspace.
28
+ */
29
+ export type ProposalPaidOutcome = "stamped" | "replay" | "already_paid" | "not_found";
30
+
31
+ /** Stamp a proposal paid by `sessionId`, at most once. */
32
+ export async function stampProposalPaid(
33
+ workspaceId: string,
34
+ pieceId: string,
35
+ sessionId: string,
36
+ now: Date,
37
+ ): Promise<ProposalPaidOutcome> {
38
+ const stamped = await prisma.piece.updateMany({
39
+ where: { id: pieceId, workspaceId, kind: "proposal", paidAt: null },
40
+ data: { paidAt: now, paidSessionId: sessionId },
41
+ });
42
+ if (stamped.count > 0) return "stamped";
43
+ const row = await prisma.piece.findFirst({
44
+ where: { id: pieceId, workspaceId, kind: "proposal" },
45
+ select: { paidSessionId: true },
46
+ });
47
+ if (row === null) return "not_found";
48
+ return row.paidSessionId === sessionId ? "replay" : "already_paid";
49
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The live rate-limiter probe's logic (#5040 D9, FR-7). Pure: takes its
3
+ * `fetch` and `sleep` so the CLI bin (`bin/rate-limit-probe.ts`) and the test
4
+ * suite can both drive it without a real network or a real 60-second wait.
5
+ * See the bin's header for what it answers and why.
6
+ */
7
+
8
+ const TEST_NET_3_PREFIX = "203.0.113.";
9
+
10
+ export type ProbeFetch = (
11
+ url: string,
12
+ init: { headers: Record<string, string> },
13
+ ) => Promise<{ status: number }>;
14
+
15
+ export interface RunRateLimitProbeOptions {
16
+ origin: string;
17
+ fetch: ProbeFetch;
18
+ limit?: number;
19
+ windowMs?: number;
20
+ sleep?: (ms: number) => Promise<void>;
21
+ log?: (msg: string) => void;
22
+ }
23
+
24
+ function isAllowedOrigin(origin: string): boolean {
25
+ let url: URL;
26
+ try {
27
+ url = new URL(origin);
28
+ } catch {
29
+ return false;
30
+ }
31
+ if (url.protocol === "https:") return true;
32
+ if (url.protocol !== "http:") return false;
33
+ const host = url.hostname;
34
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
35
+ }
36
+
37
+ function randomTestNet3(exclude: ReadonlySet<string>): string {
38
+ let candidate: string;
39
+ do {
40
+ candidate = `${TEST_NET_3_PREFIX}${1 + Math.floor(Math.random() * 254)}`;
41
+ } while (exclude.has(candidate));
42
+ return candidate;
43
+ }
44
+
45
+ function describeError(err: unknown): string {
46
+ if (
47
+ err !== null &&
48
+ typeof err === "object" &&
49
+ "code" in err &&
50
+ typeof (err as { code?: unknown }).code === "string"
51
+ ) {
52
+ return (err as { code: string }).code;
53
+ }
54
+ return err instanceof Error ? err.message : String(err);
55
+ }
56
+
57
+ const PROBE_PATH = "/v1/aeo/score/example";
58
+
59
+ /**
60
+ * Runs the two-fact probe (#5040 D9) against `origin`. Returns the process
61
+ * exit code the bin should use: `0` on `burst: holds`, `1` on every other
62
+ * conclusive-but-not-holding outcome (`limiter: not-observed`,
63
+ * `burst: exceeded`, `burst: inconclusive`), `2` on a refused origin or a
64
+ * network/status error.
65
+ */
66
+ export async function runRateLimitProbe(
67
+ opts: RunRateLimitProbeOptions,
68
+ ): Promise<number> {
69
+ const {
70
+ origin,
71
+ fetch,
72
+ limit = 10,
73
+ windowMs = 60_000,
74
+ sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
75
+ log = (msg: string) => console.log(msg),
76
+ } = opts;
77
+
78
+ if (!isAllowedOrigin(origin)) {
79
+ log("probe: error invalid-origin");
80
+ return 2;
81
+ }
82
+
83
+ const url = `${origin}${PROBE_PATH}`;
84
+ const used = new Set<string>();
85
+ const identityA = randomTestNet3(used);
86
+ used.add(identityA);
87
+
88
+ async function send(identity: string): Promise<number> {
89
+ const res = await fetch(url, { headers: { "x-forwarded-for": identity } });
90
+ return res.status;
91
+ }
92
+
93
+ let lastStatus: number | undefined;
94
+ try {
95
+ for (let i = 0; i < limit + 1; i++) {
96
+ const status = await send(identityA);
97
+ if (status !== 200 && status !== 429) {
98
+ log(`probe: error ${status}`);
99
+ return 2;
100
+ }
101
+ lastStatus = status;
102
+ }
103
+ } catch (err) {
104
+ log(`probe: error ${describeError(err)}`);
105
+ return 2;
106
+ }
107
+
108
+ if (lastStatus !== 429) {
109
+ log("limiter: not-observed");
110
+ return 1;
111
+ }
112
+
113
+ const identityB = randomTestNet3(used);
114
+ used.add(identityB);
115
+ let survives: boolean;
116
+ try {
117
+ const status = await send(identityB);
118
+ if (status !== 200 && status !== 429) {
119
+ log(`probe: error ${status}`);
120
+ return 2;
121
+ }
122
+ survives = status === 200;
123
+ } catch (err) {
124
+ log(`probe: error ${describeError(err)}`);
125
+ return 2;
126
+ }
127
+ log(survives ? "xff: survives" : "xff: overwritten");
128
+
129
+ let burstIdentity: string;
130
+ if (survives) {
131
+ burstIdentity = randomTestNet3(used);
132
+ } else {
133
+ await sleep(windowMs + 2_000);
134
+ burstIdentity = identityA;
135
+ }
136
+
137
+ const total = 3 * limit;
138
+ let successes = 0;
139
+ try {
140
+ const results = await Promise.all(
141
+ Array.from({ length: total }, () => send(burstIdentity)),
142
+ );
143
+ for (const status of results) {
144
+ if (status !== 200 && status !== 429) {
145
+ log(`probe: error ${status}`);
146
+ return 2;
147
+ }
148
+ if (status === 200) successes += 1;
149
+ }
150
+ } catch (err) {
151
+ log(`probe: error ${describeError(err)}`);
152
+ return 2;
153
+ }
154
+
155
+ if (successes === 0) {
156
+ log(`burst: inconclusive (0/${total})`);
157
+ return 1;
158
+ }
159
+ if (successes > limit) {
160
+ log(`burst: exceeded (${successes}/${total})`);
161
+ return 1;
162
+ }
163
+ log(`burst: holds (${successes}/${total})`);
164
+ return 0;
165
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The rate limiter's database store (#5040 D1/D3/D4). Counts a bucket in
3
+ * `rate_limit_counters` so one visitor's limit holds across every concurrent
4
+ * isolate on a deployment with a database — see
5
+ * `apps/api/src/middleware/rate-limit.ts`'s `SHARED STORE` header for the
6
+ * full picture (what this protects against, and what it does not).
7
+ *
8
+ * The only file naming `rate_limit_counters` outside `database/`. All SQL is
9
+ * `db.$queryRaw` / `db.$executeRaw` tagged templates — never `…Unsafe` — so
10
+ * every value is a bound parameter, never string-interpolated.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+
14
+ import type { Prisma } from "@working-theory/database";
15
+
16
+ /** One bucket's state, as both the memory and database stores report it. */
17
+ export type RateLimitBucket = {
18
+ count: number;
19
+ windowStartMs: number;
20
+ };
21
+
22
+ /** What a store call reports when it ran against a backing database. */
23
+ export type RateLimitStore = {
24
+ /** Count one request against `key`'s visitor bucket. */
25
+ hit(key: string, now: number, windowMs: number): Promise<RateLimitBucket>;
26
+ /** Count one request against `key`'s forwarding-key bucket (#7394 DD-8). */
27
+ hitKey(key: string, now: number, windowMs: number): Promise<RateLimitBucket>;
28
+ /** True when `key` has a bucket whose window has not yet ended. */
29
+ hasOpenWindow(key: string, now: number, windowMs: number): Promise<boolean>;
30
+ /** Evict expired buckets. `reason` is why the sweep fired (D6). */
31
+ sweep(now: number, windowMs: number, reason: "interval" | "limited"): Promise<void>;
32
+ };
33
+
34
+ /** What `createDatabaseRateLimitStore` needs of a Prisma client (D3). */
35
+ export type RateLimitSqlClient = Pick<
36
+ Prisma.TransactionClient,
37
+ "$queryRaw" | "$executeRaw"
38
+ >;
39
+
40
+ function sha256Hex(value: string): string {
41
+ return createHash("sha256").update(value).digest("hex");
42
+ }
43
+
44
+ /**
45
+ * The database store (D1/D3): one atomic upsert per hit, same window rules as
46
+ * the memory store. `bucketKey` is the exact string the memory store uses as
47
+ * its Map key; this store hashes it before it ever reaches SQL (FR-5) — the
48
+ * table never holds a plaintext IP or bucket key.
49
+ */
50
+ export function createDatabaseRateLimitStore(
51
+ db: RateLimitSqlClient,
52
+ ): RateLimitStore {
53
+ async function countHit(
54
+ bucketKey: string,
55
+ now: number,
56
+ windowMs: number,
57
+ ): Promise<RateLimitBucket> {
58
+ const hash = sha256Hex(bucketKey);
59
+ const rows = await db.$queryRaw<
60
+ Array<{ count: number; window_start_ms: bigint | string }>
61
+ >`
62
+ INSERT INTO "rate_limit_counters" ("key_hash", "window_start_ms", "window_ms", "count")
63
+ VALUES (${hash}, ${now}::bigint, ${windowMs}::integer, 1)
64
+ ON CONFLICT ("key_hash") DO UPDATE SET
65
+ "count" = CASE WHEN ${now}::bigint - "rate_limit_counters"."window_start_ms" >= ${windowMs}::integer
66
+ THEN 1 ELSE "rate_limit_counters"."count" + 1 END,
67
+ "window_start_ms" = CASE WHEN ${now}::bigint - "rate_limit_counters"."window_start_ms" >= ${windowMs}::integer
68
+ THEN ${now}::bigint ELSE "rate_limit_counters"."window_start_ms" END,
69
+ "window_ms" = ${windowMs}::integer
70
+ RETURNING "count", "window_start_ms"
71
+ `;
72
+ const row = rows[0];
73
+ if (!row) {
74
+ throw new Error("rate_limit_counters upsert returned no row");
75
+ }
76
+ return {
77
+ count: Number(row.count),
78
+ windowStartMs: Number(row.window_start_ms),
79
+ };
80
+ }
81
+
82
+ async function hasOpenWindow(
83
+ bucketKey: string,
84
+ now: number,
85
+ windowMs: number,
86
+ ): Promise<boolean> {
87
+ const hash = sha256Hex(bucketKey);
88
+ const rows = await db.$queryRaw<Array<{ one: number }>>`
89
+ SELECT 1 AS "one" FROM "rate_limit_counters"
90
+ WHERE "key_hash" = ${hash}
91
+ AND ${now}::bigint - "window_start_ms" < ${windowMs}::integer
92
+ LIMIT 1
93
+ `;
94
+ return rows.length > 0;
95
+ }
96
+
97
+ return {
98
+ hit: (key, now, windowMs) => countHit(key, now, windowMs),
99
+ hitKey: (key, now, windowMs) => countHit(key, now, windowMs),
100
+ hasOpenWindow,
101
+ async sweep(now, _windowMs, reason) {
102
+ // D6 — the database store sweeps on the interval trigger only. Sweeping
103
+ // on every 429 would let an attacker buy a table scan per refused
104
+ // request.
105
+ if (reason !== "interval") return;
106
+ await db.$executeRaw`
107
+ DELETE FROM "rate_limit_counters"
108
+ WHERE "window_start_ms" + "window_ms" <= ${now}::bigint
109
+ `;
110
+ },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Which store a registration with no explicit `store` option uses (D4),
116
+ * resolved once per process and memoized by the middleware. `warn` is called
117
+ * exactly once for each of the two misconfiguration rows below.
118
+ */
119
+ export type RateLimitStoreEnv = {
120
+ DATABASE_URL?: string | undefined;
121
+ AILK_RATE_LIMIT_STORE?: string | undefined;
122
+ };
123
+
124
+ export function resolveRateLimitStoreKind(
125
+ env: RateLimitStoreEnv,
126
+ warn: (msg: string) => void,
127
+ ): "memory" | "database" {
128
+ const hasDatabase = !!env.DATABASE_URL;
129
+ const raw = env.AILK_RATE_LIMIT_STORE;
130
+
131
+ if (raw === undefined || raw === "") {
132
+ return hasDatabase ? "database" : "memory";
133
+ }
134
+ if (raw === "memory") return "memory";
135
+ if (raw === "database") {
136
+ if (hasDatabase) return "database";
137
+ warn("AILK_RATE_LIMIT_STORE=database needs DATABASE_URL; using memory");
138
+ return "memory";
139
+ }
140
+ warn("AILK_RATE_LIMIT_STORE is not memory or database and is ignored");
141
+ return hasDatabase ? "database" : "memory";
142
+ }
@@ -77,6 +77,9 @@ const ADMITTED = [
77
77
  "POST /v1/member/deletion-request",
78
78
  "GET /v1/member/consents",
79
79
  "POST /v1/member/consents",
80
+ // #8515 D13 — the client's onboarding read and write.
81
+ "GET /v1/member/onboarding",
82
+ "POST /v1/member/onboarding",
80
83
  ];
81
84
 
82
85
  /** Every route the credential must NOT reach, each a recording stub. */
@@ -174,9 +177,9 @@ afterEach(async () => {
174
177
  });
175
178
 
176
179
  describe("HUB_SESSION_ROUTES", () => {
177
- it("is exactly the 20 entries, by method and registered URL — never a prefix (AC-1)", () => {
180
+ it("is exactly the 24 entries, by method and registered URL — never a prefix (AC-1, #8515 AC-6)", () => {
178
181
  expect([...HUB_SESSION_ROUTES]).toEqual(ADMITTED);
179
- expect(HUB_SESSION_ROUTES.length).toBe(22);
182
+ expect(HUB_SESSION_ROUTES.length).toBe(24);
180
183
  for (const route of HUB_SESSION_ROUTES) {
181
184
  expect({
182
185
  route,
@@ -580,6 +580,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
580
580
  organizationId: DEFAULT_ORG_ID,
581
581
  workspaceId: DEFAULT_WORKSPACE_ID,
582
582
  role: "member",
583
+ agent: true,
583
584
  });
584
585
  await app.close();
585
586
  });
@@ -599,6 +600,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
599
600
  organizationId: DEFAULT_ORG_ID,
600
601
  workspaceId: DEFAULT_WORKSPACE_ID,
601
602
  role: "member",
603
+ agent: true,
602
604
  grants: ["content-edit", "magnets-manage"],
603
605
  });
604
606
  await app.close();