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