create-ailk 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +0 -5
  2. package/dist/add-route.js +1 -0
  3. package/dist/cli.js +15 -1
  4. package/dist/lib/env-catalog.js +1 -1
  5. package/dist/lib/import-mdx.js +18 -9
  6. package/dist/lib/scaffold-render-sweep.d.ts +1 -1
  7. package/dist/lib/scaffold-render-sweep.js +1 -1
  8. package/dist/module-architecture.js +6 -1
  9. package/dist/provision-env.d.ts +28 -0
  10. package/dist/provision-env.js +210 -0
  11. package/dist/surfaces.js +3 -0
  12. package/dist/topology.js +11 -0
  13. package/package.json +3 -3
  14. package/templates/.env.example +7 -0
  15. package/templates/apps/api/CLAUDE.md +7 -3
  16. package/templates/apps/mcp/.env.example +20 -0
  17. package/templates/apps/mcp/CLAUDE.md +42 -8
  18. package/templates/apps/web/CLAUDE.md +24 -1
  19. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
  20. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
  21. package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
  22. package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
  23. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
  24. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
  25. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
  26. package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
  27. package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
  28. package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
  29. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  30. package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
  31. package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
  32. package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
  33. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
  34. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
  35. package/templates/apps/web/app/llms.txt/route.ts +10 -0
  36. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
  37. package/templates/apps/web/app/openapi.json/route.ts +4 -0
  38. package/templates/apps/web/app/robots.txt/route.ts +18 -1
  39. package/templates/apps/web/app/sitemap.xml/route.ts +10 -0
  40. package/templates/apps/web/auth.ts +184 -7
  41. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
  42. package/templates/apps/web/lib/auth-routes.ts +5 -3
  43. package/templates/apps/web/lib/mcp-oauth.ts +702 -0
  44. package/templates/apps/web/messages/en.json +42 -13
  45. package/templates/database/seed.ts +6 -0
  46. package/versions.json +48 -48
@@ -26,11 +26,14 @@
26
26
  */
27
27
 
28
28
  import type { PrismaClient } from "@working-theory/database";
29
- import { betterAuth } from "better-auth";
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 two delegate methods `onPasswordReset` reaches for, named so the call
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` and
147
- * `oAuthAuthorization.updateMany` is reachable through it. The second ends
148
- * every credential a spoke holds for the person (#7894 D4, SEC-7894-5).
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 five auth routes", () => {
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
- * and `/verify-email`, each under next-intl's `as-needed` prefixing (bare for
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 five segments the `(auth)` route group serves. */
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];