@aotter/mantle-cloudflare 0.1.0-alpha.9 → 0.1.2-alpha.2

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 (85) hide show
  1. package/README.md +88 -13
  2. package/dist/auth/conventionalAuth.d.ts +1 -0
  3. package/dist/auth/conventionalAuth.d.ts.map +1 -1
  4. package/dist/auth/conventionalAuth.js +2 -0
  5. package/dist/auth/conventionalAuth.js.map +1 -1
  6. package/dist/auth/createAuth.d.ts +24 -11
  7. package/dist/auth/createAuth.d.ts.map +1 -1
  8. package/dist/auth/createAuth.js +274 -23
  9. package/dist/auth/createAuth.js.map +1 -1
  10. package/dist/auth/emailTemplates.d.ts +7 -0
  11. package/dist/auth/emailTemplates.d.ts.map +1 -0
  12. package/dist/auth/emailTemplates.js +27 -0
  13. package/dist/auth/emailTemplates.js.map +1 -0
  14. package/dist/bindings/KvSiteConfigRepository.d.ts +44 -0
  15. package/dist/bindings/KvSiteConfigRepository.d.ts.map +1 -0
  16. package/dist/bindings/KvSiteConfigRepository.js +286 -0
  17. package/dist/bindings/KvSiteConfigRepository.js.map +1 -0
  18. package/dist/bindings/R2MediaStorage.d.ts +2 -3
  19. package/dist/bindings/R2MediaStorage.d.ts.map +1 -1
  20. package/dist/bindings/R2MediaStorage.js +59 -42
  21. package/dist/bindings/R2MediaStorage.js.map +1 -1
  22. package/dist/bindings/WorkersQueueHookDispatcher.d.ts.map +1 -1
  23. package/dist/bindings/WorkersQueueHookDispatcher.js +10 -8
  24. package/dist/bindings/WorkersQueueHookDispatcher.js.map +1 -1
  25. package/dist/bindings/conventionalBindings.d.ts +3 -1
  26. package/dist/bindings/conventionalBindings.d.ts.map +1 -1
  27. package/dist/bindings/conventionalBindings.js +4 -1
  28. package/dist/bindings/conventionalBindings.js.map +1 -1
  29. package/dist/bindings/index.d.ts +1 -0
  30. package/dist/bindings/index.d.ts.map +1 -1
  31. package/dist/bindings/index.js +1 -0
  32. package/dist/bindings/index.js.map +1 -1
  33. package/dist/mount/bootRuntimeOnce.d.ts +4 -0
  34. package/dist/mount/bootRuntimeOnce.d.ts.map +1 -1
  35. package/dist/mount/bootRuntimeOnce.js +53 -27
  36. package/dist/mount/bootRuntimeOnce.js.map +1 -1
  37. package/dist/mount/cmsConfig.d.ts +12 -1
  38. package/dist/mount/cmsConfig.d.ts.map +1 -1
  39. package/dist/mount/mountMcp.d.ts.map +1 -1
  40. package/dist/mount/mountMcp.js +76 -92
  41. package/dist/mount/mountMcp.js.map +1 -1
  42. package/dist/mount/mountPublicRoutes.d.ts +1 -0
  43. package/dist/mount/mountPublicRoutes.d.ts.map +1 -1
  44. package/dist/mount/mountPublicRoutes.js +74 -59
  45. package/dist/mount/mountPublicRoutes.js.map +1 -1
  46. package/dist/mount/mountRuntimeEndpoints.d.ts.map +1 -1
  47. package/dist/mount/mountRuntimeEndpoints.js +20 -3
  48. package/dist/mount/mountRuntimeEndpoints.js.map +1 -1
  49. package/dist/mount/resolveCaller.d.ts +1 -1
  50. package/dist/mount/resolveCaller.d.ts.map +1 -1
  51. package/dist/mount/resolveCaller.js +5 -3
  52. package/dist/mount/resolveCaller.js.map +1 -1
  53. package/dist/oauth/cachePolicy.d.ts +3 -1
  54. package/dist/oauth/cachePolicy.d.ts.map +1 -1
  55. package/dist/oauth/cachePolicy.js +10 -2
  56. package/dist/oauth/cachePolicy.js.map +1 -1
  57. package/dist/oauth/index.d.ts +2 -2
  58. package/dist/oauth/index.d.ts.map +1 -1
  59. package/dist/oauth/index.js +2 -2
  60. package/dist/oauth/index.js.map +1 -1
  61. package/dist/requestDiagnostics.d.ts +53 -0
  62. package/dist/requestDiagnostics.d.ts.map +1 -0
  63. package/dist/requestDiagnostics.js +32 -0
  64. package/dist/requestDiagnostics.js.map +1 -0
  65. package/dist/testing.d.ts +19 -0
  66. package/dist/testing.d.ts.map +1 -0
  67. package/dist/testing.js +263 -0
  68. package/dist/testing.js.map +1 -0
  69. package/dist/worker/createMantleWorker.d.ts +5 -0
  70. package/dist/worker/createMantleWorker.d.ts.map +1 -1
  71. package/dist/worker/createMantleWorker.js +58 -19
  72. package/dist/worker/createMantleWorker.js.map +1 -1
  73. package/package.json +24 -17
  74. package/dist/auth/rejectCrossOriginMutation.d.ts +0 -2
  75. package/dist/auth/rejectCrossOriginMutation.d.ts.map +0 -1
  76. package/dist/auth/rejectCrossOriginMutation.js +0 -15
  77. package/dist/auth/rejectCrossOriginMutation.js.map +0 -1
  78. package/dist/oauth/consentHtml.d.ts +0 -15
  79. package/dist/oauth/consentHtml.d.ts.map +0 -1
  80. package/dist/oauth/consentHtml.js +0 -91
  81. package/dist/oauth/consentHtml.js.map +0 -1
  82. package/dist/oauth/mountOAuth.d.ts +0 -9
  83. package/dist/oauth/mountOAuth.d.ts.map +0 -1
  84. package/dist/oauth/mountOAuth.js +0 -37
  85. package/dist/oauth/mountOAuth.js.map +0 -1
@@ -1,4 +1,7 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { betterAuth } from "better-auth";
3
+ import { getMigrations } from "better-auth/db/migration";
4
+ import { D1DatabaseDriver } from "../bindings/D1DatabaseDriver.js";
2
5
  import { createDpopReplayStore, enforceDpopBinding, isDpopBindingError, parseAccessTokenAuthorization, verifyJwsAccessToken, } from "better-auth/oauth2";
3
6
  import { admin, emailOTP, jwt, magicLink } from "better-auth/plugins";
4
7
  import { createAccessControl } from "better-auth/plugins/access";
@@ -8,8 +11,22 @@ import { splitSetCookieHeader } from "better-auth/cookies";
8
11
  import { oauthProvider } from "@better-auth/oauth-provider";
9
12
  import { mcp } from "@better-auth/mcp";
10
13
  import { cimd } from "@better-auth/cimd";
11
- import { decodeMemberCursor, encodeMemberCursor } from "@aotter/mantle-admin";
14
+ import { decodeMemberCursor, encodeMemberCursor, } from "@aotter/mantle-admin";
15
+ import { signInCodeEmail, signInLinkEmail, staffInvitationEmail } from "./emailTemplates.js";
12
16
  import { STAFF_ROLES } from "@aotter/mantle-spec";
17
+ // Better Auth 1.7.2 initializes its shared stores asynchronously. Seed them
18
+ // before any request can be canceled; the accessor-identity regression test
19
+ // pins this version-specific integration to the stores Better Auth uses.
20
+ const betterAuthGlobalKey = Symbol.for("better-auth:global");
21
+ const betterAuthGlobals = globalThis;
22
+ const betterAuthGlobal = betterAuthGlobals[betterAuthGlobalKey] ??= {
23
+ version: "",
24
+ epoch: 0,
25
+ context: {},
26
+ };
27
+ betterAuthGlobal.context.requestStateAsyncStorage ??= new AsyncLocalStorage();
28
+ betterAuthGlobal.context.endpointContextAsyncStorage ??= new AsyncLocalStorage();
29
+ betterAuthGlobal.context.adapterAsyncStorage ??= new AsyncLocalStorage();
13
30
  export { decodeMemberCursor, encodeMemberCursor };
14
31
  export { STAFF_ROLES };
15
32
  /**
@@ -231,8 +248,7 @@ function buildMagicLinkPlugin(method) {
231
248
  const locale = pickLocale(ctx?.request, fallback);
232
249
  return method.sender.send({
233
250
  to: data.email,
234
- subject: "Your sign-in link",
235
- text: `Click to sign in: ${data.url}\nThe link expires shortly. If you didn't request this, ignore this email.`,
251
+ ...signInLinkEmail(data.url),
236
252
  locale,
237
253
  category: "auth.magic-link.sign-in",
238
254
  });
@@ -259,8 +275,7 @@ function buildEmailOTPPlugin(method) {
259
275
  const locale = pickLocale(ctx?.request, fallback);
260
276
  return method.sender.send({
261
277
  to: data.email,
262
- subject: `Your sign-in code: ${data.otp}`,
263
- text: `Your one-time code is ${data.otp}. It expires shortly. If you didn't request this, ignore this email.`,
278
+ ...signInCodeEmail(data.otp),
264
279
  locale,
265
280
  category: `auth.email-otp.${data.type}`,
266
281
  });
@@ -398,6 +413,9 @@ export function buildOAuthProviderOptions(config) {
398
413
  };
399
414
  }
400
415
  function buildAuth(config) {
416
+ if (config.hostOnlyCookies && (new URL(config.baseURL).protocol !== "https:" || config.crossSubDomainCookies?.enabled)) {
417
+ throw new Error("createAuth: hostOnlyCookies requires HTTPS and cannot share cookies across subdomains.");
418
+ }
401
419
  if (config.methods.length === 0) {
402
420
  throw new Error("createAuth: methods[] is empty — register at least one AuthMethodConfig so staff can sign in.");
403
421
  }
@@ -412,19 +430,18 @@ function buildAuth(config) {
412
430
  const providerOptions = config.oauthProvider
413
431
  ? buildOAuthProviderOptions(config.oauthProvider)
414
432
  : null;
415
- // Rate limit: Better Auth's per-route limits gate on
416
- // `process.env.NODE_ENV === "production"`, which is unset on
417
- // Cloudflare Workers — leaving the limits silently off. When any
418
- // email-shaped method is wired (free email-send-to-any-address
419
- // surface) we ALWAYS turn limits on; adopter can override
420
- // window/max via `config.rateLimit`.
433
+ // Workers may not set NODE_ENV. Explicitly enable the provider's route
434
+ // limits too (notably anonymous DCR: 5/minute).
435
+ // ponytail: memory limits are per isolate; use an ingress rate-limit rule
436
+ // when a deployment needs a distributed abuse quota.
421
437
  const hasEmailMethod = !!(emailOtpMethod || magicLinkMethod);
422
- const rateLimitDefault = hasEmailMethod
423
- ? { window: 60, max: 10, enabled: true }
424
- : null;
425
- const rateLimit = config.rateLimit
426
- ? { ...config.rateLimit, enabled: true }
427
- : rateLimitDefault;
438
+ const rateLimit = {
439
+ window: 60,
440
+ max: hasEmailMethod ? 10 : 100,
441
+ ...config.rateLimit,
442
+ enabled: true,
443
+ storage: "memory",
444
+ };
428
445
  // `trustedOrigins`: per-provider auto-origins (Apple needs
429
446
  // `https://appleid.apple.com`) plus adopter-owned first-party
430
447
  // origins for flows such as hosted auth across trusted subdomains.
@@ -457,6 +474,11 @@ function buildAuth(config) {
457
474
  ? mcp({
458
475
  ...providerOptions,
459
476
  resource: config.oauthProvider.mcpResource,
477
+ extensions: [{
478
+ claims: {
479
+ accessToken: ({ referenceId }) => ({ mantle_consent_id: referenceId ?? null }),
480
+ },
481
+ }],
460
482
  })
461
483
  : oauthProvider(providerOptions),
462
484
  ...(config.oauthProvider.mcpResource
@@ -466,7 +488,9 @@ function buildAuth(config) {
466
488
  // runtime network boundary: resolution and connection stay
467
489
  // on the public Internet. Better Auth owns timeout, limits,
468
490
  // validation, caching, and redirect rejection above it.
469
- fetchClientMetadataResource: (input, init) => fetch(input, { ...init, redirect: "error" }),
491
+ // Workers does not implement `redirect: "error"`; `manual`
492
+ // exposes 3xx responses so Better Auth can reject them.
493
+ fetchClientMetadataResource: (input, init) => fetch(input, { ...init, redirect: "manual" }),
470
494
  metadataProfile: "mcp-2026-07-28",
471
495
  }),
472
496
  ]
@@ -492,6 +516,8 @@ function buildAuth(config) {
492
516
  // it (Better Auth's default raises a state-mismatch).
493
517
  const appleNeedsCrossSite = methodsRequireSameSiteNone(config.methods);
494
518
  const advancedConfig = {
519
+ // Cloudflare overwrites this at ingress. Never trust client-controlled XFF.
520
+ ipAddress: { ipAddressHeaders: ["cf-connecting-ip"] },
495
521
  ...(appleNeedsCrossSite
496
522
  ? {
497
523
  // Browsers require `secure: true` whenever `sameSite: "none"`.
@@ -502,6 +528,13 @@ function buildAuth(config) {
502
528
  ? { crossSubDomainCookies: config.crossSubDomainCookies }
503
529
  : {}),
504
530
  ...(config.cookiePrefix ? { cookiePrefix: config.cookiePrefix } : {}),
531
+ ...(config.hostOnlyCookies ? {
532
+ // Better Auth prepends __Secure- otherwise. Secure is set explicitly below.
533
+ useSecureCookies: false,
534
+ cookiePrefix: `__Host-${config.cookiePrefix || "better-auth"}`,
535
+ crossSubDomainCookies: { enabled: false },
536
+ defaultCookieAttributes: { secure: true, path: "/", ...(appleNeedsCrossSite ? { sameSite: "none" } : {}) },
537
+ } : {}),
505
538
  // Fire-and-forget hook closes the user-existence timing oracle
506
539
  // on OTP send — see § "Auth as contract" notes in ADR-0014.
507
540
  backgroundTasks: {
@@ -549,16 +582,59 @@ function buildAuth(config) {
549
582
  before: async (user, context) => guardGithubLoginProfile(user, context, config.methods),
550
583
  },
551
584
  },
585
+ verification: {
586
+ create: {
587
+ before: async (verification) => {
588
+ if (!config.oauthProvider?.mcpResource)
589
+ return;
590
+ let value;
591
+ try {
592
+ value = JSON.parse(verification.value);
593
+ }
594
+ catch {
595
+ return; // OTPs and other verification values are not OAuth grants.
596
+ }
597
+ if (value?.type !== "authorization_code" || typeof value.userId !== "string" ||
598
+ typeof value.query?.client_id !== "string")
599
+ return;
600
+ const consent = await config.database
601
+ .prepare("SELECT id FROM oauthConsent WHERE userId = ? AND clientId = ? LIMIT 1")
602
+ .bind(value.userId, value.query.client_id)
603
+ .first();
604
+ // Better Auth carries referenceId from this code through every
605
+ // refresh rotation. Never rebind an old lineage to a new consent.
606
+ return { data: { value: JSON.stringify({ ...value, referenceId: consent?.id ?? "" }) } };
607
+ },
608
+ },
609
+ },
552
610
  };
611
+ const secondaryStorage = config.sessionCacheKv ? {
612
+ get: (key) => key.startsWith("verification:")
613
+ ? Promise.resolve(null)
614
+ : config.sessionCacheKv.get(`better-auth:${key}`),
615
+ set: (key, value, ttl) => key.startsWith("verification:")
616
+ ? Promise.resolve()
617
+ : config.sessionCacheKv.put(`better-auth:${key}`, value, ttl ? { expirationTtl: Math.max(60, Math.ceil(ttl)) } : undefined),
618
+ delete: (key) => key.startsWith("verification:")
619
+ ? Promise.resolve()
620
+ : config.sessionCacheKv.delete(`better-auth:${key}`),
621
+ // Verification and rate limiting stay in D1/memory below. Fail loudly if
622
+ // Better Auth starts routing either atomic operation through this adapter.
623
+ getAndDelete: async () => { throw new Error("Better Auth KV getAndDelete is disabled"); },
624
+ increment: async () => { throw new Error("Better Auth KV increment is disabled"); },
625
+ } : undefined;
553
626
  return betterAuth({
554
627
  database: config.database,
628
+ secondaryStorage,
629
+ session: { storeSessionInDatabase: true },
630
+ verification: { storeInDatabase: true },
555
631
  secret: config.secret,
556
632
  baseURL: config.baseURL,
557
633
  basePath: normalizeAuthBasePath(config.basePath),
558
634
  onAPIError: { errorURL: normalizeAuthErrorURL(config.errorURL, config.baseURL) },
559
635
  socialProviders,
560
636
  user: userConfig,
561
- ...(rateLimit ? { rateLimit } : {}),
637
+ rateLimit,
562
638
  trustedOrigins,
563
639
  advanced: advancedConfig,
564
640
  plugins: sdkPlugins,
@@ -574,6 +650,34 @@ const LEGACY_DCR_TTL_MS = 90 * 24 * 60 * 60 * 1_000;
574
650
  const LEGACY_DCR_CLEANUP_INTERVAL_MS = 60 * 60 * 1_000;
575
651
  export function createAuth(config) {
576
652
  const auth = buildAuth(config);
653
+ const ready = auth.$context.then(() => undefined);
654
+ // Observe eager initialization even for low-level callers; keep the original
655
+ // rejection available to callers awaiting ready and Better Auth's handlers.
656
+ void ready.catch(() => { });
657
+ // Auth owns its schema even when content uses a different semantic store.
658
+ // Keep schema work lazy: static/plan-only routes must not prepare tables.
659
+ let schemaReady = null;
660
+ const prepareAuth = () => schemaReady ??= (async () => {
661
+ const context = await auth.$context;
662
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(context.tables)));
663
+ const id = `auth-schema:1:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("")}`;
664
+ try {
665
+ const applied = await config.database.prepare("SELECT id FROM _migrations WHERE id = ?").bind(id).first();
666
+ if (applied?.id === id)
667
+ return;
668
+ }
669
+ catch (error) {
670
+ // A new, auth-only database has no legacy Runtime ledger yet.
671
+ if (!/no such table: _migrations/i.test(String(error)))
672
+ throw error;
673
+ }
674
+ const { compileMigrations } = await getMigrations(context.options);
675
+ await new D1DatabaseDriver(config.database).migrations.runAll([{
676
+ id,
677
+ description: "Selected Better Auth schema and staff-role access path",
678
+ sql: `${await compileMigrations()}\nCREATE INDEX IF NOT EXISTS user_role_idx ON user (role) WHERE role IS NOT NULL;`,
679
+ }]);
680
+ })().catch(error => { schemaReady = null; throw error; });
577
681
  const basePath = normalizeAuthBasePath(config.basePath);
578
682
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
579
683
  const api = auth.api;
@@ -588,8 +692,13 @@ export function createAuth(config) {
588
692
  };
589
693
  const verifyAccessToken = config.oauthProvider
590
694
  ? async (token, audience) => {
695
+ await prepareAuth();
591
696
  const context = await auth.$context;
592
- return verifyOAuthJwtWithLocalJwks(token, audience, context.baseURL, async () => api.getJwks(), localJwksCacheKey);
697
+ const claims = await verifyOAuthJwtWithLocalJwks(token, audience, context.baseURL, async () => api.getJwks(), localJwksCacheKey);
698
+ if (audience === config.oauthProvider?.mcpResource) {
699
+ await assertActiveMcpGrant(config.database, claims, audience);
700
+ }
701
+ return claims;
593
702
  }
594
703
  : null;
595
704
  let nextDcrCleanupAt = 0;
@@ -612,18 +721,41 @@ export function createAuth(config) {
612
721
  };
613
722
  return {
614
723
  basePath,
724
+ ready,
615
725
  ...(config.oauthProvider?.mcpResource
616
726
  ? { mcpResource: config.oauthProvider.mcpResource }
617
727
  : {}),
618
728
  handler: async (request) => {
729
+ await prepareAuth();
619
730
  const pathname = new URL(request.url).pathname;
620
731
  if (pathname.startsWith(`${basePath}/oauth2/`)) {
621
732
  await pruneExpiredDynamicClients();
622
733
  }
623
734
  return normalizeAuthResponseCookies(await auth.handler(request));
624
735
  },
625
- getSession: (request) => api.getSession({ headers: request.headers }).then((r) => r ?? null),
736
+ getSession: async (request) => {
737
+ let session;
738
+ try {
739
+ session = await api.getSession({ headers: request.headers });
740
+ }
741
+ catch {
742
+ // Existing sessions resolve from KV without paying the schema-ledger read.
743
+ // A fresh database with a stale cookie prepares once, then retries safely.
744
+ await prepareAuth();
745
+ session = await api.getSession({ headers: request.headers });
746
+ }
747
+ return session
748
+ ? {
749
+ ...session,
750
+ user: {
751
+ ...session.user,
752
+ ...(Object.hasOwn(session.user, "role") ? { roleCurrent: true } : {}),
753
+ },
754
+ }
755
+ : null;
756
+ },
626
757
  getUserRole: async (userId) => {
758
+ await prepareAuth();
627
759
  const row = await config.database
628
760
  .prepare("SELECT role FROM user WHERE id = ? LIMIT 1")
629
761
  .bind(userId)
@@ -631,6 +763,7 @@ export function createAuth(config) {
631
763
  return row?.role ?? null;
632
764
  },
633
765
  getUser: async (userId) => {
766
+ await prepareAuth();
634
767
  const row = await config.database
635
768
  .prepare("SELECT id, email, name, image, role, githubLogin, emailVerified, createdAt FROM user WHERE id = ? LIMIT 1")
636
769
  .bind(userId)
@@ -653,6 +786,7 @@ export function createAuth(config) {
653
786
  };
654
787
  },
655
788
  getProviderAccessToken: async (request, providerId) => {
789
+ await prepareAuth();
656
790
  const session = await api.getSession({ headers: request.headers });
657
791
  const userId = session?.user?.id;
658
792
  const account = userId
@@ -676,6 +810,7 @@ export function createAuth(config) {
676
810
  const clientId = url.searchParams.get("client_id");
677
811
  if (!clientId || !url.search)
678
812
  return null;
813
+ await prepareAuth();
679
814
  const oauthQuery = url.search.slice(1);
680
815
  const client = await api.getOAuthClientPublicPrelogin({
681
816
  headers: request.headers,
@@ -706,6 +841,7 @@ export function createAuth(config) {
706
841
  if (typeof oauthQuery !== "string" || oauthQuery.length === 0) {
707
842
  throw new Error("completeOAuthConsent: oauth_query is missing.");
708
843
  }
844
+ await prepareAuth();
709
845
  const headers = new Headers(request.headers);
710
846
  headers.set("content-type", "application/json");
711
847
  headers.delete("content-length");
@@ -723,6 +859,57 @@ export function createAuth(config) {
723
859
  }
724
860
  return result.url;
725
861
  },
862
+ ...(config.oauthProvider
863
+ ? {
864
+ listOAuthConsents: async (userId) => {
865
+ await prepareAuth();
866
+ const result = await config.database
867
+ .prepare(`SELECT consent.id, consent.clientId,
868
+ COALESCE(client.name, consent.clientId) AS clientName,
869
+ consent.scopes
870
+ FROM oauthConsent AS consent
871
+ LEFT JOIN oauthClient AS client ON client.clientId = consent.clientId
872
+ WHERE consent.userId = ?
873
+ ORDER BY consent.updatedAt DESC, consent.id ASC`)
874
+ .bind(userId)
875
+ .all();
876
+ return (result.results ?? []).map((row) => ({
877
+ id: row.id,
878
+ clientId: row.clientId,
879
+ clientName: row.clientName,
880
+ scopes: parseStoredStringArray(row.scopes) ?? [],
881
+ }));
882
+ },
883
+ revokeOAuthConsent: async (userId, consentId) => {
884
+ await prepareAuth();
885
+ const consent = await config.database
886
+ .prepare("SELECT clientId FROM oauthConsent WHERE id = ? AND userId = ? LIMIT 1")
887
+ .bind(consentId, userId)
888
+ .first();
889
+ if (!consent)
890
+ return false;
891
+ const revokedAt = new Date().toISOString();
892
+ await config.database.batch([
893
+ config.database
894
+ .prepare("UPDATE oauthRefreshToken SET revoked = ? WHERE userId = ? AND clientId = ? AND revoked IS NULL")
895
+ .bind(revokedAt, userId, consent.clientId),
896
+ config.database
897
+ .prepare("UPDATE oauthAccessToken SET revoked = ? WHERE userId = ? AND clientId = ? AND revoked IS NULL")
898
+ .bind(revokedAt, userId, consent.clientId),
899
+ config.database
900
+ .prepare(`DELETE FROM verification
901
+ WHERE CASE WHEN json_valid(value) THEN json_extract(value, '$.type') END = 'authorization_code'
902
+ AND CASE WHEN json_valid(value) THEN json_extract(value, '$.userId') END = ?
903
+ AND CASE WHEN json_valid(value) THEN json_extract(value, '$.query.client_id') END = ?`)
904
+ .bind(userId, consent.clientId),
905
+ config.database
906
+ .prepare("DELETE FROM oauthConsent WHERE userId = ? AND clientId = ?")
907
+ .bind(userId, consent.clientId),
908
+ ]);
909
+ return true;
910
+ },
911
+ }
912
+ : {}),
726
913
  methods: config.methods.map((m) => {
727
914
  switch (m.kind) {
728
915
  case "social":
@@ -739,6 +926,7 @@ export function createAuth(config) {
739
926
  }
740
927
  }),
741
928
  listLinkedAccounts: async (userId) => {
929
+ await prepareAuth();
742
930
  const result = await config.database
743
931
  .prepare("SELECT id, providerId, accountId, createdAt, updatedAt FROM account WHERE userId = ? ORDER BY createdAt ASC, id ASC")
744
932
  .bind(userId)
@@ -752,6 +940,7 @@ export function createAuth(config) {
752
940
  }));
753
941
  },
754
942
  unlinkAccount: async (userId, providerId) => {
943
+ await prepareAuth();
755
944
  const result = await config.database
756
945
  .prepare("DELETE FROM account WHERE userId = ? AND providerId = ?")
757
946
  .bind(userId, providerId)
@@ -759,6 +948,7 @@ export function createAuth(config) {
759
948
  return (result.meta?.changes ?? 0) > 0;
760
949
  },
761
950
  listUsers: async () => {
951
+ await prepareAuth();
762
952
  const placeholders = STAFF_ROLES.map(() => "?").join(",");
763
953
  const result = await config.database
764
954
  .prepare(`SELECT id, email, name, role, githubLogin, emailVerified, createdAt FROM user WHERE role IN (${placeholders}) ORDER BY createdAt ASC, id ASC`)
@@ -775,6 +965,7 @@ export function createAuth(config) {
775
965
  }));
776
966
  },
777
967
  listMembers: async ({ search, cursor, cursorDirection = "forward", limit }) => {
968
+ await prepareAuth();
778
969
  const parsedCursor = cursor ? decodeMemberCursor(cursor) : null;
779
970
  const backward = cursorDirection === "backward";
780
971
  const conditions = [
@@ -783,9 +974,9 @@ export function createAuth(config) {
783
974
  const bindings = [...STAFF_ROLES];
784
975
  const term = search?.trim().toLowerCase();
785
976
  if (term) {
786
- conditions.push("(LOWER(name) LIKE ? ESCAPE '\\' OR LOWER(email) LIKE ? ESCAPE '\\')");
977
+ conditions.push("(LOWER(id) LIKE ? ESCAPE '\\' OR LOWER(name) LIKE ? ESCAPE '\\' OR LOWER(email) LIKE ? ESCAPE '\\')");
787
978
  const like = `%${term.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
788
- bindings.push(like, like);
979
+ bindings.push(like, like, like);
789
980
  }
790
981
  if (parsedCursor) {
791
982
  const operator = backward ? "<" : ">";
@@ -822,6 +1013,14 @@ export function createAuth(config) {
822
1013
  if (role !== null && !STAFF_ROLE_SET.has(role)) {
823
1014
  throw new Error(`setUserRole: '${role}' is not a staff role — expected one of [${STAFF_ROLES.join(", ")}] or null.`);
824
1015
  }
1016
+ await prepareAuth();
1017
+ if (config.sessionCacheKv) {
1018
+ const context = await auth.$context;
1019
+ return !!await context.internalAdapter.updateUser(userId, {
1020
+ role,
1021
+ updatedAt: new Date(),
1022
+ });
1023
+ }
825
1024
  const result = await config.database
826
1025
  .prepare("UPDATE user SET role = ?, updatedAt = ? WHERE id = ?")
827
1026
  .bind(role, new Date().toISOString(), userId)
@@ -832,6 +1031,7 @@ export function createAuth(config) {
832
1031
  if (!STAFF_ROLE_SET.has(role)) {
833
1032
  throw new Error(`inviteUser: '${role}' is not a staff role — expected one of [${STAFF_ROLES.join(", ")}].`);
834
1033
  }
1034
+ await prepareAuth();
835
1035
  const normalized = email.trim().toLowerCase();
836
1036
  const existing = await config.database
837
1037
  .prepare("SELECT id FROM user WHERE email = ? LIMIT 1")
@@ -850,7 +1050,19 @@ export function createAuth(config) {
850
1050
  .run();
851
1051
  return { kind: "created", id };
852
1052
  },
1053
+ ...(config.staffInvitationSender ? {
1054
+ sendStaffInvitation: async (email, role) => {
1055
+ const normalized = email.trim().toLowerCase();
1056
+ await config.staffInvitationSender.send({
1057
+ to: normalized,
1058
+ ...staffInvitationEmail(role, new URL("/admin/sign-in", config.baseURL).href),
1059
+ locale: "en",
1060
+ category: "auth.staff-invitation",
1061
+ });
1062
+ },
1063
+ } : {}),
853
1064
  revokeInvite: async (userId) => {
1065
+ await prepareAuth();
854
1066
  const result = await config.database
855
1067
  .prepare("DELETE FROM user WHERE id = ? AND emailVerified = 0 AND NOT EXISTS (SELECT 1 FROM account WHERE account.userId = user.id)")
856
1068
  .bind(userId)
@@ -861,6 +1073,7 @@ export function createAuth(config) {
861
1073
  if (!config.oauthProvider) {
862
1074
  throw new Error("registerOAuthClient: oauthProvider is not configured.");
863
1075
  }
1076
+ await prepareAuth();
864
1077
  const created = await api.adminCreateOAuthClient({
865
1078
  headers: new Headers(input.requestHeaders),
866
1079
  body: {
@@ -968,6 +1181,44 @@ function scopesFromClaim(value) {
968
1181
  }
969
1182
  return [];
970
1183
  }
1184
+ function parseStoredStringArray(value) {
1185
+ if (typeof value !== "string")
1186
+ return null;
1187
+ try {
1188
+ const parsed = JSON.parse(value);
1189
+ return Array.isArray(parsed) && parsed.every((item) => typeof item === "string")
1190
+ ? parsed
1191
+ : null;
1192
+ }
1193
+ catch {
1194
+ return null;
1195
+ }
1196
+ }
1197
+ async function assertActiveMcpGrant(database, claims, audience) {
1198
+ const userId = claims["sub"];
1199
+ const clientId = claims["azp"];
1200
+ const sessionId = claims["sid"];
1201
+ const consentId = claims["mantle_consent_id"];
1202
+ if (typeof userId !== "string" ||
1203
+ typeof clientId !== "string" ||
1204
+ typeof sessionId !== "string" ||
1205
+ typeof consentId !== "string" || !consentId) {
1206
+ throw new Error("MCP token is not bound to a user session.");
1207
+ }
1208
+ const result = await database
1209
+ .prepare("SELECT c.resources, c.scopes FROM oauthConsent AS c " +
1210
+ "JOIN session AS s ON s.id = ? AND s.userId = c.userId AND s.expiresAt > ? " +
1211
+ "WHERE c.id = ? AND c.userId = ? AND c.clientId = ?")
1212
+ .bind(sessionId, new Date().toISOString(), consentId, userId, clientId)
1213
+ .first();
1214
+ const tokenScopes = scopesFromClaim(claims["scope"]);
1215
+ const resources = parseStoredStringArray(result?.resources);
1216
+ const scopes = parseStoredStringArray(result?.scopes);
1217
+ const active = resources?.includes(audience) === true && scopes !== null &&
1218
+ tokenScopes.every((scope) => scopes.includes(scope));
1219
+ if (!active)
1220
+ throw new Error("MCP authorization grant is no longer active.");
1221
+ }
971
1222
  /** @internal exported to keep same-Worker OAuth verification off the network. */
972
1223
  export function verifyOAuthJwtWithLocalJwks(token, audience, issuer, jwksFetch, jwksCacheKey) {
973
1224
  return verifyJwsAccessToken(token, {