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