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.
- package/README.md +64 -7
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- 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 +32 -2
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.js +67 -2
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- 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/_gitignore +162 -0
- package/templates/apps/api/CLAUDE.md +14 -10
- 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/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/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 +95 -8
- 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/_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]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -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/robots.txt/route.ts +13 -10
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- 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/relay.ts +20 -0
- package/templates/apps/web/messages/en.json +67 -4
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +39 -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/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +38 -12
- 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/project.yaml +6 -3
- package/versions.json +35 -35
- 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
|
|
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();
|