create-ailk 0.17.0 → 0.19.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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/env-catalog.js +5 -9
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +15 -2
- package/dist/topology.js +83 -4
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +58 -7
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +167 -0
- package/templates/apps/mcp/src/http-transport.ts +132 -88
- package/templates/apps/mcp/src/index.ts +19 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/mcp/tsconfig.json +2 -0
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/site-brand.tsx +22 -6
- package/templates/apps/web/lib/topology.generated.ts +27 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file cors.test.ts
|
|
3
|
-
* @description CORS preflight contract (#5397).
|
|
4
|
-
*
|
|
5
|
-
* The one test file that does NOT mock `@fastify/cors`. Its siblings
|
|
6
|
-
* (`server.test.ts`, `module-exclusion.test.ts`, `index.test.ts`) stub the
|
|
7
|
-
* plugin at the module boundary, which is why the defect this file pins was
|
|
8
|
-
* invisible to the whole suite: with the plugin mocked, the registration
|
|
9
|
-
* options are never read and no preflight is ever answered. The plugin needs
|
|
10
|
-
* no live infrastructure — it is a header writer — so the real one runs here.
|
|
11
|
-
*
|
|
12
|
-
* The defect: `@fastify/cors` defaults `methods` to `GET,HEAD,POST`
|
|
13
|
-
* (node_modules/@fastify/cors/index.js `defaultOptions`), and `server.ts`
|
|
14
|
-
* passed only `origin` + `credentials`. So the preflight for
|
|
15
|
-
* `PATCH /v1/project-listings/drafts/:id` answered
|
|
16
|
-
* `access-control-allow-methods: GET, HEAD, POST` and the browser refused the
|
|
17
|
-
* request — a split-origin site could create a listing draft but never save
|
|
18
|
-
* one. Observed against the salessmyth/samhenry deploy, 2026-09-13.
|
|
19
|
-
*
|
|
20
|
-
* Each case rebuilds the module graph (`jest.resetModules()`) because
|
|
21
|
-
* `config/index.ts` snapshots `FRONTEND_URL` and `NODE_ENV` at import time.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
// Mocked for the same reasons the sibling suites give: rate-limit wants a live
|
|
25
|
-
// store, and @working-theory/email pulls an ESM-only transitive dep that
|
|
26
|
-
// jest-resolve cannot load in CommonJS mode. `@fastify/cors` is deliberately
|
|
27
|
-
// NOT mocked — it is the subject.
|
|
28
|
-
jest.mock("@fastify/rate-limit", () => jest.fn().mockResolvedValue(undefined));
|
|
29
|
-
|
|
30
|
-
jest.mock("@working-theory/email", () => ({
|
|
31
|
-
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
32
|
-
}));
|
|
33
|
-
|
|
34
|
-
import { REGISTERED_ROUTES_DECORATOR } from "../openapi/spec";
|
|
35
|
-
import type { RegisteredRoute } from "../openapi/surface-drift";
|
|
36
|
-
|
|
37
|
-
const FRONTEND_URL = "https://site.example.com";
|
|
38
|
-
const FOREIGN_ORIGIN = "https://attacker.example.net";
|
|
39
|
-
|
|
40
|
-
// The route the defect was found on: ListingApplicationFlow's `patchDraft`
|
|
41
|
-
// runs in the browser on every advance after step one.
|
|
42
|
-
const DRAFT_PATCH_URL = "/v1/project-listings/drafts/listing_abc123";
|
|
43
|
-
|
|
44
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
|
|
45
|
-
type App = any;
|
|
46
|
-
|
|
47
|
-
const ENV_KEYS = ["FRONTEND_URL", "AILK_TRUSTED_ORIGINS", "NODE_ENV"] as const;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Build a server whose `config` was resolved from `env`. Restores the prior
|
|
51
|
-
* environment through `restoreEnv()` in `afterEach`.
|
|
52
|
-
*/
|
|
53
|
-
async function buildWith(env: {
|
|
54
|
-
FRONTEND_URL?: string;
|
|
55
|
-
AILK_TRUSTED_ORIGINS?: string;
|
|
56
|
-
NODE_ENV: string;
|
|
57
|
-
}): Promise<App> {
|
|
58
|
-
for (const key of ENV_KEYS) {
|
|
59
|
-
const value = env[key];
|
|
60
|
-
if (value === undefined) {
|
|
61
|
-
delete process.env[key];
|
|
62
|
-
} else {
|
|
63
|
-
process.env[key] = value;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
jest.resetModules();
|
|
67
|
-
|
|
68
|
-
const { build } = require("../server") as typeof import("../server");
|
|
69
|
-
return build();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Issue a CORS preflight the way a browser would. */
|
|
73
|
-
function preflight(
|
|
74
|
-
app: App,
|
|
75
|
-
origin: string,
|
|
76
|
-
method: string,
|
|
77
|
-
url = DRAFT_PATCH_URL,
|
|
78
|
-
) {
|
|
79
|
-
return app.inject({
|
|
80
|
-
method: "OPTIONS",
|
|
81
|
-
url,
|
|
82
|
-
headers: {
|
|
83
|
-
origin,
|
|
84
|
-
"access-control-request-method": method,
|
|
85
|
-
"access-control-request-headers": "authorization,content-type",
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** `Access-Control-Allow-Methods` as an uppercased, trimmed set. */
|
|
91
|
-
function allowedMethods(response: {
|
|
92
|
-
headers: Record<string, unknown>;
|
|
93
|
-
}): string[] {
|
|
94
|
-
const header = response.headers["access-control-allow-methods"];
|
|
95
|
-
if (typeof header !== "string") return [];
|
|
96
|
-
return header.split(",").map((method) => method.trim().toUpperCase());
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
describe("CORS preflight (#5397)", () => {
|
|
100
|
-
const priorEnv = new Map<string, string | undefined>(
|
|
101
|
-
ENV_KEYS.map((key) => [key, process.env[key]]),
|
|
102
|
-
);
|
|
103
|
-
let app: App;
|
|
104
|
-
|
|
105
|
-
afterEach(async () => {
|
|
106
|
-
if (app) {
|
|
107
|
-
await app.close();
|
|
108
|
-
app = undefined;
|
|
109
|
-
}
|
|
110
|
-
for (const [key, value] of priorEnv) {
|
|
111
|
-
if (value === undefined) {
|
|
112
|
-
delete process.env[key];
|
|
113
|
-
} else {
|
|
114
|
-
process.env[key] = value;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
jest.resetModules();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("allows PATCH and the authorization header for the configured FRONTEND_URL origin", async () => {
|
|
121
|
-
// ARRANGE
|
|
122
|
-
app = await buildWith({ FRONTEND_URL, NODE_ENV: "production" });
|
|
123
|
-
|
|
124
|
-
// ACT
|
|
125
|
-
const response = await preflight(app, FRONTEND_URL, "PATCH");
|
|
126
|
-
|
|
127
|
-
// ASSERT — the origin is allowed, with credentials, exactly as before.
|
|
128
|
-
expect(response.headers["access-control-allow-origin"]).toBe(FRONTEND_URL);
|
|
129
|
-
expect(response.headers["access-control-allow-credentials"]).toBe("true");
|
|
130
|
-
// ...and the method the browser asked about is in the answer. Without an
|
|
131
|
-
// explicit `methods` option this is `GET, HEAD, POST` and the browser
|
|
132
|
-
// refuses the PATCH.
|
|
133
|
-
expect(allowedMethods(response)).toEqual(
|
|
134
|
-
expect.arrayContaining(["GET", "HEAD", "POST", "PATCH"]),
|
|
135
|
-
);
|
|
136
|
-
// The bearer token ListingApplicationFlow sends must survive the preflight.
|
|
137
|
-
const allowHeaders = String(
|
|
138
|
-
response.headers["access-control-allow-headers"] ?? "",
|
|
139
|
-
).toLowerCase();
|
|
140
|
-
expect(allowHeaders).toContain("authorization");
|
|
141
|
-
expect(allowHeaders).toContain("content-type");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("refuses a preflight from an origin other than FRONTEND_URL in production", async () => {
|
|
145
|
-
// ARRANGE
|
|
146
|
-
app = await buildWith({ FRONTEND_URL, NODE_ENV: "production" });
|
|
147
|
-
|
|
148
|
-
// ACT
|
|
149
|
-
const response = await preflight(app, FOREIGN_ORIGIN, "PATCH");
|
|
150
|
-
|
|
151
|
-
// ASSERT — the refusal is the browser's: it blocks any response whose
|
|
152
|
-
// allow-origin is not its own origin. So the contract to pin is that the
|
|
153
|
-
// foreign origin is never named back, and the wildcard never appears —
|
|
154
|
-
// `credentials: true` with `*` would let any site read credentialed
|
|
155
|
-
// responses (the security-review M1 posture server.ts already documents).
|
|
156
|
-
// Widening `methods` must not widen WHO may use them. With the ARRAY
|
|
157
|
-
// `origin` option (#7395 D4), @fastify/cors writes no allow-origin header
|
|
158
|
-
// at all for an unlisted origin (D6.4); before #7395 it echoed the one
|
|
159
|
-
// configured string.
|
|
160
|
-
const allowOrigin = response.headers["access-control-allow-origin"];
|
|
161
|
-
expect(allowOrigin).not.toBe(FOREIGN_ORIGIN);
|
|
162
|
-
expect(allowOrigin).not.toBe("*");
|
|
163
|
-
expect(allowOrigin).toBeUndefined();
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("advertises every method the registered route table actually serves", async () => {
|
|
167
|
-
// ARRANGE — every paid module on, so the widest route surface is measured.
|
|
168
|
-
process.env.AILK_ENABLED_MODULES = "multi-tenant,scheduling,content-write";
|
|
169
|
-
try {
|
|
170
|
-
app = await buildWith({ FRONTEND_URL, NODE_ENV: "production" });
|
|
171
|
-
await app.ready();
|
|
172
|
-
|
|
173
|
-
// ACT
|
|
174
|
-
const response = await preflight(app, FRONTEND_URL, "GET", "/health");
|
|
175
|
-
const advertised = allowedMethods(response);
|
|
176
|
-
const registered = new Set(
|
|
177
|
-
(app[REGISTERED_ROUTES_DECORATOR] as RegisteredRoute[]).map((route) =>
|
|
178
|
-
route.method.toUpperCase(),
|
|
179
|
-
),
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
// ASSERT — a route added in a method nobody added to the CORS list is
|
|
183
|
-
// unreachable from a browser on a split-origin deploy. This is the drift
|
|
184
|
-
// guard: add a DELETE route, and this fails until `methods` says DELETE.
|
|
185
|
-
// Reported as a set so a drift names every missing method at once.
|
|
186
|
-
const missing = [...registered].filter(
|
|
187
|
-
(method) => !advertised.includes(method),
|
|
188
|
-
);
|
|
189
|
-
expect(missing).toEqual([]);
|
|
190
|
-
// HEAD is never in the route table (Fastify synthesises it from GET, and
|
|
191
|
-
// server.ts's onRoute hook drops it), so assert it separately.
|
|
192
|
-
expect(advertised).toContain("HEAD");
|
|
193
|
-
} finally {
|
|
194
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
195
|
-
}
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// ── #7395 — the trusted-origin allowlist (AC-4) ──────────────────────────────
|
|
200
|
-
//
|
|
201
|
-
// One deployment serves the hub site, its Admin app and every spoke, so CORS
|
|
202
|
-
// answers for each origin the operator listed in FRONTEND_URL or
|
|
203
|
-
// AILK_TRUSTED_ORIGINS, and for no other — in development as in production,
|
|
204
|
-
// since the reflect-any dev arm is gone (D4). A credentialed GET, not only a
|
|
205
|
-
// preflight, carries the header, so a hand-written OPTIONS route cannot pass.
|
|
206
|
-
|
|
207
|
-
describe("CORS trusted-origin allowlist (#7395)", () => {
|
|
208
|
-
const HUB = "https://hub.example";
|
|
209
|
-
const SPOKE_A = "https://spoke-a.example";
|
|
210
|
-
const priorEnv = new Map<string, string | undefined>(
|
|
211
|
-
ENV_KEYS.map((key) => [key, process.env[key]]),
|
|
212
|
-
);
|
|
213
|
-
let app: App;
|
|
214
|
-
|
|
215
|
-
afterEach(async () => {
|
|
216
|
-
if (app) {
|
|
217
|
-
await app.close();
|
|
218
|
-
app = undefined;
|
|
219
|
-
}
|
|
220
|
-
for (const [key, value] of priorEnv) {
|
|
221
|
-
if (value === undefined) {
|
|
222
|
-
delete process.env[key];
|
|
223
|
-
} else {
|
|
224
|
-
process.env[key] = value;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
jest.resetModules();
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
describe.each(["production", "development"])("NODE_ENV=%s", (nodeEnv) => {
|
|
231
|
-
beforeEach(async () => {
|
|
232
|
-
app = await buildWith({
|
|
233
|
-
FRONTEND_URL: HUB,
|
|
234
|
-
AILK_TRUSTED_ORIGINS: SPOKE_A,
|
|
235
|
-
NODE_ENV: nodeEnv,
|
|
236
|
-
});
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("allows a PATCH preflight from a listed spoke, with credentials and Vary: Origin", async () => {
|
|
240
|
-
const response = await preflight(app, SPOKE_A, "PATCH");
|
|
241
|
-
|
|
242
|
-
expect(response.headers["access-control-allow-origin"]).toBe(SPOKE_A);
|
|
243
|
-
expect(response.headers["access-control-allow-credentials"]).toBe(
|
|
244
|
-
"true",
|
|
245
|
-
);
|
|
246
|
-
expect(String(response.headers["vary"] ?? "")).toMatch(/\bOrigin\b/i);
|
|
247
|
-
expect(allowedMethods(response)).toContain("PATCH");
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it("names a listed spoke on a credentialed GET, not only on a preflight", async () => {
|
|
251
|
-
const response = await app.inject({
|
|
252
|
-
method: "GET",
|
|
253
|
-
url: "/health",
|
|
254
|
-
headers: { origin: SPOKE_A, cookie: "better-auth.session_token=x" },
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
expect(response.statusCode).toBe(200);
|
|
258
|
-
expect(response.headers["access-control-allow-origin"]).toBe(SPOKE_A);
|
|
259
|
-
expect(response.headers["access-control-allow-credentials"]).toBe(
|
|
260
|
-
"true",
|
|
261
|
-
);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("still allows the primary FRONTEND_URL origin", async () => {
|
|
265
|
-
const response = await preflight(app, HUB, "PATCH");
|
|
266
|
-
|
|
267
|
-
expect(response.headers["access-control-allow-origin"]).toBe(HUB);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("writes no allow-origin header for an unlisted origin", async () => {
|
|
271
|
-
const response = await preflight(app, FOREIGN_ORIGIN, "PATCH");
|
|
272
|
-
|
|
273
|
-
expect(response.headers["access-control-allow-origin"]).toBeUndefined();
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
});
|
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file module-exclusion.test.ts
|
|
3
|
-
* @description Module-exclusion regression test (S1, #5088).
|
|
4
|
-
*
|
|
5
|
-
* Proves the mechanical claim card S1 exists to make true: when a paid
|
|
6
|
-
* module is excluded (`AILK_ENABLED_MODULES` omits it), none of the HTTP
|
|
7
|
-
* routes that module contributes register — Fastify's default 404 is the
|
|
8
|
-
* signal, since a request to an unregistered route always 404s regardless
|
|
9
|
-
* of auth/validation state, while a registered-but-rejected request 4xxs
|
|
10
|
-
* with a DIFFERENT code (401/403/400/...). This is the runtime half of "a
|
|
11
|
-
* route and its MCP tool appear or vanish together" (API↔MCP parity) — see
|
|
12
|
-
* the sibling `apps/mcp/__tests__/module-exclusion.test.ts`.
|
|
13
|
-
*
|
|
14
|
-
* Reverting one of `server.ts`'s `isModuleEnabled(...)` conditionals makes
|
|
15
|
-
* the matching case below fail RED — see #5088 S1 verification notes.
|
|
16
|
-
*
|
|
17
|
-
* Mirrors `src/__tests__/server.test.ts`'s external-boundary mocks (CORS,
|
|
18
|
-
* rate-limit, email) — see that file's header for why each is mocked.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
jest.mock('@fastify/cors', () => {
|
|
22
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
jest.mock('@fastify/rate-limit', () => {
|
|
26
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
jest.mock('@working-theory/email', () => ({
|
|
30
|
-
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
31
|
-
}));
|
|
32
|
-
|
|
33
|
-
import { build } from '../server';
|
|
34
|
-
|
|
35
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
|
|
36
|
-
type App = any;
|
|
37
|
-
|
|
38
|
-
async function withEnabledModules<T>(
|
|
39
|
-
enabled: string,
|
|
40
|
-
fn: () => Promise<T>,
|
|
41
|
-
): Promise<T> {
|
|
42
|
-
const prior = process.env.AILK_ENABLED_MODULES;
|
|
43
|
-
process.env.AILK_ENABLED_MODULES = enabled;
|
|
44
|
-
try {
|
|
45
|
-
return await fn();
|
|
46
|
-
} finally {
|
|
47
|
-
if (prior === undefined) {
|
|
48
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
49
|
-
} else {
|
|
50
|
-
process.env.AILK_ENABLED_MODULES = prior;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// One representative route per module-conditional registration block in
|
|
56
|
-
// server.ts. `always404WhenExcluded` routes are expected to 404 ONLY when
|
|
57
|
-
// their module is absent; every other module stays enabled for each case so
|
|
58
|
-
// a bug that over-excludes (drops an unrelated module's route too) is also
|
|
59
|
-
// caught by the "still registered" assertions below.
|
|
60
|
-
const MODULE_ROUTES: Record<string, { method: 'GET' | 'POST'; url: string }[]> =
|
|
61
|
-
{
|
|
62
|
-
// /v1/waitlist-experiments and /v1/project-listings are deliberately NOT
|
|
63
|
-
// listed here since #5677: the review surface registers under EITHER
|
|
64
|
-
// multi-tenant OR signal-collectors, so excluding multi-tenant alone
|
|
65
|
-
// leaves them registered by design — the same shape the workspace-settings
|
|
66
|
-
// routes have. Their matrix is `review-surface-registration.test.ts`,
|
|
67
|
-
// beside `workspace-module-registration.test.ts`.
|
|
68
|
-
'multi-tenant': [
|
|
69
|
-
{ method: 'GET', url: '/v1/workspaces' },
|
|
70
|
-
{ method: 'GET', url: '/v1/billing' },
|
|
71
|
-
],
|
|
72
|
-
// #6085 (brief H7) adds two more representative routes for this SAME
|
|
73
|
-
// module key: the webhook receiver (unsigned → 400 if registered, never
|
|
74
|
-
// 404 without seed data) and the tenant-guarded appointments read
|
|
75
|
-
// (unauthenticated → 401 if registered, the same shape /v1/billing
|
|
76
|
-
// takes in the multi-tenant row above).
|
|
77
|
-
scheduling: [
|
|
78
|
-
{ method: 'GET', url: '/v1/schedule/availability' },
|
|
79
|
-
{ method: 'POST', url: '/v1/webhooks/calcom' },
|
|
80
|
-
{ method: 'GET', url: '/v1/schedule/appointments' },
|
|
81
|
-
],
|
|
82
|
-
// #5853 retired the untenanted /v1/content writes; content-write's
|
|
83
|
-
// representative route is now the tenant-guarded editor surface. A
|
|
84
|
-
// REGISTERED route answers 401 here (no session on the inject), an
|
|
85
|
-
// unregistered one answers Fastify's own 404 — so the "still registered
|
|
86
|
-
// => not 404" assertion below stays unambiguous, which this matrix's
|
|
87
|
-
// header requires. The retired URLs are asserted separately, and
|
|
88
|
-
// module-independently, by RETIRED_ROUTES below.
|
|
89
|
-
'content-write': [
|
|
90
|
-
{
|
|
91
|
-
method: 'POST',
|
|
92
|
-
url: '/v1/workspaces/wksp_00000000-0000-7000-8000-0000000000aa/content',
|
|
93
|
-
},
|
|
94
|
-
],
|
|
95
|
-
// D-48 signal-collectors (#5366). Each route is chosen so a REGISTERED
|
|
96
|
-
// handler never itself answers 404 without seed data (an unregistered
|
|
97
|
-
// route is Fastify's own 404, so a route whose "not found" business
|
|
98
|
-
// outcome is also 404 — /v1/flows/:slug with a real slug,
|
|
99
|
-
// /v1/deliverables/:grantId, which is a deliberately uniform 404 by
|
|
100
|
-
// design — would be ambiguous evidence either way):
|
|
101
|
-
// - /v1/flows/INVALID_SLUG fails flowSlugSchema's kebab-case regex ->
|
|
102
|
-
// 400, before any flow lookup (flowsScope gate)
|
|
103
|
-
// - /v1/waitlist-signups/me is authScope-only (no tenant guard) -> 401
|
|
104
|
-
// with no session (one of two authScope gates, D2)
|
|
105
|
-
// - /v1/slack/actions with no signature -> 401/503, never 404
|
|
106
|
-
// (webhooksScope gate)
|
|
107
|
-
// NOT any /v1/project-listings/* static path (D4's authScope gate, or
|
|
108
|
-
// the leadsScope gate's four anonymous routes): whenever this static
|
|
109
|
-
// route is unregistered, multi-tenant's tenant-scoped parametric
|
|
110
|
-
// /v1/project-listings/:id (registered whenever multi-tenant is enabled,
|
|
111
|
-
// which every case below does) matches the last path segment as an :id
|
|
112
|
-
// value and answers 401 (auth required) instead of Fastify's own 404 —
|
|
113
|
-
// a real routing precedence effect (server.ts's own comment on
|
|
114
|
-
// projectListingMeRoute), confirmed for both /me and /public, not a
|
|
115
|
-
// gating defect but ambiguous evidence for this test's shape.
|
|
116
|
-
'signal-collectors': [
|
|
117
|
-
{ method: 'GET', url: '/v1/flows/INVALID_SLUG' },
|
|
118
|
-
{ method: 'GET', url: '/v1/waitlist-signups/me' },
|
|
119
|
-
{ method: 'POST', url: '/v1/slack/actions' },
|
|
120
|
-
],
|
|
121
|
-
// D-47 commerce (#5364), narrowed by D-64 (#5796): the flow-checkouts
|
|
122
|
-
// read and the recommender's two email-moment routes — commerce OVERLAY,
|
|
123
|
-
// still one representative route per registration site server.ts gates on
|
|
124
|
-
// isModuleEnabled("commerce").
|
|
125
|
-
//
|
|
126
|
-
// /v1/checkout/sessions and /v1/webhooks/stripe are deliberately NOT
|
|
127
|
-
// listed here any more: they are the base-tree PRIMITIVES, registered
|
|
128
|
-
// unconditionally and gated on the `commerce` LICENCE CLAIM instead. They
|
|
129
|
-
// still 404 on every case in this file (no licence is configured in the
|
|
130
|
-
// test env, so the claim is absent), but they would 404 under the
|
|
131
|
-
// "every module enabled" case too, which is the opposite of what this
|
|
132
|
-
// matrix asserts. Their matrix is `commerce-claim-registration.test.ts`.
|
|
133
|
-
commerce: [
|
|
134
|
-
{ method: 'GET', url: '/v1/flow-checkouts/me' },
|
|
135
|
-
{ method: 'POST', url: '/v1/flows/x/checkout' },
|
|
136
|
-
{ method: 'POST', url: '/v1/flows/x/save' },
|
|
137
|
-
],
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* The untenanted content-write routes #5853 RETIRED by deletion.
|
|
142
|
-
*
|
|
143
|
-
* Unlike every row in MODULE_ROUTES these belong to no module: they must
|
|
144
|
-
* answer Fastify's own 404 on EVERY build, whatever AILK_ENABLED_MODULES
|
|
145
|
-
* says, which is AC-1's retirement half. They are asserted inside each case
|
|
146
|
-
* below rather than in one case of their own, because "on every build" is
|
|
147
|
-
* precisely the claim.
|
|
148
|
-
*
|
|
149
|
-
* MODULE_ROUTES' method union is 'GET' | 'POST', so this carries its own.
|
|
150
|
-
*/
|
|
151
|
-
const RETIRED_ROUTES: {
|
|
152
|
-
method: 'POST' | 'PATCH' | 'DELETE';
|
|
153
|
-
url: string;
|
|
154
|
-
}[] = [
|
|
155
|
-
{ method: 'POST', url: '/v1/content/BlogPosting' },
|
|
156
|
-
{ method: 'PATCH', url: '/v1/content/BlogPosting/x' },
|
|
157
|
-
{ method: 'DELETE', url: '/v1/content/BlogPosting/x' },
|
|
158
|
-
];
|
|
159
|
-
|
|
160
|
-
async function expectRetiredRoutesGone(app: App): Promise<void> {
|
|
161
|
-
for (const { method, url } of RETIRED_ROUTES) {
|
|
162
|
-
const res = await app.inject({ method, url });
|
|
163
|
-
expect(res.statusCode).toBe(404);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const ALL_MODULES = Object.keys(MODULE_ROUTES);
|
|
168
|
-
|
|
169
|
-
// A module whose registration is ALSO gated on another module in this matrix.
|
|
170
|
-
// content-write's scope is gated `content-write && (workspace || multi-tenant)`
|
|
171
|
-
// (#5853): its route file imports lib/tenant-db.ts, which only the tenancy
|
|
172
|
-
// slices deliver, so a build without a tenancy module must not register it.
|
|
173
|
-
// `workspace` is not a row of this matrix, so within it the dependency is on
|
|
174
|
-
// multi-tenant alone: excluding multi-tenant legitimately 404s content-write's
|
|
175
|
-
// route too, and the "every OTHER module stays registered" sweep skips that
|
|
176
|
-
// one pair. Every other pair is asserted as before.
|
|
177
|
-
const MODULE_DEPENDENCIES: Record<string, readonly string[]> = {
|
|
178
|
-
'content-write': ['multi-tenant'],
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
describe('Module exclusion — registration vanishes with the module (S1, #5088)', () => {
|
|
182
|
-
let app: App;
|
|
183
|
-
|
|
184
|
-
afterEach(async () => {
|
|
185
|
-
if (app) {
|
|
186
|
-
await app.close();
|
|
187
|
-
app = undefined;
|
|
188
|
-
}
|
|
189
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it.each(ALL_MODULES)(
|
|
193
|
-
"excluding '%s' 404s every route it owns, while every OTHER module's routes stay registered",
|
|
194
|
-
async (excluded) => {
|
|
195
|
-
const enabled = ALL_MODULES.filter((m) => m !== excluded).join(',');
|
|
196
|
-
app = await withEnabledModules(enabled, () => build());
|
|
197
|
-
|
|
198
|
-
for (const { method, url } of MODULE_ROUTES[excluded]!) {
|
|
199
|
-
const res = await app.inject({ method, url });
|
|
200
|
-
expect(res.statusCode).toBe(404);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
for (const other of ALL_MODULES) {
|
|
204
|
-
if (other === excluded) continue;
|
|
205
|
-
if ((MODULE_DEPENDENCIES[other] ?? []).includes(excluded)) continue;
|
|
206
|
-
for (const { method, url } of MODULE_ROUTES[other]!) {
|
|
207
|
-
const res = await app.inject({ method, url });
|
|
208
|
-
expect(res.statusCode).not.toBe(404);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
await expectRetiredRoutesGone(app);
|
|
213
|
-
},
|
|
214
|
-
);
|
|
215
|
-
|
|
216
|
-
it('with every module explicitly enabled, every module route registers — no over-exclusion', async () => {
|
|
217
|
-
app = await withEnabledModules(ALL_MODULES.join(','), () => build());
|
|
218
|
-
for (const routes of Object.values(MODULE_ROUTES)) {
|
|
219
|
-
for (const { method, url } of routes) {
|
|
220
|
-
const res = await app.inject({ method, url });
|
|
221
|
-
expect(res.statusCode).not.toBe(404);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
// Free/never-gate surfaces are unaffected by any module exclusion.
|
|
225
|
-
const health = await app.inject({ method: 'GET', url: '/health' });
|
|
226
|
-
expect(health.statusCode).toBe(200);
|
|
227
|
-
const contentTypes = await app.inject({
|
|
228
|
-
method: 'GET',
|
|
229
|
-
url: '/v1/content/types',
|
|
230
|
-
});
|
|
231
|
-
expect(contentTypes.statusCode).not.toBe(404);
|
|
232
|
-
|
|
233
|
-
// AC-1's retirement half: gone even with EVERY module enabled.
|
|
234
|
-
await expectRetiredRoutesGone(app);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('excluding every paid module at once still serves the free surface', async () => {
|
|
238
|
-
app = await withEnabledModules('', () => build());
|
|
239
|
-
const health = await app.inject({ method: 'GET', url: '/health' });
|
|
240
|
-
expect(health.statusCode).toBe(200);
|
|
241
|
-
const contentTypes = await app.inject({
|
|
242
|
-
method: 'GET',
|
|
243
|
-
url: '/v1/content/types',
|
|
244
|
-
});
|
|
245
|
-
expect(contentTypes.statusCode).not.toBe(404);
|
|
246
|
-
|
|
247
|
-
for (const routes of Object.values(MODULE_ROUTES)) {
|
|
248
|
-
for (const { method, url } of routes) {
|
|
249
|
-
const res = await app.inject({ method, url });
|
|
250
|
-
expect(res.statusCode).toBe(404);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
await expectRetiredRoutesGone(app);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// D-44 / #5368: an unset AILK_ENABLED_MODULES is now the same as an
|
|
258
|
-
// explicit empty string — no paid module registers by default.
|
|
259
|
-
it('with AILK_ENABLED_MODULES unset (the new default), every module route 404s', async () => {
|
|
260
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
261
|
-
app = await build();
|
|
262
|
-
const health = await app.inject({ method: 'GET', url: '/health' });
|
|
263
|
-
expect(health.statusCode).toBe(200);
|
|
264
|
-
const contentTypes = await app.inject({
|
|
265
|
-
method: 'GET',
|
|
266
|
-
url: '/v1/content/types',
|
|
267
|
-
});
|
|
268
|
-
expect(contentTypes.statusCode).not.toBe(404);
|
|
269
|
-
|
|
270
|
-
for (const routes of Object.values(MODULE_ROUTES)) {
|
|
271
|
-
for (const { method, url } of routes) {
|
|
272
|
-
const res = await app.inject({ method, url });
|
|
273
|
-
expect(res.statusCode).toBe(404);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
await expectRetiredRoutesGone(app);
|
|
278
|
-
});
|
|
279
|
-
});
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file openapi-keyless-hub.test.ts
|
|
3
|
-
* @description #7947 — a hub that refuses keyless public reads (#7633) still
|
|
4
|
-
* serves `GET /v1/openapi.json` to a caller with no spoke key, through the
|
|
5
|
-
* real `build()`. A spoke's `/openapi.json` proxies that document with a plain
|
|
6
|
-
* fetch (no `x-ailk-spoke-key`), so the exemption is what keeps a spoke's
|
|
7
|
-
* discovery surface from answering 502.
|
|
8
|
-
*
|
|
9
|
-
* The risk pinned here is the route moving into a keyed scope, which only a
|
|
10
|
-
* `build()`-level test can see (spec
|
|
11
|
-
* docs/specs/7947-spoke-openapi-keyless-discovery-spec.md D2). Each case also
|
|
12
|
-
* reads `GET /v1/schedule/meeting-types` keyless in the same app and expects
|
|
13
|
-
* the refusal answer, so a 200 on the document cannot pass on a hub that
|
|
14
|
-
* refuses nothing (D3). Two refusing modes (D4): strict `on`, and the default
|
|
15
|
-
* `auto` with a registered spoke.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
// eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
|
|
19
|
-
jest.mock("../lib/prisma.js");
|
|
20
|
-
|
|
21
|
-
jest.mock("@fastify/cors", () => {
|
|
22
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
jest.mock("@working-theory/email", () => ({
|
|
26
|
-
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
27
|
-
}));
|
|
28
|
-
|
|
29
|
-
import * as configModule from "../config/index.js";
|
|
30
|
-
import type { HubSpokeKeyMode } from "../config/index.js";
|
|
31
|
-
import { prisma } from "../lib/prisma.js";
|
|
32
|
-
import { __resetBuckets } from "../middleware/rate-limit";
|
|
33
|
-
import { build } from "../server";
|
|
34
|
-
import { __resetHubStatusForTests } from "../services/public-site-resolver.js";
|
|
35
|
-
|
|
36
|
-
const db = jest.mocked(prisma);
|
|
37
|
-
|
|
38
|
-
function setMode(mode: HubSpokeKeyMode): void {
|
|
39
|
-
jest.replaceProperty(configModule, "config", {
|
|
40
|
-
...configModule.config,
|
|
41
|
-
AILK_HUB_REQUIRE_SPOKE_KEY: mode,
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** What a hub that refuses the caller answers on the public meeting-types read. */
|
|
46
|
-
const REFUSAL = { ok: true, data: { slugs: [] } };
|
|
47
|
-
|
|
48
|
-
describe("GET /v1/openapi.json against a hub that refuses keyless callers (#7947)", () => {
|
|
49
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any matches server.test.ts
|
|
50
|
-
let app: any;
|
|
51
|
-
const prior = {
|
|
52
|
-
modules: process.env.AILK_ENABLED_MODULES,
|
|
53
|
-
databaseUrl: process.env.DATABASE_URL,
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
async function startApp(): Promise<void> {
|
|
57
|
-
app = await build();
|
|
58
|
-
await app.ready();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
beforeEach(() => {
|
|
62
|
-
__resetBuckets();
|
|
63
|
-
__resetHubStatusForTests();
|
|
64
|
-
process.env.AILK_ENABLED_MODULES = "scheduling";
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
afterEach(async () => {
|
|
68
|
-
if (app) await app.close();
|
|
69
|
-
app = undefined;
|
|
70
|
-
jest.restoreAllMocks();
|
|
71
|
-
db.spoke.findFirst.mockReset();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
afterAll(() => {
|
|
75
|
-
for (const [name, value] of [
|
|
76
|
-
["AILK_ENABLED_MODULES", prior.modules],
|
|
77
|
-
["DATABASE_URL", prior.databaseUrl],
|
|
78
|
-
] as const) {
|
|
79
|
-
if (value === undefined) delete process.env[name];
|
|
80
|
-
else process.env[name] = value;
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("mode `on`: a keyless caller gets the document and the same app refuses its keyless workspace read (AC-1)", async () => {
|
|
85
|
-
setMode("on");
|
|
86
|
-
await startApp();
|
|
87
|
-
|
|
88
|
-
const doc = await app.inject({ method: "GET", url: "/v1/openapi.json" });
|
|
89
|
-
expect(doc.statusCode).toBe(200);
|
|
90
|
-
expect(doc.json().openapi).toBe("3.1.0");
|
|
91
|
-
|
|
92
|
-
const refused = await app.inject({
|
|
93
|
-
method: "GET",
|
|
94
|
-
url: "/v1/schedule/meeting-types",
|
|
95
|
-
});
|
|
96
|
-
expect(refused.statusCode).toBe(200);
|
|
97
|
-
expect(refused.json()).toEqual(REFUSAL);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("mode `auto` with a registered spoke: the same two answers (AC-2)", async () => {
|
|
101
|
-
process.env.DATABASE_URL ??=
|
|
102
|
-
"postgresql://placeholder:placeholder@localhost:5432/placeholder";
|
|
103
|
-
setMode("auto");
|
|
104
|
-
// A non-empty registry makes this deployment a hub (#7633 D1).
|
|
105
|
-
db.spoke.findFirst.mockResolvedValue({
|
|
106
|
-
id: "spoke_01920000-0000-7000-8000-0000000000c1",
|
|
107
|
-
} as never);
|
|
108
|
-
await startApp();
|
|
109
|
-
|
|
110
|
-
const doc = await app.inject({ method: "GET", url: "/v1/openapi.json" });
|
|
111
|
-
expect(doc.statusCode).toBe(200);
|
|
112
|
-
expect(doc.json().openapi).toBe("3.1.0");
|
|
113
|
-
|
|
114
|
-
const refused = await app.inject({
|
|
115
|
-
method: "GET",
|
|
116
|
-
url: "/v1/schedule/meeting-types",
|
|
117
|
-
});
|
|
118
|
-
expect(refused.statusCode).toBe(200);
|
|
119
|
-
expect(refused.json()).toEqual(REFUSAL);
|
|
120
|
-
expect(db.spoke.findFirst).toHaveBeenCalled();
|
|
121
|
-
});
|
|
122
|
-
});
|