create-ailk 0.19.1 → 0.21.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.
- package/README.md +84 -7
- package/component-catalog.json +184 -6
- package/component-catalog.md +20 -9
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/module-architecture.js +55 -2
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +76 -22
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +78 -2
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +123 -1
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/update.d.ts +53 -0
- package/dist/update.js +606 -0
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/README.md +26 -25
- package/templates/_gitignore +162 -0
- package/templates/apps/api/CLAUDE.md +18 -10
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
- package/templates/apps/api/src/middleware/auth.ts +8 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
- package/templates/apps/api/src/server.ts +14 -2
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +139 -44
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/CLAUDE.md +26 -1
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +48 -4
- package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +46 -4
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/jest.config.cjs +4 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +100 -9
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/deck-page.ts +186 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/messages/en.json +83 -9
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +48 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/__tests__/seed.test.ts +39 -2
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +38 -12
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +29 -0
- package/templates/project.yaml +6 -3
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -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
|
|
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(
|
|
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();
|
|
@@ -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
|
+
});
|