create-ailk 0.29.0 → 0.30.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/README.md +0 -5
- package/dist/add-route.js +1 -0
- package/dist/cli.js +15 -1
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/import-mdx.js +18 -9
- package/dist/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/surfaces.js +3 -0
- package/dist/topology.js +11 -0
- package/package.json +3 -3
- package/templates/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +7 -3
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +42 -8
- package/templates/apps/web/CLAUDE.md +24 -1
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
- package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
- package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
- package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
- package/templates/apps/web/app/llms.txt/route.ts +10 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
- package/templates/apps/web/app/openapi.json/route.ts +4 -0
- package/templates/apps/web/app/robots.txt/route.ts +18 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +10 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
- package/templates/apps/web/lib/auth-routes.ts +5 -3
- package/templates/apps/web/lib/mcp-oauth.ts +702 -0
- package/templates/apps/web/messages/en.json +42 -13
- package/templates/database/seed.ts +6 -0
- package/versions.json +48 -48
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The MCP sign-in leg's continuation (#10479): the hub's sealed ref, under a
|
|
7
|
+
* name of its own. Pure functions over `seal.ts`; no mocks.
|
|
8
|
+
*/
|
|
9
|
+
import { mintContinuation } from "../../hub/continuation";
|
|
10
|
+
import {
|
|
11
|
+
MCP_CONTINUE_PATH,
|
|
12
|
+
mintMcpContinuation,
|
|
13
|
+
openMcpContinuation,
|
|
14
|
+
sealMcpCallbacks,
|
|
15
|
+
} from "../continuation";
|
|
16
|
+
|
|
17
|
+
const SECRET = "k".repeat(43);
|
|
18
|
+
const T = 1_790_000_000_000;
|
|
19
|
+
|
|
20
|
+
const Q = new URLSearchParams({
|
|
21
|
+
response_type: "code",
|
|
22
|
+
client_id: "mcp-client",
|
|
23
|
+
redirect_uri: "http://127.0.0.1:53682/callback",
|
|
24
|
+
state: "s".repeat(43),
|
|
25
|
+
code_challenge: "c".repeat(43),
|
|
26
|
+
code_challenge_method: "S256",
|
|
27
|
+
}).toString();
|
|
28
|
+
|
|
29
|
+
it("pins the continue path", () => {
|
|
30
|
+
expect(MCP_CONTINUE_PATH).toBe("/api/auth/mcp/continue");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("round-trips the authorize query, scheme-bearing redirect_uri included", () => {
|
|
34
|
+
expect(openMcpContinuation(mintMcpContinuation(Q, SECRET, T), SECRET, T + 1_000)).toBe(Q);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("mints a ref no decode can change: base64url only, so no ':', '%', '?', '&' or '='", () => {
|
|
38
|
+
expect(mintMcpContinuation(Q, SECRET, T)).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("expires at 600 s", () => {
|
|
42
|
+
expect(openMcpContinuation(mintMcpContinuation(Q, SECRET, T), SECRET, T + 600_000)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("never opens a hub ref, nor under another secret", () => {
|
|
46
|
+
expect(openMcpContinuation(mintContinuation(Q, SECRET, T), SECRET, T)).toBeNull();
|
|
47
|
+
expect(openMcpContinuation(mintMcpContinuation(Q, SECRET, T), "x".repeat(43), T)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("sealMcpCallbacks — the sign-in body's MCP resume, sealed", () => {
|
|
51
|
+
const next = `/api/auth/mcp/authorize?${Q}`;
|
|
52
|
+
const refOf = (path: string | undefined) =>
|
|
53
|
+
/^\/api\/auth\/mcp\/continue\/([A-Za-z0-9_-]+)$/.exec(path ?? "")?.[1] ?? "";
|
|
54
|
+
|
|
55
|
+
it("seals callbackURL and the next inside the sign-in page's errorCallbackURL; both open to the query", () => {
|
|
56
|
+
const sealed = sealMcpCallbacks(
|
|
57
|
+
{ callbackURL: next, errorCallbackURL: `/de/sign-in?next=${encodeURIComponent(next)}` },
|
|
58
|
+
SECRET,
|
|
59
|
+
);
|
|
60
|
+
expect(openMcpContinuation(refOf(sealed?.callbackURL), SECRET)).toBe(Q);
|
|
61
|
+
const error = new URL(sealed?.errorCallbackURL ?? "", "http://x.invalid");
|
|
62
|
+
expect(error.pathname).toBe("/de/sign-in");
|
|
63
|
+
expect(openMcpContinuation(refOf(error.searchParams.get("next") ?? ""), SECRET)).toBe(Q);
|
|
64
|
+
// No decode of either value can produce a ':'.
|
|
65
|
+
expect(decodeURIComponent(decodeURIComponent(sealed?.errorCallbackURL ?? ""))).not.toContain(":");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each([
|
|
69
|
+
["no callbacks", {}],
|
|
70
|
+
["an ordinary next", { callbackURL: "/account", errorCallbackURL: "/sign-in?next=%2Faccount" }],
|
|
71
|
+
["the hub's own authorize path", { callbackURL: `/api/auth/hub/authorize?${Q}` }],
|
|
72
|
+
["a foreign errorCallbackURL", { errorCallbackURL: `https://evil.example/?next=${encodeURIComponent(next)}` }],
|
|
73
|
+
["a non-string callbackURL", { callbackURL: [next] }],
|
|
74
|
+
// Security review: URL's dot-segment collapse would emit //evil.example.
|
|
75
|
+
["a dot-segment errorCallbackURL", { errorCallbackURL: `/.//evil.example?next=${encodeURIComponent(next)}` }],
|
|
76
|
+
["an errorCallbackURL on another page", { errorCallbackURL: `/account?next=${encodeURIComponent(next)}` }],
|
|
77
|
+
["an errorCallbackURL with a second key", { errorCallbackURL: `/sign-in?next=${encodeURIComponent(next)}&x=1` }],
|
|
78
|
+
["a next holding CR/LF", { callbackURL: `${next}%0D%0A`.replace("%0D%0A", "\r\n") }],
|
|
79
|
+
["an undecodable next", { errorCallbackURL: "/sign-in?next=%E0%A4%A" }],
|
|
80
|
+
])("leaves %s alone (null)", (_name, body) => {
|
|
81
|
+
expect(sealMcpCallbacks(body, SECRET)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("seals the sign-up page's errorCallbackURL the same way", () => {
|
|
85
|
+
const sealed = sealMcpCallbacks({ errorCallbackURL: `/sign-up?next=${encodeURIComponent(next)}` }, SECRET);
|
|
86
|
+
expect(sealed?.errorCallbackURL).toMatch(/^\/sign-up\?next=%2Fapi%2Fauth%2Fmcp%2Fcontinue%2F[A-Za-z0-9_-]+$/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("leaves a query too long to seal under the continue route's cap as it was", () => {
|
|
90
|
+
expect(sealMcpCallbacks({ callbackURL: `${next}&state=${"s".repeat(4096)}` }, SECRET)).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP sign-in leg's continuation (#10479).
|
|
3
|
+
*
|
|
4
|
+
* The plugin's `loginPage` (`lib/mcp-oauth.ts`, `/mcp/sign-in`) hands
|
|
5
|
+
* `/sign-in` a `next` of `/api/auth/mcp/authorize?<query>`, and the page sends
|
|
6
|
+
* it on as the sign-in's `callbackURL`. An MCP client's query carries a
|
|
7
|
+
* scheme-bearing `redirect_uri` (`http://127.0.0.1:PORT/callback`, any
|
|
8
|
+
* `https://` callback), and Better Auth's magic-link verify decodes
|
|
9
|
+
* `callbackURL` and checks it as a relative path whose query admits no `:` —
|
|
10
|
+
* so the emailed link answered 403 INVALID_CALLBACK_URL and the person never
|
|
11
|
+
* reached `/consent`.
|
|
12
|
+
*
|
|
13
|
+
* `auth.ts` therefore rewrites such a callback, as a sign-in request arrives,
|
|
14
|
+
* to the same-origin path `/api/auth/mcp/continue/<ref>` — the hub's sealed
|
|
15
|
+
* ref (`../hub/continuation.ts`), under a name of its own so a ref minted for
|
|
16
|
+
* one leg never opens at the other — and the continue route returns the
|
|
17
|
+
* person to `/mcp/authorize`, which forces consent again.
|
|
18
|
+
*/
|
|
19
|
+
import { CONTINUATION_REF_MAX, mintContinuation, openContinuation } from "../hub/continuation";
|
|
20
|
+
|
|
21
|
+
/** The same-origin path a ref is appended to. */
|
|
22
|
+
export const MCP_CONTINUE_PATH = "/api/auth/mcp/continue";
|
|
23
|
+
|
|
24
|
+
/** The resume target the plugin's `loginPage` puts in `next`. */
|
|
25
|
+
const AUTHORIZE_PREFIX = "/api/auth/mcp/authorize?";
|
|
26
|
+
|
|
27
|
+
const NAME = "mcp-continue";
|
|
28
|
+
|
|
29
|
+
const PRINTABLE = /^[\x21-\x7e]+$/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The exact `errorCallbackURL` the sign-in and sign-up pages send:
|
|
33
|
+
* `[/<locale>]/sign-in?next=<encoded>` (or `/sign-up`). Matched on the raw
|
|
34
|
+
* string and rebuilt from its own path bytes — never through `URL`, whose
|
|
35
|
+
* dot-segment collapse would turn an accepted `/.//host` into a
|
|
36
|
+
* protocol-relative `//host` after Better Auth's origin check has run.
|
|
37
|
+
*/
|
|
38
|
+
const SIGN_IN_ERROR_CALLBACK = /^(\/(?:[a-z]{2}(?:-[A-Za-z]{2,4})?\/)?sign-(?:in|up))\?next=([^&#]*)$/;
|
|
39
|
+
|
|
40
|
+
/** `search` (the authorize query, no leading `?`) as a sealed ref. */
|
|
41
|
+
export function mintMcpContinuation(search: string, secret: string, now: number = Date.now()): string {
|
|
42
|
+
return mintContinuation(search, secret, now, NAME);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The authorize query a ref holds, or `null` (see `openContinuation`). */
|
|
46
|
+
export function openMcpContinuation(ref: string, secret: string, now: number = Date.now()): string | null {
|
|
47
|
+
return openContinuation(ref, secret, now, NAME);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `path` as a continue path when it resumes an MCP authorization, else unchanged. */
|
|
51
|
+
function sealed(path: string, secret: string): string {
|
|
52
|
+
// Printable ASCII only, as the origin check admits: nothing sealed may hold
|
|
53
|
+
// a control or non-Latin-1 character the continue route's Location refuses.
|
|
54
|
+
if (!path.startsWith(AUTHORIZE_PREFIX) || !PRINTABLE.test(path)) return path;
|
|
55
|
+
const ref = mintMcpContinuation(path.slice(AUTHORIZE_PREFIX.length), secret);
|
|
56
|
+
// Past the cap the continue route would not open it: leave the path as it was.
|
|
57
|
+
return ref.length > CONTINUATION_REF_MAX ? path : `${MCP_CONTINUE_PATH}/${ref}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The sign-in body's callbacks with every MCP resume sealed, or `null` when
|
|
62
|
+
* none is: `callbackURL` itself, and the `next` the sign-in page puts inside
|
|
63
|
+
* `errorCallbackURL` (`SIGN_IN_ERROR_CALLBACK`), whose decoded form holds the
|
|
64
|
+
* same query. Every other value — any other shape included — passes through
|
|
65
|
+
* unchanged, so this hook can only ever write a same-origin continue path.
|
|
66
|
+
*/
|
|
67
|
+
export function sealMcpCallbacks(
|
|
68
|
+
body: { callbackURL?: unknown; errorCallbackURL?: unknown },
|
|
69
|
+
secret: string,
|
|
70
|
+
): { callbackURL?: string; errorCallbackURL?: string } | null {
|
|
71
|
+
const out: { callbackURL?: string; errorCallbackURL?: string } = {};
|
|
72
|
+
if (typeof body.callbackURL === "string") {
|
|
73
|
+
const callbackURL = sealed(body.callbackURL, secret);
|
|
74
|
+
if (callbackURL !== body.callbackURL) out.callbackURL = callbackURL;
|
|
75
|
+
}
|
|
76
|
+
const match = typeof body.errorCallbackURL === "string" ? SIGN_IN_ERROR_CALLBACK.exec(body.errorCallbackURL) : null;
|
|
77
|
+
if (match) {
|
|
78
|
+
const [, page, encoded = ""] = match;
|
|
79
|
+
let next: string;
|
|
80
|
+
try {
|
|
81
|
+
next = decodeURIComponent(encoded);
|
|
82
|
+
} catch {
|
|
83
|
+
next = "";
|
|
84
|
+
}
|
|
85
|
+
const continued = sealed(next, secret);
|
|
86
|
+
if (continued !== next) out.errorCallbackURL = `${page}?next=${encodeURIComponent(continued)}`;
|
|
87
|
+
}
|
|
88
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
89
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The MCP sign-in leg's continue step (#10479): a ref returns the person to
|
|
7
|
+
* `/api/auth/mcp/authorize` with the original query, and nowhere else.
|
|
8
|
+
*/
|
|
9
|
+
import { mintContinuation } from "../../../../hub/continuation";
|
|
10
|
+
import { mintMcpContinuation } from "../../../continuation";
|
|
11
|
+
import { GET } from "../route";
|
|
12
|
+
|
|
13
|
+
const SITE = "https://site.example.com";
|
|
14
|
+
const SECRET = "flow-secret-0123456789abcdef0123456789abcdef";
|
|
15
|
+
const ORIGINAL_ENV = process.env;
|
|
16
|
+
|
|
17
|
+
const QUERY = new URLSearchParams({
|
|
18
|
+
response_type: "code",
|
|
19
|
+
client_id: "mcp-client",
|
|
20
|
+
redirect_uri: "http://127.0.0.1:53682/callback",
|
|
21
|
+
state: "s".repeat(43),
|
|
22
|
+
code_challenge: "c".repeat(43),
|
|
23
|
+
code_challenge_method: "S256",
|
|
24
|
+
}).toString();
|
|
25
|
+
|
|
26
|
+
function continueTo(ref: string): Promise<Response> {
|
|
27
|
+
return GET(new Request(`${SITE}/api/auth/mcp/continue/${ref}`), {
|
|
28
|
+
params: Promise.resolve({ ref }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
process.env = { ...ORIGINAL_ENV, BETTER_AUTH_SECRET: SECRET };
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
process.env = ORIGINAL_ENV;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("answers a valid ref with 303 to /api/auth/mcp/authorize and the same query", async () => {
|
|
41
|
+
const res = await continueTo(mintMcpContinuation(QUERY, SECRET));
|
|
42
|
+
|
|
43
|
+
expect(res.status).toBe(303);
|
|
44
|
+
expect(res.headers.get("location")).toBe(`/api/auth/mcp/authorize?${QUERY}`);
|
|
45
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
46
|
+
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it.each([
|
|
50
|
+
["a forged ref", "not-a-ref"],
|
|
51
|
+
["a hub ref", mintContinuation(QUERY, SECRET)],
|
|
52
|
+
["an expired ref", mintMcpContinuation(QUERY, SECRET, Date.now() - 601_000)],
|
|
53
|
+
["a ref sealed under another secret", mintMcpContinuation(QUERY, "x".repeat(43))],
|
|
54
|
+
])("sends %s to /account, never to a URL it carries", async (_name, ref) => {
|
|
55
|
+
const res = await continueTo(ref);
|
|
56
|
+
|
|
57
|
+
expect(res.status).toBe(303);
|
|
58
|
+
expect(res.headers.get("location")).toBe("/account");
|
|
59
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP sign-in leg's continue step (#10479).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/auth/mcp/continue/<ref>
|
|
5
|
+
*
|
|
6
|
+
* After sign-in — by the emailed link, the password or a social provider —
|
|
7
|
+
* the person lands here, and this route returns them to the authorize step:
|
|
8
|
+
*
|
|
9
|
+
* - a valid, unexpired ref: 303 to `/api/auth/mcp/authorize?<the query>`;
|
|
10
|
+
* - any other ref (forged, a hub ref, expired, unreadable): 303 to
|
|
11
|
+
* `/account`.
|
|
12
|
+
*
|
|
13
|
+
* Both targets are fixed relative paths: this route redirects nowhere else,
|
|
14
|
+
* and never to a URL carried in the ref. It reads no session — the authorize
|
|
15
|
+
* step does, and forces consent. It logs nothing.
|
|
16
|
+
*/
|
|
17
|
+
import { resolveSecret } from "../../../../../../lib/auth-secret";
|
|
18
|
+
import { openMcpContinuation } from "../../continuation";
|
|
19
|
+
|
|
20
|
+
const AUTHORIZE_PATH = "/api/auth/mcp/authorize";
|
|
21
|
+
|
|
22
|
+
/** Where an unreadable ref goes: the site's own signed-in destination. */
|
|
23
|
+
const FALLBACK_PATH = "/account";
|
|
24
|
+
|
|
25
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect step, not a versioned API contract (#10479)
|
|
26
|
+
export async function GET(
|
|
27
|
+
_request: Request,
|
|
28
|
+
{ params }: { params: Promise<{ ref: string }> },
|
|
29
|
+
): Promise<Response> {
|
|
30
|
+
const { ref } = await params;
|
|
31
|
+
const query = openMcpContinuation(ref, resolveSecret());
|
|
32
|
+
return new Response(null, {
|
|
33
|
+
status: 303,
|
|
34
|
+
headers: {
|
|
35
|
+
Location: query === null ? FALLBACK_PATH : `${AUTHORIZE_PATH}?${query}`,
|
|
36
|
+
"Cache-Control": "no-store",
|
|
37
|
+
// The authorize query rides this URL; no page may leak it through a Referer.
|
|
38
|
+
"Referrer-Policy": "no-referrer",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* `listDocsPages`).
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import { siteDiscoverable } from "@working-theory/api-client";
|
|
30
31
|
import {
|
|
31
32
|
SCHEMA_ORG_TYPES,
|
|
32
33
|
resolvedAdapterKind,
|
|
@@ -44,6 +45,10 @@ import { PUBLIC_PAGES } from "../../lib/modules.generated";
|
|
|
44
45
|
import { SITE_NAME } from "../../lib/site-brand";
|
|
45
46
|
import { pagePathFor, resolveBaseUrl } from "../../src/lib/routes";
|
|
46
47
|
|
|
48
|
+
// #10510 D4 — read per request: the owner's discovery switch is a runtime
|
|
49
|
+
// setting a prerendered file could never follow.
|
|
50
|
+
export const dynamic = "force-dynamic";
|
|
51
|
+
|
|
47
52
|
export interface LlmsHandlerDeps {
|
|
48
53
|
/**
|
|
49
54
|
* Content adapter factory. Defaults to the real implementation.
|
|
@@ -58,6 +63,8 @@ export interface LlmsHandlerDeps {
|
|
|
58
63
|
getBlogPages?: () => Array<{ url: string; data: { title: string; description?: string } }>;
|
|
59
64
|
/** The docs pages (#10041). Defaults to `listDocsPages` over this app's docs directory. */
|
|
60
65
|
getDocsPages?: () => DocsIndexPage[];
|
|
66
|
+
/** The owner's discovery switch (#10510 D4). Defaults to `siteDiscoverable`. */
|
|
67
|
+
discoverable?: () => Promise<boolean>;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
/**
|
|
@@ -68,10 +75,13 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
|
|
|
68
75
|
const resolveAdapter = deps.getContentAdapter ?? defaultGetContentAdapter;
|
|
69
76
|
const resolveBlogPages = deps.getBlogPages ?? (() => []);
|
|
70
77
|
const resolveDocsPages = deps.getDocsPages ?? listDocsPages;
|
|
78
|
+
const isDiscoverable = deps.discoverable ?? siteDiscoverable;
|
|
71
79
|
|
|
72
80
|
return async function llmsGET(): Promise<Response> {
|
|
73
81
|
// #10040 decision 7 — no public page, no content index: 404 before the adapter.
|
|
74
82
|
if (!PUBLIC_PAGES) return new Response(null, { status: 404 });
|
|
83
|
+
// #10510 D4 — hidden by the owner: 404, never cached, before the adapter.
|
|
84
|
+
if (!(await isDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
|
|
75
85
|
const adapter = resolveAdapter();
|
|
76
86
|
const baseUrl = resolveBaseUrl();
|
|
77
87
|
const lines: string[] = [`# ${SITE_NAME} — content index`, ""];
|
|
@@ -15,6 +15,21 @@
|
|
|
15
15
|
|
|
16
16
|
import { GET } from "../route";
|
|
17
17
|
|
|
18
|
+
// #10510 D4 — the owner's discovery switch, mocked so no case reads a hub. On
|
|
19
|
+
// (`true`) by default: the state every case above the #10510 describe assumes.
|
|
20
|
+
const mockSiteDiscoverable = jest.fn<Promise<boolean>, []>();
|
|
21
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
22
|
+
...jest.requireActual<typeof import("@working-theory/api-client")>(
|
|
23
|
+
"@working-theory/api-client",
|
|
24
|
+
),
|
|
25
|
+
siteDiscoverable: () => mockSiteDiscoverable(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
mockSiteDiscoverable.mockReset();
|
|
30
|
+
mockSiteDiscoverable.mockResolvedValue(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
18
33
|
// The REAL committed seam, for the #8060 hub-only guards: the mock below
|
|
19
34
|
// shadows `TOPOLOGY` per case, but whether a hub-only case runs depends on the
|
|
20
35
|
// render this scaffold carries, not on the topology a case picks.
|
|
@@ -295,3 +310,28 @@ describe("GET /openapi.json on a spoke (#7426 D12)", () => {
|
|
|
295
310
|
expect(JSON.stringify(body)).toContain("hub.example.com");
|
|
296
311
|
});
|
|
297
312
|
});
|
|
313
|
+
|
|
314
|
+
// #10510 D4, AC-1–AC-2 — the owner's discovery switch: read once per request,
|
|
315
|
+
// before the API is fetched.
|
|
316
|
+
describe("GET /openapi.json and the owner's discovery switch (#10510)", () => {
|
|
317
|
+
it("on: today's cache-control, the switch read once per request", async () => {
|
|
318
|
+
apiAnswers(200, JSON.stringify(SPEC));
|
|
319
|
+
const response = await GET();
|
|
320
|
+
expect(response.status).toBe(200);
|
|
321
|
+
expect(response.headers.get("cache-control")).toBe("public, max-age=3600");
|
|
322
|
+
expect(mockSiteDiscoverable).toHaveBeenCalledTimes(1);
|
|
323
|
+
apiAnswers(200, JSON.stringify(SPEC));
|
|
324
|
+
await GET();
|
|
325
|
+
expect(mockSiteDiscoverable).toHaveBeenCalledTimes(2);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("off: 404 with no body, never cached, and the API never fetched", async () => {
|
|
329
|
+
mockSiteDiscoverable.mockResolvedValue(false);
|
|
330
|
+
apiAnswers(200, JSON.stringify(SPEC));
|
|
331
|
+
const response = await GET();
|
|
332
|
+
expect(response.status).toBe(404);
|
|
333
|
+
expect(await response.text()).toBe("");
|
|
334
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
335
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
* host, and never calls a path this site would answer 404.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { siteDiscoverable } from "@working-theory/api-client";
|
|
27
|
+
|
|
26
28
|
import { browserApiOrigin } from "../../lib/api-origin.generated";
|
|
27
29
|
import { RELAY_ROUTES } from "../../lib/relay";
|
|
28
30
|
import { TOPOLOGY } from "../../lib/topology.generated";
|
|
@@ -157,6 +159,8 @@ function badGateway(message: string): Response {
|
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
export async function GET(): Promise<Response> {
|
|
162
|
+
// #10510 D4 — hidden by the owner: 404, never cached, the API never read.
|
|
163
|
+
if (!(await siteDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
|
|
160
164
|
let upstream: Response;
|
|
161
165
|
try {
|
|
162
166
|
upstream = await fetch(`${apiBase()}${UPSTREAM_PATH}`, {
|
|
@@ -13,12 +13,17 @@
|
|
|
13
13
|
* parse (#8511), so 1, yes and on count too, and an unknown value throws.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { siteDiscoverable } from "@working-theory/api-client";
|
|
16
17
|
import { parseStealth } from "@working-theory/web-i18n/stealth";
|
|
17
18
|
|
|
18
19
|
import { PUBLIC_PAGES } from "../../lib/modules.generated";
|
|
19
20
|
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
// #10510 D4 — read per request: the owner's discovery switch is a runtime
|
|
23
|
+
// setting a prerendered file could never follow.
|
|
24
|
+
export const dynamic = "force-dynamic";
|
|
25
|
+
|
|
26
|
+
export async function GET(): Promise<Response> {
|
|
22
27
|
const baseUrl = resolveBaseUrl();
|
|
23
28
|
const disallowEnv = process.env["AILK_ROBOTS_DISALLOW"] ?? "";
|
|
24
29
|
// #10040 decision 7 — a site with neither marketing nor docs-blog (a hub)
|
|
@@ -26,6 +31,18 @@ export function GET(): Response {
|
|
|
26
31
|
// STEALTH, read from the module list, never from a setting.
|
|
27
32
|
const stealth = parseStealth(process.env["STEALTH"]) || !PUBLIC_PAGES;
|
|
28
33
|
|
|
34
|
+
// #10510 D4 — the owner's switch, read only when neither already hides the
|
|
35
|
+
// site: off is STEALTH's body, never cached, so a crawler asks again.
|
|
36
|
+
if (!stealth && !(await siteDiscoverable())) {
|
|
37
|
+
return new Response("User-agent: *\nDisallow: /", {
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: {
|
|
40
|
+
"content-type": "text/plain; charset=utf-8",
|
|
41
|
+
"cache-control": "no-store",
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
const disallowPaths = stealth
|
|
30
47
|
? ["/"]
|
|
31
48
|
: disallowEnv
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
* directory is listed instead (`getDocsPages`, default `listDocsPages`).
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
+
import { siteDiscoverable } from "@working-theory/api-client";
|
|
40
41
|
import {
|
|
41
42
|
SCHEMA_ORG_TYPES,
|
|
42
43
|
resolvedAdapterKind,
|
|
@@ -53,6 +54,10 @@ import {
|
|
|
53
54
|
import { PUBLIC_PAGES } from "../../lib/modules.generated";
|
|
54
55
|
import { pagePathFor, resolveBaseUrl } from "../../src/lib/routes";
|
|
55
56
|
|
|
57
|
+
// #10510 D4 — read per request: the owner's discovery switch is a runtime
|
|
58
|
+
// setting a prerendered file could never follow.
|
|
59
|
+
export const dynamic = "force-dynamic";
|
|
60
|
+
|
|
56
61
|
export interface SitemapHandlerDeps {
|
|
57
62
|
/**
|
|
58
63
|
* Content adapter factory. Defaults to the real implementation.
|
|
@@ -61,6 +66,8 @@ export interface SitemapHandlerDeps {
|
|
|
61
66
|
getContentAdapter?: () => ContentAdapter;
|
|
62
67
|
/** The docs pages (#10041). Defaults to `listDocsPages` over this app's docs directory. */
|
|
63
68
|
getDocsPages?: () => DocsIndexPage[];
|
|
69
|
+
/** The owner's discovery switch (#10510 D4). Defaults to `siteDiscoverable`. */
|
|
70
|
+
discoverable?: () => Promise<boolean>;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
function escapeXml(value: string): string {
|
|
@@ -96,9 +103,12 @@ function buildLocaleUrl(
|
|
|
96
103
|
export function createSitemapHandler(deps: SitemapHandlerDeps = {}) {
|
|
97
104
|
const resolveAdapter = deps.getContentAdapter ?? defaultGetContentAdapter;
|
|
98
105
|
const resolveDocsPages = deps.getDocsPages ?? listDocsPages;
|
|
106
|
+
const isDiscoverable = deps.discoverable ?? siteDiscoverable;
|
|
99
107
|
return async function sitemapGET(): Promise<Response> {
|
|
100
108
|
// #10040 decision 7 — no public page, no sitemap: 404 before the adapter.
|
|
101
109
|
if (!PUBLIC_PAGES) return new Response(null, { status: 404 });
|
|
110
|
+
// #10510 D4 — hidden by the owner: 404, never cached, before the adapter.
|
|
111
|
+
if (!(await isDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
|
|
102
112
|
const adapter = resolveAdapter();
|
|
103
113
|
const baseUrl = resolveBaseUrl();
|
|
104
114
|
const today = new Date().toISOString().slice(0, 10);
|