create-ailk 0.12.1 → 0.13.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/dist/module-architecture.js +27 -0
- package/package.json +3 -3
- package/templates/.env.example +23 -9
- package/templates/apps/api/CLAUDE.md +21 -17
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +98 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +321 -0
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +117 -0
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +39 -14
- package/templates/apps/api/src/config/index.ts +35 -20
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +3 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +14 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +468 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +32 -2
- package/templates/apps/api/src/lib/agent-store.ts +521 -0
- package/templates/apps/api/src/lib/spoke-store.ts +13 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +384 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +61 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +176 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +76 -0
- package/templates/apps/api/src/middleware/auth.ts +219 -5
- package/templates/apps/api/src/middleware/default-tenant.ts +29 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +35 -13
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +76 -15
- package/templates/apps/api/src/routes/leads/index.ts +14 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -6
- package/templates/apps/api/src/routes/project-listings/index.ts +14 -9
- package/templates/apps/api/src/routes/project-listings/me.ts +4 -3
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +7 -5
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +198 -0
- package/templates/apps/api/src/server.ts +27 -0
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +374 -57
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +25 -2
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +303 -17
- package/templates/apps/api/src/services/audit-log.ts +278 -47
- package/templates/apps/api/src/services/default-tenant.ts +55 -5
- package/templates/apps/api/src/services/leads-inbox.ts +12 -21
- package/templates/apps/api/src/services/project-listings.ts +1 -21
- package/templates/apps/api/src/services/public-site-resolver.ts +103 -13
- package/templates/apps/mcp/CLAUDE.md +44 -5
- package/templates/apps/mcp/__tests__/parity.test.ts +53 -1
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +364 -0
- package/templates/apps/mcp/src/http-transport.ts +107 -1
- package/templates/apps/mcp/src/workspace-server.ts +193 -0
- package/templates/apps/web/.env.example +64 -37
- package/templates/apps/web/app/v1/[...path]/route.ts +16 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +515 -0
- package/templates/apps/web/lib/relay.ts +279 -0
- package/templates/apps/web/middleware.ts +5 -4
- package/templates/apps/web/next.config.mjs +6 -0
- package/templates/database/CHANGELOG.md +15 -0
- package/templates/database/__tests__/agents.db.test.ts +235 -0
- package/templates/database/auth/schema.prisma +61 -0
- package/templates/database/migrations/20261005120000_agents/migration.sql +84 -0
- package/templates/database/package.json +1 -1
- package/versions.json +34 -34
|
@@ -25,35 +25,71 @@ CONTENT_ADAPTER=mdx
|
|
|
25
25
|
# CONTENT_REVALIDATE_SECRET=
|
|
26
26
|
|
|
27
27
|
# =============================================================================
|
|
28
|
-
#
|
|
29
|
-
# =============================================================================
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
28
|
+
# The same-origin relay, and the browser-side apps/api name — #7644, #5397
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# THIS SITE'S OWN FORMS NEVER CALL apps/api FROM THE BROWSER (#7644). Lead
|
|
31
|
+
# capture, newsletter signup, step-flow submits, booking and the commerce
|
|
32
|
+
# invoice form post to THIS site's own /v1/... address; app/v1/[...path]/route.ts
|
|
33
|
+
# forwards each server-side to apps/api at AILK_API_URL, attaching this site's
|
|
34
|
+
# AILK_SPOKE_KEY when it is set and none when it is not (a single-site install
|
|
35
|
+
# takes the same path). next.config.mjs sets NEXT_PUBLIC_AILK_SAME_ORIGIN_RELAY
|
|
36
|
+
# for that — it is part of the source, not a variable to set here. The relay
|
|
37
|
+
# forwards exactly these, and refuses every other /v1 path with a 404:
|
|
38
|
+
#
|
|
39
|
+
# POST /v1/leads/submit POST /v1/leads/subscribe
|
|
40
|
+
# POST /v1/leads/callback GET /v1/schedule/availability
|
|
41
|
+
# POST /v1/schedule/book POST /v1/commerce/invoices
|
|
42
|
+
#
|
|
43
|
+
# An authored form `action` must name one of them. Adding one is a reviewed
|
|
44
|
+
# change to RELAY_ROUTES in lib/relay.ts and to this list. The visitor's
|
|
45
|
+
# x-forwarded-for and user-agent reach the hub as received (and the keyed
|
|
46
|
+
# visitor IP, with AILK_VISITOR_FORWARD_KEY — server-side only, never
|
|
47
|
+
# NEXT_PUBLIC_; see the repo-root .env.example, #7394). A cross-site post is
|
|
48
|
+
# refused before the hub is called.
|
|
49
|
+
#
|
|
50
|
+
# EDGE PRECONDITION: the relay forwards x-forwarded-for exactly as this site
|
|
51
|
+
# received it, so run the site behind an edge that OVERWRITES the client-address
|
|
52
|
+
# headers (as Vercel does), not one that appends to them. Otherwise a caller
|
|
53
|
+
# can choose its own rate-limit identity at the hub (spec SEC-7644-6). And when
|
|
54
|
+
# the HUB's edge overwrites x-forwarded-for with the address it saw (this
|
|
55
|
+
# site's server), set AILK_VISITOR_FORWARD_KEY here and on the hub: without it
|
|
56
|
+
# every visitor of this site shares one rate limit on the hub's lead routes.
|
|
57
|
+
#
|
|
58
|
+
# AILK_SPOKE_KEY ON A HUB WITH SPOKES (#7633). A hub whose spoke registry holds
|
|
59
|
+
# a row refuses every public call without a key by default, so EVERY site of it
|
|
60
|
+
# sets its own key — the hub's own site too, registered against the operator's
|
|
61
|
+
# own workspace. The relay above carries it on the browser's calls; the site's
|
|
62
|
+
# server-side reads (meeting types, the /meet pages, Server Actions) send it
|
|
63
|
+
# themselves. It is sent only when AILK_API_URL is https: or loopback.
|
|
64
|
+
# AILK_SPOKE_KEY=
|
|
65
|
+
#
|
|
66
|
+
# UPGRADE ORDER for a hub with spokes. 1) Redeploy every site on this release,
|
|
67
|
+
# the hub's own site included — a site still on the previous release keeps
|
|
68
|
+
# posting to the hub directly, and keeps working, until then. 2) Register a key
|
|
69
|
+
# for every site (the #7532 runbook) and set it as AILK_SPOKE_KEY. 3) Only then
|
|
70
|
+
# upgrade the hub to the release that refuses keyless callers (#7633; the
|
|
71
|
+
# runbook's "Before you upgrade a hub"). After that a spoke's origin can
|
|
72
|
+
# come off the hub's AILK_TRUSTED_ORIGINS — unless the site uses the commerce
|
|
73
|
+
# invoice form, a cookie-authenticated owner write whose Origin the hub still
|
|
74
|
+
# checks against that list.
|
|
75
|
+
#
|
|
76
|
+
# NEXT_PUBLIC_AILK_API_URL is still THE ONE BROWSER-SIDE NAME of apps/api's
|
|
77
|
+
# origin (#5397) for the two browser readers the relay does not cover:
|
|
42
78
|
# @working-theory/api-client's getApiBase() — which runs in the browser
|
|
43
79
|
# wherever a "use client" component binds it, e.g. the founder application's
|
|
44
|
-
# ListingApplicationClient.
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
80
|
+
# ListingApplicationClient — and the report reader's download link. It is also
|
|
81
|
+
# what packages/ui's resolveSubmitUrl falls back to in a build WITHOUT the
|
|
82
|
+
# relay (an older scaffold that upgrades @working-theory/ui alone). Should be
|
|
83
|
+
# apps/api's origin with NO trailing slash. getApiBase() previously read
|
|
84
|
+
# NEXT_PUBLIC_API_URL, a name this file never documented, so a site configured
|
|
85
|
+
# exactly per these docs sent its browser traffic to http://localhost:3001.
|
|
86
|
+
# That name is still honoured as a deprecated fallback; do not set it in a new
|
|
87
|
+
# deployment.
|
|
48
88
|
#
|
|
49
89
|
# The server-side counterpart is AILK_API_URL (see the repo-root .env.example)
|
|
50
90
|
# — deliberately NOT NEXT_PUBLIC_, and it still wins over this one when both
|
|
51
|
-
# are set, so an RSC/Server-Action call can target an internal
|
|
52
|
-
# browser never sees.
|
|
53
|
-
#
|
|
54
|
-
# A spoke calling a hub API server-to-server forwards its visitors for the
|
|
55
|
-
# hub's per-visitor lead limit with AILK_VISITOR_FORWARD_KEY — server-side
|
|
56
|
-
# only, never NEXT_PUBLIC_ (see the repo-root .env.example, #7394).
|
|
91
|
+
# are set, so an RSC/Server-Action call and the relay can target an internal
|
|
92
|
+
# origin the browser never sees.
|
|
57
93
|
# NEXT_PUBLIC_AILK_API_URL=http://localhost:3002
|
|
58
94
|
|
|
59
95
|
# =============================================================================
|
|
@@ -188,16 +224,7 @@ CONTENT_ADAPTER=mdx
|
|
|
188
224
|
# boundary checkable rather than merely intended.
|
|
189
225
|
# SCHEDULING_CAL_LINK=owner/event-slug
|
|
190
226
|
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
# `connect-src 'self' https://api.stripe.com` — so the browser blocks them and
|
|
196
|
-
# the booker shows its generic unavailable state with no explanation. Pass the
|
|
197
|
-
# API origin through the customer extension `buildHeaders` accepts:
|
|
198
|
-
#
|
|
199
|
-
# buildHeaders({ csp: { "connect-src": ["'self'", "<your apps/api origin>"] } })
|
|
200
|
-
#
|
|
201
|
-
# Deploying apps/web and apps/api on one origin (leaving
|
|
202
|
-
# NEXT_PUBLIC_AILK_API_URL unset) needs no extension — the calls stay
|
|
203
|
-
# same-origin and 'self' already covers them.
|
|
227
|
+
# The booker's /v1/schedule/* calls go through this site's same-origin relay
|
|
228
|
+
# (see the relay block above, #7644), so the baseline CSP's
|
|
229
|
+
# `connect-src 'self'` already covers them — no CSP extension is needed for
|
|
230
|
+
# them, whatever NEXT_PUBLIC_AILK_API_URL is set to.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The same-origin relay's route (#7644 D1). Every `/v1/...` request to this
|
|
3
|
+
* site lands here and is answered by `handleRelay` (lib/relay.ts): forwarded
|
|
4
|
+
* to the hub when it is one of the fixed relayed routes, refused otherwise.
|
|
5
|
+
* Next answers any other method with 405 itself.
|
|
6
|
+
*
|
|
7
|
+
* `/v1` is exempt from next-intl's locale rewrite in middleware.ts, as `/api`
|
|
8
|
+
* is; a static `v1` segment wins over `[locale]`, so no page is shadowed.
|
|
9
|
+
*/
|
|
10
|
+
import { handleRelay } from "../../../lib/relay";
|
|
11
|
+
|
|
12
|
+
export const runtime = "nodejs";
|
|
13
|
+
export const dynamic = "force-dynamic";
|
|
14
|
+
|
|
15
|
+
export const GET = handleRelay;
|
|
16
|
+
export const POST = handleRelay;
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
// The same-origin relay (#7644): the site serves the root-relative /v1 paths
|
|
5
|
+
// its own browser forms post to and forwards each, server-side, to the hub.
|
|
6
|
+
//
|
|
7
|
+
// Drives `handleRelay` with real `Request` objects through the real
|
|
8
|
+
// `relayToApi` (jest maps @working-theory/api-client to source), with the
|
|
9
|
+
// global `fetch` — the hub — stubbed.
|
|
10
|
+
//
|
|
11
|
+
// Spec: docs/specs/7644-the-browser-talks-only-to-spec.md §5.1 AC-1 to AC-4.
|
|
12
|
+
|
|
13
|
+
import { handleRelay, RELAY_ROUTES } from "../relay";
|
|
14
|
+
|
|
15
|
+
const HUB = "https://hub.example";
|
|
16
|
+
const SITE = "https://spoke.example";
|
|
17
|
+
const K1 = "K1";
|
|
18
|
+
const FORWARD_KEY = "FWD";
|
|
19
|
+
const BODY = '{"siteId":"x","email":"a@b.example"}';
|
|
20
|
+
const QUERY = "?calLink=acme/intro&start=2026-10-05";
|
|
21
|
+
|
|
22
|
+
const ENV_KEYS = [
|
|
23
|
+
"AILK_API_URL",
|
|
24
|
+
"NEXT_PUBLIC_AILK_API_URL",
|
|
25
|
+
"NEXT_PUBLIC_API_URL",
|
|
26
|
+
"AILK_SPOKE_KEY",
|
|
27
|
+
"AILK_VISITOR_FORWARD_KEY",
|
|
28
|
+
] as const;
|
|
29
|
+
const priorEnv = new Map<string, string | undefined>(
|
|
30
|
+
ENV_KEYS.map((name) => [name, process.env[name]]),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
function hubAnswer(
|
|
34
|
+
status = 201,
|
|
35
|
+
body = '{"id":"lead_1","persisted":"db"}',
|
|
36
|
+
headers: Record<string, string> = { "content-type": "application/json" },
|
|
37
|
+
): Response {
|
|
38
|
+
return new Response(body, { status, headers });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fetchMock = jest.fn(async (_url: string, _init: RequestInit) =>
|
|
42
|
+
hubAnswer(),
|
|
43
|
+
);
|
|
44
|
+
const priorFetch = global.fetch;
|
|
45
|
+
|
|
46
|
+
/** The `(url, init)` of the one hub call. */
|
|
47
|
+
function sent(): { url: string; init: RequestInit; headers: Headers } {
|
|
48
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
49
|
+
const call = fetchMock.mock.calls[0];
|
|
50
|
+
if (!call) throw new Error("the hub was not called");
|
|
51
|
+
const [url, init] = call;
|
|
52
|
+
return { url, init, headers: new Headers(init.headers) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SAME_ORIGIN = {
|
|
56
|
+
origin: SITE,
|
|
57
|
+
"sec-fetch-site": "same-origin",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A request to the relay at `path`. A same-origin browser POST by default:
|
|
62
|
+
* `host: spoke.example`, its own `Origin`, `Sec-Fetch-Site: same-origin`
|
|
63
|
+
* and a JSON body. `headers` overrides; a `null` value removes the header.
|
|
64
|
+
*/
|
|
65
|
+
function relayRequest(
|
|
66
|
+
path: string,
|
|
67
|
+
{
|
|
68
|
+
method = "POST",
|
|
69
|
+
headers = {},
|
|
70
|
+
body = BODY,
|
|
71
|
+
}: {
|
|
72
|
+
method?: string;
|
|
73
|
+
headers?: Record<string, string | null>;
|
|
74
|
+
body?: string;
|
|
75
|
+
} = {},
|
|
76
|
+
): Request {
|
|
77
|
+
const merged: Record<string, string | null> = {
|
|
78
|
+
host: "spoke.example",
|
|
79
|
+
...SAME_ORIGIN,
|
|
80
|
+
...(method === "POST" ? { "content-type": "application/json" } : {}),
|
|
81
|
+
...headers,
|
|
82
|
+
};
|
|
83
|
+
const init: RequestInit = {
|
|
84
|
+
method,
|
|
85
|
+
headers: Object.fromEntries(
|
|
86
|
+
Object.entries(merged).filter(
|
|
87
|
+
(entry): entry is [string, string] => entry[1] !== null,
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
if (method === "POST") init.body = body;
|
|
92
|
+
return new Request(`${SITE}${path}`, init);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function errorCode(res: Response): Promise<string | undefined> {
|
|
96
|
+
const json = (await res.json()) as { ok?: boolean; error?: { code?: string } };
|
|
97
|
+
expect(json.ok).toBe(false);
|
|
98
|
+
return json.error?.code;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ENTRIES: ReadonlyArray<{ method: "GET" | "POST"; path: string }> = [
|
|
102
|
+
{ method: "POST", path: "/v1/leads/submit" },
|
|
103
|
+
{ method: "POST", path: "/v1/leads/subscribe" },
|
|
104
|
+
{ method: "POST", path: "/v1/leads/callback" },
|
|
105
|
+
{ method: "GET", path: "/v1/schedule/availability" },
|
|
106
|
+
{ method: "POST", path: "/v1/schedule/book" },
|
|
107
|
+
{ method: "POST", path: "/v1/commerce/invoices" },
|
|
108
|
+
];
|
|
109
|
+
const VISITOR_ENTRIES = ENTRIES.filter(
|
|
110
|
+
(e) => e.path !== "/v1/commerce/invoices",
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
/** The relay request for one D2 entry, with the GET's query string. */
|
|
114
|
+
function entryRequest(
|
|
115
|
+
entry: { method: string; path: string },
|
|
116
|
+
headers: Record<string, string | null> = {},
|
|
117
|
+
): Request {
|
|
118
|
+
return relayRequest(
|
|
119
|
+
entry.method === "GET" ? `${entry.path}${QUERY}` : entry.path,
|
|
120
|
+
{ method: entry.method, headers },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let errorSpy: jest.SpyInstance;
|
|
125
|
+
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
fetchMock.mockClear();
|
|
128
|
+
fetchMock.mockImplementation(async () => hubAnswer());
|
|
129
|
+
global.fetch = fetchMock as jest.Mock;
|
|
130
|
+
for (const name of ENV_KEYS) delete process.env[name];
|
|
131
|
+
process.env.AILK_API_URL = HUB;
|
|
132
|
+
process.env.AILK_SPOKE_KEY = K1;
|
|
133
|
+
errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
errorSpy.mockRestore();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterAll(() => {
|
|
141
|
+
global.fetch = priorFetch;
|
|
142
|
+
for (const [name, value] of priorEnv) {
|
|
143
|
+
if (value === undefined) delete process.env[name];
|
|
144
|
+
else process.env[name] = value;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("AC-1 — forwarding, one entry each", () => {
|
|
149
|
+
it.each(ENTRIES)(
|
|
150
|
+
"$method $path is forwarded once, unchanged, and its answer passed back",
|
|
151
|
+
async (entry) => {
|
|
152
|
+
const res = await handleRelay(entryRequest(entry));
|
|
153
|
+
|
|
154
|
+
const { url, init } = sent();
|
|
155
|
+
expect(url).toBe(
|
|
156
|
+
`${HUB}${entry.path}${entry.method === "GET" ? QUERY : ""}`,
|
|
157
|
+
);
|
|
158
|
+
expect(init.method).toBe(entry.method);
|
|
159
|
+
if (entry.method === "POST") expect(init.body).toBe(BODY);
|
|
160
|
+
else expect(init.body).toBeUndefined();
|
|
161
|
+
|
|
162
|
+
expect(res.status).toBe(201);
|
|
163
|
+
expect(await res.text()).toBe('{"id":"lead_1","persisted":"db"}');
|
|
164
|
+
expect(res.headers.get("content-type")).toBe("application/json");
|
|
165
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
it("forwards the body byte-for-byte, never re-serialised", async () => {
|
|
170
|
+
const body = '{ "b":1,\n "a" : [2, 1] }';
|
|
171
|
+
await handleRelay(relayRequest("/v1/leads/submit", { body }));
|
|
172
|
+
expect(sent().init.body).toBe(body);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("drops a query string on a POST entry", async () => {
|
|
176
|
+
await handleRelay(relayRequest("/v1/leads/submit?x=1"));
|
|
177
|
+
expect(sent().url).toBe(`${HUB}/v1/leads/submit`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("passes the hub's 429 and retry-after through", async () => {
|
|
181
|
+
fetchMock.mockImplementation(async () =>
|
|
182
|
+
hubAnswer(429, '{"ok":false}', {
|
|
183
|
+
"content-type": "application/json",
|
|
184
|
+
"retry-after": "30",
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
188
|
+
expect(res.status).toBe(429);
|
|
189
|
+
expect(res.headers.get("retry-after")).toBe("30");
|
|
190
|
+
expect(await res.text()).toBe('{"ok":false}');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("answers 502 relay_unreachable when the hub cannot be reached", async () => {
|
|
194
|
+
fetchMock.mockImplementation(async () => {
|
|
195
|
+
throw new TypeError("fetch failed");
|
|
196
|
+
});
|
|
197
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
198
|
+
expect(res.status).toBe(502);
|
|
199
|
+
expect(await errorCode(res)).toBe("relay_unreachable");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("AC-2 — only the list", () => {
|
|
204
|
+
it.each([
|
|
205
|
+
["POST", "/v1/workspaces"],
|
|
206
|
+
["GET", "/v1/schedule/meeting-types"],
|
|
207
|
+
["POST", "/v1/flows/x/steps"],
|
|
208
|
+
["GET", "/v1/leads/submit"],
|
|
209
|
+
["POST", "/v1/leads/submit/"],
|
|
210
|
+
["POST", "/v1/leads/submit/extra"],
|
|
211
|
+
["POST", "/v1/Leads/submit"],
|
|
212
|
+
])("%s %s answers 404 not_found and calls no hub", async (method, path) => {
|
|
213
|
+
const res = await handleRelay(relayRequest(path, { method }));
|
|
214
|
+
expect(res.status).toBe(404);
|
|
215
|
+
expect(await errorCode(res)).toBe("not_found");
|
|
216
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("RELAY_ROUTES is exactly the six D2 pairs, and frozen", () => {
|
|
220
|
+
expect(RELAY_ROUTES).toEqual(ENTRIES);
|
|
221
|
+
expect(Object.isFrozen(RELAY_ROUTES)).toBe(true);
|
|
222
|
+
for (const route of RELAY_ROUTES) expect(Object.isFrozen(route)).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("AC-3 — the key, and only the site's key", () => {
|
|
227
|
+
it.each(ENTRIES)(
|
|
228
|
+
"$method $path carries the site's key and refuses redirects",
|
|
229
|
+
async (entry) => {
|
|
230
|
+
await handleRelay(entryRequest(entry));
|
|
231
|
+
const { init, headers } = sent();
|
|
232
|
+
expect(headers.get("x-ailk-spoke-key")).toBe(K1);
|
|
233
|
+
expect(init.redirect).toBe("error");
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
it.each(ENTRIES)(
|
|
238
|
+
"$method $path without a site key carries none and still works (the single-site path)",
|
|
239
|
+
async (entry) => {
|
|
240
|
+
delete process.env.AILK_SPOKE_KEY;
|
|
241
|
+
const res = await handleRelay(entryRequest(entry));
|
|
242
|
+
const { init, headers } = sent();
|
|
243
|
+
expect(headers.get("x-ailk-spoke-key")).toBeNull();
|
|
244
|
+
expect(init.redirect).toBe("error");
|
|
245
|
+
expect(res.status).toBe(201);
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const CLIENT_SENT = {
|
|
250
|
+
"x-ailk-spoke-key": "EVIL",
|
|
251
|
+
"x-ailk-visitor-forward-key": "EVIL",
|
|
252
|
+
"x-ailk-workspace-id": "wksp_x",
|
|
253
|
+
authorization: "Bearer t",
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
it("a client-sent key, forward key, workspace header and authorization never reach the hub", async () => {
|
|
257
|
+
await handleRelay(relayRequest("/v1/leads/submit", { headers: CLIENT_SENT }));
|
|
258
|
+
const { headers } = sent();
|
|
259
|
+
expect(headers.get("x-ailk-spoke-key")).toBe(K1);
|
|
260
|
+
expect(headers.get("x-ailk-visitor-forward-key")).toBeNull();
|
|
261
|
+
expect(headers.get("x-ailk-workspace-id")).toBeNull();
|
|
262
|
+
expect(headers.get("authorization")).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("with no site key, a client-sent key still never reaches the hub", async () => {
|
|
266
|
+
delete process.env.AILK_SPOKE_KEY;
|
|
267
|
+
await handleRelay(relayRequest("/v1/leads/submit", { headers: CLIENT_SENT }));
|
|
268
|
+
expect(sent().headers.get("x-ailk-spoke-key")).toBeNull();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("AC-4 — the visitor, unchanged; a cross-site post, refused", () => {
|
|
273
|
+
it("forwards x-forwarded-for and user-agent exactly as received", async () => {
|
|
274
|
+
await handleRelay(
|
|
275
|
+
relayRequest("/v1/leads/submit", {
|
|
276
|
+
headers: {
|
|
277
|
+
"x-forwarded-for": "203.0.113.5, 10.0.0.1",
|
|
278
|
+
"user-agent": "UA/1",
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
const { headers } = sent();
|
|
283
|
+
expect(headers.get("x-forwarded-for")).toBe("203.0.113.5, 10.0.0.1");
|
|
284
|
+
expect(headers.get("user-agent")).toBe("UA/1");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("sends x-real-ip as x-forwarded-for when that is all it has", async () => {
|
|
288
|
+
await handleRelay(
|
|
289
|
+
relayRequest("/v1/leads/submit", {
|
|
290
|
+
headers: { "x-real-ip": "203.0.113.9" },
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
expect(sent().headers.get("x-forwarded-for")).toBe("203.0.113.9");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("sends no x-forwarded-for when it has neither", async () => {
|
|
297
|
+
await handleRelay(relayRequest("/v1/leads/submit"));
|
|
298
|
+
expect(sent().headers.get("x-forwarded-for")).toBeNull();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it.each(VISITOR_ENTRIES)(
|
|
302
|
+
"$method $path carries the keyed visitor IP when the site has a forward key",
|
|
303
|
+
async (entry) => {
|
|
304
|
+
process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
|
|
305
|
+
await handleRelay(
|
|
306
|
+
entryRequest(entry, { "x-forwarded-for": "203.0.113.5, 10.0.0.1" }),
|
|
307
|
+
);
|
|
308
|
+
const { headers } = sent();
|
|
309
|
+
expect(headers.get("x-ailk-visitor-ip")).toBe("203.0.113.5");
|
|
310
|
+
expect(headers.get("x-ailk-visitor-forward-key")).toBe(FORWARD_KEY);
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
it("POST /v1/commerce/invoices carries no keyed visitor IP", async () => {
|
|
315
|
+
process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
|
|
316
|
+
await handleRelay(
|
|
317
|
+
relayRequest("/v1/commerce/invoices", {
|
|
318
|
+
headers: { "x-forwarded-for": "203.0.113.5" },
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
const { headers } = sent();
|
|
322
|
+
expect(headers.get("x-ailk-visitor-ip")).toBeNull();
|
|
323
|
+
expect(headers.get("x-ailk-visitor-forward-key")).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("forwards cookie and origin on /v1/commerce/invoices", async () => {
|
|
327
|
+
await handleRelay(
|
|
328
|
+
relayRequest("/v1/commerce/invoices", { headers: { cookie: "s=1" } }),
|
|
329
|
+
);
|
|
330
|
+
const { headers } = sent();
|
|
331
|
+
expect(headers.get("cookie")).toBe("s=1");
|
|
332
|
+
expect(headers.get("origin")).toBe(SITE);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it.each(VISITOR_ENTRIES)(
|
|
336
|
+
"$method $path forwards neither cookie nor origin",
|
|
337
|
+
async (entry) => {
|
|
338
|
+
await handleRelay(entryRequest(entry, { cookie: "s=1" }));
|
|
339
|
+
const { headers } = sent();
|
|
340
|
+
expect(headers.get("cookie")).toBeNull();
|
|
341
|
+
expect(headers.get("origin")).toBeNull();
|
|
342
|
+
},
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
it.each<[string, Record<string, string | null>]>([
|
|
346
|
+
["a foreign origin", { origin: "https://evil.example" }],
|
|
347
|
+
["origin: null", { origin: "null" }],
|
|
348
|
+
["the same host on another port", { origin: "https://spoke.example:8443" }],
|
|
349
|
+
[
|
|
350
|
+
"sec-fetch-site: cross-site with a same-host origin",
|
|
351
|
+
{ "sec-fetch-site": "cross-site" },
|
|
352
|
+
],
|
|
353
|
+
["sec-fetch-site: same-site", { "sec-fetch-site": "same-site" }],
|
|
354
|
+
])("%s answers 403 cross_site and calls no hub", async (_label, headers) => {
|
|
355
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit", { headers }));
|
|
356
|
+
expect(res.status).toBe(403);
|
|
357
|
+
expect(await errorCode(res)).toBe("cross_site");
|
|
358
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("admits an origin that matches the forwarded host behind a proxy", async () => {
|
|
362
|
+
const res = await handleRelay(
|
|
363
|
+
relayRequest("/v1/leads/submit", {
|
|
364
|
+
headers: {
|
|
365
|
+
host: "internal:3000",
|
|
366
|
+
"x-forwarded-host": "spoke.example, proxy",
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
expect(res.status).toBe(201);
|
|
371
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("admits a JSON POST that carries neither origin nor sec-fetch-site (a non-browser caller)", async () => {
|
|
375
|
+
const res = await handleRelay(
|
|
376
|
+
relayRequest("/v1/leads/submit", {
|
|
377
|
+
headers: { origin: null, "sec-fetch-site": null },
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
expect(res.status).toBe(201);
|
|
381
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("the forwarded host wins over a matching host header", async () => {
|
|
385
|
+
const res = await handleRelay(
|
|
386
|
+
relayRequest("/v1/leads/submit", {
|
|
387
|
+
headers: { host: "spoke.example", "x-forwarded-host": "proxy.example" },
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
expect(res.status).toBe(403);
|
|
391
|
+
expect(await errorCode(res)).toBe("cross_site");
|
|
392
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("a POST that is not JSON answers 415", async () => {
|
|
396
|
+
const res = await handleRelay(
|
|
397
|
+
relayRequest("/v1/leads/submit", {
|
|
398
|
+
headers: { "content-type": "text/plain" },
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
expect(res.status).toBe(415);
|
|
402
|
+
expect(await errorCode(res)).toBe("unsupported_media_type");
|
|
403
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("admits a JSON content-type with parameters, in any case", async () => {
|
|
407
|
+
const res = await handleRelay(
|
|
408
|
+
relayRequest("/v1/leads/submit", {
|
|
409
|
+
headers: { "content-type": "Application/JSON; charset=utf-8" },
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
expect(res.status).toBe(201);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("a body over 1 MiB answers 413", async () => {
|
|
416
|
+
const res = await handleRelay(
|
|
417
|
+
relayRequest("/v1/leads/submit", { body: "x".repeat(1_048_577) }),
|
|
418
|
+
);
|
|
419
|
+
expect(res.status).toBe(413);
|
|
420
|
+
expect(await errorCode(res)).toBe("payload_too_large");
|
|
421
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("a body of exactly 1 MiB is forwarded", async () => {
|
|
425
|
+
const res = await handleRelay(
|
|
426
|
+
relayRequest("/v1/leads/submit", { body: "x".repeat(1_048_576) }),
|
|
427
|
+
);
|
|
428
|
+
expect(res.status).toBe(201);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("a request already relayed answers 508 relay_loop", async () => {
|
|
432
|
+
const res = await handleRelay(
|
|
433
|
+
relayRequest("/v1/leads/submit", { headers: { "x-ailk-relayed": "1" } }),
|
|
434
|
+
);
|
|
435
|
+
expect(res.status).toBe(508);
|
|
436
|
+
expect(await errorCode(res)).toBe("relay_loop");
|
|
437
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("marks every forwarded call so a self-pointing hub address cannot loop", async () => {
|
|
441
|
+
await handleRelay(relayRequest("/v1/leads/submit"));
|
|
442
|
+
expect(sent().headers.get("x-ailk-relayed")).toBe("1");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("forwards nothing outside its allow-list", async () => {
|
|
446
|
+
await handleRelay(
|
|
447
|
+
relayRequest("/v1/leads/submit", {
|
|
448
|
+
headers: { "x-custom": "1", "accept-language": "en" },
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
const names = [...sent().headers.keys()].sort();
|
|
452
|
+
expect(names).toEqual(["content-type", "x-ailk-relayed", "x-ailk-spoke-key"]);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
describe("hardening — what the site serves, and a missing hub address", () => {
|
|
457
|
+
const mediaType = (res: Response) =>
|
|
458
|
+
res.headers.get("content-type")?.split(";")[0]?.trim();
|
|
459
|
+
|
|
460
|
+
it("passes a JSON content-type through as the hub sent it", async () => {
|
|
461
|
+
fetchMock.mockImplementation(async () =>
|
|
462
|
+
hubAnswer(201, "{}", { "content-type": "application/json; charset=utf-8" }),
|
|
463
|
+
);
|
|
464
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
465
|
+
expect(res.headers.get("content-type")).toBe(
|
|
466
|
+
"application/json; charset=utf-8",
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it.each([
|
|
471
|
+
["an HTML error page", { "content-type": "text/html" }],
|
|
472
|
+
["no content-type at all", {}],
|
|
473
|
+
])("serves %s from the hub as text/plain, never as markup", async (_label, headers) => {
|
|
474
|
+
fetchMock.mockImplementation(async () =>
|
|
475
|
+
hubAnswer(502, "<p>bad gateway</p>", headers),
|
|
476
|
+
);
|
|
477
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
478
|
+
expect(res.status).toBe(502);
|
|
479
|
+
expect(mediaType(res)).toBe("text/plain");
|
|
480
|
+
expect(await res.text()).toBe("<p>bad gateway</p>");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("marks every relayed answer and every refusal nosniff", async () => {
|
|
484
|
+
const relayed = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
485
|
+
const refused = await handleRelay(relayRequest("/v1/nope"));
|
|
486
|
+
expect(relayed.headers.get("x-content-type-options")).toBe("nosniff");
|
|
487
|
+
expect(refused.headers.get("x-content-type-options")).toBe("nosniff");
|
|
488
|
+
expect(mediaType(refused)).toBe("application/json");
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe("in production", () => {
|
|
492
|
+
const env = process.env as Record<string, string | undefined>;
|
|
493
|
+
const priorNodeEnv = env.NODE_ENV;
|
|
494
|
+
beforeEach(() => {
|
|
495
|
+
env.NODE_ENV = "production";
|
|
496
|
+
});
|
|
497
|
+
afterEach(() => {
|
|
498
|
+
env.NODE_ENV = priorNodeEnv;
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("answers 502 relay_unreachable and calls nothing when no hub address is set", async () => {
|
|
502
|
+
delete process.env.AILK_API_URL;
|
|
503
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
504
|
+
expect(res.status).toBe(502);
|
|
505
|
+
expect(await errorCode(res)).toBe("relay_unreachable");
|
|
506
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("forwards as usual when a hub address is set", async () => {
|
|
510
|
+
const res = await handleRelay(relayRequest("/v1/leads/submit"));
|
|
511
|
+
expect(res.status).toBe(201);
|
|
512
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
});
|