create-ailk 0.29.1 → 0.31.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 +71 -3
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/add-route.js +1 -0
- package/dist/cli.js +114 -1
- package/dist/cutover.d.ts +109 -0
- package/dist/cutover.js +494 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +114 -0
- package/dist/language-import.js +435 -0
- package/dist/lib/env-catalog.js +2 -2
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +411 -64
- package/dist/lib/render-comparison.d.ts +200 -0
- package/dist/lib/render-comparison.js +374 -0
- package/dist/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/surfaces.js +3 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +28 -10
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.env.example +7 -0
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +46 -8
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +39 -5
- 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]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- 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 +26 -9
- 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 +13 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
- package/templates/apps/web/lib/auth-routes.ts +5 -3
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/mcp-oauth.ts +702 -0
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +47 -14
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/database/seed.ts +6 -0
- package/templates/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- package/versions.json +48 -48
|
@@ -25,8 +25,12 @@
|
|
|
25
25
|
* the docs path; otherwise those rows are skipped (the MDX docs route never
|
|
26
26
|
* serves them) and the docs directory is listed (`getDocsPages`, default
|
|
27
27
|
* `listDocsPages`).
|
|
28
|
+
*
|
|
29
|
+
* Blog (#10644 D1): under the database adapter the adapter's `BlogList` /
|
|
30
|
+
* `BlogPosting` rows are the blog, and `getBlogPages` is not called.
|
|
28
31
|
*/
|
|
29
32
|
|
|
33
|
+
import { siteDiscoverable } from "@working-theory/api-client";
|
|
30
34
|
import {
|
|
31
35
|
SCHEMA_ORG_TYPES,
|
|
32
36
|
resolvedAdapterKind,
|
|
@@ -44,6 +48,10 @@ import { PUBLIC_PAGES } from "../../lib/modules.generated";
|
|
|
44
48
|
import { SITE_NAME } from "../../lib/site-brand";
|
|
45
49
|
import { pagePathFor, resolveBaseUrl } from "../../src/lib/routes";
|
|
46
50
|
|
|
51
|
+
// #10510 D4 — read per request: the owner's discovery switch is a runtime
|
|
52
|
+
// setting a prerendered file could never follow.
|
|
53
|
+
export const dynamic = "force-dynamic";
|
|
54
|
+
|
|
47
55
|
export interface LlmsHandlerDeps {
|
|
48
56
|
/**
|
|
49
57
|
* Content adapter factory. Defaults to the real implementation.
|
|
@@ -54,10 +62,14 @@ export interface LlmsHandlerDeps {
|
|
|
54
62
|
* Blog pages getter. Defaults to none — Free has no blog (D-46, #5365 D9).
|
|
55
63
|
* The docs + blog module's own copy of this route reads `blogSource`.
|
|
56
64
|
* Override in tests to inject stub pages.
|
|
65
|
+
* Not called under the database adapter, whose `BlogList` / `BlogPosting`
|
|
66
|
+
* rows are the blog (#10644 D1).
|
|
57
67
|
*/
|
|
58
68
|
getBlogPages?: () => Array<{ url: string; data: { title: string; description?: string } }>;
|
|
59
69
|
/** The docs pages (#10041). Defaults to `listDocsPages` over this app's docs directory. */
|
|
60
70
|
getDocsPages?: () => DocsIndexPage[];
|
|
71
|
+
/** The owner's discovery switch (#10510 D4). Defaults to `siteDiscoverable`. */
|
|
72
|
+
discoverable?: () => Promise<boolean>;
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
/**
|
|
@@ -68,10 +80,13 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
|
|
|
68
80
|
const resolveAdapter = deps.getContentAdapter ?? defaultGetContentAdapter;
|
|
69
81
|
const resolveBlogPages = deps.getBlogPages ?? (() => []);
|
|
70
82
|
const resolveDocsPages = deps.getDocsPages ?? listDocsPages;
|
|
83
|
+
const isDiscoverable = deps.discoverable ?? siteDiscoverable;
|
|
71
84
|
|
|
72
85
|
return async function llmsGET(): Promise<Response> {
|
|
73
86
|
// #10040 decision 7 — no public page, no content index: 404 before the adapter.
|
|
74
87
|
if (!PUBLIC_PAGES) return new Response(null, { status: 404 });
|
|
88
|
+
// #10510 D4 — hidden by the owner: 404, never cached, before the adapter.
|
|
89
|
+
if (!(await isDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
|
|
75
90
|
const adapter = resolveAdapter();
|
|
76
91
|
const baseUrl = resolveBaseUrl();
|
|
77
92
|
const lines: string[] = [`# ${SITE_NAME} — content index`, ""];
|
|
@@ -87,10 +102,10 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
|
|
|
87
102
|
types.map((type) => adapter.listPages(type, defaultLocale)),
|
|
88
103
|
);
|
|
89
104
|
|
|
90
|
-
const
|
|
105
|
+
const databaseCorpus = resolvedAdapterKind() === "database";
|
|
91
106
|
for (const pages of pageLists) {
|
|
92
107
|
for (const page of pages) {
|
|
93
|
-
if (isDocsType(page.type) && !
|
|
108
|
+
if (isDocsType(page.type) && !databaseCorpus) continue;
|
|
94
109
|
const path = isDocsType(page.type)
|
|
95
110
|
? adapterDocsUrl(page.type, page.slug)
|
|
96
111
|
: pagePathFor(page);
|
|
@@ -108,18 +123,20 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
|
|
|
108
123
|
// blogSource.getPages() returns pages ordered by Fumadocs; the index page
|
|
109
124
|
// (/blog) sorts first naturally because its slug array is empty.
|
|
110
125
|
lines.push("");
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
126
|
+
if (!databaseCorpus) {
|
|
127
|
+
for (const page of resolveBlogPages()) {
|
|
128
|
+
lines.push(`- Title: ${page.data.title}`);
|
|
129
|
+
lines.push(` URL: ${baseUrl}${page.url}`);
|
|
130
|
+
if (page.data.description) {
|
|
131
|
+
lines.push(` Description: ${page.data.description}`);
|
|
132
|
+
}
|
|
133
|
+
lines.push("");
|
|
116
134
|
}
|
|
117
|
-
lines.push("");
|
|
118
135
|
}
|
|
119
136
|
|
|
120
137
|
// The docs directory (#10041 D8) — not under the database adapter, whose
|
|
121
138
|
// docs rows are listed above.
|
|
122
|
-
if (!
|
|
139
|
+
if (!databaseCorpus) {
|
|
123
140
|
for (const doc of resolveDocsPages()) {
|
|
124
141
|
lines.push(`- Title: ${doc.title}`);
|
|
125
142
|
lines.push(` URL: ${baseUrl}${doc.url}`);
|
|
@@ -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);
|
|
@@ -142,6 +152,9 @@ export function createSitemapHandler(deps: SitemapHandlerDeps = {}) {
|
|
|
142
152
|
}
|
|
143
153
|
continue;
|
|
144
154
|
}
|
|
155
|
+
// #10617 — a flow's completion target renders `robots: noindex`
|
|
156
|
+
// (`renderPageMetadata`), so it is never listed, in any locale.
|
|
157
|
+
if (page.type === "ThankYouPage") continue;
|
|
145
158
|
const path = pagePathFor(page);
|
|
146
159
|
const lastmod = page.publishedAt ?? today;
|
|
147
160
|
|
|
@@ -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
|
}
|
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
// Mirrors apps/api's ts-jest setup (CONVENTIONS.md mentions Vitest aspirationally,
|
|
2
2
|
// but no Vitest is installed anywhere yet). JSDOM env so RTL can mount components.
|
|
3
|
+
/**
|
|
4
|
+
* #10699 D4: lib/runtime-source.ts derives the toc through
|
|
5
|
+
* fumadocs-core/content/toc, whose remark closure ships ESM only. These are
|
|
6
|
+
* pnpm-store name prefixes (the `tools/jest/mdxeditor-esm.cjs` shape, inlined
|
|
7
|
+
* because a scaffold's copy of this file has no `tools/`): `mdast-util-`
|
|
8
|
+
* covers every `mdast-util-*` package, `remark` covers `remark-gfm` /
|
|
9
|
+
* `remark-parse`, `vfile` covers `vfile-message`. Re-derive from the
|
|
10
|
+
* installed tree when the lockfile moves; the runtime-source suites loading
|
|
11
|
+
* is the proof.
|
|
12
|
+
*/
|
|
13
|
+
const REMARK_TOC_ESM = [
|
|
14
|
+
"bail",
|
|
15
|
+
"ccount",
|
|
16
|
+
"character-",
|
|
17
|
+
"decode-named-character-reference",
|
|
18
|
+
"devlop",
|
|
19
|
+
"escape-string-regexp",
|
|
20
|
+
"estree-util-",
|
|
21
|
+
"github-slugger",
|
|
22
|
+
"is-plain-obj",
|
|
23
|
+
"longest-streak",
|
|
24
|
+
"markdown-table",
|
|
25
|
+
"mdast-util-",
|
|
26
|
+
"micromark",
|
|
27
|
+
"remark",
|
|
28
|
+
"trough",
|
|
29
|
+
"unified",
|
|
30
|
+
"unist-util-",
|
|
31
|
+
"vfile",
|
|
32
|
+
"zwitch",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Admit REMARK_TOC_ESM beside whatever a carve-out already admits, leaving
|
|
37
|
+
* the carve-out's own literal untouched — create-ailk's template build and the
|
|
38
|
+
* docs-blog `jest-config` patch both find it by exact string.
|
|
39
|
+
*/
|
|
40
|
+
function admitRemarkToc(pattern) {
|
|
41
|
+
const head = "/node_modules/(?!(\\.pnpm/)?";
|
|
42
|
+
return pattern.replace(
|
|
43
|
+
head,
|
|
44
|
+
`${head}(?:${REMARK_TOC_ESM.join("|")})|(\\.pnpm/)?`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
3
48
|
module.exports = {
|
|
4
49
|
preset: "ts-jest",
|
|
5
50
|
globalSetup: require.resolve("../../tools/jest/pin-timezone.cjs"),
|
|
@@ -17,7 +62,7 @@ module.exports = {
|
|
|
17
62
|
// Mirrors the canonical jose carve-out used in apps/api, apps/mcp, and apps-paid/license-issuer.
|
|
18
63
|
// Added: issue #1077
|
|
19
64
|
transformIgnorePatterns: [
|
|
20
|
-
"/node_modules/(?!(\\.pnpm/)?@working-theory(?:\\+[^/]*?@?[^/]*?|/[^/]+)(/|$))",
|
|
65
|
+
admitRemarkToc("/node_modules/(?!(\\.pnpm/)?@working-theory(?:\\+[^/]*?@?[^/]*?|/[^/]+)(/|$))"),
|
|
21
66
|
],
|
|
22
67
|
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"],
|
|
23
68
|
// Workspace packages publish ESM in dist/, but ts-jest runs in CommonJS.
|
|
@@ -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
|
});
|