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.
- package/dist/add-route.js +1 -0
- package/dist/cli.js +15 -1
- package/dist/lib/env-catalog.js +1 -1
- 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 +4 -0
- package/package.json +3 -3
- package/templates/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- 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
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* `listDocsPages`).
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import { siteDiscoverable } from "@working-theory/api-client";
|
|
30
31
|
import {
|
|
31
32
|
SCHEMA_ORG_TYPES,
|
|
32
33
|
resolvedAdapterKind,
|
|
@@ -44,6 +45,10 @@ import { PUBLIC_PAGES } from "../../lib/modules.generated";
|
|
|
44
45
|
import { SITE_NAME } from "../../lib/site-brand";
|
|
45
46
|
import { pagePathFor, resolveBaseUrl } from "../../src/lib/routes";
|
|
46
47
|
|
|
48
|
+
// #10510 D4 — read per request: the owner's discovery switch is a runtime
|
|
49
|
+
// setting a prerendered file could never follow.
|
|
50
|
+
export const dynamic = "force-dynamic";
|
|
51
|
+
|
|
47
52
|
export interface LlmsHandlerDeps {
|
|
48
53
|
/**
|
|
49
54
|
* Content adapter factory. Defaults to the real implementation.
|
|
@@ -58,6 +63,8 @@ export interface LlmsHandlerDeps {
|
|
|
58
63
|
getBlogPages?: () => Array<{ url: string; data: { title: string; description?: string } }>;
|
|
59
64
|
/** The docs pages (#10041). Defaults to `listDocsPages` over this app's docs directory. */
|
|
60
65
|
getDocsPages?: () => DocsIndexPage[];
|
|
66
|
+
/** The owner's discovery switch (#10510 D4). Defaults to `siteDiscoverable`. */
|
|
67
|
+
discoverable?: () => Promise<boolean>;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
/**
|
|
@@ -68,10 +75,13 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
|
|
|
68
75
|
const resolveAdapter = deps.getContentAdapter ?? defaultGetContentAdapter;
|
|
69
76
|
const resolveBlogPages = deps.getBlogPages ?? (() => []);
|
|
70
77
|
const resolveDocsPages = deps.getDocsPages ?? listDocsPages;
|
|
78
|
+
const isDiscoverable = deps.discoverable ?? siteDiscoverable;
|
|
71
79
|
|
|
72
80
|
return async function llmsGET(): Promise<Response> {
|
|
73
81
|
// #10040 decision 7 — no public page, no content index: 404 before the adapter.
|
|
74
82
|
if (!PUBLIC_PAGES) return new Response(null, { status: 404 });
|
|
83
|
+
// #10510 D4 — hidden by the owner: 404, never cached, before the adapter.
|
|
84
|
+
if (!(await isDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
|
|
75
85
|
const adapter = resolveAdapter();
|
|
76
86
|
const baseUrl = resolveBaseUrl();
|
|
77
87
|
const lines: string[] = [`# ${SITE_NAME} — content index`, ""];
|
|
@@ -15,6 +15,21 @@
|
|
|
15
15
|
|
|
16
16
|
import { GET } from "../route";
|
|
17
17
|
|
|
18
|
+
// #10510 D4 — the owner's discovery switch, mocked so no case reads a hub. On
|
|
19
|
+
// (`true`) by default: the state every case above the #10510 describe assumes.
|
|
20
|
+
const mockSiteDiscoverable = jest.fn<Promise<boolean>, []>();
|
|
21
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
22
|
+
...jest.requireActual<typeof import("@working-theory/api-client")>(
|
|
23
|
+
"@working-theory/api-client",
|
|
24
|
+
),
|
|
25
|
+
siteDiscoverable: () => mockSiteDiscoverable(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
mockSiteDiscoverable.mockReset();
|
|
30
|
+
mockSiteDiscoverable.mockResolvedValue(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
18
33
|
// The REAL committed seam, for the #8060 hub-only guards: the mock below
|
|
19
34
|
// shadows `TOPOLOGY` per case, but whether a hub-only case runs depends on the
|
|
20
35
|
// render this scaffold carries, not on the topology a case picks.
|
|
@@ -295,3 +310,28 @@ describe("GET /openapi.json on a spoke (#7426 D12)", () => {
|
|
|
295
310
|
expect(JSON.stringify(body)).toContain("hub.example.com");
|
|
296
311
|
});
|
|
297
312
|
});
|
|
313
|
+
|
|
314
|
+
// #10510 D4, AC-1–AC-2 — the owner's discovery switch: read once per request,
|
|
315
|
+
// before the API is fetched.
|
|
316
|
+
describe("GET /openapi.json and the owner's discovery switch (#10510)", () => {
|
|
317
|
+
it("on: today's cache-control, the switch read once per request", async () => {
|
|
318
|
+
apiAnswers(200, JSON.stringify(SPEC));
|
|
319
|
+
const response = await GET();
|
|
320
|
+
expect(response.status).toBe(200);
|
|
321
|
+
expect(response.headers.get("cache-control")).toBe("public, max-age=3600");
|
|
322
|
+
expect(mockSiteDiscoverable).toHaveBeenCalledTimes(1);
|
|
323
|
+
apiAnswers(200, JSON.stringify(SPEC));
|
|
324
|
+
await GET();
|
|
325
|
+
expect(mockSiteDiscoverable).toHaveBeenCalledTimes(2);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("off: 404 with no body, never cached, and the API never fetched", async () => {
|
|
329
|
+
mockSiteDiscoverable.mockResolvedValue(false);
|
|
330
|
+
apiAnswers(200, JSON.stringify(SPEC));
|
|
331
|
+
const response = await GET();
|
|
332
|
+
expect(response.status).toBe(404);
|
|
333
|
+
expect(await response.text()).toBe("");
|
|
334
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
335
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
* host, and never calls a path this site would answer 404.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { siteDiscoverable } from "@working-theory/api-client";
|
|
27
|
+
|
|
26
28
|
import { browserApiOrigin } from "../../lib/api-origin.generated";
|
|
27
29
|
import { RELAY_ROUTES } from "../../lib/relay";
|
|
28
30
|
import { TOPOLOGY } from "../../lib/topology.generated";
|
|
@@ -157,6 +159,8 @@ function badGateway(message: string): Response {
|
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
export async function GET(): Promise<Response> {
|
|
162
|
+
// #10510 D4 — hidden by the owner: 404, never cached, the API never read.
|
|
163
|
+
if (!(await siteDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
|
|
160
164
|
let upstream: Response;
|
|
161
165
|
try {
|
|
162
166
|
upstream = await fetch(`${apiBase()}${UPSTREAM_PATH}`, {
|
|
@@ -13,12 +13,17 @@
|
|
|
13
13
|
* parse (#8511), so 1, yes and on count too, and an unknown value throws.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { siteDiscoverable } from "@working-theory/api-client";
|
|
16
17
|
import { parseStealth } from "@working-theory/web-i18n/stealth";
|
|
17
18
|
|
|
18
19
|
import { PUBLIC_PAGES } from "../../lib/modules.generated";
|
|
19
20
|
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
// #10510 D4 — read per request: the owner's discovery switch is a runtime
|
|
23
|
+
// setting a prerendered file could never follow.
|
|
24
|
+
export const dynamic = "force-dynamic";
|
|
25
|
+
|
|
26
|
+
export async function GET(): Promise<Response> {
|
|
22
27
|
const baseUrl = resolveBaseUrl();
|
|
23
28
|
const disallowEnv = process.env["AILK_ROBOTS_DISALLOW"] ?? "";
|
|
24
29
|
// #10040 decision 7 — a site with neither marketing nor docs-blog (a hub)
|
|
@@ -26,6 +31,18 @@ export function GET(): Response {
|
|
|
26
31
|
// STEALTH, read from the module list, never from a setting.
|
|
27
32
|
const stealth = parseStealth(process.env["STEALTH"]) || !PUBLIC_PAGES;
|
|
28
33
|
|
|
34
|
+
// #10510 D4 — the owner's switch, read only when neither already hides the
|
|
35
|
+
// site: off is STEALTH's body, never cached, so a crawler asks again.
|
|
36
|
+
if (!stealth && !(await siteDiscoverable())) {
|
|
37
|
+
return new Response("User-agent: *\nDisallow: /", {
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: {
|
|
40
|
+
"content-type": "text/plain; charset=utf-8",
|
|
41
|
+
"cache-control": "no-store",
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
const disallowPaths = stealth
|
|
30
47
|
? ["/"]
|
|
31
48
|
: disallowEnv
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
* directory is listed instead (`getDocsPages`, default `listDocsPages`).
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
+
import { siteDiscoverable } from "@working-theory/api-client";
|
|
40
41
|
import {
|
|
41
42
|
SCHEMA_ORG_TYPES,
|
|
42
43
|
resolvedAdapterKind,
|
|
@@ -53,6 +54,10 @@ import {
|
|
|
53
54
|
import { PUBLIC_PAGES } from "../../lib/modules.generated";
|
|
54
55
|
import { pagePathFor, resolveBaseUrl } from "../../src/lib/routes";
|
|
55
56
|
|
|
57
|
+
// #10510 D4 — read per request: the owner's discovery switch is a runtime
|
|
58
|
+
// setting a prerendered file could never follow.
|
|
59
|
+
export const dynamic = "force-dynamic";
|
|
60
|
+
|
|
56
61
|
export interface SitemapHandlerDeps {
|
|
57
62
|
/**
|
|
58
63
|
* Content adapter factory. Defaults to the real implementation.
|
|
@@ -61,6 +66,8 @@ export interface SitemapHandlerDeps {
|
|
|
61
66
|
getContentAdapter?: () => ContentAdapter;
|
|
62
67
|
/** The docs pages (#10041). Defaults to `listDocsPages` over this app's docs directory. */
|
|
63
68
|
getDocsPages?: () => DocsIndexPage[];
|
|
69
|
+
/** The owner's discovery switch (#10510 D4). Defaults to `siteDiscoverable`. */
|
|
70
|
+
discoverable?: () => Promise<boolean>;
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
function escapeXml(value: string): string {
|
|
@@ -96,9 +103,12 @@ function buildLocaleUrl(
|
|
|
96
103
|
export function createSitemapHandler(deps: SitemapHandlerDeps = {}) {
|
|
97
104
|
const resolveAdapter = deps.getContentAdapter ?? defaultGetContentAdapter;
|
|
98
105
|
const resolveDocsPages = deps.getDocsPages ?? listDocsPages;
|
|
106
|
+
const isDiscoverable = deps.discoverable ?? siteDiscoverable;
|
|
99
107
|
return async function sitemapGET(): Promise<Response> {
|
|
100
108
|
// #10040 decision 7 — no public page, no sitemap: 404 before the adapter.
|
|
101
109
|
if (!PUBLIC_PAGES) return new Response(null, { status: 404 });
|
|
110
|
+
// #10510 D4 — hidden by the owner: 404, never cached, before the adapter.
|
|
111
|
+
if (!(await isDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
|
|
102
112
|
const adapter = resolveAdapter();
|
|
103
113
|
const baseUrl = resolveBaseUrl();
|
|
104
114
|
const today = new Date().toISOString().slice(0, 10);
|
|
@@ -26,11 +26,14 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import type { PrismaClient } from "@working-theory/database";
|
|
29
|
-
import {
|
|
29
|
+
import { MCP_SCOPES } from "@working-theory/validation";
|
|
30
|
+
import { type BetterAuthPlugin, betterAuth } from "better-auth";
|
|
30
31
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
32
|
+
import type { AuthMiddleware } from "better-auth/api";
|
|
31
33
|
import { nextCookies } from "better-auth/next-js";
|
|
32
|
-
import { magicLink } from "better-auth/plugins";
|
|
34
|
+
import { magicLink, mcp } from "better-auth/plugins";
|
|
33
35
|
|
|
36
|
+
import { sealMcpCallbacks } from "./app/api/auth/mcp/continuation";
|
|
34
37
|
import {
|
|
35
38
|
authEmailTransport,
|
|
36
39
|
makeMagicLinkOptions,
|
|
@@ -44,6 +47,7 @@ import {
|
|
|
44
47
|
resetSendConfigured,
|
|
45
48
|
} from "./lib/auth-env";
|
|
46
49
|
import { resolveSecret } from "./lib/auth-secret";
|
|
50
|
+
import { type McpMembership, type McpOAuthDeps, mcpOAuthPlugin } from "./lib/mcp-oauth";
|
|
47
51
|
import {
|
|
48
52
|
signUpAgreementHooks,
|
|
49
53
|
signUpAgreementsPlugin,
|
|
@@ -67,6 +71,105 @@ const RESET_TOKEN_TTL_SECONDS = 3600;
|
|
|
67
71
|
*/
|
|
68
72
|
const VERIFY_TOKEN_TTL_SECONDS = 86_400;
|
|
69
73
|
|
|
74
|
+
// mcp-oauth:begin
|
|
75
|
+
/**
|
|
76
|
+
* The site's MCP authorization server (#10357 D4): better-auth's `mcp` plugin
|
|
77
|
+
* — dynamic client registration, PKCE, opaque tokens with refresh — and
|
|
78
|
+
* AILK's plugin beside it (`lib/mcp-oauth.ts`: the workspace + scope consent
|
|
79
|
+
* step, rotation, revocation, the token self-check). Installed only when
|
|
80
|
+
* `baseURL` is pinned: the plugin's metadata has no issuer without one, and
|
|
81
|
+
* an issuer derived from the request's host is the § D13 chain again.
|
|
82
|
+
*
|
|
83
|
+
* Every value is set explicitly, even where it equals the library default,
|
|
84
|
+
* so the numbers are reviewable.
|
|
85
|
+
*/
|
|
86
|
+
/** An authorization code's lifetime: ten minutes to finish consent. */
|
|
87
|
+
const MCP_CODE_TTL_SECONDS = 600;
|
|
88
|
+
/** An access token's lifetime: one hour. */
|
|
89
|
+
const MCP_ACCESS_TOKEN_TTL_SECONDS = 3600;
|
|
90
|
+
/** A refresh token's lifetime: seven days, sliding by rotation. */
|
|
91
|
+
const MCP_REFRESH_TOKEN_TTL_SECONDS = 604_800;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The plugin's HTTP routes AILK turns off: its consent (binds no workspace,
|
|
95
|
+
* narrows no scope — D2), `/mcp/get-session` (hands any access-token holder
|
|
96
|
+
* the row, refresh token included), and its own discovery document, which
|
|
97
|
+
* the site serves once at its root (D7).
|
|
98
|
+
*/
|
|
99
|
+
export const MCP_DISABLED_PATHS = [
|
|
100
|
+
"/oauth2/consent",
|
|
101
|
+
"/mcp/get-session",
|
|
102
|
+
"/.well-known/oauth-authorization-server",
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Rate rules for the anonymous MCP endpoints — applied only beside the other
|
|
107
|
+
* custom rules, where the client IP resolves (see the rate-limit block).
|
|
108
|
+
*/
|
|
109
|
+
export const MCP_RATE_RULES = {
|
|
110
|
+
"/mcp/register": { window: 3600, max: 10 },
|
|
111
|
+
"/mcp/token": { window: 60, max: 30 },
|
|
112
|
+
"/mcp/revoke": { window: 60, max: 30 },
|
|
113
|
+
// apps/mcp validates every OAuth bearer here from one egress address, so
|
|
114
|
+
// its lookups get their own bucket, not the global 60/60 s (#10391 D7).
|
|
115
|
+
"/mcp/token-info": { window: 60, max: 600 },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A sign-in that resumes an MCP authorization carries
|
|
120
|
+
* `/api/auth/mcp/authorize?<query>` as its callback — the `next` the plugin's
|
|
121
|
+
* `loginPage` hands `/sign-in` — which the emailed link's verify refuses once
|
|
122
|
+
* decoded (#10479). Before any sign-in endpoint reads it, this seals it to
|
|
123
|
+
* `/api/auth/mcp/continue/<ref>` (`app/api/auth/mcp/continuation.ts`).
|
|
124
|
+
*/
|
|
125
|
+
let sealMcpSignIn: AuthMiddleware | undefined;
|
|
126
|
+
|
|
127
|
+
export const mcpSignInContinuation = {
|
|
128
|
+
id: "ailk-mcp-sign-in-continuation",
|
|
129
|
+
hooks: {
|
|
130
|
+
before: [
|
|
131
|
+
{
|
|
132
|
+
matcher: (ctx) => ctx.path?.startsWith("/sign-in/") ?? false,
|
|
133
|
+
// `better-auth/api` is ESM-only and CommonJS Jest cannot load it (#507),
|
|
134
|
+
// so it is imported when a hook first runs, never when this module loads.
|
|
135
|
+
handler: async (ctx) => {
|
|
136
|
+
const { createAuthMiddleware } = await import("better-auth/api");
|
|
137
|
+
sealMcpSignIn ??= createAuthMiddleware(async (inner) => {
|
|
138
|
+
const body = inner.body as Record<string, unknown> | undefined;
|
|
139
|
+
const sealed = body ? sealMcpCallbacks(body, resolveSecret()) : null;
|
|
140
|
+
return sealed ? { context: { body: { ...body, ...sealed } } } : undefined;
|
|
141
|
+
});
|
|
142
|
+
return sealMcpSignIn(ctx);
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
} satisfies BetterAuthPlugin;
|
|
148
|
+
|
|
149
|
+
export function mcpAuthorizationServer(deps: McpOAuthDeps) {
|
|
150
|
+
return [
|
|
151
|
+
mcp({
|
|
152
|
+
loginPage: "/api/auth/mcp/sign-in",
|
|
153
|
+
oidcConfig: {
|
|
154
|
+
loginPage: "/api/auth/mcp/sign-in",
|
|
155
|
+
consentPage: "/consent",
|
|
156
|
+
// OAuth 2.1: PKCE on every authorization, S256 only.
|
|
157
|
+
requirePKCE: true,
|
|
158
|
+
allowPlainCodeChallengeMethod: false,
|
|
159
|
+
codeExpiresIn: MCP_CODE_TTL_SECONDS,
|
|
160
|
+
accessTokenExpiresIn: MCP_ACCESS_TOKEN_TTL_SECONDS,
|
|
161
|
+
refreshTokenExpiresIn: MCP_REFRESH_TOKEN_TTL_SECONDS,
|
|
162
|
+
scopes: [...MCP_SCOPES],
|
|
163
|
+
defaultScope: ["offline_access", ...MCP_SCOPES].join(" "),
|
|
164
|
+
metadata: { scopes_supported: ["offline_access", ...MCP_SCOPES] },
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
mcpOAuthPlugin(deps),
|
|
168
|
+
mcpSignInContinuation,
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
// mcp-oauth:end
|
|
172
|
+
|
|
70
173
|
let prismaForAuthLoad: Promise<PrismaClient> | undefined;
|
|
71
174
|
|
|
72
175
|
/**
|
|
@@ -140,16 +243,20 @@ function deferredPrismaForAuth(): PrismaClient {
|
|
|
140
243
|
}
|
|
141
244
|
|
|
142
245
|
/**
|
|
143
|
-
* The
|
|
246
|
+
* The delegate methods `onPasswordReset` reaches for, named so the call
|
|
144
247
|
* site needs no cast. Method shorthand for the same reason `AuthEmailPrisma`
|
|
145
248
|
* uses it: the generated `PrismaClient` satisfies this structurally, and
|
|
146
|
-
* nothing wider than `verification.deleteMany
|
|
147
|
-
* `oAuthAuthorization.updateMany`
|
|
148
|
-
*
|
|
249
|
+
* nothing wider than `verification.deleteMany`,
|
|
250
|
+
* `oAuthAuthorization.updateMany`, `session.deleteMany` and
|
|
251
|
+
* `oauthAccessToken.deleteMany` is reachable through it. The second ends
|
|
252
|
+
* every credential a spoke holds for the person (#7894 D4, SEC-7894-5); the
|
|
253
|
+
* last two every session and MCP token (#10357 D9).
|
|
149
254
|
*/
|
|
150
255
|
interface VerificationStore {
|
|
151
256
|
verification: { deleteMany(args: unknown): Promise<unknown> };
|
|
152
257
|
oAuthAuthorization: { updateMany(args: unknown): Promise<unknown> };
|
|
258
|
+
session: { deleteMany(args: unknown): Promise<unknown> };
|
|
259
|
+
oauthAccessToken: { deleteMany(args: unknown): Promise<unknown> };
|
|
153
260
|
}
|
|
154
261
|
|
|
155
262
|
type SocialProviders = {
|
|
@@ -471,6 +578,40 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
471
578
|
})}`,
|
|
472
579
|
);
|
|
473
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* And every MCP client token (#10357 D9): an attacker who connected a
|
|
583
|
+
* client before the owner recovered the account would otherwise keep the
|
|
584
|
+
* workspace by sliding refresh. Guarded the same way.
|
|
585
|
+
*
|
|
586
|
+
* The sessions go first: Better Auth deletes them only after this
|
|
587
|
+
* callback returns, and until then a live session could approve and
|
|
588
|
+
* exchange a fresh code (SEC-10357). The library's own delete then finds
|
|
589
|
+
* none.
|
|
590
|
+
*/
|
|
591
|
+
try {
|
|
592
|
+
await verificationStore.session.deleteMany({ where: { userId: user.id } });
|
|
593
|
+
} catch {
|
|
594
|
+
console.warn(
|
|
595
|
+
`[auth-email] ${JSON.stringify({
|
|
596
|
+
userId: user.id,
|
|
597
|
+
kind: "password-reset",
|
|
598
|
+
reason: "session_revocation_failed",
|
|
599
|
+
})}`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
await verificationStore.oauthAccessToken.deleteMany({
|
|
604
|
+
where: { userId: user.id },
|
|
605
|
+
});
|
|
606
|
+
} catch {
|
|
607
|
+
console.warn(
|
|
608
|
+
`[auth-email] ${JSON.stringify({
|
|
609
|
+
userId: user.id,
|
|
610
|
+
kind: "password-reset",
|
|
611
|
+
reason: "mcp_token_revocation_failed",
|
|
612
|
+
})}`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
474
615
|
/**
|
|
475
616
|
* Guarded like its sibling above, though `lib/auth-email.ts` now also
|
|
476
617
|
* guards its own signing step (#6068). BOTH, deliberately: this callback
|
|
@@ -495,6 +636,36 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
495
636
|
}
|
|
496
637
|
};
|
|
497
638
|
|
|
639
|
+
/**
|
|
640
|
+
* The person's workspaces for the MCP consent step and token self-check
|
|
641
|
+
* (#10357 D4): `listTenancy`'s rule — every workspace of every organization
|
|
642
|
+
* they belong to, with that membership's role.
|
|
643
|
+
*/
|
|
644
|
+
const readMemberships = async (userId: string): Promise<McpMembership[]> => {
|
|
645
|
+
const memberships = await prisma.membership.findMany({
|
|
646
|
+
where: { userId },
|
|
647
|
+
select: {
|
|
648
|
+
role: true,
|
|
649
|
+
organization: {
|
|
650
|
+
select: {
|
|
651
|
+
name: true,
|
|
652
|
+
workspaces: { select: { id: true, name: true }, orderBy: { createdAt: "asc" } },
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
orderBy: { createdAt: "asc" },
|
|
657
|
+
});
|
|
658
|
+
return memberships.flatMap((m) =>
|
|
659
|
+
m.organization.workspaces.map((w) => ({
|
|
660
|
+
workspaceId: w.id,
|
|
661
|
+
workspaceName: w.name,
|
|
662
|
+
organizationName: m.organization.name,
|
|
663
|
+
role: m.role,
|
|
664
|
+
})),
|
|
665
|
+
);
|
|
666
|
+
};
|
|
667
|
+
const mcpServer = baseURL ? mcpAuthorizationServer({ readMemberships }) : [];
|
|
668
|
+
|
|
498
669
|
const instance = betterAuth({
|
|
499
670
|
/**
|
|
500
671
|
* `transaction: true` (#8765 D4): Better Auth opens one `$transaction` for
|
|
@@ -538,6 +709,7 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
538
709
|
* deployment that has not pinned its origin never offers reset at all.
|
|
539
710
|
*/
|
|
540
711
|
...(baseURL ? { trustedOrigins: [baseURL] } : {}),
|
|
712
|
+
...(baseURL ? { disabledPaths: MCP_DISABLED_PATHS } : {}),
|
|
541
713
|
emailAndPassword: {
|
|
542
714
|
enabled: passwordMethodActive,
|
|
543
715
|
// Turning the password method on also opens POST /sign-up/email —
|
|
@@ -653,6 +825,8 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
653
825
|
...(magicLinkActive
|
|
654
826
|
? { "/sign-in/magic-link": { window: 900, max: 5 } }
|
|
655
827
|
: {}),
|
|
828
|
+
// #10357 D4: registration and the token endpoints.
|
|
829
|
+
...(baseURL ? MCP_RATE_RULES : {}),
|
|
656
830
|
},
|
|
657
831
|
}
|
|
658
832
|
: {}),
|
|
@@ -734,14 +908,17 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
734
908
|
* `signUpAgreementsPlugin` always (#8765 D3): while sign-up is open,
|
|
735
909
|
* `/sign-up/email` without every sign-up agreement is refused before the
|
|
736
910
|
* handler looks the address up.
|
|
911
|
+
*
|
|
912
|
+
* The MCP authorization server whenever `baseURL` is pinned (#10357 D4).
|
|
737
913
|
*/
|
|
738
914
|
plugins: magicLinkActive
|
|
739
915
|
? [
|
|
740
916
|
nextCookies(),
|
|
741
917
|
signUpAgreementsPlugin(),
|
|
742
918
|
magicLink(makeMagicLinkOptions({ send: authEmailTransport })),
|
|
919
|
+
...mcpServer,
|
|
743
920
|
]
|
|
744
|
-
: [nextCookies(), signUpAgreementsPlugin()],
|
|
921
|
+
: [nextCookies(), signUpAgreementsPlugin(), ...mcpServer],
|
|
745
922
|
});
|
|
746
923
|
return instance;
|
|
747
924
|
}
|
|
@@ -12,13 +12,14 @@ import { AUTH_SEGMENTS, isAccountPath, isAuthPath, isBookingPath } from "../auth
|
|
|
12
12
|
const LOCALES = ["en", "fr", "de"];
|
|
13
13
|
|
|
14
14
|
describe("AUTH_SEGMENTS", () => {
|
|
15
|
-
it("names exactly the
|
|
15
|
+
it("names exactly the six auth routes", () => {
|
|
16
16
|
expect([...AUTH_SEGMENTS]).toEqual([
|
|
17
17
|
"sign-in",
|
|
18
18
|
"sign-up",
|
|
19
19
|
"forgot-password",
|
|
20
20
|
"reset-password",
|
|
21
21
|
"verify-email",
|
|
22
|
+
"consent",
|
|
22
23
|
]);
|
|
23
24
|
});
|
|
24
25
|
});
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* ONE module, so the list `app/[locale]/layout.tsx` branches on to drop the
|
|
5
5
|
* marketing chrome and the list the `(auth)` group's page segments exist at
|
|
6
6
|
* cannot drift. A route group contributes no URL segment, so these are the
|
|
7
|
-
* real paths: `/sign-in`, `/sign-up`, `/forgot-password`, `/reset-password
|
|
8
|
-
*
|
|
7
|
+
* real paths: `/sign-in`, `/sign-up`, `/forgot-password`, `/reset-password`,
|
|
8
|
+
* `/verify-email` and the MCP consent step `/consent` (#10357), each under next-intl's `as-needed` prefixing (bare for
|
|
9
9
|
* the default locale, `/<locale>/…` otherwise).
|
|
10
10
|
*
|
|
11
11
|
* `isAccountPath()` (#7430, spec 7423 D4) is the same predicate for the
|
|
@@ -21,13 +21,15 @@
|
|
|
21
21
|
* No Next.js import — pure functions, asserted directly by their own suite.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
/** The
|
|
24
|
+
/** The six segments the `(auth)` route group serves. */
|
|
25
25
|
export const AUTH_SEGMENTS = [
|
|
26
26
|
"sign-in",
|
|
27
27
|
"sign-up",
|
|
28
28
|
"forgot-password",
|
|
29
29
|
"reset-password",
|
|
30
30
|
"verify-email",
|
|
31
|
+
// The MCP consent step (#10357 D6).
|
|
32
|
+
"consent",
|
|
31
33
|
] as const;
|
|
32
34
|
|
|
33
35
|
export type AuthSegment = (typeof AUTH_SEGMENTS)[number];
|