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,56 @@
1
+ /**
2
+ * @jest-environment node
3
+ *
4
+ * `decideConsent` — the consent page's Allow and Deny (#10357 D6). `auth` is
5
+ * mocked; `POST /mcp/consent` runs for real in
6
+ * `__tests__/auth/mcp-oauth-flow.test.ts`. This suite proves the fallback
7
+ * redirect is always a path on this site.
8
+ */
9
+ const mockConsent = jest.fn();
10
+ const mockRedirect = jest.fn();
11
+
12
+ // eslint-disable-next-line no-restricted-syntax -- #507: auth.ts loads ESM-only better-auth; its consent endpoint runs for real in mcp-oauth-flow.test.ts.
13
+ jest.mock("../../../../../auth", () => ({
14
+ auth: { api: { mcpConsent: (...args: unknown[]) => mockConsent(...args) } },
15
+ }));
16
+
17
+ jest.mock("next/headers", () => ({ headers: jest.fn().mockResolvedValue(new Headers()) }));
18
+
19
+ jest.mock("next/navigation", () => ({ redirect: (...args: unknown[]) => mockRedirect(...args) }));
20
+
21
+ import { decideConsent } from "../consent.actions";
22
+
23
+ function form(values: Record<string, string>): FormData {
24
+ const data = new FormData();
25
+ for (const [key, value] of Object.entries(values)) data.append(key, value);
26
+ return data;
27
+ }
28
+
29
+ beforeEach(() => {
30
+ mockConsent.mockReset();
31
+ mockRedirect.mockReset();
32
+ });
33
+
34
+ it("goes to the client's callback when the consent endpoint answers one", async () => {
35
+ mockConsent.mockResolvedValue({ redirectURI: "http://127.0.0.1:53682/callback?code=c" });
36
+ await decideConsent(form({ consent_code: "abc", locale: "en", decision: "deny" }));
37
+ expect(mockRedirect).toHaveBeenCalledWith("http://127.0.0.1:53682/callback?code=c");
38
+ });
39
+
40
+ it("returns to this site's consent page on a refusal", async () => {
41
+ mockConsent.mockRejectedValue(new Error("refused"));
42
+ await decideConsent(form({ consent_code: "abc", locale: "en", decision: "allow" }));
43
+ expect(mockRedirect).toHaveBeenCalledWith(expect.stringMatching(/^\/(en\/)?consent\?consent_code=abc$/));
44
+ });
45
+
46
+ it.each(["/evil.example", "//evil.example", "en/../..", "https://evil.example"])(
47
+ "never builds the fallback from a posted locale of %s",
48
+ async (locale) => {
49
+ mockConsent.mockRejectedValue(new Error("refused"));
50
+ await decideConsent(form({ consent_code: "abc", locale, decision: "allow" }));
51
+ const target = String(mockRedirect.mock.calls[0]?.[0]);
52
+ expect(target.startsWith("//")).toBe(false);
53
+ expect(target).not.toContain("evil.example");
54
+ expect(target).toMatch(/^\/(en\/)?consent\?consent_code=abc$/);
55
+ },
56
+ );
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The MCP consent page (#10357 D6, AC-6). `auth` is mocked — the endpoints it
3
+ * calls run for real in `__tests__/auth/mcp-oauth-flow.test.ts`; this suite
4
+ * proves what the page renders from their answer and what it never reads.
5
+ *
6
+ * `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
7
+ * the real en.json.
8
+ */
9
+ import { fireEvent, render, screen } from "@testing-library/react";
10
+
11
+ const mockGetSession = jest.fn();
12
+ const mockDetails = jest.fn();
13
+
14
+ // eslint-disable-next-line no-restricted-syntax -- #507: auth.ts loads ESM-only better-auth; its consent endpoints run for real in mcp-oauth-flow.test.ts.
15
+ jest.mock("../../../../../auth", () => ({
16
+ auth: {
17
+ api: {
18
+ getSession: (...args: unknown[]) => mockGetSession(...args),
19
+ mcpConsentDetails: (...args: unknown[]) => mockDetails(...args),
20
+ },
21
+ },
22
+ }));
23
+
24
+ // eslint-disable-next-line no-restricted-syntax -- #507: the server action imports auth.ts (ESM-only better-auth).
25
+ jest.mock("../consent.actions", () => ({ decideConsent: jest.fn() }));
26
+
27
+ jest.mock("next/headers", () => ({
28
+ headers: jest.fn().mockResolvedValue(new Headers({ cookie: "session=1" })),
29
+ }));
30
+
31
+ jest.mock("better-auth/react", () => ({
32
+ __esModule: true,
33
+ createAuthClient: () => ({ signOut: jest.fn() }),
34
+ }));
35
+
36
+ // The shared mock neither interpolates nor reads nested keys; this page shows
37
+ // the email, the client name and the scope labels, so this suite's mock does.
38
+ jest.mock("next-intl/server", () => {
39
+ const catalog = jest.requireActual("../../../../../messages/en.json") as Record<string, unknown>;
40
+ return {
41
+ getTranslations: async ({ namespace }: { namespace: string }) =>
42
+ (key: string, values: Record<string, string> = {}) => {
43
+ const found = `${namespace}.${key}`
44
+ .split(".")
45
+ .reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], catalog);
46
+ return String(found ?? `${namespace}.${key}`).replace(/\{(\w+)\}/g, (_, name: string) => values[name] ?? "");
47
+ },
48
+ };
49
+ });
50
+
51
+ jest.mock("next/navigation", () => ({
52
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
53
+ redirect: jest.fn(),
54
+ }));
55
+
56
+ import en from "../../../../../messages/en.json";
57
+ import ConsentPage, { generateMetadata } from "../page";
58
+
59
+ const params = Promise.resolve({ locale: "en" });
60
+ const MEMBER_SCOPES = ["ailk:people-read", "ailk:member-pieces-manage", "ailk:people-memory-propose", "ailk:inbox-act"];
61
+ const t = en.mcpConsent;
62
+
63
+ const twoWorkspaces = {
64
+ client: { name: "Some App", redirectOrigin: "http://127.0.0.1:53682" },
65
+ workspaces: [
66
+ { id: "wksp_a", name: "Alpha", organizationName: "Alpha Co", role: "member", offered: MEMBER_SCOPES },
67
+ { id: "wksp_b", name: "Beta", organizationName: "Beta Co", role: "member", offered: MEMBER_SCOPES },
68
+ ],
69
+ };
70
+
71
+ async function renderPage(query: Record<string, string> = { consent_code: "code-1" }) {
72
+ render(await ConsentPage({ params, searchParams: Promise.resolve(query) }));
73
+ }
74
+
75
+ beforeEach(() => {
76
+ mockGetSession.mockResolvedValue({ user: { email: "person@example.test" } });
77
+ mockDetails.mockResolvedValue(twoWorkspaces);
78
+ });
79
+
80
+ describe("the consent page (#10357 AC-6)", () => {
81
+ it("names the signed-in person, the client's own claim and where the code goes", async () => {
82
+ await renderPage();
83
+ expect(screen.getByTestId("mcp-consent-signed-in")).toHaveTextContent("person@example.test");
84
+ expect(screen.getByTestId("mcp-consent-client")).toHaveTextContent(
85
+ t.clientClaim.replace("{name}", "Some App"),
86
+ );
87
+ expect(screen.getByTestId("mcp-consent-redirect-origin")).toHaveTextContent("http://127.0.0.1:53682");
88
+ expect(screen.getByTestId("mcp-consent-sign-out")).toBeInTheDocument();
89
+ });
90
+
91
+ it("renders one radio per workspace, none checked when there are two, and Allow disabled", async () => {
92
+ await renderPage();
93
+ for (const id of ["wksp_a", "wksp_b"]) {
94
+ expect(screen.getByTestId(`mcp-consent-workspace-${id}`)).toHaveAttribute("aria-checked", "false");
95
+ }
96
+ expect(screen.getByTestId("mcp-consent-allow")).toBeDisabled();
97
+ expect(screen.queryByTestId("mcp-consent-scope-people-read")).toBeNull();
98
+ });
99
+
100
+ it("on choosing a workspace shows its offered scopes unticked, the member-reads line, and enables Allow", async () => {
101
+ await renderPage();
102
+ fireEvent.click(screen.getByTestId("mcp-consent-workspace-wksp_b"));
103
+ for (const scope of MEMBER_SCOPES) {
104
+ const permission = scope.slice("ailk:".length);
105
+ expect(screen.getByTestId(`mcp-consent-scope-${permission}`)).toHaveAttribute("aria-checked", "false");
106
+ }
107
+ expect(screen.queryByTestId("mcp-consent-scope-content-edit")).toBeNull();
108
+ expect(screen.getByText(t.scopes["people-read"])).toBeInTheDocument();
109
+ expect(screen.getByTestId("mcp-consent-member-reads")).toHaveTextContent(t.memberReads);
110
+ expect(screen.getByTestId("mcp-consent-allow")).not.toBeDisabled();
111
+ });
112
+
113
+ it("pre-selects the only workspace", async () => {
114
+ mockDetails.mockResolvedValue({ ...twoWorkspaces, workspaces: [twoWorkspaces.workspaces[0]] });
115
+ await renderPage();
116
+ expect(screen.getByTestId("mcp-consent-workspace-wksp_a")).toHaveAttribute("aria-checked", "true");
117
+ expect(screen.getByTestId("mcp-consent-allow")).not.toBeDisabled();
118
+ });
119
+
120
+ it("reads only consent_code from its query — never a client-controlled scope or client_id", async () => {
121
+ await renderPage({ consent_code: "code-1", scope: "ailk:content-edit", client_id: "evil" });
122
+ expect(mockDetails).toHaveBeenCalledWith(
123
+ expect.objectContaining({ query: { consent_code: "code-1" } }),
124
+ );
125
+ fireEvent.click(screen.getByTestId("mcp-consent-workspace-wksp_a"));
126
+ expect(screen.queryByTestId("mcp-consent-scope-content-edit")).toBeNull();
127
+ expect(document.body.textContent).not.toContain(t.scopes["content-edit"]);
128
+ expect(document.body.textContent).not.toContain("evil");
129
+ });
130
+
131
+ it("has Allow and Deny as 44 px buttons in one form", async () => {
132
+ await renderPage();
133
+ expect(screen.getByTestId("mcp-consent-form")).toContainElement(screen.getByTestId("mcp-consent-deny"));
134
+ expect(screen.getByTestId("mcp-consent-deny").className).toContain("control-height-big");
135
+ });
136
+
137
+ it("renders the unavailable line and no form when details are refused", async () => {
138
+ mockDetails.mockRejectedValue(new Error("invalid_request"));
139
+ await renderPage();
140
+ expect(screen.getByTestId("mcp-consent-unavailable")).toHaveTextContent(t.unavailable);
141
+ expect(screen.queryByTestId("mcp-consent-form")).toBeNull();
142
+ });
143
+
144
+ it("renders the unavailable line with no session, and without a consent_code", async () => {
145
+ mockGetSession.mockResolvedValue(null);
146
+ await renderPage();
147
+ expect(screen.getByTestId("mcp-consent-unavailable")).toBeInTheDocument();
148
+ mockGetSession.mockResolvedValue({ user: { email: "person@example.test" } });
149
+ });
150
+
151
+ it("offers Deny only when the person has no workspace", async () => {
152
+ mockDetails.mockResolvedValue({ ...twoWorkspaces, workspaces: [] });
153
+ await renderPage();
154
+ expect(screen.getByTestId("mcp-consent-no-workspace")).toHaveTextContent(t.noWorkspace);
155
+ expect(screen.queryByTestId("mcp-consent-allow")).toBeNull();
156
+ expect(screen.getByTestId("mcp-consent-deny")).toBeInTheDocument();
157
+ });
158
+
159
+ it("is noindex and sends no cross-site referrer", async () => {
160
+ const metadata = await generateMetadata({ params, searchParams: Promise.resolve({}) });
161
+ expect(metadata.robots).toEqual({ index: false, follow: false });
162
+ expect(metadata.referrer).toBe("same-origin");
163
+ });
164
+ });
@@ -0,0 +1,132 @@
1
+ "use client"; // the workspace choice decides which scopes show and whether Allow is enabled
2
+
3
+ /**
4
+ * The consent page's client island (#10357 D6): the workspace radio list, the
5
+ * chosen workspace's offered scopes as checkboxes — none ticked — and Allow,
6
+ * disabled until a workspace is chosen. Deliberate selection is the defence
7
+ * against a phished authorization (SEC-10357-11, -13). Strings arrive
8
+ * translated from the server page.
9
+ */
10
+ import {
11
+ Button,
12
+ Checkbox,
13
+ Input,
14
+ Label,
15
+ RadioGroup,
16
+ RadioGroupItem,
17
+ Text,
18
+ } from "@working-theory/ui/primitives";
19
+ import type { ReactElement } from "react";
20
+ import { useState } from "react";
21
+
22
+ import { decideConsent } from "./consent.actions";
23
+
24
+ export interface ConsentWorkspace {
25
+ id: string;
26
+ name: string;
27
+ organizationName: string;
28
+ /** Each offered scope with its label. */
29
+ offered: Array<{ scope: string; permission: string; label: string }>;
30
+ }
31
+
32
+ export interface ConsentFormText {
33
+ workspaceLegend: string;
34
+ scopesLegend: string;
35
+ memberReads: string;
36
+ noScopes: string;
37
+ allow: string;
38
+ deny: string;
39
+ }
40
+
41
+ export interface ConsentFormProps {
42
+ consentCode: string;
43
+ locale: string;
44
+ workspaces: ConsentWorkspace[];
45
+ text: ConsentFormText;
46
+ }
47
+
48
+ export function ConsentForm({ consentCode, locale, workspaces, text }: ConsentFormProps): ReactElement {
49
+ const [workspaceId, setWorkspaceId] = useState<string>(
50
+ workspaces.length === 1 ? (workspaces[0]?.id ?? "") : "",
51
+ );
52
+ const chosen = workspaces.find((w) => w.id === workspaceId);
53
+
54
+ return (
55
+ <form action={decideConsent} className="flex flex-col gap-6" data-testid="mcp-consent-form">
56
+ <Input type="hidden" name="consent_code" value={consentCode} />
57
+ <Input type="hidden" name="locale" value={locale} />
58
+ {workspaces.length > 0 ? (
59
+ <div className="flex flex-col gap-3">
60
+ <Text as="p" variant="body-sm" className="font-semibold">
61
+ {text.workspaceLegend}
62
+ </Text>
63
+ <RadioGroup
64
+ name="workspace_id"
65
+ value={workspaceId}
66
+ onValueChange={setWorkspaceId}
67
+ aria-label={text.workspaceLegend}
68
+ >
69
+ {workspaces.map((w) => (
70
+ <div key={w.id} className="flex min-h-11 items-center gap-3">
71
+ <RadioGroupItem
72
+ id={`mcp-consent-ws-${w.id}`}
73
+ value={w.id}
74
+ data-testid={`mcp-consent-workspace-${w.id}`}
75
+ />
76
+ <Label htmlFor={`mcp-consent-ws-${w.id}`}>
77
+ {/* i18n-allow: a separator between two data values */}
78
+ {w.name} · {w.organizationName}
79
+ </Label>
80
+ </div>
81
+ ))}
82
+ </RadioGroup>
83
+ </div>
84
+ ) : null}
85
+ {chosen ? (
86
+ <div className="flex flex-col gap-3">
87
+ <Text as="p" variant="body-sm" className="font-semibold">
88
+ {text.scopesLegend}
89
+ </Text>
90
+ {chosen.offered.length === 0 ? (
91
+ <Text as="p" variant="body-sm" tone="muted">
92
+ {text.noScopes}
93
+ </Text>
94
+ ) : (
95
+ chosen.offered.map((o) => (
96
+ <div key={o.scope} className="flex min-h-11 items-center gap-3">
97
+ <Checkbox
98
+ id={`mcp-consent-scope-${o.permission}`}
99
+ name="scopes"
100
+ value={o.scope}
101
+ defaultChecked={false}
102
+ data-testid={`mcp-consent-scope-${o.permission}`}
103
+ />
104
+ <Label htmlFor={`mcp-consent-scope-${o.permission}`}>{o.label}</Label>
105
+ </div>
106
+ ))
107
+ )}
108
+ <Text as="p" variant="body-sm" tone="muted" data-testid="mcp-consent-member-reads">
109
+ {text.memberReads}
110
+ </Text>
111
+ </div>
112
+ ) : null}
113
+ <div className="flex flex-wrap gap-3">
114
+ {workspaces.length > 0 ? (
115
+ <Button
116
+ type="submit"
117
+ name="decision"
118
+ value="allow"
119
+ size="big"
120
+ disabled={!chosen}
121
+ data-testid="mcp-consent-allow"
122
+ >
123
+ {text.allow}
124
+ </Button>
125
+ ) : null}
126
+ <Button type="submit" name="decision" value="deny" variant="secondary" size="big" data-testid="mcp-consent-deny">
127
+ {text.deny}
128
+ </Button>
129
+ </div>
130
+ </form>
131
+ );
132
+ }
@@ -0,0 +1,51 @@
1
+ "use server";
2
+
3
+ /**
4
+ * `decideConsent` — the consent page's Allow and Deny (#10357 D6).
5
+ *
6
+ * Hands the person's choice to AILK's `POST /mcp/consent` in-process
7
+ * (`auth.api.mcpConsent`), which checks the session, the signed consent
8
+ * cookie and the role bound on every scope, and answers the client's own
9
+ * callback URL. Next's Server Action origin check plus that cookie match make
10
+ * a cross-site submit fail. A refusal returns to the page, which then renders
11
+ * what is still true.
12
+ */
13
+ import { localeHref } from "@working-theory/ui";
14
+ import { headers } from "next/headers";
15
+ import { redirect } from "next/navigation";
16
+
17
+ import { auth } from "../../../../auth";
18
+
19
+ const LOCALE_PATTERN = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
20
+
21
+ export async function decideConsent(formData: FormData): Promise<void> {
22
+ const field = (name: string): string => {
23
+ const value = formData.get(name);
24
+ return typeof value === "string" ? value : "";
25
+ };
26
+ const consentCode = field("consent_code");
27
+ // The fallback redirect is built from it, so only a locale-shaped value
28
+ // counts: `/evil.example` would make `//evil.example/…`.
29
+ const locale = LOCALE_PATTERN.test(field("locale")) ? field("locale") : "en";
30
+ const accept = formData.get("decision") === "allow";
31
+ const workspaceId = formData.get("workspace_id");
32
+ const scopes = formData.getAll("scopes").filter((s): s is string => typeof s === "string");
33
+
34
+ let redirectURI: string | null;
35
+ try {
36
+ const answer = await auth.api.mcpConsent({
37
+ headers: await headers(),
38
+ body: {
39
+ consent_code: consentCode,
40
+ accept,
41
+ ...(accept && typeof workspaceId === "string" ? { workspace_id: workspaceId, scopes } : {}),
42
+ },
43
+ });
44
+ redirectURI = answer.redirectURI;
45
+ } catch {
46
+ redirectURI = null;
47
+ }
48
+ redirect(
49
+ redirectURI ?? localeHref(locale, `/consent?consent_code=${encodeURIComponent(consentCode)}`),
50
+ );
51
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `/[locale]/consent` — the MCP consent step (#10357 D6).
3
+ *
4
+ * The `mcp` plugin sends every authorization here (AILK's before-hook forces
5
+ * `prompt=consent`, D2) with a `consent_code` and a signed cookie. The page
6
+ * reads ONLY `consent_code` from its query — never `client_id` or `scope`,
7
+ * which the client controls — and asks AILK's `GET /mcp/consent-details`
8
+ * in-process. That endpoint checks the session, the cookie and that the code
9
+ * was issued to this person, and answers the client's self-claimed name,
10
+ * where the code will go, and each workspace with the scopes the person's
11
+ * role there may approve. Any refusal, or no session, renders the
12
+ * `unavailable` line and no form.
13
+ *
14
+ * It names who is signed in (with a sign-out back to this page), names the
15
+ * client as the client names itself — an unchecked claim — and its redirect
16
+ * origin, then hands the workspaces to the `ConsentForm` island. `noindex`,
17
+ * `referrer: same-origin` (the `account/connect` precedent). Framed by
18
+ * `AuthShell` like sign-in: `"consent"` is an `AUTH_SEGMENTS` entry.
19
+ */
20
+ import { localeHref } from "@working-theory/ui";
21
+ import { Button, Heading, Text } from "@working-theory/ui/primitives";
22
+ import { SCOPE_GRANT_MAP, type McpScope } from "@working-theory/validation";
23
+ import type { Metadata } from "next";
24
+ import { headers } from "next/headers";
25
+ import { getTranslations } from "next-intl/server";
26
+ import type { ReactElement } from "react";
27
+
28
+ import { auth } from "../../../../auth";
29
+ import { SignOutTrigger } from "../../../../components/sign-out-trigger";
30
+
31
+ import { ConsentForm, type ConsentWorkspace } from "./consent-form";
32
+
33
+ interface Props {
34
+ params: Promise<{ locale: string }>;
35
+ searchParams: Promise<{ consent_code?: string | string[] }>;
36
+ }
37
+
38
+ interface ConsentDetails {
39
+ client: { name: string; redirectOrigin: string };
40
+ workspaces: Array<{ id: string; name: string; organizationName: string; offered: McpScope[] }>;
41
+ }
42
+
43
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
44
+ const { locale } = await params;
45
+ const t = await getTranslations({ locale, namespace: "mcpConsent" });
46
+ return {
47
+ title: t("metaTitle"),
48
+ robots: { index: false, follow: false },
49
+ referrer: "same-origin",
50
+ };
51
+ }
52
+
53
+ /** The details and the signed-in email, or `null` on any refusal. */
54
+ async function readDetails(consentCode: string): Promise<{ details: ConsentDetails; email: string } | null> {
55
+ try {
56
+ const requestHeaders = await headers();
57
+ const session = await auth.api.getSession({ headers: requestHeaders });
58
+ if (!session) return null;
59
+ const details = (await auth.api.mcpConsentDetails({
60
+ headers: requestHeaders,
61
+ query: { consent_code: consentCode },
62
+ })) as ConsentDetails;
63
+ return { details, email: session.user.email };
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export default async function ConsentPage({ params, searchParams }: Props): Promise<ReactElement> {
70
+ const { locale } = await params;
71
+ const { consent_code: raw } = await searchParams;
72
+ const t = await getTranslations({ locale, namespace: "mcpConsent" });
73
+ const consentCode = typeof raw === "string" ? raw : "";
74
+ const read = consentCode ? await readDetails(consentCode) : null;
75
+
76
+ if (!read) {
77
+ return (
78
+ <div className="flex flex-col gap-4" data-testid="mcp-consent-unavailable">
79
+ <Heading level={1} visual={3}>
80
+ {t("heading")}
81
+ </Heading>
82
+ <Text as="p" variant="body-sm" tone="muted">
83
+ {t("unavailable")}
84
+ </Text>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ const { details, email } = read;
90
+ const here = localeHref(locale, `/consent?consent_code=${encodeURIComponent(consentCode)}`);
91
+ const workspaces: ConsentWorkspace[] = details.workspaces.map((w) => ({
92
+ id: w.id,
93
+ name: w.name,
94
+ organizationName: w.organizationName,
95
+ offered: w.offered.map((scope) => {
96
+ const permission = SCOPE_GRANT_MAP[scope];
97
+ return { scope, permission, label: t(`scopes.${permission}`) };
98
+ }),
99
+ }));
100
+
101
+ return (
102
+ <div className="flex flex-col gap-6" data-testid="mcp-consent">
103
+ <div className="flex flex-col gap-2">
104
+ <Heading level={1} visual={3}>
105
+ {t("heading")}
106
+ </Heading>
107
+ <Text as="p" variant="body-sm" data-testid="mcp-consent-signed-in">
108
+ {t("signedInAs", { email })}
109
+ </Text>
110
+ <SignOutTrigger homeHref={here}>
111
+ <Button type="button" variant="link" data-testid="mcp-consent-sign-out">
112
+ {t("signOut")}
113
+ </Button>
114
+ </SignOutTrigger>
115
+ </div>
116
+ <div className="flex flex-col gap-2">
117
+ <Text as="p" variant="body-sm" data-testid="mcp-consent-client">
118
+ {t("clientClaim", { name: details.client.name })}
119
+ </Text>
120
+ <Text as="p" variant="body-sm" tone="muted" data-testid="mcp-consent-redirect-origin">
121
+ {t("returnsTo", { origin: details.client.redirectOrigin })}
122
+ </Text>
123
+ </div>
124
+ {workspaces.length === 0 ? (
125
+ <Text as="p" variant="body-sm" tone="muted" data-testid="mcp-consent-no-workspace">
126
+ {t("noWorkspace")}
127
+ </Text>
128
+ ) : null}
129
+ <ConsentForm
130
+ consentCode={consentCode}
131
+ locale={locale}
132
+ workspaces={workspaces}
133
+ text={{
134
+ workspaceLegend: t("workspaceLegend"),
135
+ scopesLegend: t("scopesLegend"),
136
+ memberReads: t("memberReads"),
137
+ noScopes: t("noScopes"),
138
+ allow: t("allow"),
139
+ deny: t("deny"),
140
+ }}
141
+ />
142
+ </div>
143
+ );
144
+ }
@@ -329,7 +329,7 @@ describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug>
329
329
 
330
330
  it("sitemap.xml: one <loc> ending in /about/sam-henry, none ending in a bare /sam-henry", async () => {
331
331
  const stubAdapter = makeStubAdapter();
332
- const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [] });
332
+ const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [], discoverable: async () => true });
333
333
  const response = await GET();
334
334
  const text = await response.text();
335
335
  const locs = Array.from(text.matchAll(/<loc>([^<]*)<\/loc>/g)).map(
@@ -341,7 +341,7 @@ describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug>
341
341
 
342
342
  it("llms.txt: names a URL ending in /about/sam-henry", async () => {
343
343
  const stubAdapter = makeStubAdapter();
344
- const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [] });
344
+ const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [], discoverable: async () => true });
345
345
  const response = await GET();
346
346
  const text = await response.text();
347
347
  expect(text).toContain("URL: https://site.example/about/sam-henry");
@@ -30,7 +30,10 @@ export const CONTINUATION_REF_MAX = 4096;
30
30
  */
31
31
  export const CONTINUATION_TTL_SECONDS = 600;
32
32
 
33
- /** A fixed name: a value sealed for a cookie never opens here, nor the reverse. */
33
+ /**
34
+ * A fixed name: a value sealed for a cookie never opens here, nor the reverse.
35
+ * The MCP sign-in leg seals under its own name (`../mcp/continuation.ts`).
36
+ */
34
37
  const AAD = "hub-continue";
35
38
 
36
39
  const BASE64URL = /^[A-Za-z0-9_-]+$/;
@@ -40,12 +43,13 @@ export function mintContinuation(
40
43
  search: string,
41
44
  secret: string,
42
45
  now: number = Date.now(),
46
+ name: string = AAD,
43
47
  ): string {
44
48
  return seal(
45
49
  { v: 1, q: search, exp: now + CONTINUATION_TTL_SECONDS * 1000 },
46
50
  secret,
47
51
  "continue",
48
- AAD,
52
+ name,
49
53
  );
50
54
  }
51
55
 
@@ -62,9 +66,10 @@ export function openContinuation(
62
66
  ref: string,
63
67
  secret: string,
64
68
  now: number = Date.now(),
69
+ name: string = AAD,
65
70
  ): string | null {
66
71
  if (ref.length > CONTINUATION_REF_MAX || !BASE64URL.test(ref)) return null;
67
- const value = unseal(ref, secret, "continue", AAD);
72
+ const value = unseal(ref, secret, "continue", name);
68
73
  if (typeof value !== "object" || value === null) return null;
69
74
  const { v, q, exp } = value as Record<string, unknown>;
70
75
  if (v !== 1 || typeof q !== "string" || q === "") return null;