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,591 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file server.test.ts
|
|
3
|
-
* @description Unit tests for server.ts - Server setup
|
|
4
|
-
*
|
|
5
|
-
* Uses the real build() function with real route plugins. All internal modules
|
|
6
|
-
* run real; external boundaries (CORS, rate-limit) are mocked at the plugin
|
|
7
|
-
* boundary since they would require live network/Redis in production.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { build, requestLogSerializer } from "../server";
|
|
11
|
-
|
|
12
|
-
// External service boundaries — mocked because they require live infrastructure
|
|
13
|
-
// or have ESM-only transitive deps that Jest's CJS preset cannot resolve:
|
|
14
|
-
// @fastify/cors, @fastify/rate-limit — Fastify plugins that would require
|
|
15
|
-
// live network infrastructure (CORS policy sync, Redis store) in unit tests.
|
|
16
|
-
// @working-theory/email — transitive dep on @react-email/components which is ESM-only
|
|
17
|
-
// and cannot be resolved by jest-resolve in CommonJS mode.
|
|
18
|
-
jest.mock("@fastify/cors", () => {
|
|
19
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
jest.mock("@fastify/rate-limit", () => {
|
|
23
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
jest.mock("@working-theory/email", () => ({
|
|
27
|
-
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
describe("Server", () => {
|
|
31
|
-
// 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
|
|
32
|
-
let app: any;
|
|
33
|
-
|
|
34
|
-
beforeEach(async () => {
|
|
35
|
-
jest.clearAllMocks();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
afterEach(async () => {
|
|
39
|
-
if (app) {
|
|
40
|
-
await app.close();
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe("build function", () => {
|
|
45
|
-
it("should build Fastify server instance", async () => {
|
|
46
|
-
// ACT
|
|
47
|
-
app = await build();
|
|
48
|
-
|
|
49
|
-
// ASSERT
|
|
50
|
-
expect(app).toBeDefined();
|
|
51
|
-
expect(typeof app.inject).toBe("function");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("should register health check route returning static OK without external calls", async () => {
|
|
55
|
-
// ARRANGE
|
|
56
|
-
app = await build();
|
|
57
|
-
|
|
58
|
-
// ACT
|
|
59
|
-
const response = await app.inject({
|
|
60
|
-
method: "GET",
|
|
61
|
-
url: "/health",
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// ASSERT
|
|
65
|
-
expect(response.statusCode).toBe(200);
|
|
66
|
-
const body = JSON.parse(response.body);
|
|
67
|
-
expect(body.status).toBe("healthy");
|
|
68
|
-
expect(body.timestamp).toBeDefined();
|
|
69
|
-
// No external dependencies in the health check anymore
|
|
70
|
-
expect(body.platform).toBeUndefined();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("should register root endpoint", async () => {
|
|
74
|
-
// ARRANGE
|
|
75
|
-
app = await build();
|
|
76
|
-
|
|
77
|
-
// ACT
|
|
78
|
-
const response = await app.inject({
|
|
79
|
-
method: "GET",
|
|
80
|
-
url: "/",
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
// ASSERT
|
|
84
|
-
expect(response.statusCode).toBe(200);
|
|
85
|
-
const body = JSON.parse(response.body);
|
|
86
|
-
expect(body.name).toBe("AI Launch Kit API");
|
|
87
|
-
expect(body.endpoints).toBeDefined();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("should register CORS", async () => {
|
|
91
|
-
// ACT
|
|
92
|
-
app = await build();
|
|
93
|
-
|
|
94
|
-
// ASSERT - CORS should be registered (no error thrown)
|
|
95
|
-
expect(app).toBeDefined();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("should register all route plugins", async () => {
|
|
99
|
-
// ARRANGE
|
|
100
|
-
app = await build();
|
|
101
|
-
|
|
102
|
-
// ACT & ASSERT - Test that routes are registered by checking they don't 404
|
|
103
|
-
const healthResponse = await app.inject({
|
|
104
|
-
method: "GET",
|
|
105
|
-
url: "/health",
|
|
106
|
-
});
|
|
107
|
-
expect(healthResponse.statusCode).toBe(200);
|
|
108
|
-
|
|
109
|
-
const rootResponse = await app.inject({
|
|
110
|
-
method: "GET",
|
|
111
|
-
url: "/",
|
|
112
|
-
});
|
|
113
|
-
expect(rootResponse.statusCode).toBe(200);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("should include all endpoints in root response", async () => {
|
|
117
|
-
// ARRANGE
|
|
118
|
-
app = await build();
|
|
119
|
-
|
|
120
|
-
// ACT
|
|
121
|
-
const response = await app.inject({
|
|
122
|
-
method: "GET",
|
|
123
|
-
url: "/",
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// ASSERT
|
|
127
|
-
const body = JSON.parse(response.body);
|
|
128
|
-
expect(body.endpoints).toHaveProperty("health");
|
|
129
|
-
expect(body.endpoints).toHaveProperty("tokens");
|
|
130
|
-
expect(body.endpoints).toHaveProperty("profile");
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("startup throws when LEAD_CAPTURE_FILE points at a missing directory", async () => {
|
|
134
|
-
// ARRANGE — set LEAD_CAPTURE_FILE to a path whose parent dir doesn't exist
|
|
135
|
-
// so the real validateLeadCaptureDir() throws (AC8). Restore after test.
|
|
136
|
-
const prior = process.env.LEAD_CAPTURE_FILE;
|
|
137
|
-
process.env.LEAD_CAPTURE_FILE = "/nonexistent-ailk-test-dir/leads.jsonl";
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
// ACT & ASSERT
|
|
141
|
-
await expect(build()).rejects.toThrow(/LEAD_CAPTURE_FILE/);
|
|
142
|
-
} finally {
|
|
143
|
-
if (prior === undefined) {
|
|
144
|
-
delete process.env.LEAD_CAPTURE_FILE;
|
|
145
|
-
} else {
|
|
146
|
-
process.env.LEAD_CAPTURE_FILE = prior;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
// ── Bootstrap test: raw-body parser scope encapsulation (AC 13) ──────────────
|
|
154
|
-
//
|
|
155
|
-
// Proves that registering the webhooks plugin (with its raw-body parser scoped
|
|
156
|
-
// to application/json) does NOT leak that parser to other routes. The global
|
|
157
|
-
// Fastify JSON parser must remain active for all non-webhook routes.
|
|
158
|
-
|
|
159
|
-
import Fastify from "fastify";
|
|
160
|
-
import { webhooksRoutes } from "../routes/webhooks/index.js";
|
|
161
|
-
|
|
162
|
-
describe("Parser scope — webhooks raw-body parser does not leak", () => {
|
|
163
|
-
it("other routes still receive parsed JSON objects after webhooks plugin registers", async () => {
|
|
164
|
-
// Build a minimal server that mirrors the encapsulation pattern in server.ts:
|
|
165
|
-
// 1. Register a JSON route that echoes the parsed body.
|
|
166
|
-
// 2. Register the webhooks plugin inside an encapsulated scope with a
|
|
167
|
-
// raw-body (buffer) parser.
|
|
168
|
-
// If the raw-body parser leaks out of the webhooks scope, the echo route
|
|
169
|
-
// would receive a Buffer instead of a parsed object.
|
|
170
|
-
|
|
171
|
-
const server = Fastify({ logger: false });
|
|
172
|
-
|
|
173
|
-
// A simple JSON echo route — simulates any existing JSON-parsed route
|
|
174
|
-
server.post("/api/echo", async (req) => ({ received: req.body }));
|
|
175
|
-
|
|
176
|
-
// Mirror of server.ts registration pattern
|
|
177
|
-
await server.register(async (webhooksScope) => {
|
|
178
|
-
webhooksScope.addContentTypeParser(
|
|
179
|
-
"application/json",
|
|
180
|
-
{ parseAs: "buffer" },
|
|
181
|
-
(_req, body, done) => {
|
|
182
|
-
done(null, body);
|
|
183
|
-
},
|
|
184
|
-
);
|
|
185
|
-
await webhooksScope.register(webhooksRoutes);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
await server.ready();
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const response = await server.inject({
|
|
192
|
-
method: "POST",
|
|
193
|
-
url: "/api/echo",
|
|
194
|
-
payload: { hello: "world" },
|
|
195
|
-
headers: { "content-type": "application/json" },
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
expect(response.statusCode).toBe(200);
|
|
199
|
-
const body = JSON.parse(response.body);
|
|
200
|
-
// Must be a plain object, not a stringified Buffer
|
|
201
|
-
expect(body.received).toEqual({ hello: "world" });
|
|
202
|
-
expect(Buffer.isBuffer(body.received)).toBe(false);
|
|
203
|
-
} finally {
|
|
204
|
-
await server.close();
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
describe("per-scope rate-limit defaults (#5111, DEF-004)", () => {
|
|
209
|
-
// Real end-to-end behavior against the real in-house rate-limit
|
|
210
|
-
// middleware (NOT mocked — this repo's convention is to mock only real
|
|
211
|
-
// external boundaries, never an internal module, see the file header).
|
|
212
|
-
// aeoScope's `/v1/aeo/score/example` needs no DB/network (a bundled
|
|
213
|
-
// example, `loadExampleScore()`); flowsScope's `/v1/flows/:slug` with a
|
|
214
|
-
// schema-invalid slug returns 400 before any DB access — both let the
|
|
215
|
-
// onRequest rate-limit hook be the only thing under test, regardless of
|
|
216
|
-
// the eventual handler status.
|
|
217
|
-
it("caps aeoScope at the strict pre-#4730 default (10/min) — the 11th request 429s", async () => {
|
|
218
|
-
app = await build();
|
|
219
|
-
const ip = "198.51.100.11";
|
|
220
|
-
|
|
221
|
-
for (let i = 0; i < 10; i++) {
|
|
222
|
-
const res = await app.inject({
|
|
223
|
-
method: "GET",
|
|
224
|
-
url: "/v1/aeo/score/example",
|
|
225
|
-
headers: { "x-forwarded-for": ip },
|
|
226
|
-
});
|
|
227
|
-
expect(res.statusCode).not.toBe(429);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const eleventh = await app.inject({
|
|
231
|
-
method: "GET",
|
|
232
|
-
url: "/v1/aeo/score/example",
|
|
233
|
-
headers: { "x-forwarded-for": ip },
|
|
234
|
-
});
|
|
235
|
-
expect(eleventh.statusCode).toBe(429);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("leaves flowsScope above the strict default — 11 requests never 429", async () => {
|
|
239
|
-
// #5366 (D-48): /v1/flows/:slug only registers when signal-collectors
|
|
240
|
-
// is enabled.
|
|
241
|
-
const prior = process.env.AILK_ENABLED_MODULES;
|
|
242
|
-
process.env.AILK_ENABLED_MODULES = "signal-collectors";
|
|
243
|
-
try {
|
|
244
|
-
app = await build();
|
|
245
|
-
const ip = "198.51.100.12";
|
|
246
|
-
|
|
247
|
-
for (let i = 0; i < 11; i++) {
|
|
248
|
-
const res = await app.inject({
|
|
249
|
-
method: "GET",
|
|
250
|
-
url: "/v1/flows/not-a-real-slug!!!",
|
|
251
|
-
headers: { "x-forwarded-for": ip },
|
|
252
|
-
});
|
|
253
|
-
// Schema-invalid slug → 400, well before any rate limit or DB call.
|
|
254
|
-
expect(res.statusCode).toBe(400);
|
|
255
|
-
}
|
|
256
|
-
} finally {
|
|
257
|
-
if (prior === undefined) {
|
|
258
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
259
|
-
} else {
|
|
260
|
-
process.env.AILK_ENABLED_MODULES = prior;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
// ── #5970 — the request log must never carry a query string ────────────────
|
|
268
|
-
//
|
|
269
|
-
// The security review's High finding: a bare `logger: true` gives pino's stock
|
|
270
|
-
// `req` serializer, which logs `req.raw.url` VERBATIM — so every open of
|
|
271
|
-
// `/v1/requests/:id/confirm?token=…` wrote a live seven-day credential into
|
|
272
|
-
// the log drain, from a hook that fires before any route-level code can
|
|
273
|
-
// intervene. The four fixes for that finding shipped with no assertion between
|
|
274
|
-
// them, which is precisely how this class of bug comes back: a future refactor
|
|
275
|
-
// restores `request.url` and nothing turns red.
|
|
276
|
-
//
|
|
277
|
-
// Asserted both ways — the serializer's own output, and a real captured line,
|
|
278
|
-
// so neither the function nor its WIRING can regress unnoticed.
|
|
279
|
-
describe("#5970 — the request log carries no query string", () => {
|
|
280
|
-
const asRequest = (url: string) =>
|
|
281
|
-
({
|
|
282
|
-
url,
|
|
283
|
-
method: "GET",
|
|
284
|
-
hostname: "api.example.com",
|
|
285
|
-
ip: "203.0.113.1",
|
|
286
|
-
}) as never;
|
|
287
|
-
|
|
288
|
-
it("drops the query string but keeps the path and its route parameters", () => {
|
|
289
|
-
expect(
|
|
290
|
-
requestLogSerializer(
|
|
291
|
-
asRequest("/v1/requests/callbk_1/confirm?token=SUPERSECRETTOKEN"),
|
|
292
|
-
),
|
|
293
|
-
).toEqual({
|
|
294
|
-
method: "GET",
|
|
295
|
-
url: "/v1/requests/callbk_1/confirm",
|
|
296
|
-
host: "api.example.com",
|
|
297
|
-
remoteAddress: "203.0.113.1",
|
|
298
|
-
});
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
it("leaves a URL with no query exactly as it is", () => {
|
|
302
|
-
expect(requestLogSerializer(asRequest("/health")).url).toBe("/health");
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it("is actually wired into the instance — a captured line carries no token", async () => {
|
|
306
|
-
const lines: string[] = [];
|
|
307
|
-
const fastify = require("fastify")({
|
|
308
|
-
logger: {
|
|
309
|
-
level: "info",
|
|
310
|
-
serializers: { req: requestLogSerializer },
|
|
311
|
-
stream: { write: (msg: string) => lines.push(msg) },
|
|
312
|
-
},
|
|
313
|
-
});
|
|
314
|
-
fastify.get("/v1/requests/:id/confirm", async () => ({ ok: true }));
|
|
315
|
-
await fastify.ready();
|
|
316
|
-
try {
|
|
317
|
-
await fastify.inject({
|
|
318
|
-
method: "GET",
|
|
319
|
-
url: "/v1/requests/callbk_1/confirm?token=SUPERSECRETTOKEN",
|
|
320
|
-
});
|
|
321
|
-
const captured = lines.join("\n");
|
|
322
|
-
expect(captured).toContain("/v1/requests/callbk_1/confirm");
|
|
323
|
-
expect(captured).not.toContain("SUPERSECRETTOKEN");
|
|
324
|
-
expect(captured).not.toContain("token=");
|
|
325
|
-
} finally {
|
|
326
|
-
await fastify.close();
|
|
327
|
-
}
|
|
328
|
-
});
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
// #6084 D14/AC-9 — `GET /v1/checkout/sessions/:id` puts its credential in
|
|
332
|
-
// the PATH, which the #5970 query-string strip above does not cover. A
|
|
333
|
-
// `cs_…`-shaped PATH SEGMENT is truncated to its first 8 characters plus
|
|
334
|
-
// `…`; the rule is segment-scoped so it cannot corrupt an unrelated URL.
|
|
335
|
-
describe("#6084 D14 — a cs_… path segment is truncated to 8 characters", () => {
|
|
336
|
-
const asRequest = (url: string) =>
|
|
337
|
-
({
|
|
338
|
-
url,
|
|
339
|
-
method: "GET",
|
|
340
|
-
hostname: "api.example.com",
|
|
341
|
-
ip: "203.0.113.1",
|
|
342
|
-
}) as never;
|
|
343
|
-
|
|
344
|
-
it("truncates a cs_… session id segment to its first 8 characters plus an ellipsis", () => {
|
|
345
|
-
expect(
|
|
346
|
-
requestLogSerializer(
|
|
347
|
-
asRequest(
|
|
348
|
-
"/v1/checkout/sessions/cs_test_a1B2c3D4e5F6g7H8i9J0",
|
|
349
|
-
),
|
|
350
|
-
),
|
|
351
|
-
).toEqual({
|
|
352
|
-
method: "GET",
|
|
353
|
-
url: "/v1/checkout/sessions/cs_test_…",
|
|
354
|
-
host: "api.example.com",
|
|
355
|
-
remoteAddress: "203.0.113.1",
|
|
356
|
-
});
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
it("leaves an unrelated path byte-identical to today (no cs_ segment present)", () => {
|
|
360
|
-
expect(requestLogSerializer(asRequest("/v1/checkout/sessions")).url).toBe(
|
|
361
|
-
"/v1/checkout/sessions",
|
|
362
|
-
);
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it("does not truncate a segment that merely CONTAINS cs_ mid-string, only one that IS cs_…-shaped", () => {
|
|
366
|
-
expect(
|
|
367
|
-
requestLogSerializer(asRequest("/v1/content/discs_catalog")).url,
|
|
368
|
-
).toBe("/v1/content/discs_catalog");
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it("is actually wired into the instance — a captured line carries no full session id", async () => {
|
|
372
|
-
const lines: string[] = [];
|
|
373
|
-
const fastify = require("fastify")({
|
|
374
|
-
logger: {
|
|
375
|
-
level: "info",
|
|
376
|
-
serializers: { req: requestLogSerializer },
|
|
377
|
-
stream: { write: (msg: string) => lines.push(msg) },
|
|
378
|
-
},
|
|
379
|
-
});
|
|
380
|
-
fastify.get("/v1/checkout/sessions/:id", async () => ({ ok: true }));
|
|
381
|
-
await fastify.ready();
|
|
382
|
-
try {
|
|
383
|
-
await fastify.inject({
|
|
384
|
-
method: "GET",
|
|
385
|
-
url: "/v1/checkout/sessions/cs_test_a1B2c3D4e5F6g7H8i9J0",
|
|
386
|
-
});
|
|
387
|
-
const captured = lines.join("\n");
|
|
388
|
-
expect(captured).toContain("/v1/checkout/sessions/cs_test_…");
|
|
389
|
-
expect(captured).not.toContain(
|
|
390
|
-
"cs_test_a1B2c3D4e5F6g7H8i9J0",
|
|
391
|
-
);
|
|
392
|
-
} finally {
|
|
393
|
-
await fastify.close();
|
|
394
|
-
}
|
|
395
|
-
});
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
// #6216 D11/§ 9 A-3 — the SAME rule's second arm: a `lead_…`-shaped PATH
|
|
399
|
-
// SEGMENT (every item read / PATCH under `/v1/workspaces/:id/messages/:leadId`)
|
|
400
|
-
// is truncated to 8 characters plus `…`, exactly like the `cs_` case above.
|
|
401
|
-
// The `cs_…` behaviour must stay byte-identical (asserted in the SAME test).
|
|
402
|
-
describe("#6216 D11 — a lead_… path segment is truncated to 8 characters, cs_ unchanged", () => {
|
|
403
|
-
const LEAD_ID = "lead_01928374-65ba-7cde-89ab-0123456789ab";
|
|
404
|
-
|
|
405
|
-
const asRequest = (url: string) =>
|
|
406
|
-
({
|
|
407
|
-
url,
|
|
408
|
-
method: "GET",
|
|
409
|
-
hostname: "api.example.com",
|
|
410
|
-
ip: "203.0.113.1",
|
|
411
|
-
}) as never;
|
|
412
|
-
|
|
413
|
-
it("truncates a lead_… id segment to its first 8 characters plus an ellipsis", () => {
|
|
414
|
-
expect(
|
|
415
|
-
requestLogSerializer(
|
|
416
|
-
asRequest(`/v1/workspaces/wksp_00000000-0000-7000-8000-000000000001/messages/${LEAD_ID}`),
|
|
417
|
-
),
|
|
418
|
-
).toEqual({
|
|
419
|
-
method: "GET",
|
|
420
|
-
url: "/v1/workspaces/wksp_00000000-0000-7000-8000-000000000001/messages/lead_019…",
|
|
421
|
-
host: "api.example.com",
|
|
422
|
-
remoteAddress: "203.0.113.1",
|
|
423
|
-
});
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
it("leaves the list path (no :leadId segment) byte-identical to today", () => {
|
|
427
|
-
expect(
|
|
428
|
-
requestLogSerializer(
|
|
429
|
-
asRequest(
|
|
430
|
-
"/v1/workspaces/wksp_00000000-0000-7000-8000-000000000001/messages",
|
|
431
|
-
),
|
|
432
|
-
).url,
|
|
433
|
-
).toBe(
|
|
434
|
-
"/v1/workspaces/wksp_00000000-0000-7000-8000-000000000001/messages",
|
|
435
|
-
);
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
it("the cs_… behaviour is byte-identical after adding the lead_ arm", () => {
|
|
439
|
-
expect(
|
|
440
|
-
requestLogSerializer(
|
|
441
|
-
asRequest("/v1/checkout/sessions/cs_test_a1B2c3D4e5F6g7H8i9J0"),
|
|
442
|
-
).url,
|
|
443
|
-
).toBe("/v1/checkout/sessions/cs_test_…");
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
it("is actually wired into the instance — a captured line carries no full lead id", async () => {
|
|
447
|
-
const lines: string[] = [];
|
|
448
|
-
const fastify = require("fastify")({
|
|
449
|
-
logger: {
|
|
450
|
-
level: "info",
|
|
451
|
-
serializers: { req: requestLogSerializer },
|
|
452
|
-
stream: { write: (msg: string) => lines.push(msg) },
|
|
453
|
-
},
|
|
454
|
-
});
|
|
455
|
-
fastify.get("/v1/workspaces/:workspaceId/messages/:leadId", async () => ({
|
|
456
|
-
ok: true,
|
|
457
|
-
}));
|
|
458
|
-
await fastify.ready();
|
|
459
|
-
try {
|
|
460
|
-
await fastify.inject({
|
|
461
|
-
method: "GET",
|
|
462
|
-
url: `/v1/workspaces/wksp_00000000-0000-7000-8000-000000000001/messages/${LEAD_ID}`,
|
|
463
|
-
});
|
|
464
|
-
const captured = lines.join("\n");
|
|
465
|
-
expect(captured).toContain("/messages/lead_019…");
|
|
466
|
-
expect(captured).not.toContain(LEAD_ID);
|
|
467
|
-
} finally {
|
|
468
|
-
await fastify.close();
|
|
469
|
-
}
|
|
470
|
-
});
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
// ── #6889 — production startup warns when a checkout protection is unset ─────
|
|
474
|
-
//
|
|
475
|
-
// POST /v1/checkout/sessions admits a caller without X-Internal-Auth when
|
|
476
|
-
// OSS_API_INTERNAL_SECRET is unset, and with FRONTEND_URL unset its redirect
|
|
477
|
-
// lock answers 500 while CORS and cookie writes trust only AILK_TRUSTED_ORIGINS
|
|
478
|
-
// (#7395 D5; before #7395 they pinned to localhost). Both
|
|
479
|
-
// are optional by design (OSS standalone compat), so startup must never fail
|
|
480
|
-
// on them — but a production boot says so, once per variable. Each case rebuilds the module graph because `config/index.ts`
|
|
481
|
-
// snapshots NODE_ENV at import time.
|
|
482
|
-
|
|
483
|
-
describe("#6889 — production startup warns on an unset FRONTEND_URL or OSS_API_INTERNAL_SECRET", () => {
|
|
484
|
-
const ENV_KEYS = [
|
|
485
|
-
"NODE_ENV",
|
|
486
|
-
"FRONTEND_URL",
|
|
487
|
-
"OSS_API_INTERNAL_SECRET",
|
|
488
|
-
// Cleared so a production boot never trips config's database-module exit.
|
|
489
|
-
"AILK_ENABLED_MODULES",
|
|
490
|
-
] as const;
|
|
491
|
-
type EnvKey = (typeof ENV_KEYS)[number];
|
|
492
|
-
const priorEnv = new Map<string, string | undefined>(
|
|
493
|
-
ENV_KEYS.map((key) => [key, process.env[key]]),
|
|
494
|
-
);
|
|
495
|
-
// 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
|
|
496
|
-
let app: any;
|
|
497
|
-
|
|
498
|
-
afterEach(async () => {
|
|
499
|
-
if (app) {
|
|
500
|
-
await app.close();
|
|
501
|
-
app = undefined;
|
|
502
|
-
}
|
|
503
|
-
for (const [key, value] of priorEnv) {
|
|
504
|
-
if (value === undefined) {
|
|
505
|
-
delete process.env[key];
|
|
506
|
-
} else {
|
|
507
|
-
process.env[key] = value;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
jest.restoreAllMocks();
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
/** Boot a fresh module graph under `env` and return every warning message. */
|
|
514
|
-
async function bootWarnings(
|
|
515
|
-
env: Partial<Record<EnvKey, string>>,
|
|
516
|
-
): Promise<string[]> {
|
|
517
|
-
for (const key of ENV_KEYS) {
|
|
518
|
-
const value = env[key];
|
|
519
|
-
if (value === undefined) {
|
|
520
|
-
delete process.env[key];
|
|
521
|
-
} else {
|
|
522
|
-
process.env[key] = value;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
jest.resetModules();
|
|
526
|
-
const { logger } =
|
|
527
|
-
require("@working-theory/observability") as typeof import("@working-theory/observability");
|
|
528
|
-
const warn = jest.spyOn(logger, "warn");
|
|
529
|
-
const { build: freshBuild } =
|
|
530
|
-
require("../server") as typeof import("../server");
|
|
531
|
-
app = await freshBuild();
|
|
532
|
-
return warn.mock.calls.map((args) =>
|
|
533
|
-
args
|
|
534
|
-
.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg)))
|
|
535
|
-
.join(" "),
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
const naming = (messages: string[], name: string) =>
|
|
540
|
-
messages.filter((message) => message.includes(name));
|
|
541
|
-
|
|
542
|
-
it("warns exactly once naming FRONTEND_URL when it is unset in production", async () => {
|
|
543
|
-
const messages = await bootWarnings({
|
|
544
|
-
NODE_ENV: "production",
|
|
545
|
-
OSS_API_INTERNAL_SECRET: "internal-secret",
|
|
546
|
-
});
|
|
547
|
-
expect(naming(messages, "FRONTEND_URL")).toHaveLength(1);
|
|
548
|
-
expect(naming(messages, "OSS_API_INTERNAL_SECRET")).toHaveLength(0);
|
|
549
|
-
// #7395 D5 — nothing is pinned to localhost any more; an unset
|
|
550
|
-
// FRONTEND_URL in production trusts only AILK_TRUSTED_ORIGINS.
|
|
551
|
-
expect(naming(messages, "FRONTEND_URL")[0]).not.toContain("localhost:3000");
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
it("warns exactly once naming OSS_API_INTERNAL_SECRET when it is unset in production", async () => {
|
|
555
|
-
const messages = await bootWarnings({
|
|
556
|
-
NODE_ENV: "production",
|
|
557
|
-
FRONTEND_URL: "https://site.example.com",
|
|
558
|
-
});
|
|
559
|
-
expect(naming(messages, "OSS_API_INTERNAL_SECRET")).toHaveLength(1);
|
|
560
|
-
expect(naming(messages, "FRONTEND_URL")).toHaveLength(0);
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
it("warns once for each when both are unset in production, and still starts", async () => {
|
|
564
|
-
const messages = await bootWarnings({ NODE_ENV: "production" });
|
|
565
|
-
expect(naming(messages, "FRONTEND_URL")).toHaveLength(1);
|
|
566
|
-
expect(naming(messages, "OSS_API_INTERNAL_SECRET")).toHaveLength(1);
|
|
567
|
-
const health = await app.inject({ method: "GET", url: "/health" });
|
|
568
|
-
expect(health.statusCode).toBe(200);
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
it("logs no warning for either when both are set in production", async () => {
|
|
572
|
-
const messages = await bootWarnings({
|
|
573
|
-
NODE_ENV: "production",
|
|
574
|
-
FRONTEND_URL: "https://site.example.com",
|
|
575
|
-
OSS_API_INTERNAL_SECRET: "internal-secret",
|
|
576
|
-
});
|
|
577
|
-
expect(naming(messages, "FRONTEND_URL")).toHaveLength(0);
|
|
578
|
-
expect(naming(messages, "OSS_API_INTERNAL_SECRET")).toHaveLength(0);
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
it.each(["development", "test"])(
|
|
582
|
-
"logs no warning for either outside production (NODE_ENV=%s), both unset",
|
|
583
|
-
async (nodeEnv) => {
|
|
584
|
-
const messages = await bootWarnings({ NODE_ENV: nodeEnv });
|
|
585
|
-
expect(naming(messages, "FRONTEND_URL")).toHaveLength(0);
|
|
586
|
-
expect(naming(messages, "OSS_API_INTERNAL_SECRET")).toHaveLength(0);
|
|
587
|
-
const health = await app.inject({ method: "GET", url: "/health" });
|
|
588
|
-
expect(health.statusCode).toBe(200);
|
|
589
|
-
},
|
|
590
|
-
);
|
|
591
|
-
});
|