create-ailk 0.18.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/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 +7 -2
- package/dist/topology.js +60 -3
- 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 +9 -4
- 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__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- 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/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- 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/[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 +24 -45
- 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/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/topology.generated.ts +24 -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,175 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file workspace-module-registration.test.ts
|
|
3
|
-
* @description The `workspace || multi-tenant` registration matrix (#5371 D2).
|
|
4
|
-
*
|
|
5
|
-
* `workspace` cannot be folded into the sibling `module-exclusion.test.ts`:
|
|
6
|
-
* that suite's shape is "excluding module X 404s every route X owns", and the
|
|
7
|
-
* three routes here register under EITHER module, so excluding `workspace`
|
|
8
|
-
* while `multi-tenant` is on leaves them registered by design. That is the
|
|
9
|
-
* whole point of D2's gate, so it gets its own matrix.
|
|
10
|
-
*
|
|
11
|
-
* The three cases §5.1 names:
|
|
12
|
-
*
|
|
13
|
-
* 1. workspace only — the three workspace-settings routes resolve for a
|
|
14
|
-
* single-tenant session; billing / workspace-session
|
|
15
|
-
* / org routes stay unregistered.
|
|
16
|
-
* 2. multi-tenant only — everything resolves exactly as it did before this
|
|
17
|
-
* change (the regression floor).
|
|
18
|
-
* 3. both enabled — each of workspaceConfigRoutes /
|
|
19
|
-
* workspaceGeneralRoutes / workspaceMembersRoutes
|
|
20
|
-
* registers exactly ONCE; Fastify throws on a
|
|
21
|
-
* duplicate route at build() time, so a successful
|
|
22
|
-
* build IS the assertion, backed by an explicit
|
|
23
|
-
* count over the router tree.
|
|
24
|
-
*
|
|
25
|
-
* "Resolves" is measured as "not Fastify's own 404". An unauthenticated
|
|
26
|
-
* request to a registered tenant route answers 401, never 404, so the two
|
|
27
|
-
* states are unambiguous — the same signal `module-exclusion.test.ts` uses.
|
|
28
|
-
*
|
|
29
|
-
* Mirrors `src/__tests__/server.test.ts`'s external-boundary mocks (CORS,
|
|
30
|
-
* rate-limit, email) — see that file's header for why each is mocked.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
jest.mock('@fastify/cors', () => {
|
|
34
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
jest.mock('@fastify/rate-limit', () => {
|
|
38
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
jest.mock('@working-theory/email', () => ({
|
|
42
|
-
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
43
|
-
}));
|
|
44
|
-
|
|
45
|
-
import { build } from '../server';
|
|
46
|
-
|
|
47
|
-
// 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
|
|
48
|
-
type App = any;
|
|
49
|
-
|
|
50
|
-
const WS = 'wksp_00000000-0000-7000-8000-0000000000aa';
|
|
51
|
-
|
|
52
|
-
/** The routes the widened gate must serve under EITHER module. */
|
|
53
|
-
const SHARED_TENANT_ROUTES = [
|
|
54
|
-
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/theme` },
|
|
55
|
-
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/identity` },
|
|
56
|
-
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/seo` },
|
|
57
|
-
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/general` },
|
|
58
|
-
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/members` },
|
|
59
|
-
];
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Routes that must NOT register under `workspace` alone — the gate's other
|
|
63
|
-
* half. `/v1/waitlist-experiments` is on this list because the review surface
|
|
64
|
-
* is gated on `multi-tenant || signal-collectors` (#5677), neither of which is
|
|
65
|
-
* enabled in this file's `workspace`-only cases; it is no longer
|
|
66
|
-
* multi-tenant-EXCLUSIVE, and its own matrix is
|
|
67
|
-
* `review-surface-registration.test.ts`.
|
|
68
|
-
*/
|
|
69
|
-
const MULTI_TENANT_ONLY_ROUTES = [
|
|
70
|
-
{ method: 'GET' as const, url: '/v1/workspaces' },
|
|
71
|
-
{ method: 'GET' as const, url: '/v1/billing' },
|
|
72
|
-
{ method: 'GET' as const, url: '/v1/waitlist-experiments' },
|
|
73
|
-
{ method: 'POST' as const, url: `/v1/workspaces/${WS}/activate` },
|
|
74
|
-
{ method: 'GET' as const, url: `/v1/workspaces/${WS}/sessions` },
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
async function withEnabledModules<T>(
|
|
78
|
-
enabled: string,
|
|
79
|
-
fn: () => Promise<T>,
|
|
80
|
-
): Promise<T> {
|
|
81
|
-
const prior = process.env.AILK_ENABLED_MODULES;
|
|
82
|
-
process.env.AILK_ENABLED_MODULES = enabled;
|
|
83
|
-
try {
|
|
84
|
-
return await fn();
|
|
85
|
-
} finally {
|
|
86
|
-
if (prior === undefined) {
|
|
87
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
88
|
-
} else {
|
|
89
|
-
process.env.AILK_ENABLED_MODULES = prior;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Every (method, path) pair Fastify's router holds, one per line. */
|
|
95
|
-
function routeLines(app: App): string[] {
|
|
96
|
-
return (app.printRoutes({ commonPrefix: false }) as string)
|
|
97
|
-
.split('\n')
|
|
98
|
-
.map((line: string) => line.trim())
|
|
99
|
-
.filter(Boolean);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
describe('workspace-settings registration matrix (#5371 D2)', () => {
|
|
103
|
-
let app: App;
|
|
104
|
-
|
|
105
|
-
afterEach(async () => {
|
|
106
|
-
if (app) {
|
|
107
|
-
await app.close();
|
|
108
|
-
app = undefined;
|
|
109
|
-
}
|
|
110
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("with 'workspace' enabled and 'multi-tenant' disabled, the five settings routes resolve", async () => {
|
|
114
|
-
app = await withEnabledModules('workspace', () => build());
|
|
115
|
-
|
|
116
|
-
for (const { method, url } of SHARED_TENANT_ROUTES) {
|
|
117
|
-
const res = await app.inject({ method, url });
|
|
118
|
-
expect({ url, status: res.statusCode }).toEqual({ url, status: 401 });
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("with 'workspace' alone, billing / workspace-session / org routes stay unregistered", async () => {
|
|
123
|
-
app = await withEnabledModules('workspace', () => build());
|
|
124
|
-
|
|
125
|
-
for (const { method, url } of MULTI_TENANT_ONLY_ROUTES) {
|
|
126
|
-
const res = await app.inject({ method, url });
|
|
127
|
-
expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("with 'multi-tenant' enabled and 'workspace' disabled, every route resolves as before", async () => {
|
|
132
|
-
app = await withEnabledModules('multi-tenant', () => build());
|
|
133
|
-
|
|
134
|
-
for (const { method, url } of [
|
|
135
|
-
...SHARED_TENANT_ROUTES,
|
|
136
|
-
...MULTI_TENANT_ONLY_ROUTES,
|
|
137
|
-
]) {
|
|
138
|
-
const res = await app.inject({ method, url });
|
|
139
|
-
expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('with BOTH enabled, each shared route registers exactly once', async () => {
|
|
144
|
-
// build() itself is the first assertion: Fastify throws
|
|
145
|
-
// FST_ERR_DUPLICATED_ROUTE at registration time, so a double-registration
|
|
146
|
-
// never reaches the router tree to be counted.
|
|
147
|
-
app = await withEnabledModules('workspace,multi-tenant', () => build());
|
|
148
|
-
|
|
149
|
-
const lines = routeLines(app);
|
|
150
|
-
for (const segment of ['theme', 'identity', 'seo', 'general', 'members']) {
|
|
151
|
-
const matches = lines.filter((line) => line.includes(segment));
|
|
152
|
-
expect({ segment, count: matches.length }).toEqual({
|
|
153
|
-
segment,
|
|
154
|
-
count: 1,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
for (const { method, url } of [
|
|
159
|
-
...SHARED_TENANT_ROUTES,
|
|
160
|
-
...MULTI_TENANT_ONLY_ROUTES,
|
|
161
|
-
]) {
|
|
162
|
-
const res = await app.inject({ method, url });
|
|
163
|
-
expect({ url, status: res.statusCode }).not.toEqual({ url, status: 404 });
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('with neither enabled, none of the five settings routes registers', async () => {
|
|
168
|
-
app = await withEnabledModules('', () => build());
|
|
169
|
-
|
|
170
|
-
for (const { method, url } of SHARED_TENANT_ROUTES) {
|
|
171
|
-
const res = await app.inject({ method, url });
|
|
172
|
-
expect({ url, status: res.statusCode }).toEqual({ url, status: 404 });
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
});
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file surface-drift.test.ts
|
|
3
|
-
* @description The self-description drift gate (#5314).
|
|
4
|
-
*
|
|
5
|
-
* Builds the REAL server, takes the route table Fastify actually registered,
|
|
6
|
-
* and diffs it against both surfaces that claim to describe it — the root
|
|
7
|
-
* index `endpoints` object and the served OpenAPI `paths` object.
|
|
8
|
-
*
|
|
9
|
-
* Both surfaces are read over `inject`, i.e. exactly what an agent would
|
|
10
|
-
* fetch, never the object the builder returned.
|
|
11
|
-
*
|
|
12
|
-
* This gate was RED before the derivation landed: 61 registered routes were
|
|
13
|
-
* missing from `paths` and 53 from `endpoints`. Two of its five concerns
|
|
14
|
-
* (AC-1 coverage on each surface) are unfailable now that both surfaces
|
|
15
|
-
* derive from the same registry they are compared against — that is the
|
|
16
|
-
* inherent consequence of AC-2's "derived" branch, and the spec's D5 says so.
|
|
17
|
-
* What stays live is the overlay join (a hand-authored entry whose route no
|
|
18
|
-
* longer registers — the concern that caught `DELETE /v1/auth/tokens`, whose
|
|
19
|
-
* real path is `/v1/auth/tokens/:id`), the anti-vacuity floor (an empty or
|
|
20
|
-
* stale registry would pass a coverage check trivially, so it is asserted
|
|
21
|
-
* against directly), and the module-agreement case's non-empty guard, which
|
|
22
|
-
* fails if module gating ever collapses so both configs register identically.
|
|
23
|
-
*
|
|
24
|
-
* Mirrors `src/__tests__/module-exclusion.test.ts`'s external-boundary mocks
|
|
25
|
-
* (CORS, rate-limit, email) — see that file's header for why each is mocked.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
jest.mock("@fastify/cors", () => {
|
|
29
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
jest.mock("@fastify/rate-limit", () => {
|
|
33
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
jest.mock("@working-theory/email", () => ({
|
|
37
|
-
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
38
|
-
}));
|
|
39
|
-
|
|
40
|
-
import { build } from "../../server";
|
|
41
|
-
import { readRegisteredRoutes } from "../spec";
|
|
42
|
-
import {
|
|
43
|
-
compareSurfaces,
|
|
44
|
-
formatSurfaceDrift,
|
|
45
|
-
hasDrift,
|
|
46
|
-
openApiSurfaceKeys,
|
|
47
|
-
rootIndexSurfaceKeys,
|
|
48
|
-
routeKey,
|
|
49
|
-
toOpenApiPath,
|
|
50
|
-
type RegisteredRoute,
|
|
51
|
-
} from "../surface-drift";
|
|
52
|
-
|
|
53
|
-
/** A route every build registers, outside every module conditional. */
|
|
54
|
-
const FLOOR_ROUTE = routeKey("GET", "/v1/content/types");
|
|
55
|
-
|
|
56
|
-
interface Surfaces {
|
|
57
|
-
registered: RegisteredRoute[];
|
|
58
|
-
paths: Record<string, Record<string, unknown>>;
|
|
59
|
-
endpoints: Record<string, string>;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Build the server under one module configuration and read both surfaces back
|
|
64
|
-
* over the wire. `undefined` means "leave AILK_ENABLED_MODULES unset", which
|
|
65
|
-
* is now the default deployment shape (no paid module, D-44 / #5368).
|
|
66
|
-
*/
|
|
67
|
-
async function surfacesFor(enabled: string | undefined): Promise<Surfaces> {
|
|
68
|
-
const prior = process.env.AILK_ENABLED_MODULES;
|
|
69
|
-
if (enabled === undefined) {
|
|
70
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
71
|
-
} else {
|
|
72
|
-
process.env.AILK_ENABLED_MODULES = enabled;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
const app = await build();
|
|
77
|
-
await app.ready();
|
|
78
|
-
|
|
79
|
-
const registered = [...readRegisteredRoutes(app)];
|
|
80
|
-
|
|
81
|
-
const rootRes = await app.inject({ method: "GET", url: "/" });
|
|
82
|
-
const specRes = await app.inject({
|
|
83
|
-
method: "GET",
|
|
84
|
-
url: "/v1/openapi.json",
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
await app.close();
|
|
88
|
-
|
|
89
|
-
expect(rootRes.statusCode).toBe(200);
|
|
90
|
-
expect(specRes.statusCode).toBe(200);
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
registered,
|
|
94
|
-
paths: JSON.parse(specRes.body).paths,
|
|
95
|
-
endpoints: JSON.parse(rootRes.body).endpoints,
|
|
96
|
-
};
|
|
97
|
-
} finally {
|
|
98
|
-
if (prior === undefined) {
|
|
99
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
100
|
-
} else {
|
|
101
|
-
process.env.AILK_ENABLED_MODULES = prior;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// The module configurations every claim below is asserted under. Unset is now
|
|
107
|
-
// the default deployment (no paid module, D-44 / #5368), the same as an
|
|
108
|
-
// explicit "" (the OSS-strip build target); a full allowlist is the
|
|
109
|
-
// every-module-on case. A slice route absent under "" must be absent from
|
|
110
|
-
// BOTH surfaces there, which is what makes its absence agreement rather than
|
|
111
|
-
// drift.
|
|
112
|
-
const ALL_PAID_MODULES =
|
|
113
|
-
"docs,multi-tenant,scheduling,content-write,signal-collectors";
|
|
114
|
-
const CONFIGS: Array<[string, string | undefined]> = [
|
|
115
|
-
["AILK_ENABLED_MODULES unset (no paid module, the default)", undefined],
|
|
116
|
-
['AILK_ENABLED_MODULES="" (no paid module)', ""],
|
|
117
|
-
[
|
|
118
|
-
`AILK_ENABLED_MODULES="${ALL_PAID_MODULES}" (every paid module on)`,
|
|
119
|
-
ALL_PAID_MODULES,
|
|
120
|
-
],
|
|
121
|
-
];
|
|
122
|
-
|
|
123
|
-
describe("API self-description drift (#5314)", () => {
|
|
124
|
-
describe.each(CONFIGS)("%s", (_label, enabled) => {
|
|
125
|
-
let surfaces: Surfaces;
|
|
126
|
-
|
|
127
|
-
beforeAll(async () => {
|
|
128
|
-
surfaces = await surfacesFor(enabled);
|
|
129
|
-
}, 60000);
|
|
130
|
-
|
|
131
|
-
// Anti-vacuity floor. A coverage assertion over an empty registry passes
|
|
132
|
-
// trivially, so an empty or stale registry is failed directly — the most
|
|
133
|
-
// likely way a derivation gate silently stops testing anything.
|
|
134
|
-
it("registers a non-empty route table including the floor route", () => {
|
|
135
|
-
expect(surfaces.registered.length).toBeGreaterThan(0);
|
|
136
|
-
const keys = surfaces.registered.map((r) => routeKey(r.method, r.url));
|
|
137
|
-
expect(keys).toContain(FLOOR_ROUTE);
|
|
138
|
-
expect(new Set(keys).size).toBe(keys.length);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("publishes no surface entry for a route the server does not register", () => {
|
|
142
|
-
const diffs = compareSurfaces(
|
|
143
|
-
surfaces.registered,
|
|
144
|
-
surfaces.paths,
|
|
145
|
-
surfaces.endpoints,
|
|
146
|
-
);
|
|
147
|
-
const orphaned = diffs.filter((d) => d.orphanedInSurface.length > 0);
|
|
148
|
-
expect(formatSurfaceDrift(orphaned)).toBe("");
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("describes every registered route on both surfaces", () => {
|
|
152
|
-
const diffs = compareSurfaces(
|
|
153
|
-
surfaces.registered,
|
|
154
|
-
surfaces.paths,
|
|
155
|
-
surfaces.endpoints,
|
|
156
|
-
);
|
|
157
|
-
expect(formatSurfaceDrift(diffs)).toBe("");
|
|
158
|
-
expect(hasDrift(diffs)).toBe(false);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
// AC-1 names this endpoint specifically: it returns 200 and is what the
|
|
162
|
-
// consuming site depends on, and before #5314 both surfaces omitted it.
|
|
163
|
-
// #5366 (D-48) moved it to signal-collectors: the no-drift half
|
|
164
|
-
// (`missingFromSurface`) holds regardless of whether the route
|
|
165
|
-
// registers at all, but the route itself now only registers when
|
|
166
|
-
// signal-collectors is one of the enabled modules for this config.
|
|
167
|
-
it("describes GET /v1/project-listings/public on both surfaces when registered", () => {
|
|
168
|
-
const diffs = compareSurfaces(
|
|
169
|
-
surfaces.registered,
|
|
170
|
-
surfaces.paths,
|
|
171
|
-
surfaces.endpoints,
|
|
172
|
-
);
|
|
173
|
-
for (const d of diffs) {
|
|
174
|
-
expect(d.missingFromSurface).not.toContain(
|
|
175
|
-
routeKey("GET", "/v1/project-listings/public"),
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
const signalCollectorsEnabled = (enabled ?? "")
|
|
179
|
-
.split(",")
|
|
180
|
-
.map((s) => s.trim())
|
|
181
|
-
.includes("signal-collectors");
|
|
182
|
-
expect(
|
|
183
|
-
surfaces.registered.some(
|
|
184
|
-
(r) => r.url === "/v1/project-listings/public" && r.method === "GET",
|
|
185
|
-
),
|
|
186
|
-
).toBe(signalCollectorsEnabled);
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// Regression: the route table is per-instance, not module state.
|
|
191
|
-
//
|
|
192
|
-
// `vercel-handler.ts`'s `getServer()` is a check-then-set — it tests
|
|
193
|
-
// `!serverInstance`, then awaits twice before assigning — so two concurrent
|
|
194
|
-
// invocations can both call `build()`. With a module-level table, one
|
|
195
|
-
// build's reset would land mid-fill of the other and leave a truncated or
|
|
196
|
-
// doubled set, and both surfaces would silently misreport. Building two
|
|
197
|
-
// servers under DIFFERENT module configs and asserting each keeps its own
|
|
198
|
-
// table is what makes that unreachable rather than merely unlikely.
|
|
199
|
-
it("keeps each server's route table on its own instance", async () => {
|
|
200
|
-
const prior = process.env.AILK_ENABLED_MODULES;
|
|
201
|
-
try {
|
|
202
|
-
process.env.AILK_ENABLED_MODULES = ALL_PAID_MODULES;
|
|
203
|
-
const allOn = await build();
|
|
204
|
-
await allOn.ready();
|
|
205
|
-
|
|
206
|
-
process.env.AILK_ENABLED_MODULES = "";
|
|
207
|
-
const noneOn = await build();
|
|
208
|
-
await noneOn.ready();
|
|
209
|
-
|
|
210
|
-
const allKeys = readRegisteredRoutes(allOn).map((r) =>
|
|
211
|
-
routeKey(r.method, r.url),
|
|
212
|
-
);
|
|
213
|
-
const noneKeys = readRegisteredRoutes(noneOn).map((r) =>
|
|
214
|
-
routeKey(r.method, r.url),
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
// The second build must not have emptied, truncated or doubled the
|
|
218
|
-
// first — the exact corruption a shared table would produce.
|
|
219
|
-
expect(allKeys.length).toBeGreaterThan(noneKeys.length);
|
|
220
|
-
expect(new Set(allKeys).size).toBe(allKeys.length);
|
|
221
|
-
expect(new Set(noneKeys).size).toBe(noneKeys.length);
|
|
222
|
-
expect(allKeys).toContain(FLOOR_ROUTE);
|
|
223
|
-
expect(noneKeys).toContain(FLOOR_ROUTE);
|
|
224
|
-
|
|
225
|
-
// And each server's own OpenAPI document still matches its own table.
|
|
226
|
-
for (const app of [allOn, noneOn]) {
|
|
227
|
-
const paths = JSON.parse(
|
|
228
|
-
(await app.inject({ method: "GET", url: "/v1/openapi.json" })).body,
|
|
229
|
-
).paths;
|
|
230
|
-
const endpoints = JSON.parse(
|
|
231
|
-
(await app.inject({ method: "GET", url: "/" })).body,
|
|
232
|
-
).endpoints;
|
|
233
|
-
expect(
|
|
234
|
-
formatSurfaceDrift(
|
|
235
|
-
compareSurfaces(readRegisteredRoutes(app), paths, endpoints),
|
|
236
|
-
),
|
|
237
|
-
).toBe("");
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
await allOn.close();
|
|
241
|
-
await noneOn.close();
|
|
242
|
-
} finally {
|
|
243
|
-
if (prior === undefined) {
|
|
244
|
-
delete process.env.AILK_ENABLED_MODULES;
|
|
245
|
-
} else {
|
|
246
|
-
process.env.AILK_ENABLED_MODULES = prior;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}, 60000);
|
|
250
|
-
|
|
251
|
-
// AC-4. Asserted as agreement rather than as an exemption list: a disabled
|
|
252
|
-
// module's routes vanish from the registered set and from both surfaces at
|
|
253
|
-
// once, so there is nothing for the gate to forgive.
|
|
254
|
-
it("drops a disabled module's routes from the route table AND both surfaces together", async () => {
|
|
255
|
-
const allOn = await surfacesFor(ALL_PAID_MODULES);
|
|
256
|
-
const noneOn = await surfacesFor("");
|
|
257
|
-
|
|
258
|
-
const noneKeys = new Set(
|
|
259
|
-
noneOn.registered.map((r) => routeKey(r.method, r.url)),
|
|
260
|
-
);
|
|
261
|
-
const moduleOnly = allOn.registered.filter(
|
|
262
|
-
(r) => !noneKeys.has(routeKey(r.method, r.url)),
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
// The configurations must actually differ, or this asserts nothing.
|
|
266
|
-
expect(moduleOnly.length).toBeGreaterThan(0);
|
|
267
|
-
|
|
268
|
-
// Every module-only route is described when its module is on...
|
|
269
|
-
expect(
|
|
270
|
-
formatSurfaceDrift(
|
|
271
|
-
compareSurfaces(allOn.registered, allOn.paths, allOn.endpoints),
|
|
272
|
-
),
|
|
273
|
-
).toBe("");
|
|
274
|
-
|
|
275
|
-
// ...and named by neither surface when it is off, which is agreement.
|
|
276
|
-
//
|
|
277
|
-
// Each surface is parsed by the instrument's own reader, and each route is
|
|
278
|
-
// compared in that surface's vocabulary — OpenAPI-templated for `paths`,
|
|
279
|
-
// Fastify-shaped for `endpoints`. Comparing raw values instead would make
|
|
280
|
-
// this assertion quietly weaker than it reads: `paths` keys `{id}` where a
|
|
281
|
-
// registered route says `:id`, so every parametric route would "pass" on a
|
|
282
|
-
// vocabulary mismatch, and `endpoints` groups methods into one value
|
|
283
|
-
// (`"GET/PUT/DELETE /v1/user/data/:key"`), which never equals a
|
|
284
|
-
// single-method key. Both halves would then be vacuous for exactly the
|
|
285
|
-
// routes most likely to regress.
|
|
286
|
-
const offPathKeys = openApiSurfaceKeys(noneOn.paths);
|
|
287
|
-
const offEndpointKeys = rootIndexSurfaceKeys(noneOn.endpoints);
|
|
288
|
-
for (const { method, url } of moduleOnly) {
|
|
289
|
-
expect(offPathKeys).not.toContain(routeKey(method, toOpenApiPath(url)));
|
|
290
|
-
expect(offEndpointKeys).not.toContain(routeKey(method, url));
|
|
291
|
-
}
|
|
292
|
-
}, 60000);
|
|
293
|
-
});
|