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