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.
- package/dist/lib/env-catalog.js +5 -9
- package/dist/spoke-checkout.d.ts +27 -2
- package/dist/spoke-checkout.js +207 -4
- package/dist/topology.d.ts +8 -0
- package/dist/topology.js +29 -5
- package/package.json +3 -3
- package/templates/apps/mcp/CLAUDE.md +51 -5
- package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/boot.test.ts +203 -0
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- package/templates/apps/mcp/src/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +185 -0
- package/templates/apps/mcp/src/http-transport.ts +39 -15
- package/templates/apps/mcp/src/index.ts +17 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/tsconfig.json +2 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +18 -4
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +46 -28
- package/templates/apps/web/lib/__tests__/relay.test.ts +13 -7
- package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/site-brand.tsx +22 -6
- package/templates/apps/web/lib/topology.generated.ts +3 -0
- 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 {
|
|
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
|
|
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 ??
|
|
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 {
|
|
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
|
|
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 ??
|
|
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 {
|
|
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
|
|
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 ??
|
|
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 {
|
|
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
|
|
42
|
-
*
|
|
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:
|
|
51
|
-
const root = contentRoot ??
|
|
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 {
|
|
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
|
|
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:
|
|
52
|
-
const root = contentRoot ??
|
|
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 {
|
|
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
|
|
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 ??
|
|
53
|
+
const root = contentRoot ?? mcpContentRoot();
|
|
53
54
|
const locale = resolveLocale(input.locale);
|
|
54
55
|
const adapter = getMcpContentAdapter({ contentRoot: root, locale });
|
|
55
56
|
|
|
@@ -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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
49
|
-
mockCookies.mockResolvedValue({
|
|
50
|
-
toString: () => "better-auth.session_token=abc",
|
|
51
|
-
});
|
|
52
|
-
mockHeaders.mockResolvedValue(new Headers());
|
|
42
|
+
const init = await forwardedCookieInit();
|
|
53
43
|
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|