create-ailk 0.14.0 → 0.15.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/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +34 -2
- package/dist/add-route.js +18 -2
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +23 -3
- package/dist/derive-architecture.d.ts +15 -2
- package/dist/derive-architecture.js +34 -6
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/extract-module-bundle.d.ts +11 -0
- package/dist/lib/extract-module-bundle.js +36 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +6 -0
- package/dist/parse-args.d.ts +22 -0
- package/dist/parse-args.js +73 -0
- package/dist/programmatic.d.ts +20 -0
- package/dist/programmatic.js +110 -9
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +12 -5
- package/dist/site-manifest.js +25 -11
- package/dist/sync-routes.js +11 -2
- package/dist/topology.d.ts +131 -0
- package/dist/topology.js +533 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/apps/api/CLAUDE.md +4 -2
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +18 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/lead-routing.ts +148 -29
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +71 -19
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
- package/templates/apps/web/app/openapi.json/route.ts +69 -1
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/relay.ts +206 -2
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +23 -0
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +25 -0
- package/templates/database/__tests__/agents.db.test.ts +65 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/auth/schema.prisma +23 -0
- package/templates/database/inbox/schema.prisma +13 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/package.json +1 -1
- package/versions.json +34 -33
|
@@ -15,13 +15,29 @@
|
|
|
15
15
|
|
|
16
16
|
import { GET } from "../route";
|
|
17
17
|
|
|
18
|
+
// The generated topology seam (#7426 D5), mocked so each case picks its
|
|
19
|
+
// topology: the hub cases below run against `"hub"`, exactly as before; the
|
|
20
|
+
// spoke cases (D12) flip it. Mocked rather than imported because the hub
|
|
21
|
+
// render imports `../auth`, which this route never touches.
|
|
22
|
+
const mockTopology: { value: "hub" | "spoke" } = { value: "hub" };
|
|
23
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426 D5), not a runtime input; the seam IS the boundary between a hub and a spoke build, and there is no env or HTTP surface that selects it.
|
|
24
|
+
jest.mock("../../../lib/topology.generated", () => ({
|
|
25
|
+
get TOPOLOGY() {
|
|
26
|
+
return mockTopology.value;
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
18
30
|
const SPEC = {
|
|
19
31
|
openapi: "3.1.0",
|
|
20
32
|
info: { title: "Example API", version: "1.0.0" },
|
|
21
33
|
paths: { "/v1/content/search": {} },
|
|
22
34
|
};
|
|
23
35
|
|
|
24
|
-
const API_ENV = [
|
|
36
|
+
const API_ENV = [
|
|
37
|
+
"AILK_API_URL",
|
|
38
|
+
"NEXT_PUBLIC_AILK_API_URL",
|
|
39
|
+
"NEXT_PUBLIC_BASE_URL",
|
|
40
|
+
] as const;
|
|
25
41
|
const saved: Record<string, string | undefined> = {};
|
|
26
42
|
let fetchSpy: jest.SpyInstance;
|
|
27
43
|
|
|
@@ -34,6 +50,7 @@ beforeEach(() => {
|
|
|
34
50
|
});
|
|
35
51
|
|
|
36
52
|
afterEach(() => {
|
|
53
|
+
mockTopology.value = "hub";
|
|
37
54
|
for (const name of API_ENV) {
|
|
38
55
|
if (saved[name] === undefined) delete process.env[name];
|
|
39
56
|
else process.env[name] = saved[name];
|
|
@@ -174,3 +191,74 @@ describe("GET /openapi.json", () => {
|
|
|
174
191
|
expect(await response.text()).not.toContain("api.internal");
|
|
175
192
|
});
|
|
176
193
|
});
|
|
194
|
+
|
|
195
|
+
describe("GET /openapi.json on a spoke (#7426 D12)", () => {
|
|
196
|
+
const HUB_SPEC = {
|
|
197
|
+
openapi: "3.1.0",
|
|
198
|
+
info: { title: "Hub API", version: "1.0.0" },
|
|
199
|
+
servers: [{ url: "https://hub.example.com", description: "The hub" }],
|
|
200
|
+
paths: {
|
|
201
|
+
"/v1/leads/submit": {
|
|
202
|
+
post: { summary: "Submit a lead" },
|
|
203
|
+
get: { summary: "Not relayed" },
|
|
204
|
+
},
|
|
205
|
+
"/v1/schedule/availability": {
|
|
206
|
+
get: {
|
|
207
|
+
summary: "Availability",
|
|
208
|
+
servers: [{ url: "https://hub.example.com/schedule" }],
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
"/v1/content/search": { get: { summary: "Hub-only search" } },
|
|
212
|
+
"/v1/admin/sites": { post: { summary: "Hub-only admin" } },
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
beforeEach(() => {
|
|
217
|
+
mockTopology.value = "spoke";
|
|
218
|
+
process.env["AILK_API_URL"] = "https://hub.example.com";
|
|
219
|
+
process.env["NEXT_PUBLIC_BASE_URL"] = "https://spoke.example.com";
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("names only this spoke's own origin in servers[]", async () => {
|
|
223
|
+
apiAnswers(200, JSON.stringify(HUB_SPEC));
|
|
224
|
+
|
|
225
|
+
const response = await GET();
|
|
226
|
+
const body = (await response.json()) as { servers: unknown };
|
|
227
|
+
|
|
228
|
+
expect(response.status).toBe(200);
|
|
229
|
+
expect(body.servers).toEqual([{ url: "https://spoke.example.com" }]);
|
|
230
|
+
expect(JSON.stringify(body)).not.toContain("hub.example.com");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("keeps only the paths and methods the relay forwards", async () => {
|
|
234
|
+
apiAnswers(200, JSON.stringify(HUB_SPEC));
|
|
235
|
+
|
|
236
|
+
const body = (await (await GET()).json()) as {
|
|
237
|
+
paths: Record<string, Record<string, unknown>>;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
expect(body.paths).toEqual({
|
|
241
|
+
"/v1/leads/submit": { post: { summary: "Submit a lead" } },
|
|
242
|
+
"/v1/schedule/availability": { get: { summary: "Availability" } },
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("still reads the hub's document at AILK_API_URL", async () => {
|
|
247
|
+
apiAnswers(200, JSON.stringify(HUB_SPEC));
|
|
248
|
+
|
|
249
|
+
await GET();
|
|
250
|
+
|
|
251
|
+
expect(requestedUrl()).toBe("https://hub.example.com/v1/openapi.json");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("on a hub, leaves servers[] and paths as the API declared them", async () => {
|
|
255
|
+
mockTopology.value = "hub";
|
|
256
|
+
apiAnswers(200, JSON.stringify(HUB_SPEC));
|
|
257
|
+
|
|
258
|
+
const body = (await (await GET()).json()) as typeof HUB_SPEC;
|
|
259
|
+
|
|
260
|
+
expect(body.servers).toEqual(HUB_SPEC.servers);
|
|
261
|
+
expect(body.paths).toEqual(HUB_SPEC.paths);
|
|
262
|
+
expect(JSON.stringify(body)).toContain("hub.example.com");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -15,8 +15,18 @@
|
|
|
15
15
|
* When the API cannot be reached, or answers anything but an OpenAPI
|
|
16
16
|
* document with a 200, this route answers 502 with a JSON error that names no
|
|
17
17
|
* upstream origin, and is never cached.
|
|
18
|
+
*
|
|
19
|
+
* On a spoke (#7426 D12) the API is the hub's, so the document is rewritten
|
|
20
|
+
* to describe THIS host only: `servers[]` becomes this site's own origin, and
|
|
21
|
+
* `paths` keeps only the (path, method) pairs the same-origin relay forwards
|
|
22
|
+
* (`RELAY_ROUTES`, lib/relay.ts) — an agent reading it never learns the hub's
|
|
23
|
+
* host, and never calls a path this site would answer 404.
|
|
18
24
|
*/
|
|
19
25
|
|
|
26
|
+
import { RELAY_ROUTES } from "../../lib/relay";
|
|
27
|
+
import { TOPOLOGY } from "../../lib/topology.generated";
|
|
28
|
+
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
29
|
+
|
|
20
30
|
// Read per request: the document is the running API's, and `next build` has
|
|
21
31
|
// no API to read it from.
|
|
22
32
|
export const dynamic = "force-dynamic";
|
|
@@ -58,6 +68,7 @@ function stripSlash(url: string): string {
|
|
|
58
68
|
interface OpenApiDocument {
|
|
59
69
|
openapi: string;
|
|
60
70
|
servers?: Array<{ url?: unknown; description?: unknown }>;
|
|
71
|
+
paths?: Record<string, unknown>;
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
function isOpenApiDocument(value: unknown): value is OpenApiDocument {
|
|
@@ -86,6 +97,61 @@ function withAbsoluteServers(spec: OpenApiDocument): OpenApiDocument {
|
|
|
86
97
|
};
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
/** The operation keys an OpenAPI path item may carry (OpenAPI 3.1 § 4.8.9). */
|
|
101
|
+
const OPERATION_KEYS = new Set([
|
|
102
|
+
"get",
|
|
103
|
+
"put",
|
|
104
|
+
"post",
|
|
105
|
+
"delete",
|
|
106
|
+
"options",
|
|
107
|
+
"head",
|
|
108
|
+
"patch",
|
|
109
|
+
"trace",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The document as a spoke serves it (#7426 D12): this host's own origin as
|
|
114
|
+
* the only server, and only the relayed (path, method) pairs. A path item's
|
|
115
|
+
* non-operation keys (`summary`, `parameters`, ...) ride along with a kept
|
|
116
|
+
* operation; a `servers` list — on the path item or on a kept operation —
|
|
117
|
+
* never does: it would name the hub.
|
|
118
|
+
*/
|
|
119
|
+
function withoutServers(operation: unknown): unknown {
|
|
120
|
+
if (typeof operation !== "object" || operation === null) return operation;
|
|
121
|
+
const { servers: _servers, ...rest } = operation as Record<string, unknown>;
|
|
122
|
+
return rest;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function asSpokeDocument(spec: OpenApiDocument): OpenApiDocument {
|
|
126
|
+
const relayed = new Map<string, Set<string>>();
|
|
127
|
+
for (const { method, path } of RELAY_ROUTES) {
|
|
128
|
+
const methods = relayed.get(path) ?? new Set<string>();
|
|
129
|
+
methods.add(method.toLowerCase());
|
|
130
|
+
relayed.set(path, methods);
|
|
131
|
+
}
|
|
132
|
+
const paths: Record<string, unknown> = {};
|
|
133
|
+
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
|
134
|
+
const methods = relayed.get(path);
|
|
135
|
+
if (methods === undefined || typeof item !== "object" || item === null) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const kept = Object.fromEntries(
|
|
139
|
+
Object.entries(item)
|
|
140
|
+
.filter(([key]) =>
|
|
141
|
+
OPERATION_KEYS.has(key) ? methods.has(key) : key !== "servers",
|
|
142
|
+
)
|
|
143
|
+
.map(([key, value]) => [
|
|
144
|
+
key,
|
|
145
|
+
OPERATION_KEYS.has(key) ? withoutServers(value) : value,
|
|
146
|
+
]),
|
|
147
|
+
);
|
|
148
|
+
if (Object.keys(kept).some((key) => OPERATION_KEYS.has(key))) {
|
|
149
|
+
paths[path] = kept;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { ...spec, servers: [{ url: stripSlash(resolveBaseUrl()) }], paths };
|
|
153
|
+
}
|
|
154
|
+
|
|
89
155
|
function badGateway(message: string): Response {
|
|
90
156
|
return Response.json(
|
|
91
157
|
{ error: { code: "upstream_unavailable", message } },
|
|
@@ -120,7 +186,9 @@ export async function GET(): Promise<Response> {
|
|
|
120
186
|
return badGateway("The API answered JSON that is not an OpenAPI document.");
|
|
121
187
|
}
|
|
122
188
|
|
|
123
|
-
|
|
189
|
+
const served =
|
|
190
|
+
TOPOLOGY === "spoke" ? asSpokeDocument(spec) : withAbsoluteServers(spec);
|
|
191
|
+
return Response.json(served, {
|
|
124
192
|
status: 200,
|
|
125
193
|
headers: { "cache-control": "public, max-age=3600" },
|
|
126
194
|
});
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
* Next.js instrumentation hook — runs once per server instance at boot.
|
|
3
3
|
*
|
|
4
4
|
* Registers the durable home of a renewed licence key (#6677 DD-6) before any
|
|
5
|
-
* request resolves the licence
|
|
6
|
-
*
|
|
5
|
+
* request resolves the licence, through the generated topology seam (#7426
|
|
6
|
+
* D5): a hub registers the database-backed store; a spoke has no database,
|
|
7
|
+
* so its render registers nothing. Node runtime only: the store reaches the
|
|
8
|
+
* database through Prisma, which the Edge runtime cannot load — the import
|
|
9
|
+
* stays inside the runtime check so the Edge bundle never carries it.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
export async function register(): Promise<void> {
|
|
10
13
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
11
|
-
const {
|
|
12
|
-
await
|
|
14
|
+
const { registerSiteStores } = await import("./lib/topology.generated");
|
|
15
|
+
await registerSiteStores();
|
|
13
16
|
}
|
|
14
17
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isClosingFooterPath,
|
|
3
|
+
normalizeRoutePath,
|
|
4
|
+
} from "../closing-footer-path";
|
|
5
|
+
|
|
6
|
+
const LOCALES = ["en", "es"];
|
|
7
|
+
|
|
8
|
+
describe("normalizeRoutePath (#7797)", () => {
|
|
9
|
+
it.each([
|
|
10
|
+
["/about", "/about"],
|
|
11
|
+
["/about/", "/about"],
|
|
12
|
+
["/about//", "/about"],
|
|
13
|
+
["/es/about", "/about"],
|
|
14
|
+
["/es/about/", "/about"],
|
|
15
|
+
["/about?x=1", "/about"],
|
|
16
|
+
["/about#team", "/about"],
|
|
17
|
+
["/services/first-engagement/", "/services/first-engagement"],
|
|
18
|
+
["/", "/"],
|
|
19
|
+
["", "/"],
|
|
20
|
+
["/es", "/"],
|
|
21
|
+
["/es/", "/"],
|
|
22
|
+
])("%s → %s", (input, expected) => {
|
|
23
|
+
expect(normalizeRoutePath(input, LOCALES)).toBe(expected);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("strips only a whole leading locale segment", () => {
|
|
27
|
+
expect(normalizeRoutePath("/espresso", LOCALES)).toBe("/espresso");
|
|
28
|
+
expect(normalizeRoutePath("/about/es", LOCALES)).toBe("/about/es");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("strips no prefix when no locale is enabled", () => {
|
|
32
|
+
expect(normalizeRoutePath("/es/about", [])).toBe("/es/about");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("isClosingFooterPath (#7797)", () => {
|
|
37
|
+
const paths = ["/about", "/services/first-engagement/"];
|
|
38
|
+
|
|
39
|
+
it.each([
|
|
40
|
+
"/about",
|
|
41
|
+
"/about/",
|
|
42
|
+
"/es/about",
|
|
43
|
+
"/es/about/",
|
|
44
|
+
"/services/first-engagement",
|
|
45
|
+
])("%s is a listed page", (pathname) => {
|
|
46
|
+
expect(isClosingFooterPath({ pathname, paths, locales: LOCALES })).toBe(
|
|
47
|
+
true,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it.each(["/services", "/about/team", "/aboutus", "/privacy", "/es/contact"])(
|
|
52
|
+
"%s is not a listed page",
|
|
53
|
+
(pathname) => {
|
|
54
|
+
expect(isClosingFooterPath({ pathname, paths, locales: LOCALES })).toBe(
|
|
55
|
+
false,
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
it("matches a listed path spelled with a locale prefix or a trailing slash", () => {
|
|
61
|
+
expect(
|
|
62
|
+
isClosingFooterPath({
|
|
63
|
+
pathname: "/about",
|
|
64
|
+
paths: ["/es/about/"],
|
|
65
|
+
locales: LOCALES,
|
|
66
|
+
}),
|
|
67
|
+
).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("is false with no list, an empty list, or no pathname", () => {
|
|
71
|
+
expect(
|
|
72
|
+
isClosingFooterPath({
|
|
73
|
+
pathname: "/about",
|
|
74
|
+
paths: undefined,
|
|
75
|
+
locales: LOCALES,
|
|
76
|
+
}),
|
|
77
|
+
).toBe(false);
|
|
78
|
+
expect(
|
|
79
|
+
isClosingFooterPath({ pathname: "/about", paths: [], locales: LOCALES }),
|
|
80
|
+
).toBe(false);
|
|
81
|
+
expect(
|
|
82
|
+
isClosingFooterPath({ pathname: null, paths, locales: LOCALES }),
|
|
83
|
+
).toBe(false);
|
|
84
|
+
expect(isClosingFooterPath({ pathname: "", paths, locales: LOCALES })).toBe(
|
|
85
|
+
false,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("never selects the home, even when it is listed", () => {
|
|
90
|
+
for (const pathname of ["/", "/es", "/es/"]) {
|
|
91
|
+
expect(
|
|
92
|
+
isClosingFooterPath({ pathname, paths: ["/"], locales: LOCALES }),
|
|
93
|
+
).toBe(false);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// Spec: docs/specs/7644-the-browser-talks-only-to-spec.md §5.1 AC-1 to AC-4.
|
|
12
12
|
|
|
13
13
|
import { handleRelay, RELAY_ROUTES } from "../relay";
|
|
14
|
+
import { __resetRelayRateLimit, __relayRateLimitBucketCount } from "../relay";
|
|
14
15
|
|
|
15
16
|
const HUB = "https://hub.example";
|
|
16
17
|
const SITE = "https://spoke.example";
|
|
@@ -124,6 +125,7 @@ function entryRequest(
|
|
|
124
125
|
let errorSpy: jest.SpyInstance;
|
|
125
126
|
|
|
126
127
|
beforeEach(() => {
|
|
128
|
+
__resetRelayRateLimit();
|
|
127
129
|
fetchMock.mockClear();
|
|
128
130
|
fetchMock.mockImplementation(async () => hubAnswer());
|
|
129
131
|
global.fetch = fetchMock as jest.Mock;
|
|
@@ -513,3 +515,291 @@ describe("hardening — what the site serves, and a missing hub address", () =>
|
|
|
513
515
|
});
|
|
514
516
|
});
|
|
515
517
|
});
|
|
518
|
+
|
|
519
|
+
// The relay's own per-visitor rate limit (#7707, spec
|
|
520
|
+
// docs/specs/7707-relay-rate-limit-spec.md §5.1 AC-1 to AC-9).
|
|
521
|
+
describe("the per-visitor rate limit (#7707)", () => {
|
|
522
|
+
const VISITOR = { "x-forwarded-for": "203.0.113.7" };
|
|
523
|
+
|
|
524
|
+
/** Spends the first visitor's 8-call visitor budget, then clears fetchMock. */
|
|
525
|
+
async function spendVisitorBudget(
|
|
526
|
+
headers: Record<string, string | null> = VISITOR,
|
|
527
|
+
): Promise<void> {
|
|
528
|
+
for (let i = 0; i < 8; i++) {
|
|
529
|
+
await handleRelay(relayRequest("/v1/leads/submit", { headers }));
|
|
530
|
+
}
|
|
531
|
+
fetchMock.mockClear();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
describe("AC-1 — 8 forwarded, the 9th refused, on each visitor route", () => {
|
|
535
|
+
it.each(VISITOR_ENTRIES)(
|
|
536
|
+
"$method $path: first 8 calls forwarded, the 9th refused",
|
|
537
|
+
async (entry) => {
|
|
538
|
+
for (let i = 0; i < 8; i++) {
|
|
539
|
+
const res = await handleRelay(entryRequest(entry, VISITOR));
|
|
540
|
+
expect(res.status).not.toBe(429);
|
|
541
|
+
}
|
|
542
|
+
fetchMock.mockClear();
|
|
543
|
+
|
|
544
|
+
const res = await handleRelay(entryRequest(entry, VISITOR));
|
|
545
|
+
expect(res.status).toBe(429);
|
|
546
|
+
expect(await errorCode(res)).toBe("rate_limited");
|
|
547
|
+
expect(res.headers.get("retry-after")).toBe("600");
|
|
548
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
549
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
550
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
describe("AC-2 — the budget is shared across the five visitor routes", () => {
|
|
556
|
+
it("2 calls each to submit/subscribe/callback/availability, then book is refused", async () => {
|
|
557
|
+
const shared = VISITOR_ENTRIES.slice(0, 4);
|
|
558
|
+
for (const entry of shared) {
|
|
559
|
+
for (let i = 0; i < 2; i++) {
|
|
560
|
+
const res = await handleRelay(entryRequest(entry, VISITOR));
|
|
561
|
+
expect(res.status).not.toBe(429);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const book = VISITOR_ENTRIES[4];
|
|
565
|
+
if (!book) throw new Error("expected a fifth visitor entry");
|
|
566
|
+
const res = await handleRelay(entryRequest(book, VISITOR));
|
|
567
|
+
expect(res.status).toBe(429);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
describe("AC-3 — the budget applies with and without AILK_VISITOR_FORWARD_KEY", () => {
|
|
572
|
+
const FORWARD_KEY_32 = "f".repeat(32);
|
|
573
|
+
|
|
574
|
+
it.each([
|
|
575
|
+
["unset", undefined],
|
|
576
|
+
["set", FORWARD_KEY_32],
|
|
577
|
+
])("forward key %s: still 8 forwarded then refused", async (_label, key) => {
|
|
578
|
+
if (key) process.env.AILK_VISITOR_FORWARD_KEY = key;
|
|
579
|
+
else delete process.env.AILK_VISITOR_FORWARD_KEY;
|
|
580
|
+
|
|
581
|
+
await spendVisitorBudget();
|
|
582
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit", { headers: VISITOR }));
|
|
583
|
+
expect(res.status).toBe(429);
|
|
584
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe("AC-4 — identity", () => {
|
|
589
|
+
it("a different visitor IP is forwarded", async () => {
|
|
590
|
+
await spendVisitorBudget();
|
|
591
|
+
const res = await handleRelay(
|
|
592
|
+
relayRequest("/v1/leads/submit", {
|
|
593
|
+
headers: { "x-forwarded-for": "198.51.100.9" },
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
596
|
+
expect(res.status).not.toBe(429);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("the first value of a multi-hop x-forwarded-for is the identity", async () => {
|
|
600
|
+
await spendVisitorBudget();
|
|
601
|
+
const res = await handleRelay(
|
|
602
|
+
relayRequest("/v1/leads/submit", {
|
|
603
|
+
headers: { "x-forwarded-for": "203.0.113.7, 10.0.0.1" },
|
|
604
|
+
}),
|
|
605
|
+
);
|
|
606
|
+
expect(res.status).toBe(429);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("an IPv4-mapped IPv6 address maps to its IPv4 identity", async () => {
|
|
610
|
+
await spendVisitorBudget();
|
|
611
|
+
const res = await handleRelay(
|
|
612
|
+
relayRequest("/v1/leads/submit", {
|
|
613
|
+
headers: { "x-forwarded-for": "::ffff:203.0.113.7" },
|
|
614
|
+
}),
|
|
615
|
+
);
|
|
616
|
+
expect(res.status).toBe(429);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it("x-real-ip alone resolves to the same identity as x-forwarded-for", async () => {
|
|
620
|
+
await spendVisitorBudget();
|
|
621
|
+
const res = await handleRelay(
|
|
622
|
+
relayRequest("/v1/leads/submit", {
|
|
623
|
+
headers: { "x-forwarded-for": null, "x-real-ip": "203.0.113.7" },
|
|
624
|
+
}),
|
|
625
|
+
);
|
|
626
|
+
expect(res.status).toBe(429);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("an IPv6 /64 prefix is one identity", async () => {
|
|
630
|
+
await spendVisitorBudget({ "x-forwarded-for": "2001:db8:0:0::1" });
|
|
631
|
+
|
|
632
|
+
const blocked = await handleRelay(
|
|
633
|
+
relayRequest("/v1/leads/submit", {
|
|
634
|
+
headers: { "x-forwarded-for": "2001:db8::2" },
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
expect(blocked.status).toBe(429);
|
|
638
|
+
|
|
639
|
+
const forwarded = await handleRelay(
|
|
640
|
+
relayRequest("/v1/leads/submit", {
|
|
641
|
+
headers: { "x-forwarded-for": "2001:db8:0:1::1" },
|
|
642
|
+
}),
|
|
643
|
+
);
|
|
644
|
+
expect(forwarded.status).not.toBe(429);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("an unparseable address shares the unknown identity with no address at all", async () => {
|
|
648
|
+
await spendVisitorBudget({ "x-forwarded-for": null, "x-real-ip": null });
|
|
649
|
+
const res = await handleRelay(
|
|
650
|
+
relayRequest("/v1/leads/submit", {
|
|
651
|
+
headers: { "x-forwarded-for": "not-an-ip" },
|
|
652
|
+
}),
|
|
653
|
+
);
|
|
654
|
+
expect(res.status).toBe(429);
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
describe("AC-5 — the window resets after windowMs", () => {
|
|
659
|
+
it("refused just before the window ends, forwarded once it does", async () => {
|
|
660
|
+
const T0 = Date.now();
|
|
661
|
+
const nowSpy = jest.spyOn(Date, "now").mockReturnValue(T0);
|
|
662
|
+
|
|
663
|
+
await spendVisitorBudget();
|
|
664
|
+
const stillBlocked = await handleRelay(
|
|
665
|
+
relayRequest("/v1/leads/submit", { headers: VISITOR }),
|
|
666
|
+
);
|
|
667
|
+
expect(stillBlocked.status).toBe(429);
|
|
668
|
+
|
|
669
|
+
nowSpy.mockReturnValue(T0 + 599_000);
|
|
670
|
+
const almostThere = await handleRelay(
|
|
671
|
+
relayRequest("/v1/leads/submit", { headers: VISITOR }),
|
|
672
|
+
);
|
|
673
|
+
expect(almostThere.status).toBe(429);
|
|
674
|
+
expect(almostThere.headers.get("retry-after")).toBe("1");
|
|
675
|
+
|
|
676
|
+
nowSpy.mockReturnValue(T0 + 600_000);
|
|
677
|
+
const freshWindow = await handleRelay(
|
|
678
|
+
relayRequest("/v1/leads/submit", { headers: VISITOR }),
|
|
679
|
+
);
|
|
680
|
+
expect(freshWindow.status).not.toBe(429);
|
|
681
|
+
|
|
682
|
+
nowSpy.mockRestore();
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
describe("AC-6 — refusals before the budget check spend nothing", () => {
|
|
687
|
+
it("403s, 404s and 508s before the budget check don't consume it", async () => {
|
|
688
|
+
for (let i = 0; i < 12; i++) {
|
|
689
|
+
const res = await handleRelay(
|
|
690
|
+
relayRequest("/v1/leads/submit", {
|
|
691
|
+
headers: { ...VISITOR, "sec-fetch-site": "cross-site" },
|
|
692
|
+
}),
|
|
693
|
+
);
|
|
694
|
+
expect(res.status).toBe(403);
|
|
695
|
+
}
|
|
696
|
+
for (let i = 0; i < 12; i++) {
|
|
697
|
+
const res = await handleRelay(
|
|
698
|
+
relayRequest("/v1/workspaces", { method: "POST", headers: VISITOR }),
|
|
699
|
+
);
|
|
700
|
+
expect(res.status).toBe(404);
|
|
701
|
+
}
|
|
702
|
+
for (let i = 0; i < 12; i++) {
|
|
703
|
+
const res = await handleRelay(
|
|
704
|
+
relayRequest("/v1/leads/submit", {
|
|
705
|
+
headers: { ...VISITOR, "x-ailk-relayed": "1" },
|
|
706
|
+
}),
|
|
707
|
+
);
|
|
708
|
+
expect(res.status).toBe(508);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
fetchMock.mockClear();
|
|
712
|
+
for (let i = 0; i < 8; i++) {
|
|
713
|
+
const res = await handleRelay(
|
|
714
|
+
relayRequest("/v1/leads/submit", { headers: VISITOR }),
|
|
715
|
+
);
|
|
716
|
+
expect(res.status).not.toBe(429);
|
|
717
|
+
}
|
|
718
|
+
expect(fetchMock).toHaveBeenCalledTimes(8);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
describe("AC-7 — the budget check precedes content-type and body-size checks", () => {
|
|
723
|
+
it("a 9th call with a bad content-type gets 429, not 415", async () => {
|
|
724
|
+
await spendVisitorBudget();
|
|
725
|
+
const res = await handleRelay(
|
|
726
|
+
relayRequest("/v1/leads/submit", {
|
|
727
|
+
headers: { ...VISITOR, "content-type": "text/plain" },
|
|
728
|
+
}),
|
|
729
|
+
);
|
|
730
|
+
expect(res.status).toBe(429);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("a 9th call with an oversized body gets 429, not 413", async () => {
|
|
734
|
+
await spendVisitorBudget();
|
|
735
|
+
const res = await handleRelay(
|
|
736
|
+
relayRequest("/v1/leads/submit", {
|
|
737
|
+
headers: VISITOR,
|
|
738
|
+
body: "x".repeat(1_048_577),
|
|
739
|
+
}),
|
|
740
|
+
);
|
|
741
|
+
expect(res.status).toBe(429);
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
describe("AC-8 — the session route has its own, wider, independent budget", () => {
|
|
746
|
+
it("30 forwarded, the 31st refused; the visitor budget is untouched", async () => {
|
|
747
|
+
for (let i = 0; i < 30; i++) {
|
|
748
|
+
const res = await handleRelay(
|
|
749
|
+
relayRequest("/v1/commerce/invoices", { headers: VISITOR }),
|
|
750
|
+
);
|
|
751
|
+
expect(res.status).not.toBe(429);
|
|
752
|
+
}
|
|
753
|
+
fetchMock.mockClear();
|
|
754
|
+
|
|
755
|
+
const refused = await handleRelay(
|
|
756
|
+
relayRequest("/v1/commerce/invoices", { headers: VISITOR }),
|
|
757
|
+
);
|
|
758
|
+
expect(refused.status).toBe(429);
|
|
759
|
+
expect(refused.headers.get("retry-after")).toBe("600");
|
|
760
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
761
|
+
|
|
762
|
+
const stillVisitor = await handleRelay(
|
|
763
|
+
relayRequest("/v1/leads/submit", { headers: VISITOR }),
|
|
764
|
+
);
|
|
765
|
+
expect(stillVisitor.status).not.toBe(429);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("a fresh visitor's visitor budget doesn't touch the session budget", async () => {
|
|
769
|
+
const OTHER = { "x-forwarded-for": "198.51.100.42" };
|
|
770
|
+
for (let i = 0; i < 8; i++) {
|
|
771
|
+
await handleRelay(relayRequest("/v1/leads/submit", { headers: OTHER }));
|
|
772
|
+
}
|
|
773
|
+
const res = await handleRelay(
|
|
774
|
+
relayRequest("/v1/commerce/invoices", { headers: OTHER }),
|
|
775
|
+
);
|
|
776
|
+
expect(res.status).not.toBe(429);
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
describe("AC-9 — the store is capped and evicts the oldest entry first", () => {
|
|
781
|
+
it(
|
|
782
|
+
"the first visitor's bucket is evicted at capacity, so its next call opens a fresh window",
|
|
783
|
+
async () => {
|
|
784
|
+
await spendVisitorBudget();
|
|
785
|
+
for (let i = 0; i < 10_000; i++) {
|
|
786
|
+
const ip = `10.${(i >> 16) & 0xff}.${(i >> 8) & 0xff}.${i & 0xff}`;
|
|
787
|
+
await handleRelay(
|
|
788
|
+
relayRequest("/v1/leads/submit", {
|
|
789
|
+
headers: { "x-forwarded-for": ip },
|
|
790
|
+
}),
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
expect(__relayRateLimitBucketCount()).toBe(10_000);
|
|
794
|
+
|
|
795
|
+
fetchMock.mockClear();
|
|
796
|
+
const res = await handleRelay(
|
|
797
|
+
relayRequest("/v1/leads/submit", { headers: VISITOR }),
|
|
798
|
+
);
|
|
799
|
+
expect(res.status).not.toBe(429);
|
|
800
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
801
|
+
},
|
|
802
|
+
30_000,
|
|
803
|
+
);
|
|
804
|
+
});
|
|
805
|
+
});
|