create-ailk 0.16.0 → 0.18.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 (42) hide show
  1. package/dist/lib/env-catalog.js +5 -9
  2. package/dist/spoke-checkout.d.ts +27 -2
  3. package/dist/spoke-checkout.js +207 -4
  4. package/dist/topology.d.ts +8 -0
  5. package/dist/topology.js +29 -5
  6. package/package.json +3 -3
  7. package/templates/apps/mcp/CLAUDE.md +51 -5
  8. package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
  9. package/templates/apps/mcp/__tests__/boot.test.ts +203 -0
  10. package/templates/apps/mcp/__tests__/index.test.ts +86 -47
  11. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
  12. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
  13. package/templates/apps/mcp/__tests__/server.test.ts +63 -6
  14. package/templates/apps/mcp/src/app.ts +32 -0
  15. package/templates/apps/mcp/src/boot.ts +185 -0
  16. package/templates/apps/mcp/src/http-transport.ts +39 -15
  17. package/templates/apps/mcp/src/index.ts +17 -126
  18. package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
  19. package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
  20. package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
  21. package/templates/apps/mcp/src/server.ts +10 -2
  22. package/templates/apps/mcp/src/serverless-app.ts +88 -0
  23. package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
  24. package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
  25. package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
  26. package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
  27. package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
  28. package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
  29. package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
  30. package/templates/apps/mcp/src/tools/read_content.ts +7 -7
  31. package/templates/apps/mcp/src/tools/search_content.ts +6 -5
  32. package/templates/apps/mcp/tsconfig.json +2 -0
  33. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
  34. package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
  35. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +18 -4
  36. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +46 -28
  37. package/templates/apps/web/lib/__tests__/relay.test.ts +13 -7
  38. package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
  39. package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
  40. package/templates/apps/web/lib/site-brand.tsx +22 -6
  41. package/templates/apps/web/lib/topology.generated.ts +3 -0
  42. package/versions.json +1 -1
@@ -21,8 +21,6 @@
21
21
  * Session/scope: none — read-only public content.
22
22
  */
23
23
 
24
- import { join } from "node:path";
25
-
26
24
  import { type SchemaOrgType } from "@working-theory/content-adapters";
27
25
  import {
28
26
  getOfferingsInputSchema,
@@ -31,7 +29,10 @@ import {
31
29
  type GetOfferingsOutput,
32
30
  } from "@working-theory/validation";
33
31
 
34
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
32
+ import {
33
+ getMcpContentAdapter,
34
+ mcpContentRoot,
35
+ } from "../lib/content-adapter.js";
35
36
  import { resolveLocale } from "../lib/resolve-locale.js";
36
37
 
37
38
  export { getOfferingsInputSchema, getOfferingsOutputSchema };
@@ -41,7 +42,7 @@ export type { GetOfferingsInput, GetOfferingsOutput };
41
42
  * get_offerings handler.
42
43
  *
43
44
  * Accepts an optional `contentRoot` override for testing. When omitted,
44
- * resolves the content root relative to cwd (supports ESM runtime).
45
+ * resolves it through `mcpContentRoot()` (#8070 D3).
45
46
  */
46
47
  export async function ailkGetOfferings(
47
48
  input: GetOfferingsInput,
@@ -49,7 +50,7 @@ export async function ailkGetOfferings(
49
50
  ): Promise<GetOfferingsOutput> {
50
51
  getOfferingsInputSchema.parse(input);
51
52
 
52
- const root = contentRoot ?? join(process.cwd(), "content");
53
+ const root = contentRoot ?? mcpContentRoot();
53
54
  const locale = resolveLocale(input.locale);
54
55
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
55
56
 
@@ -21,8 +21,6 @@
21
21
  * Session/scope: none — read-only public content.
22
22
  */
23
23
 
24
- import { join } from "node:path";
25
-
26
24
  import {
27
25
  getPricingInputSchema,
28
26
  getPricingOutputSchema,
@@ -30,7 +28,10 @@ import {
30
28
  type GetPricingOutput,
31
29
  } from "@working-theory/validation";
32
30
 
33
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
31
+ import {
32
+ getMcpContentAdapter,
33
+ mcpContentRoot,
34
+ } from "../lib/content-adapter.js";
34
35
  import { resolveLocale } from "../lib/resolve-locale.js";
35
36
 
36
37
  export { getPricingInputSchema, getPricingOutputSchema };
@@ -42,7 +43,7 @@ const DEFAULT_SLUG = "pricing";
42
43
  * get_pricing handler.
43
44
  *
44
45
  * Accepts an optional `contentRoot` override for testing. When omitted,
45
- * resolves the content root relative to cwd (supports ESM runtime).
46
+ * resolves it through `mcpContentRoot()` (#8070 D3).
46
47
  */
47
48
  export async function ailkGetPricing(
48
49
  input: GetPricingInput,
@@ -50,7 +51,7 @@ export async function ailkGetPricing(
50
51
  ): Promise<GetPricingOutput> {
51
52
  getPricingInputSchema.parse(input);
52
53
 
53
- const root = contentRoot ?? join(process.cwd(), "content");
54
+ const root = contentRoot ?? mcpContentRoot();
54
55
  const locale = resolveLocale(input.locale);
55
56
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
56
57
 
@@ -15,8 +15,6 @@
15
15
  * Session/scope: none — read-only public content.
16
16
  */
17
17
 
18
- import { join } from "node:path";
19
-
20
18
  import {
21
19
  getSiteInfoInputSchema,
22
20
  getSiteInfoOutputSchema,
@@ -24,7 +22,10 @@ import {
24
22
  type GetSiteInfoOutput,
25
23
  } from "@working-theory/validation";
26
24
 
27
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
25
+ import {
26
+ getMcpContentAdapter,
27
+ mcpContentRoot,
28
+ } from "../lib/content-adapter.js";
28
29
  import { resolveLocale } from "../lib/resolve-locale.js";
29
30
 
30
31
  export { getSiteInfoInputSchema, getSiteInfoOutputSchema };
@@ -36,7 +37,7 @@ const DEFAULT_SLUG = "home";
36
37
  * get_site_info handler.
37
38
  *
38
39
  * Accepts an optional `contentRoot` override for testing. When omitted,
39
- * resolves the content root relative to cwd (supports ESM runtime).
40
+ * resolves it through `mcpContentRoot()` (#8070 D3).
40
41
  */
41
42
  export async function ailkGetSiteInfo(
42
43
  input: GetSiteInfoInput,
@@ -44,7 +45,7 @@ export async function ailkGetSiteInfo(
44
45
  ): Promise<GetSiteInfoOutput> {
45
46
  getSiteInfoInputSchema.parse(input);
46
47
 
47
- const root = contentRoot ?? join(process.cwd(), "content");
48
+ const root = contentRoot ?? mcpContentRoot();
48
49
  const locale = resolveLocale(input.locale);
49
50
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
50
51
 
@@ -15,8 +15,6 @@
15
15
  * Session/scope: none — read-only public content.
16
16
  */
17
17
 
18
- import { join } from "node:path";
19
-
20
18
  import {
21
19
  SCHEMA_ORG_TYPES,
22
20
  type SchemaOrgType,
@@ -28,7 +26,10 @@ import {
28
26
  type ListPagesOutput,
29
27
  } from "@working-theory/validation";
30
28
 
31
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
29
+ import {
30
+ getMcpContentAdapter,
31
+ mcpContentRoot,
32
+ } from "../lib/content-adapter.js";
32
33
  import { resolveLocale } from "../lib/resolve-locale.js";
33
34
 
34
35
  export { listPagesInputSchema, listPagesOutputSchema };
@@ -38,8 +39,8 @@ export type { ListPagesInput, ListPagesOutput };
38
39
  * list_pages handler.
39
40
  *
40
41
  * Accepts an optional `contentRoot` override for testing (avoids coupling
41
- * unit tests to the deployed filesystem layout). When omitted, resolves the
42
- * content root relative to this file at call time (supports ESM runtime).
42
+ * unit tests to the deployed filesystem layout). When omitted, resolves it
43
+ * through `mcpContentRoot()` (#8070 D3).
43
44
  */
44
45
  export async function ailkListPages(
45
46
  input: ListPagesInput,
@@ -47,8 +48,8 @@ export async function ailkListPages(
47
48
  ): Promise<ListPagesOutput> {
48
49
  listPagesInputSchema.parse(input);
49
50
 
50
- // Default: resolve content/ relative to cwd (works in ESM and jest/commonjs).
51
- const root = contentRoot ?? join(process.cwd(), "content");
51
+ // Default: the one content-root resolver (#8070 D3).
52
+ const root = contentRoot ?? mcpContentRoot();
52
53
  const locale = resolveLocale(input.locale);
53
54
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
54
55
 
@@ -20,8 +20,6 @@
20
20
  * Session/scope: none — read-only public content.
21
21
  */
22
22
 
23
- import { join } from "node:path";
24
-
25
23
  import {
26
24
  readContentInputSchema,
27
25
  readContentOutputSchema,
@@ -29,7 +27,10 @@ import {
29
27
  type ReadContentOutput,
30
28
  } from "@working-theory/validation";
31
29
 
32
- import { getMcpMdxContentAdapter } from "../lib/content-adapter.js";
30
+ import {
31
+ getMcpMdxContentAdapter,
32
+ mcpContentRoot,
33
+ } from "../lib/content-adapter.js";
33
34
  import { resolveLocale } from "../lib/resolve-locale.js";
34
35
 
35
36
  export { readContentInputSchema, readContentOutputSchema };
@@ -39,8 +40,7 @@ export type { ReadContentInput, ReadContentOutput };
39
40
  * read_content handler.
40
41
  *
41
42
  * Accepts an optional `contentRoot` override for testing. When omitted,
42
- * resolves the content root relative to this file at call time (supports
43
- * ESM runtime).
43
+ * resolves it through `mcpContentRoot()` (#8070 D3).
44
44
  */
45
45
  export async function ailkReadContent(
46
46
  input: ReadContentInput,
@@ -48,8 +48,8 @@ export async function ailkReadContent(
48
48
  ): Promise<ReadContentOutput> {
49
49
  readContentInputSchema.parse(input);
50
50
 
51
- // Default: resolve content/ relative to cwd (works in ESM and jest/commonjs).
52
- const root = contentRoot ?? join(process.cwd(), "content");
51
+ // Default: the one content-root resolver (#8070 D3).
52
+ const root = contentRoot ?? mcpContentRoot();
53
53
  const locale = resolveLocale(input.locale);
54
54
 
55
55
  const adapter = getMcpMdxContentAdapter({ contentRoot: root, locale });
@@ -20,8 +20,6 @@
20
20
  * Session/scope: none — read-only public content.
21
21
  */
22
22
 
23
- import { join } from "node:path";
24
-
25
23
  import {
26
24
  searchContentInputSchema,
27
25
  searchContentOutputSchema,
@@ -29,7 +27,10 @@ import {
29
27
  type SearchContentOutput,
30
28
  } from "@working-theory/validation";
31
29
 
32
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
30
+ import {
31
+ getMcpContentAdapter,
32
+ mcpContentRoot,
33
+ } from "../lib/content-adapter.js";
33
34
  import { resolveLocale } from "../lib/resolve-locale.js";
34
35
 
35
36
  export { searchContentInputSchema, searchContentOutputSchema };
@@ -41,7 +42,7 @@ const DEFAULT_LIMIT = 10;
41
42
  * search_content handler.
42
43
  *
43
44
  * Accepts an optional `contentRoot` override for testing. When omitted,
44
- * resolves the content root relative to cwd (supports ESM runtime).
45
+ * resolves it through `mcpContentRoot()` (#8070 D3).
45
46
  */
46
47
  export async function ailkSearchContent(
47
48
  input: SearchContentInput,
@@ -49,7 +50,7 @@ export async function ailkSearchContent(
49
50
  ): Promise<SearchContentOutput> {
50
51
  searchContentInputSchema.parse(input);
51
52
 
52
- const root = contentRoot ?? join(process.cwd(), "content");
53
+ const root = contentRoot ?? mcpContentRoot();
53
54
  const locale = resolveLocale(input.locale);
54
55
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
55
56
 
@@ -4,6 +4,8 @@
4
4
  "outDir": "./dist",
5
5
  "rootDir": "./src",
6
6
  "composite": true,
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
7
9
  "types": ["node"]
8
10
  },
9
11
  "include": ["src"],
@@ -19,6 +19,11 @@
19
19
  * Issue #7396 — `mcpEndpoint` is the configured MCP HTTP host or absent. It
20
20
  * was `${baseUrl}/api/mcp`, a path apps/web does not serve, so every agent
21
21
  * that followed it got a 404.
22
+ * Issue #8070 — `unservedTools` drops the tools this topology's MCP server
23
+ * does not register (`create_project` on a spoke).
24
+ * Issue #8057 — `description` resolves from SITE_DESCRIPTION
25
+ * (`siteIdentity.description` in _site.mdx → neutral default) instead of the
26
+ * template's own blurb on every site.
22
27
  */
23
28
 
24
29
  import {
@@ -27,7 +32,7 @@ import {
27
32
  type McpToolCatalogEntry,
28
33
  } from "@working-theory/metadata";
29
34
 
30
- import { SITE_NAME } from "../../../lib/site-brand";
35
+ import { SITE_DESCRIPTION, SITE_NAME } from "../../../lib/site-brand";
31
36
 
32
37
  /**
33
38
  * Inline transport descriptor shape — mirrors McpTransportDescriptor from
@@ -107,6 +112,14 @@ export interface BuildMcpManifestOptions {
107
112
  /** Overrides `MCP_FEATURE_GATE_MODE` — a test seam; `route.ts` never
108
113
  * passes it. */
109
114
  featureGate?: McpCatalogOptions["featureGate"];
115
+ /**
116
+ * Tool names this site's MCP server does not serve (#8070 D7) — the
117
+ * topology seam's `UNSERVED_MCP_TOOLS`: `create_project` on a spoke, none
118
+ * on a hub. Dropped from `tools`, so the manifest never advertises a tool
119
+ * the server will not register. Injected, like `commerceClaimed`. Omitted ⇒
120
+ * none dropped.
121
+ */
122
+ unservedTools?: readonly string[];
110
123
  }
111
124
 
112
125
  /** MCP HTTP transport version this server implements. */
@@ -132,8 +145,7 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
132
145
  return {
133
146
  schemaVersion: "1",
134
147
  name: SITE_NAME,
135
- description:
136
- "AX-first OSS template — ship software with API + MCP as canonical agent surfaces.",
148
+ description: SITE_DESCRIPTION,
137
149
  // Spread conditional to satisfy exactOptionalPropertyTypes: the key is
138
150
  // absent, not undefined, when no MCP HTTP host is configured.
139
151
  ...(opts.mcpHttpUrl ? { mcpEndpoint: opts.mcpHttpUrl } : {}),
@@ -141,7 +153,7 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
141
153
  addons: { commerce: opts.commerceClaimed === true },
142
154
  features: opts.heldFeatures ?? [],
143
155
  ...(opts.featureGate ? { featureGate: opts.featureGate } : {}),
144
- }),
156
+ }).filter((tool) => !(opts.unservedTools ?? []).includes(tool.name)),
145
157
  transport: {
146
158
  type: "http",
147
159
  version: MCP_HTTP_VERSION,
@@ -26,6 +26,7 @@
26
26
  * is appropriate per the MCP discovery spec.
27
27
  */
28
28
 
29
+ import { UNSERVED_MCP_TOOLS } from "../../../lib/topology.generated";
29
30
  import packageJson from "../../../package.json";
30
31
  import {
31
32
  COMMERCE_FEATURE,
@@ -59,6 +60,8 @@ export async function GET(): Promise<Response> {
59
60
  ailkVersion: packageJson.version,
60
61
  commerceClaimed: heldFeatures.includes(COMMERCE_FEATURE),
61
62
  heldFeatures,
63
+ // The tools this topology's MCP server does not register (#8070 D7).
64
+ unservedTools: UNSERVED_MCP_TOOLS,
62
65
  // Spread conditional to satisfy exactOptionalPropertyTypes: omit the key
63
66
  // entirely when the env var is not set rather than passing undefined.
64
67
  ...(mcpHttpUrlEnv ? { mcpHttpUrl: mcpHttpUrlEnv } : {}),
@@ -13,10 +13,24 @@
13
13
  * navigation telemetry for a Server Action, which is inherent to any hosted-
14
14
  * portal handoff, not something this design avoids).
15
15
  *
16
- * CSRF is already handled: `forwardedCookieInit()` forwards the incoming
17
- * request's real `Origin` alongside the cookie, and `apps/api`'s
18
- * `authPreHandler` requires a matching `Origin` on this cookie-authenticated
19
- * mutating request (D3.1/D5).
16
+ * CSRF protection depends on the render (`forwardedCookieInit()` branches on
17
+ * `SIGN_IN_PROVIDER`, `lib/server-api.ts`):
18
+ *
19
+ * - Hub render (`SIGN_IN_PROVIDER === "site"`): `forwardedCookieInit()`
20
+ * forwards the incoming request's real `Origin` alongside the Better Auth
21
+ * cookie, and `apps/api`'s `authPreHandler` requires that `Origin` to be one
22
+ * of its trusted origins on this cookie-authenticated mutating request
23
+ * (`isOriginAllowed`, `middleware/auth.ts`). The portal-session route also
24
+ * accepts only a cookie session (`request.sessionTenant`), never a bearer
25
+ * (D3.1/D5).
26
+ * - Spoke render, sign-in through the hub (`SIGN_IN_PROVIDER === "hub"`):
27
+ * there is no cookie and no `Origin` forward. `hubSessionInit()`
28
+ * (`lib/hub-session/session.ts`) sends the sealed session's credential as
29
+ * `Authorization: Bearer`, and `authPreHandler` skips its origin check for a
30
+ * bearer. This action does not complete on a spoke: the hub admits a hub
31
+ * credential on four read routes only (`HUB_SESSION_ROUTES`), so
32
+ * `POST /v1/subscription/portal-session` is a 404 there, and Settings hides
33
+ * the Billing button because `subscription.me()` is a 404 too.
20
34
  *
21
35
  * `redirect()` throws `NEXT_REDIRECT` by design — called OUTSIDE the
22
36
  * try/catch (mirrors `checkout.actions.ts`'s idiom) so the catch never
@@ -15,6 +15,13 @@
15
15
 
16
16
  import { GET } from "../route";
17
17
 
18
+ // The REAL committed seam, for the #8060 hub-only guards: the mock below
19
+ // shadows `TOPOLOGY` per case, but whether a hub-only case runs depends on the
20
+ // render this scaffold carries, not on the topology a case picks.
21
+ const { TOPOLOGY: SEAM_TOPOLOGY } = jest.requireActual<
22
+ typeof import("../../../lib/topology.generated")
23
+ >("../../../lib/topology.generated");
24
+
18
25
  // The generated topology seam (#7426 D5), mocked so each case picks its
19
26
  // topology: the hub cases below run against `"hub"`, exactly as before; the
20
27
  // spoke cases (D12) flip it. Mocked rather than imported because the hub
@@ -93,17 +100,21 @@ describe("GET /openapi.json", () => {
93
100
  expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
94
101
  });
95
102
 
96
- it("falls back to NEXT_PUBLIC_AILK_API_URL, then the local-dev API origin", async () => {
97
- apiAnswers(200, JSON.stringify(SPEC));
98
- await GET();
99
- expect(requestedUrl()).toBe("http://localhost:3001/v1/openapi.json");
100
-
101
- fetchSpy.mockClear();
102
- process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
103
- apiAnswers(200, JSON.stringify(SPEC));
104
- await GET();
105
- expect(requestedUrl()).toBe("https://api.example.com/v1/openapi.json");
106
- });
103
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
104
+ (SEAM_TOPOLOGY === "hub" ? it : it.skip)(
105
+ "falls back to NEXT_PUBLIC_AILK_API_URL, then the local-dev API origin",
106
+ async () => {
107
+ apiAnswers(200, JSON.stringify(SPEC));
108
+ await GET();
109
+ expect(requestedUrl()).toBe("http://localhost:3001/v1/openapi.json");
110
+
111
+ fetchSpy.mockClear();
112
+ process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
113
+ apiAnswers(200, JSON.stringify(SPEC));
114
+ await GET();
115
+ expect(requestedUrl()).toBe("https://api.example.com/v1/openapi.json");
116
+ },
117
+ );
107
118
 
108
119
  // #7874 D12 — the hub case above runs the committed (hub) render of the
109
120
  // browser API origin seam unmocked; the spoke render reads no env at all.
@@ -141,23 +152,30 @@ describe("GET /openapi.json", () => {
141
152
  expect(response.headers.get("cache-control")).toBe("no-store");
142
153
  });
143
154
 
144
- it("resolves a relative servers[] URL against the public API origin", async () => {
145
- process.env["AILK_API_URL"] = "http://api.internal:4000";
146
- process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
147
- apiAnswers(
148
- 200,
149
- JSON.stringify({ ...SPEC, servers: [{ url: "/", description: "x" }] }),
150
- );
151
-
152
- const body = (await (await GET()).json()) as typeof SPEC & {
153
- servers: Array<{ url: string }>;
154
- };
155
-
156
- expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
157
- expect(body.servers).toEqual([
158
- { url: "https://api.example.com", description: "This deployment's API" },
159
- ]);
160
- });
155
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
156
+ (SEAM_TOPOLOGY === "hub" ? it : it.skip)(
157
+ "resolves a relative servers[] URL against the public API origin",
158
+ async () => {
159
+ process.env["AILK_API_URL"] = "http://api.internal:4000";
160
+ process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
161
+ apiAnswers(
162
+ 200,
163
+ JSON.stringify({ ...SPEC, servers: [{ url: "/", description: "x" }] }),
164
+ );
165
+
166
+ const body = (await (await GET()).json()) as typeof SPEC & {
167
+ servers: Array<{ url: string }>;
168
+ };
169
+
170
+ expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
171
+ expect(body.servers).toEqual([
172
+ {
173
+ url: "https://api.example.com",
174
+ description: "This deployment's API",
175
+ },
176
+ ]);
177
+ },
178
+ );
161
179
 
162
180
  it("resolves a relative servers[] URL against AILK_API_URL when no public origin is set", async () => {
163
181
  process.env["AILK_API_URL"] = "http://localhost:3700";
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { handleRelay, RELAY_ROUTES } from "../relay";
14
14
  import { __resetRelayRateLimit, __relayRateLimitBucketCount } from "../relay";
15
+ import { TOPOLOGY } from "../topology.generated";
15
16
 
16
17
  const HUB = "https://hub.example";
17
18
  const SITE = "https://spoke.example";
@@ -516,13 +517,18 @@ describe("hardening — what the site serves, and a missing hub address", () =>
516
517
 
517
518
  // #7874 D12 — the fallback reads the browser API origin seam. The
518
519
  // committed seam is the hub render, so this case runs it unmocked.
519
- it("on a hub, NEXT_PUBLIC_AILK_API_URL alone still counts as a hub address", async () => {
520
- delete process.env.AILK_API_URL;
521
- process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
522
- const res = await handleRelay(relayRequest("/v1/leads/submit"));
523
- expect(res.status).toBe(201);
524
- expect(fetchMock).toHaveBeenCalledTimes(1);
525
- });
520
+ // #8060 — hub-only: a spoke render of the seam reads no
521
+ // NEXT_PUBLIC_AILK_API_URL, so a spoke scaffold skips this case.
522
+ (TOPOLOGY === "hub" ? it : it.skip)(
523
+ "on a hub, NEXT_PUBLIC_AILK_API_URL alone still counts as a hub address",
524
+ async () => {
525
+ delete process.env.AILK_API_URL;
526
+ process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
527
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
528
+ expect(res.status).toBe(201);
529
+ expect(fetchMock).toHaveBeenCalledTimes(1);
530
+ },
531
+ );
526
532
 
527
533
  it("on a spoke (the seam's spoke render), that fallback is skipped", async () => {
528
534
  delete process.env.AILK_API_URL;
@@ -11,6 +11,7 @@
11
11
  * sets `Origin`.
12
12
  */
13
13
  import { forwardedCookieInit } from "../server-api";
14
+ import { TOPOLOGY } from "../topology.generated";
14
15
 
15
16
  const mockCookies = jest.fn();
16
17
  const mockHeaders = jest.fn();
@@ -27,35 +28,43 @@ describe("forwardedCookieInit", () => {
27
28
  mockHeaders.mockReset();
28
29
  });
29
30
 
30
- it("forwards cookie + origin when the incoming request carries an Origin header", async () => {
31
- mockCookies.mockResolvedValue({
32
- toString: () => "better-auth.session_token=abc",
33
- });
34
- mockHeaders.mockResolvedValue(
35
- new Headers({ origin: "https://app.example.com" }),
36
- );
37
-
38
- const init = await forwardedCookieInit();
39
-
40
- expect(init).toEqual({
41
- headers: {
42
- cookie: "better-auth.session_token=abc",
43
- origin: "https://app.example.com",
44
- },
45
- });
46
- });
31
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
32
+ (TOPOLOGY === "hub" ? it : it.skip)(
33
+ "forwards cookie + origin when the incoming request carries an Origin header",
34
+ async () => {
35
+ mockCookies.mockResolvedValue({
36
+ toString: () => "better-auth.session_token=abc",
37
+ });
38
+ mockHeaders.mockResolvedValue(
39
+ new Headers({ origin: "https://app.example.com" }),
40
+ );
47
41
 
48
- it("forwards cookie alone — no origin key at all — when Origin is absent", async () => {
49
- mockCookies.mockResolvedValue({
50
- toString: () => "better-auth.session_token=abc",
51
- });
52
- mockHeaders.mockResolvedValue(new Headers());
42
+ const init = await forwardedCookieInit();
53
43
 
54
- const init = await forwardedCookieInit();
44
+ expect(init).toEqual({
45
+ headers: {
46
+ cookie: "better-auth.session_token=abc",
47
+ origin: "https://app.example.com",
48
+ },
49
+ });
50
+ },
51
+ );
55
52
 
56
- expect(init).toEqual({
57
- headers: { cookie: "better-auth.session_token=abc" },
58
- });
59
- expect(init.headers).not.toHaveProperty("origin");
60
- });
53
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
54
+ (TOPOLOGY === "hub" ? it : it.skip)(
55
+ "forwards cookie alone — no origin key at all — when Origin is absent",
56
+ async () => {
57
+ mockCookies.mockResolvedValue({
58
+ toString: () => "better-auth.session_token=abc",
59
+ });
60
+ mockHeaders.mockResolvedValue(new Headers());
61
+
62
+ const init = await forwardedCookieInit();
63
+
64
+ expect(init).toEqual({
65
+ headers: { cookie: "better-auth.session_token=abc" },
66
+ });
67
+ expect(init.headers).not.toHaveProperty("origin");
68
+ },
69
+ );
61
70
  });
@@ -170,6 +170,48 @@ describe("COPYRIGHT_HOLDER (#7796)", () => {
170
170
  });
171
171
  });
172
172
 
173
+ /**
174
+ * `SITE_DESCRIPTION` — `siteIdentity.description`, else a neutral fallback
175
+ * that never names the template (#8057).
176
+ */
177
+ describe("SITE_DESCRIPTION (#8057)", () => {
178
+ function writeDescription(identityLines: string): void {
179
+ writeFileSync(
180
+ join(tmpRoot, "_site.mdx"),
181
+ `---\nsiteName: Fixture Co\nsiteIdentity:\n kind: Organization\n name: Fixture Co\n${identityLines}---\n`,
182
+ );
183
+ process.env["AILK_CONTENT_ROOT"] = tmpRoot;
184
+ delete process.env["NEXT_PUBLIC_SITE_NAME"];
185
+ }
186
+
187
+ it("is siteIdentity.description when the site declares one (folded scalar trimmed)", async () => {
188
+ writeDescription(" description: >\n Fixture Co sells widgets.\n");
189
+ const { SITE_DESCRIPTION } = await loadSiteBrand();
190
+
191
+ expect(SITE_DESCRIPTION).toBe("Fixture Co sells widgets.");
192
+ });
193
+
194
+ it("falls back to a neutral description that does not mention the template", async () => {
195
+ writeDescription("");
196
+ const { SITE_DESCRIPTION, FALLBACK_SITE_DESCRIPTION } =
197
+ await loadSiteBrand();
198
+
199
+ expect(SITE_DESCRIPTION).toBe(FALLBACK_SITE_DESCRIPTION);
200
+ expect(SITE_DESCRIPTION).not.toMatch(/template/i);
201
+ expect(SITE_DESCRIPTION).not.toMatch(/AX-first/);
202
+ });
203
+
204
+ it("falls back when description is blank or _site.mdx is absent", async () => {
205
+ writeDescription(' description: " "\n');
206
+ let mod = await loadSiteBrand();
207
+ expect(mod.SITE_DESCRIPTION).toBe(mod.FALLBACK_SITE_DESCRIPTION);
208
+
209
+ rmSync(join(tmpRoot, "_site.mdx"));
210
+ mod = await loadSiteBrand();
211
+ expect(mod.SITE_DESCRIPTION).toBe(mod.FALLBACK_SITE_DESCRIPTION);
212
+ });
213
+ });
214
+
173
215
  /**
174
216
  * `siteLogo()` — the brand triad's logomark (#6820 D5). No `brand:` renders
175
217
  * exactly today's element; a declared mark is the one rendered, with its dark