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.
Files changed (167) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/lib/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. 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
- });