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.
Files changed (46) hide show
  1. package/README.md +0 -5
  2. package/dist/add-route.js +1 -0
  3. package/dist/cli.js +15 -1
  4. package/dist/lib/env-catalog.js +1 -1
  5. package/dist/lib/import-mdx.js +18 -9
  6. package/dist/lib/scaffold-render-sweep.d.ts +1 -1
  7. package/dist/lib/scaffold-render-sweep.js +1 -1
  8. package/dist/module-architecture.js +6 -1
  9. package/dist/provision-env.d.ts +28 -0
  10. package/dist/provision-env.js +210 -0
  11. package/dist/surfaces.js +3 -0
  12. package/dist/topology.js +11 -0
  13. package/package.json +3 -3
  14. package/templates/.env.example +7 -0
  15. package/templates/apps/api/CLAUDE.md +7 -3
  16. package/templates/apps/mcp/.env.example +20 -0
  17. package/templates/apps/mcp/CLAUDE.md +42 -8
  18. package/templates/apps/web/CLAUDE.md +24 -1
  19. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
  20. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
  21. package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
  22. package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
  23. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
  24. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
  25. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
  26. package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
  27. package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
  28. package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
  29. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  30. package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
  31. package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
  32. package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
  33. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
  34. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
  35. package/templates/apps/web/app/llms.txt/route.ts +10 -0
  36. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
  37. package/templates/apps/web/app/openapi.json/route.ts +4 -0
  38. package/templates/apps/web/app/robots.txt/route.ts +18 -1
  39. package/templates/apps/web/app/sitemap.xml/route.ts +10 -0
  40. package/templates/apps/web/auth.ts +184 -7
  41. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
  42. package/templates/apps/web/lib/auth-routes.ts +5 -3
  43. package/templates/apps/web/lib/mcp-oauth.ts +702 -0
  44. package/templates/apps/web/messages/en.json +42 -13
  45. package/templates/database/seed.ts +6 -0
  46. 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
- export function GET(): Response {
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);