create-ailk 0.14.0 → 0.16.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 (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Start a sign-in through the hub (#7894 D2, D7) — `GET /api/hub-session/sign-in?next=<path>`.
3
+ *
4
+ * Mints a `state` and a PKCE verifier (32 random bytes each, base64url), seals
5
+ * them with the sanitized `next` into the flow cookie for ten minutes, and
6
+ * sends the visitor to the hub's `/api/auth/hub/authorize` with the S256
7
+ * challenge. The redirect URI is this site's configured origin plus
8
+ * `/api/hub-session/callback` — never derived from the request's `Host`.
9
+ *
10
+ * - not a hub-mode spoke (`SIGN_IN_PROVIDER` is not `"hub"`): 404;
11
+ * - hub mode but not configured: 303 to `/account`, whose gate says so.
12
+ *
13
+ * Nothing here logs the state, the verifier or the challenge.
14
+ */
15
+ import { createHash, randomBytes } from "node:crypto";
16
+
17
+ import { hubSessionConfig } from "../../../../lib/hub-session/config";
18
+ import { seal } from "../../../../lib/hub-session/seal";
19
+ import {
20
+ HUB_SESSION_FALLBACK_PATH,
21
+ cookieHeader,
22
+ flowCookieName,
23
+ hubNextPath,
24
+ } from "../../../../lib/hub-session/session";
25
+ import { SIGN_IN_PROVIDER } from "../../../../lib/topology.generated";
26
+
27
+ /** How long a sign-in may take on the hub before the flow cookie expires. */
28
+ const FLOW_TTL_SECONDS = 600;
29
+
30
+ /** The hub's browser step (spec 7894 D4). */
31
+ const AUTHORIZE_PATH = "/api/auth/hub/authorize";
32
+
33
+ function see(location: string, setCookie?: string): Response {
34
+ const headers = new Headers({
35
+ Location: location,
36
+ "Cache-Control": "no-store",
37
+ "Referrer-Policy": "no-referrer",
38
+ });
39
+ if (setCookie !== undefined) headers.append("Set-Cookie", setCookie);
40
+ return new Response(null, { status: 303, headers });
41
+ }
42
+
43
+ // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
44
+ export function GET(request: Request): Response {
45
+ if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
46
+ const config = hubSessionConfig();
47
+ if (config === null) return see(HUB_SESSION_FALLBACK_PATH);
48
+
49
+ const next = hubNextPath(new URL(request.url).searchParams.get("next"));
50
+ const state = randomBytes(32).toString("base64url");
51
+ const verifier = randomBytes(32).toString("base64url");
52
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
53
+
54
+ const name = flowCookieName(config);
55
+ const flow = seal(
56
+ { v: 1, state, verifier, next, expiresAt: Date.now() + FLOW_TTL_SECONDS * 1000 },
57
+ config.sessionSecret,
58
+ "flow",
59
+ name,
60
+ );
61
+
62
+ const authorize = new URL(AUTHORIZE_PATH, config.hubOrigin);
63
+ authorize.search = new URLSearchParams({
64
+ response_type: "code",
65
+ client_id: config.clientId,
66
+ redirect_uri: config.redirectUri,
67
+ state,
68
+ code_challenge: challenge,
69
+ code_challenge_method: "S256",
70
+ }).toString();
71
+
72
+ return see(
73
+ authorize.toString(),
74
+ cookieHeader(name, flow, FLOW_TTL_SECONDS, config),
75
+ );
76
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Sign out of a spoke that signs in through its hub (#7894 D7) — `POST /api/hub-session/sign-out`.
3
+ *
4
+ * The signed-in Nav's "Sign out" posts here (`SIGN_OUT_ENDPOINT`). It revokes
5
+ * the credential at the hub (best-effort: a hub that cannot be reached still
6
+ * leaves the visitor signed out here), clears the session cookie and answers
7
+ * 204.
8
+ *
9
+ * Same-origin only: a `Sec-Fetch-Site` other than `same-origin`, an `Origin`
10
+ * that is not this site's configured origin, or a request carrying neither is
11
+ * refused 403 before anything is revoked, so another site cannot sign a
12
+ * visitor out. POST only — Next answers 405 to any other method.
13
+ */
14
+ import { hubSignIn } from "@working-theory/api-client";
15
+
16
+ import { hubSessionConfig } from "../../../../lib/hub-session/config";
17
+ import {
18
+ cookieHeader,
19
+ readHubSessionPayload,
20
+ sessionCookieName,
21
+ } from "../../../../lib/hub-session/session";
22
+ import { SIGN_IN_PROVIDER } from "../../../../lib/topology.generated";
23
+
24
+ /** Whether `request` comes from a page on `siteOrigin`. */
25
+ function isSameOrigin(request: Request, siteOrigin: string | null): boolean {
26
+ const fetchSite = request.headers.get("sec-fetch-site");
27
+ const origin = request.headers.get("origin");
28
+ if (fetchSite === null && origin === null) return false;
29
+ if (fetchSite !== null && fetchSite !== "same-origin") return false;
30
+ if (origin !== null && origin !== siteOrigin) return false;
31
+ return true;
32
+ }
33
+
34
+ // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
35
+ export async function POST(request: Request): Promise<Response> {
36
+ if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
37
+ const config = hubSessionConfig();
38
+ if (!isSameOrigin(request, config?.siteOrigin ?? null)) {
39
+ return new Response(null, { status: 403 });
40
+ }
41
+ if (config === null) return new Response(null, { status: 204 });
42
+
43
+ const payload = readHubSessionPayload(request.headers);
44
+ if (payload !== null) {
45
+ try {
46
+ await hubSignIn.revoke(
47
+ { clientId: config.clientId, clientSecret: config.clientSecret },
48
+ { accessToken: payload.accessToken },
49
+ );
50
+ } catch (err) {
51
+ console.error(
52
+ `[hub-session] revoke failed errName=${err instanceof Error ? err.name : "unknown"}`,
53
+ );
54
+ }
55
+ }
56
+ return new Response(null, {
57
+ status: 204,
58
+ headers: {
59
+ "Cache-Control": "no-store",
60
+ "Set-Cookie": cookieHeader(sessionCookieName(config), "", 0, config),
61
+ },
62
+ });
63
+ }
@@ -15,13 +15,29 @@
15
15
 
16
16
  import { GET } from "../route";
17
17
 
18
+ // The generated topology seam (#7426 D5), mocked so each case picks its
19
+ // topology: the hub cases below run against `"hub"`, exactly as before; the
20
+ // spoke cases (D12) flip it. Mocked rather than imported because the hub
21
+ // render imports `../auth`, which this route never touches.
22
+ const mockTopology: { value: "hub" | "spoke" } = { value: "hub" };
23
+ // eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426 D5), not a runtime input; the seam IS the boundary between a hub and a spoke build, and there is no env or HTTP surface that selects it.
24
+ jest.mock("../../../lib/topology.generated", () => ({
25
+ get TOPOLOGY() {
26
+ return mockTopology.value;
27
+ },
28
+ }));
29
+
18
30
  const SPEC = {
19
31
  openapi: "3.1.0",
20
32
  info: { title: "Example API", version: "1.0.0" },
21
33
  paths: { "/v1/content/search": {} },
22
34
  };
23
35
 
24
- const API_ENV = ["AILK_API_URL", "NEXT_PUBLIC_AILK_API_URL"] as const;
36
+ const API_ENV = [
37
+ "AILK_API_URL",
38
+ "NEXT_PUBLIC_AILK_API_URL",
39
+ "NEXT_PUBLIC_BASE_URL",
40
+ ] as const;
25
41
  const saved: Record<string, string | undefined> = {};
26
42
  let fetchSpy: jest.SpyInstance;
27
43
 
@@ -34,6 +50,7 @@ beforeEach(() => {
34
50
  });
35
51
 
36
52
  afterEach(() => {
53
+ mockTopology.value = "hub";
37
54
  for (const name of API_ENV) {
38
55
  if (saved[name] === undefined) delete process.env[name];
39
56
  else process.env[name] = saved[name];
@@ -88,6 +105,21 @@ describe("GET /openapi.json", () => {
88
105
  expect(requestedUrl()).toBe("https://api.example.com/v1/openapi.json");
89
106
  });
90
107
 
108
+ // #7874 D12 — the hub case above runs the committed (hub) render of the
109
+ // browser API origin seam unmocked; the spoke render reads no env at all.
110
+ it("skips the NEXT_PUBLIC_AILK_API_URL fallback under the seam's spoke render", async () => {
111
+ process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
112
+ apiAnswers(200, JSON.stringify(SPEC));
113
+ await jest.isolateModulesAsync(async () => {
114
+ jest.doMock("../../../lib/api-origin.generated", () => ({
115
+ browserApiOrigin: (): string | undefined => undefined,
116
+ }));
117
+ const spoke = await import("../route");
118
+ await spoke.GET();
119
+ });
120
+ expect(requestedUrl()).toBe("http://localhost:3001/v1/openapi.json");
121
+ });
122
+
91
123
  it("answers 502, not 404, when the API cannot be reached", async () => {
92
124
  fetchSpy.mockRejectedValue(new TypeError("fetch failed"));
93
125
 
@@ -174,3 +206,74 @@ describe("GET /openapi.json", () => {
174
206
  expect(await response.text()).not.toContain("api.internal");
175
207
  });
176
208
  });
209
+
210
+ describe("GET /openapi.json on a spoke (#7426 D12)", () => {
211
+ const HUB_SPEC = {
212
+ openapi: "3.1.0",
213
+ info: { title: "Hub API", version: "1.0.0" },
214
+ servers: [{ url: "https://hub.example.com", description: "The hub" }],
215
+ paths: {
216
+ "/v1/leads/submit": {
217
+ post: { summary: "Submit a lead" },
218
+ get: { summary: "Not relayed" },
219
+ },
220
+ "/v1/schedule/availability": {
221
+ get: {
222
+ summary: "Availability",
223
+ servers: [{ url: "https://hub.example.com/schedule" }],
224
+ },
225
+ },
226
+ "/v1/content/search": { get: { summary: "Hub-only search" } },
227
+ "/v1/admin/sites": { post: { summary: "Hub-only admin" } },
228
+ },
229
+ };
230
+
231
+ beforeEach(() => {
232
+ mockTopology.value = "spoke";
233
+ process.env["AILK_API_URL"] = "https://hub.example.com";
234
+ process.env["NEXT_PUBLIC_BASE_URL"] = "https://spoke.example.com";
235
+ });
236
+
237
+ it("names only this spoke's own origin in servers[]", async () => {
238
+ apiAnswers(200, JSON.stringify(HUB_SPEC));
239
+
240
+ const response = await GET();
241
+ const body = (await response.json()) as { servers: unknown };
242
+
243
+ expect(response.status).toBe(200);
244
+ expect(body.servers).toEqual([{ url: "https://spoke.example.com" }]);
245
+ expect(JSON.stringify(body)).not.toContain("hub.example.com");
246
+ });
247
+
248
+ it("keeps only the paths and methods the relay forwards", async () => {
249
+ apiAnswers(200, JSON.stringify(HUB_SPEC));
250
+
251
+ const body = (await (await GET()).json()) as {
252
+ paths: Record<string, Record<string, unknown>>;
253
+ };
254
+
255
+ expect(body.paths).toEqual({
256
+ "/v1/leads/submit": { post: { summary: "Submit a lead" } },
257
+ "/v1/schedule/availability": { get: { summary: "Availability" } },
258
+ });
259
+ });
260
+
261
+ it("still reads the hub's document at AILK_API_URL", async () => {
262
+ apiAnswers(200, JSON.stringify(HUB_SPEC));
263
+
264
+ await GET();
265
+
266
+ expect(requestedUrl()).toBe("https://hub.example.com/v1/openapi.json");
267
+ });
268
+
269
+ it("on a hub, leaves servers[] and paths as the API declared them", async () => {
270
+ mockTopology.value = "hub";
271
+ apiAnswers(200, JSON.stringify(HUB_SPEC));
272
+
273
+ const body = (await (await GET()).json()) as typeof HUB_SPEC;
274
+
275
+ expect(body.servers).toEqual(HUB_SPEC.servers);
276
+ expect(body.paths).toEqual(HUB_SPEC.paths);
277
+ expect(JSON.stringify(body)).toContain("hub.example.com");
278
+ });
279
+ });
@@ -15,8 +15,19 @@
15
15
  * When the API cannot be reached, or answers anything but an OpenAPI
16
16
  * document with a 200, this route answers 502 with a JSON error that names no
17
17
  * upstream origin, and is never cached.
18
+ *
19
+ * On a spoke (#7426 D12) the API is the hub's, so the document is rewritten
20
+ * to describe THIS host only: `servers[]` becomes this site's own origin, and
21
+ * `paths` keeps only the (path, method) pairs the same-origin relay forwards
22
+ * (`RELAY_ROUTES`, lib/relay.ts) — an agent reading it never learns the hub's
23
+ * host, and never calls a path this site would answer 404.
18
24
  */
19
25
 
26
+ import { browserApiOrigin } from "../../lib/api-origin.generated";
27
+ import { RELAY_ROUTES } from "../../lib/relay";
28
+ import { TOPOLOGY } from "../../lib/topology.generated";
29
+ import { resolveBaseUrl } from "../../src/lib/routes";
30
+
20
31
  // Read per request: the document is the running API's, and `next build` has
21
32
  // no API to read it from.
22
33
  export const dynamic = "force-dynamic";
@@ -33,9 +44,7 @@ const LOCAL_API_ORIGIN = "http://localhost:3001";
33
44
  */
34
45
  function apiBase(): string {
35
46
  return stripSlash(
36
- process.env["AILK_API_URL"] ||
37
- process.env["NEXT_PUBLIC_AILK_API_URL"] ||
38
- LOCAL_API_ORIGIN,
47
+ process.env["AILK_API_URL"] || browserApiOrigin() || LOCAL_API_ORIGIN,
39
48
  );
40
49
  }
41
50
 
@@ -45,9 +54,7 @@ function apiBase(): string {
45
54
  */
46
55
  function publicApiBase(): string {
47
56
  return stripSlash(
48
- process.env["NEXT_PUBLIC_AILK_API_URL"] ||
49
- process.env["AILK_API_URL"] ||
50
- LOCAL_API_ORIGIN,
57
+ browserApiOrigin() || process.env["AILK_API_URL"] || LOCAL_API_ORIGIN,
51
58
  );
52
59
  }
53
60
 
@@ -58,6 +65,7 @@ function stripSlash(url: string): string {
58
65
  interface OpenApiDocument {
59
66
  openapi: string;
60
67
  servers?: Array<{ url?: unknown; description?: unknown }>;
68
+ paths?: Record<string, unknown>;
61
69
  }
62
70
 
63
71
  function isOpenApiDocument(value: unknown): value is OpenApiDocument {
@@ -86,6 +94,61 @@ function withAbsoluteServers(spec: OpenApiDocument): OpenApiDocument {
86
94
  };
87
95
  }
88
96
 
97
+ /** The operation keys an OpenAPI path item may carry (OpenAPI 3.1 § 4.8.9). */
98
+ const OPERATION_KEYS = new Set([
99
+ "get",
100
+ "put",
101
+ "post",
102
+ "delete",
103
+ "options",
104
+ "head",
105
+ "patch",
106
+ "trace",
107
+ ]);
108
+
109
+ /**
110
+ * The document as a spoke serves it (#7426 D12): this host's own origin as
111
+ * the only server, and only the relayed (path, method) pairs. A path item's
112
+ * non-operation keys (`summary`, `parameters`, ...) ride along with a kept
113
+ * operation; a `servers` list — on the path item or on a kept operation —
114
+ * never does: it would name the hub.
115
+ */
116
+ function withoutServers(operation: unknown): unknown {
117
+ if (typeof operation !== "object" || operation === null) return operation;
118
+ const { servers: _servers, ...rest } = operation as Record<string, unknown>;
119
+ return rest;
120
+ }
121
+
122
+ function asSpokeDocument(spec: OpenApiDocument): OpenApiDocument {
123
+ const relayed = new Map<string, Set<string>>();
124
+ for (const { method, path } of RELAY_ROUTES) {
125
+ const methods = relayed.get(path) ?? new Set<string>();
126
+ methods.add(method.toLowerCase());
127
+ relayed.set(path, methods);
128
+ }
129
+ const paths: Record<string, unknown> = {};
130
+ for (const [path, item] of Object.entries(spec.paths ?? {})) {
131
+ const methods = relayed.get(path);
132
+ if (methods === undefined || typeof item !== "object" || item === null) {
133
+ continue;
134
+ }
135
+ const kept = Object.fromEntries(
136
+ Object.entries(item)
137
+ .filter(([key]) =>
138
+ OPERATION_KEYS.has(key) ? methods.has(key) : key !== "servers",
139
+ )
140
+ .map(([key, value]) => [
141
+ key,
142
+ OPERATION_KEYS.has(key) ? withoutServers(value) : value,
143
+ ]),
144
+ );
145
+ if (Object.keys(kept).some((key) => OPERATION_KEYS.has(key))) {
146
+ paths[path] = kept;
147
+ }
148
+ }
149
+ return { ...spec, servers: [{ url: stripSlash(resolveBaseUrl()) }], paths };
150
+ }
151
+
89
152
  function badGateway(message: string): Response {
90
153
  return Response.json(
91
154
  { error: { code: "upstream_unavailable", message } },
@@ -120,7 +183,9 @@ export async function GET(): Promise<Response> {
120
183
  return badGateway("The API answered JSON that is not an OpenAPI document.");
121
184
  }
122
185
 
123
- return Response.json(withAbsoluteServers(spec), {
186
+ const served =
187
+ TOPOLOGY === "spoke" ? asSpokeDocument(spec) : withAbsoluteServers(spec);
188
+ return Response.json(served, {
124
189
  status: 200,
125
190
  headers: { "cache-control": "public, max-age=3600" },
126
191
  });
@@ -134,13 +134,16 @@ function deferredPrismaForAuth(): PrismaClient {
134
134
  }
135
135
 
136
136
  /**
137
- * The one delegate method `onPasswordReset` reaches for, named so the call
137
+ * The two delegate methods `onPasswordReset` reaches for, named so the call
138
138
  * site needs no cast. Method shorthand for the same reason `AuthEmailPrisma`
139
139
  * uses it: the generated `PrismaClient` satisfies this structurally, and
140
- * nothing wider than `verification.deleteMany` is reachable through it.
140
+ * nothing wider than `verification.deleteMany` and
141
+ * `oAuthAuthorization.updateMany` is reachable through it. The second ends
142
+ * every credential a spoke holds for the person (#7894 D4, SEC-7894-5).
141
143
  */
142
144
  interface VerificationStore {
143
145
  verification: { deleteMany(args: unknown): Promise<unknown> };
146
+ oAuthAuthorization: { updateMany(args: unknown): Promise<unknown> };
144
147
  }
145
148
 
146
149
  type SocialProviders = {
@@ -364,6 +367,30 @@ export function createAuth(deps: CreateAuthDeps = {}) {
364
367
  })}`,
365
368
  );
366
369
  }
370
+ /**
371
+ * A completed reset also ends every credential a SPOKE holds for this
372
+ * person (#7894 D4, SEC-7894-5). A hub session an attacker held minted
373
+ * spoke credentials of its own; recovering the account must end those too,
374
+ * or the attacker keeps reading the person's data on every spoke for the
375
+ * rest of the credential's 24 hours. The same posture as the sibling
376
+ * delete above: the reset ALREADY SUCCEEDED, so a failed revoke is
377
+ * logged (the user id and a reason, never a credential) and never turns
378
+ * it into a 500.
379
+ */
380
+ try {
381
+ await verificationStore.oAuthAuthorization.updateMany({
382
+ where: { userId: user.id, revokedAt: null },
383
+ data: { revokedAt: new Date() },
384
+ });
385
+ } catch {
386
+ console.warn(
387
+ `[auth-email] ${JSON.stringify({
388
+ userId: user.id,
389
+ kind: "password-reset",
390
+ reason: "spoke_credential_revocation_failed",
391
+ })}`,
392
+ );
393
+ }
367
394
  /**
368
395
  * Guarded like its sibling above, though `lib/auth-email.ts` now also
369
396
  * guards its own signing step (#6068). BOTH, deliberately: this callback
@@ -3,6 +3,11 @@
3
3
  /**
4
4
  * SignOutTrigger — wraps the signed-in Nav's "Sign out" trigger (#4984).
5
5
  *
6
+ * With an `endpoint` (a spoke that signs in through its hub, #7894 D10) it
7
+ * POSTs there — the hub-session sign-out, which revokes the credential and
8
+ * clears the session cookie — and navigates home only when that succeeded;
9
+ * without one it calls Better Auth's client exactly as before.
10
+ *
6
11
  * The locale layout (RSC) reads the session and authors the "Sign out" nav
7
12
  * action as `kind: "flow"`; Nav hands the rendered trigger `<Button>` back
8
13
  * through `renderFlowTrigger` — composed in `SignedInNav`, on the client side
@@ -22,16 +27,30 @@ export interface SignOutTriggerProps {
22
27
  children: ReactNode;
23
28
  /** Where to land after signing out. Defaults to the site home. */
24
29
  homeHref?: string;
30
+ /** A same-origin sign-out route to POST to instead of Better Auth's client. */
31
+ endpoint?: string;
25
32
  }
26
33
 
27
34
  export function SignOutTrigger({
28
35
  children,
29
36
  homeHref = "/",
37
+ endpoint,
30
38
  }: SignOutTriggerProps): ReactElement {
31
39
  const router = useRouter();
32
40
 
33
41
  const handleSignOut = async (): Promise<void> => {
34
- await authClient.signOut();
42
+ if (endpoint !== undefined) {
43
+ // A refused or failed sign-out leaves the visitor signed in, so the
44
+ // page must not pretend otherwise by navigating away.
45
+ try {
46
+ const res = await fetch(endpoint, { method: "POST", credentials: "same-origin" });
47
+ if (!res.ok) return;
48
+ } catch {
49
+ return;
50
+ }
51
+ } else {
52
+ await authClient.signOut();
53
+ }
35
54
  router.push(homeHref);
36
55
  router.refresh();
37
56
  };
@@ -32,9 +32,19 @@ import { SignOutTrigger } from "./sign-out-trigger";
32
32
  export type SignedInNavProps = Omit<
33
33
  NavProps,
34
34
  "navItems" | "renderFlowTrigger" | "translate" | "onSearchOpen"
35
- >;
35
+ > & {
36
+ /**
37
+ * Where "Sign out" POSTs, from the generated seam's `SIGN_OUT_ENDPOINT`
38
+ * (#7894 D10) — a spoke that signs in through its hub. Absent or `null`,
39
+ * Sign out goes through Better Auth's client, as on a hub.
40
+ */
41
+ signOutEndpoint?: string | null;
42
+ };
36
43
 
37
- export function SignedInNav(props: SignedInNavProps): ReactElement {
44
+ export function SignedInNav({
45
+ signOutEndpoint,
46
+ ...props
47
+ }: SignedInNavProps): ReactElement {
38
48
  return (
39
49
  <Nav
40
50
  {...props}
@@ -43,7 +53,11 @@ export function SignedInNav(props: SignedInNavProps): ReactElement {
43
53
  // SignOutTrigger, the account action a non-navigating name label.
44
54
  renderFlowTrigger={(action, trigger) =>
45
55
  action.labelKey === "signOut" ? (
46
- <SignOutTrigger>{trigger}</SignOutTrigger>
56
+ <SignOutTrigger
57
+ {...(signOutEndpoint ? { endpoint: signOutEndpoint } : {})}
58
+ >
59
+ {trigger}
60
+ </SignOutTrigger>
47
61
  ) : (
48
62
  <span
49
63
  className="text-body-sm text-(--color-text-muted)"
@@ -2,13 +2,16 @@
2
2
  * Next.js instrumentation hook — runs once per server instance at boot.
3
3
  *
4
4
  * Registers the durable home of a renewed licence key (#6677 DD-6) before any
5
- * request resolves the licence. Node runtime only: the store reaches the
6
- * database through Prisma, which the Edge runtime cannot load.
5
+ * request resolves the licence, through the generated topology seam (#7426
6
+ * D5): a hub registers the database-backed store; a spoke has no database,
7
+ * so its render registers nothing. Node runtime only: the store reaches the
8
+ * database through Prisma, which the Edge runtime cannot load — the import
9
+ * stays inside the runtime check so the Edge bundle never carries it.
7
10
  */
8
11
 
9
12
  export async function register(): Promise<void> {
10
13
  if (process.env.NEXT_RUNTIME === "nodejs") {
11
- const { registerLicenseKeyStore } = await import("./lib/license-key-store");
12
- await registerLicenseKeyStore();
14
+ const { registerSiteStores } = await import("./lib/topology.generated");
15
+ await registerSiteStores();
13
16
  }
14
17
  }
@@ -107,10 +107,12 @@ describe("account-config exports (AC-6)", () => {
107
107
  expect(Array.isArray(accountForYouSources)).toBe(true);
108
108
  });
109
109
 
110
- it("ships exactly the two base Inbox kinds, both Recents rows (D6a, D9)", () => {
110
+ it("ships exactly the base Inbox kinds and the member kinds (#7920), all Recents rows (D6a, D9)", () => {
111
111
  expect(accountInboxKinds.map((k) => [k.kind, k.section])).toEqual([
112
112
  ["result", "updates"],
113
113
  ["purchase", "updates"],
114
+ ["piece", "updates"],
115
+ ["message", "updates"],
114
116
  ]);
115
117
  // Nothing in the base area fills Scheduled (the old Upcoming), and no
116
118
  // Inbox row asks for a decision — that is the For-you panel's job.
@@ -118,10 +120,11 @@ describe("account-config exports (AC-6)", () => {
118
120
  expect(accountInboxKinds.some((k) => k.section === "needs-you")).toBe(false);
119
121
  });
120
122
 
121
- it("ships email-unverified and payment-due as For-you sources, not Inbox kinds (D9 as amended)", () => {
123
+ it("ships email-unverified, payment-due and piece-needs-client (#7920) as For-you sources, not Inbox kinds (D9 as amended)", () => {
122
124
  expect(accountForYouSources.map((s) => s.id)).toEqual([
123
125
  "email-unverified",
124
126
  "payment-due",
127
+ "piece-needs-client",
125
128
  ]);
126
129
  const kinds = accountInboxKinds.map((k) => k.kind);
127
130
  expect(kinds).not.toContain("email-unverified");
@@ -0,0 +1,96 @@
1
+ import {
2
+ isClosingFooterPath,
3
+ normalizeRoutePath,
4
+ } from "../closing-footer-path";
5
+
6
+ const LOCALES = ["en", "es"];
7
+
8
+ describe("normalizeRoutePath (#7797)", () => {
9
+ it.each([
10
+ ["/about", "/about"],
11
+ ["/about/", "/about"],
12
+ ["/about//", "/about"],
13
+ ["/es/about", "/about"],
14
+ ["/es/about/", "/about"],
15
+ ["/about?x=1", "/about"],
16
+ ["/about#team", "/about"],
17
+ ["/services/first-engagement/", "/services/first-engagement"],
18
+ ["/", "/"],
19
+ ["", "/"],
20
+ ["/es", "/"],
21
+ ["/es/", "/"],
22
+ ])("%s → %s", (input, expected) => {
23
+ expect(normalizeRoutePath(input, LOCALES)).toBe(expected);
24
+ });
25
+
26
+ it("strips only a whole leading locale segment", () => {
27
+ expect(normalizeRoutePath("/espresso", LOCALES)).toBe("/espresso");
28
+ expect(normalizeRoutePath("/about/es", LOCALES)).toBe("/about/es");
29
+ });
30
+
31
+ it("strips no prefix when no locale is enabled", () => {
32
+ expect(normalizeRoutePath("/es/about", [])).toBe("/es/about");
33
+ });
34
+ });
35
+
36
+ describe("isClosingFooterPath (#7797)", () => {
37
+ const paths = ["/about", "/services/first-engagement/"];
38
+
39
+ it.each([
40
+ "/about",
41
+ "/about/",
42
+ "/es/about",
43
+ "/es/about/",
44
+ "/services/first-engagement",
45
+ ])("%s is a listed page", (pathname) => {
46
+ expect(isClosingFooterPath({ pathname, paths, locales: LOCALES })).toBe(
47
+ true,
48
+ );
49
+ });
50
+
51
+ it.each(["/services", "/about/team", "/aboutus", "/privacy", "/es/contact"])(
52
+ "%s is not a listed page",
53
+ (pathname) => {
54
+ expect(isClosingFooterPath({ pathname, paths, locales: LOCALES })).toBe(
55
+ false,
56
+ );
57
+ },
58
+ );
59
+
60
+ it("matches a listed path spelled with a locale prefix or a trailing slash", () => {
61
+ expect(
62
+ isClosingFooterPath({
63
+ pathname: "/about",
64
+ paths: ["/es/about/"],
65
+ locales: LOCALES,
66
+ }),
67
+ ).toBe(true);
68
+ });
69
+
70
+ it("is false with no list, an empty list, or no pathname", () => {
71
+ expect(
72
+ isClosingFooterPath({
73
+ pathname: "/about",
74
+ paths: undefined,
75
+ locales: LOCALES,
76
+ }),
77
+ ).toBe(false);
78
+ expect(
79
+ isClosingFooterPath({ pathname: "/about", paths: [], locales: LOCALES }),
80
+ ).toBe(false);
81
+ expect(
82
+ isClosingFooterPath({ pathname: null, paths, locales: LOCALES }),
83
+ ).toBe(false);
84
+ expect(isClosingFooterPath({ pathname: "", paths, locales: LOCALES })).toBe(
85
+ false,
86
+ );
87
+ });
88
+
89
+ it("never selects the home, even when it is listed", () => {
90
+ for (const pathname of ["/", "/es", "/es/"]) {
91
+ expect(
92
+ isClosingFooterPath({ pathname, paths: ["/"], locales: LOCALES }),
93
+ ).toBe(false);
94
+ }
95
+ });
96
+ });