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,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
|
-
/**
|
|
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
|
-
|
|
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",
|
|
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;
|