@aotter/mantle 0.2.0-alpha.7 → 0.2.0-alpha.8

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 (108) hide show
  1. package/README.md +10 -5
  2. package/dist/auth/caller.js +2 -1
  3. package/dist/auth/identity.d.ts +2 -2
  4. package/dist/auth/oauth.d.ts +33 -1
  5. package/dist/auth/oauth.js +121 -28
  6. package/dist/auth/routes.js +3 -2
  7. package/dist/build/compiler.d.ts +44 -122
  8. package/dist/build/compiler.js +202 -277
  9. package/dist/build/spec/domain/model/ManifestGrammar.d.ts +6 -5
  10. package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
  11. package/dist/build/spec/domain/service/PlanFingerprint.d.ts +2 -0
  12. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  13. package/dist/domain/describe/{chunk-ZIQVLA7X.js → chunk-ZVXBGP7Y.js} +69 -8
  14. package/dist/domain/describe/index.js +1 -1
  15. package/dist/domain/describe/target-hash.js +1 -1
  16. package/dist/domain/describe/targets.d.ts +1 -1
  17. package/dist/domain/generated-tools.js +2 -0
  18. package/dist/domain/paging.js +41 -15
  19. package/dist/infrastructure/bundle.js +105 -15
  20. package/dist/infrastructure/cli.js +11 -15
  21. package/dist/infrastructure/generate.d.ts +1 -3
  22. package/dist/infrastructure/generate.js +2 -2
  23. package/dist/infrastructure/generateAuth.js +6 -0
  24. package/dist/infrastructure/inputs.js +8 -3
  25. package/dist/infrastructure/wrangler.js +35 -12
  26. package/dist/kernel/drain.d.ts +5 -0
  27. package/dist/kernel/drain.js +35 -0
  28. package/dist/kernel/driver/postgres.js +3 -0
  29. package/dist/kernel/host-caller.d.ts +1 -0
  30. package/dist/kernel/host-caller.js +1 -0
  31. package/dist/kernel/mcp-apps.d.ts +29 -9
  32. package/dist/kernel/mcp-apps.js +2 -2
  33. package/dist/kernel/mcp-handler.js +100 -17
  34. package/dist/kernel/mcp.d.ts +7 -2
  35. package/dist/kernel/mcp.js +29 -5
  36. package/dist/kernel/rest-router.js +3 -2
  37. package/dist/kernel/site.d.ts +1 -3
  38. package/dist/kernel/site.js +2 -2
  39. package/dist/kernel/web.d.ts +3 -1
  40. package/dist/kernel/web.js +3 -0
  41. package/dist/usecase/adminBindings.js +2 -3
  42. package/dist/usecase/adminClient.js +87 -0
  43. package/dist/usecase/adminDescriptors.js +19 -65
  44. package/dist/usecase/generate.js +5 -4
  45. package/dist/usecase/mcp-media.js +2 -2
  46. package/dist/usecase/mcp.js +16 -12
  47. package/dist/usecase/paging.js +2 -3
  48. package/dist/usecase/publishing.js +2 -2
  49. package/dist/usecase/tool-version.js +9 -0
  50. package/dist/usecase/translations.js +1 -1
  51. package/dist/usecase/validation.js +120 -19
  52. package/dist/usecase/web.js +3 -2
  53. package/docs/examples/README.md +1 -1
  54. package/docs/examples/app-shell/README.md +39 -1
  55. package/docs/examples/app-shell/package.json +14 -6
  56. package/docs/examples/app-shell/smoke-kit.mjs +54 -0
  57. package/docs/examples/app-shell/src/auth.ts +4 -2
  58. package/docs/examples/app-shell/src/service.ts +2 -2
  59. package/docs/examples/app-shell/tsconfig.json +4 -1
  60. package/docs/examples/app-shell/worker-configuration.d.ts +2 -2
  61. package/docs/examples/app-shell/wrangler.jsonc +5 -4
  62. package/docs/examples/generated-web/README.md +6 -2
  63. package/docs/examples/generated-web/wrangler.jsonc +1 -0
  64. package/docs/examples/purchasing/README.md +1 -1
  65. package/docs/examples/purchasing/manifests/purchasing.yaml +1 -1
  66. package/docs/examples/purchasing/smoke.mjs +10 -4
  67. package/docs/examples/purchasing/src/admin.tsx +13 -0
  68. package/docs/examples/purchasing/wrangler.jsonc +5 -4
  69. package/docs/examples/recipes/wrangler.jsonc +5 -4
  70. package/docs/handbook/cloudflare/authentication.md +22 -2
  71. package/docs/handbook/cloudflare/deploy-and-operate.md +21 -0
  72. package/docs/handbook/cloudflare/service-entry.md +55 -28
  73. package/docs/handbook/concepts/authorization.md +1 -1
  74. package/docs/handbook/concepts/mcp-and-agents.md +19 -0
  75. package/docs/handbook/examples/hub.md +1 -1
  76. package/docs/handbook/guides/admin-ui.md +107 -1
  77. package/docs/handbook/guides/agent-setup.md +27 -9
  78. package/docs/handbook/guides/mcp-apps.md +445 -6
  79. package/docs/handbook/guides/native-admin.md +296 -51
  80. package/docs/handbook/guides/oauth.md +61 -5
  81. package/docs/handbook/guides/public-pages.md +2 -2
  82. package/docs/handbook/guides/size-and-startup.md +1 -1
  83. package/docs/handbook/navigation.json +0 -4
  84. package/docs/handbook/reference/admin-descriptors.md +0 -35
  85. package/docs/handbook/reference/procedure.md +5 -1
  86. package/docs/handbook/reference/schema.md +13 -3
  87. package/docs/handbook/reference/surface.md +123 -13
  88. package/docs/handbook/reference/trigger.md +34 -1
  89. package/docs/handbook/reference/view.md +1 -1
  90. package/docs/handbook/releases/index.md +74 -2
  91. package/docs/handbook/start/overview.md +1 -1
  92. package/docs/handbook/start/project-and-cli.md +8 -8
  93. package/docs/handbook/start/quickstart-worker.md +1 -1
  94. package/docs/skills/develop/SKILL.md +4 -3
  95. package/docs/skills/integrate/SKILL.md +1 -1
  96. package/docs/skills/media-gc/SKILL.md +7 -5
  97. package/docs/skills/provision/SKILL.md +4 -1
  98. package/docs/skills/update/SKILL.md +1 -1
  99. package/docs/upgrade-0.1-to-0.2.md +6 -6
  100. package/package.json +2 -1
  101. package/skills/README.md +11 -16
  102. package/skills/mantle/SKILL.md +4 -4
  103. package/skills/mantle/references/local-testing.md +4 -0
  104. package/skills/mantle/references/web.md +1 -1
  105. package/skills/mantle/references/website.md +24 -22
  106. package/docs/examples/app-shell/src/admin.ts +0 -10
  107. package/docs/examples/purchasing/src/admin.ts +0 -12
  108. package/docs/handbook/guides/admin-extensions.md +0 -20
package/README.md CHANGED
@@ -34,7 +34,7 @@ For transports, `paged/views/<name>.ts` returns `{ rows, nextCursor? }` with key
34
34
  [surface deltas](./docs/handbook/reference/surface.md) define the REST/MCP wire contract.
35
35
  Proved internal Views also get paging without a public route/tool: one SELECT
36
36
  per page, validated scalar cursor keys and hidden-key removal. Schema tuples
37
- need indexed order after fixed equality prefixes; complete groups and singleton
37
+ need indexed order after fixed equality prefixes ([index prefixes](./docs/handbook/reference/schema.md#index-prefixes-for-paged-views)); complete groups and singleton
38
38
  joins are supported. Grouping still scans/aggregates its source: inspect EXPLAIN.
39
39
  Unsupported internal SQL retains its bare module.
40
40
 
@@ -97,16 +97,21 @@ For `cloudflare-d1` and `cloudflare-postgres`, generation configures Wrangler to
97
97
  `mantle bundle <entry> <outdir>` automatically. Node 22.22 or newer is required. Run from the project root;
98
98
  regenerate after manifest or auth option changes.
99
99
 
100
- `--entry` defaults to `src/worker.ts`, `--outdir` to `dist`. `--wrangler` defaults to the
100
+ `--entry` defaults to `src/worker.ts`, `--outdir` to `dist`; with `--admin`, `--assets` (Wrangler's `assets.directory`) defaults to `<outdir>/client`. `--wrangler` defaults to the
101
101
  existing `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`, otherwise `wrangler.jsonc`.
102
102
  JSONC edits preserve comments and unrelated settings and are idempotent. A new config
103
103
  needs its `name`, `compatibility_date` and bindings filled in. TOML only prints instructions.
104
104
 
105
- esbuild emits minified split ESM; Wrangler uploads it with `no_bundle`,
106
- `find_additional_modules`, `base_dir` and an ESModule rule. `nodejs_compat` is ensured.
105
+ esbuild emits minified split ESM into `<outdir>/worker`; Wrangler uploads it with `no_bundle`,
106
+ `find_additional_modules`, `base_dir` and an ESModule rule. Generation ensures
107
+ `nodejs_compat` and defaults to `global_fetch_strictly_public`, including explicit
108
+ environment flag overrides; see [Workers deployment](./docs/handbook/cloudflare/deploy-and-operate.md).
107
109
  The metafile guard rejects `better-auth` / `@better-auth/*` in the static startup graph:
108
110
  use `authLoader(() => import(...))` ([Auth](./docs/handbook/cloudflare/authentication.md#worker-entry)).
109
- The output directory is cleaned first; keep source and generated code elsewhere.
111
+ The Worker directory is cleaned first; keep source and generated code elsewhere. A Worker that imports
112
+ `@aotter/mantle-admin/server` also gets Admin, built with Vite into `<assets>/admin` (default `<outdir>/client/admin`)
113
+ from `admin.tsx` beside the entry or the built-in entry. The built-in entry is skipped while the installed
114
+ Admin and its direct dependencies are unchanged; an `admin.tsx` entry builds every time.
110
115
 
111
116
  ## D1 Sessions
112
117
 
@@ -91,7 +91,8 @@ export async function resolveCaller(request, auth, options = {}) {
91
91
  const { oauthProviderResourceClient } = await import("@better-auth/oauth-provider/resource-client");
92
92
  const { createDpopReplayStore } = await import("better-auth/oauth2");
93
93
  const context = await instance.$context;
94
- const verifier = oauthProviderResourceClient(instance).getActions();
94
+ // Issuance uses the normalized auth context URL, not the raw origin-only baseURL option.
95
+ const verifier = oauthProviderResourceClient({ options: { ...instance.options, baseURL: context.baseURL, basePath: "" }, $context: instance.$context }).getActions();
95
96
  let claims;
96
97
  try {
97
98
  claims = await verifier.verifyAccessTokenRequest(request, { verifyOptions: { audience: options.audience }, dpop: { replayStore: createDpopReplayStore(context.internalAdapter) } });
@@ -1,6 +1,6 @@
1
1
  /** Optional identity operations. Better Auth owns access control, writes and user hooks. */
2
2
  import { type AuthInstance } from "./caller.js";
3
- import { type Caller, type Postgres } from "../kernel/contracts.js";
3
+ import { type Caller, type Postgres, type Unsafe } from "../kernel/contracts.js";
4
4
  import type { Dialect } from "../kernel/dialect/common.js";
5
5
  import { type Reader } from "../kernel/driver/d1.js";
6
6
  type User = {
@@ -94,7 +94,7 @@ declare function directory(auth: IdentityAuth, _caller: Caller, headers: Headers
94
94
  };
95
95
  export type IdentityDirectory = ReturnType<typeof directory>;
96
96
  export declare const d1Identity: (db: Reader, auth: IdentityAuth, caller: Caller, headers: Headers) => IdentityDirectory;
97
- export declare const postgresIdentity: (db: Postgres, auth: IdentityAuth, caller: Caller, headers: Headers) => IdentityDirectory;
97
+ export declare const postgresIdentity: (db: Unsafe, auth: IdentityAuth, caller: Caller, headers: Headers) => IdentityDirectory;
98
98
  /**
99
99
  * Server-side reads of Better Auth users for code without a session (cron, system callers): no SQL against Better Auth's
100
100
  * tables, each instance's own table mapping, banned users excluded. Read-only; writes stay with the caller-bound directory.
@@ -1,11 +1,43 @@
1
1
  import type { oauthProvider } from "@better-auth/oauth-provider";
2
2
  import type { AuthInstance } from "./caller.js";
3
- /** Fail closed for unbound tokens; reconsent cannot resurrect an issued JWT. */
3
+ /**
4
+ * Fail closed for unbound tokens; reconsent cannot resurrect an issued JWT. As in Better Auth, an `offline_access` grant outlives
5
+ * the browser session: without a live session it stands on an unrevoked, unexpired offline refresh token for the same user and client.
6
+ */
4
7
  export declare function assertActiveUserGrant(auth: AuthInstance, claims: Record<string, unknown>, audience: string | readonly string[]): Promise<{
5
8
  id: string;
6
9
  }>;
7
10
  /** Add first to oauthProvider/mcp extensions; Better Auth retains ownership of referenceId. */
8
11
  export declare const oauthGrantExtension: NonNullable<Parameters<typeof oauthProvider>[0]["extensions"]>[number];
12
+ export declare const OAUTH_GRANT_REVOCATION = "mantle-oauth-grant-revocation";
13
+ /**
14
+ * Add to the Better Auth plugins with the OAuth provider. An `offline_access` grant outlives sign-out and session expiry
15
+ * (as in Better Auth), but a successful password reset, a password change that signs out other devices, an admin ban
16
+ * (`/admin/ban-user`, or `/admin/update-user` setting `banned: true`), an admin session revocation or an admin password
17
+ * change ends every OAuth grant of that user: tokens, consents and unredeemed codes. So does an email OTP or magic-link
18
+ * sign-in attempt on an unverified user: Better Auth strips what was set up before that proof, possibly by someone else
19
+ * who registered the address first, and Mantle ends that party's grants before the attempt is checked.
20
+ */
21
+ export declare function oauthGrantRevocation(): {
22
+ id: "mantle-oauth-grant-revocation";
23
+ hooks: {
24
+ before: ({
25
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
26
+ handler: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<{
27
+ context: {
28
+ mantleResetPasswordUser: string;
29
+ };
30
+ } | undefined>>;
31
+ } | {
32
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
33
+ handler: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>;
34
+ })[];
35
+ after: {
36
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
37
+ handler: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>;
38
+ }[];
39
+ };
40
+ };
9
41
  export interface OAuthConsentRequest {
10
42
  readonly clientName: string;
11
43
  readonly redirectUri: string;
@@ -1,3 +1,4 @@
1
+ import { createAuthMiddleware } from "@better-auth/core/api";
1
2
  import { nativeAuthRead } from "./adapter.js";
2
3
  import { ContractError } from "../kernel/contracts.js";
3
4
  const pair = (field, value) => ({ field, value });
@@ -10,39 +11,147 @@ const array = (value) => {
10
11
  return null;
11
12
  }
12
13
  };
13
- /** Fail closed for unbound tokens; reconsent cannot resurrect an issued JWT. */
14
+ /**
15
+ * Fail closed for unbound tokens; reconsent cannot resurrect an issued JWT. As in Better Auth, an `offline_access` grant outlives
16
+ * the browser session: without a live session it stands on an unrevoked, unexpired offline refresh token for the same user and client.
17
+ */
14
18
  export async function assertActiveUserGrant(auth, claims, audience) {
15
19
  const { sub, azp, sid, mantle_consent_id: id, mantle_consent_reference: reference, mantle_skip_consent: skip } = claims;
16
- if (![sub, azp, sid].every(x => typeof x === "string" && x.length) || !(skip === true && id === null || typeof id === "string" && id.length))
20
+ const tokenScopes = typeof claims.scope === "string" ? claims.scope.split(/\s+/).filter(Boolean) : array(claims.scope);
21
+ const offline = !!tokenScopes?.includes("offline_access");
22
+ if (![sub, azp].every(x => typeof x === "string" && x.length) || !(offline && sid == null || typeof sid === "string" && sid.length) || !(skip === true && id === null || typeof id === "string" && id.length))
17
23
  throw new ContractError("UNAUTHENTICATED", "OAuth token is not bound to a user session.");
18
24
  const { exec, param, now, table, column } = nativeAuthRead(auth.options);
19
- const c = (field) => `c.${column("oauthConsent", field)}`, s = (field) => `s.${column("session", field)}`, client = (field) => `client.${column("oauthClient", field)}`;
20
- const { rows } = await exec(`SELECT ${s("userId")} AS id, ${c("id")} AS "consentId", ${c("referenceId")} AS reference, ${c("resources")} AS resources, ${c("scopes")} AS scopes,
25
+ const at = now(Date.now());
26
+ const c = (field) => `c.${column("oauthConsent", field)}`, s = (field) => `s.${column("session", field)}`, client = (field) => `client.${column("oauthClient", field)}`, r = (field) => column("oauthRefreshToken", field);
27
+ const { rows } = await exec(`SELECT ${s("id")} AS session, ${c("id")} AS "consentId", ${c("referenceId")} AS reference, ${c("resources")} AS resources, ${c("scopes")} AS scopes,
21
28
  ${client("skipConsent")} AS "skipConsent", ${client("disabled")} AS disabled, ${client("scopes")} AS "clientScopes"
22
- FROM ${table("oauthClient")} client JOIN ${table("session")} s ON ${s("id")} = ${param(1)} AND ${s("userId")} = ${param(2)} AND ${s("expiresAt")} > ${param(3)}
23
- LEFT JOIN ${table("oauthConsent")} c ON ${c("id")} = ${param(4)} AND ${c("userId")} = ${s("userId")} AND ${c("clientId")} = ${client("clientId")}
24
- WHERE ${client("clientId")} = ${param(5)}`, [sid, sub, now(Date.now()), id, azp]);
29
+ FROM ${table("oauthClient")} client LEFT JOIN ${table("session")} s ON ${s("id")} = ${param(1)} AND ${s("userId")} = ${param(2)} AND ${s("expiresAt")} > ${param(3)}
30
+ LEFT JOIN ${table("oauthConsent")} c ON ${c("id")} = ${param(4)} AND ${c("userId")} = ${param(2)} AND ${c("clientId")} = ${client("clientId")}
31
+ WHERE ${client("clientId")} = ${param(5)}`, [sid ?? null, sub, at, id, azp]);
25
32
  const grant = rows[0];
33
+ const live = !!grant?.session || offline && !!grant && (await exec(`SELECT ${r("scopes")} AS scopes FROM ${table("oauthRefreshToken")}
34
+ WHERE ${r("userId")} = ${param(1)} AND ${r("clientId")} = ${param(2)} AND ${r("revoked")} IS NULL AND ${r("expiresAt")} > ${param(3)}`, [sub, azp, at])).rows.some(row => array(row.scopes)?.includes("offline_access"));
26
35
  const provider = auth.options.plugins?.find(plugin => plugin.id === "oauth-provider");
27
36
  const firstParty = skip === true && id === null && !!grant?.skipConsent;
28
37
  const resources = firstParty ? (typeof claims.aud === "string" ? [claims.aud] : array(claims.aud)) : array(grant?.resources);
29
38
  const scopes = firstParty ? array(grant?.clientScopes) ?? provider?.options?.scopes : array(grant?.scopes);
30
39
  const requested = typeof audience === "string" ? [audience] : audience;
31
40
  const tokenAudiences = typeof claims.aud === "string" ? [claims.aud] : array(claims.aud) ?? [];
32
- const tokenScopes = typeof claims.scope === "string" ? claims.scope.split(/\s+/).filter(Boolean) : array(claims.scope);
33
- if (!grant || grant.disabled || !firstParty && (!grant.consentId || (grant.reference ?? null) !== reference) || !resources || !scopes || !tokenScopes || !requested.some(x => resources.includes(x) && tokenAudiences.includes(x)) || !tokenScopes.every(x => scopes.includes(x)))
41
+ if (!grant || !live || grant.disabled || !firstParty && (!grant.consentId || (grant.reference ?? null) !== reference) || !resources || !scopes || !tokenScopes || !requested.some(x => resources.includes(x) && tokenAudiences.includes(x)) || !tokenScopes.every(x => scopes.includes(x)))
34
42
  throw new ContractError("UNAUTHENTICATED", "OAuth authorization grant is no longer active.");
35
- return { id: grant.id };
43
+ return { id: sub };
36
44
  }
37
45
  /** Add first to oauthProvider/mcp extensions; Better Auth retains ownership of referenceId. */
38
46
  export const oauthGrantExtension = {
39
- claims: { accessToken: async ({ ctx, user, client, referenceId }) => {
47
+ claims: { accessToken: async ({ ctx, user, client, referenceId, grantType, scopes, sessionId }) => {
48
+ // Narrowing away offline_access on refresh would mint a session-bound token for a session that has already ended.
49
+ if (grantType === "refresh_token" && !scopes.includes("offline_access")) {
50
+ const session = sessionId ? await ctx.context.adapter.findOne({ model: "session", where: [pair("id", sessionId)] }) : null;
51
+ if (!session || new Date(session.expiresAt).getTime() <= Date.now())
52
+ throw ctx.error("BAD_REQUEST", { error: "invalid_grant", error_description: "The browser session for this grant has ended; keep offline_access or sign in again." });
53
+ }
40
54
  if (client.skipConsent)
41
55
  return { mantle_consent_id: null, mantle_skip_consent: true };
42
56
  const consent = user && await ctx.context.adapter.findOne({ model: "oauthConsent", where: [pair("userId", user.id), pair("clientId", client.clientId), { field: "referenceId", value: referenceId ?? null }] });
43
57
  return { mantle_consent_id: consent?.id ?? null, mantle_consent_reference: referenceId ?? null };
44
58
  } },
45
59
  };
60
+ export const OAUTH_GRANT_REVOCATION = "mantle-oauth-grant-revocation";
61
+ /** Password resets (all channels) and an admin password change: the old credential may be compromised, so every session ends too. */
62
+ const RESETS = ["/reset-password", "/email-otp/reset-password", "/phone-number/reset-password", "/admin/set-user-password"];
63
+ const ENDING = [...RESETS, "/change-password", "/admin/ban-user", "/admin/update-user", "/admin/revoke-user-sessions"];
64
+ const RESET_USER = "mantleResetPasswordUser";
65
+ /** Better Auth's email-proof sign-ins: on an unverified user they delete its accounts and sessions (revokeUnprovenAccountAccess). */
66
+ const PROVING = ["/sign-in/email-otp", "/magic-link/verify"];
67
+ /**
68
+ * Add to the Better Auth plugins with the OAuth provider. An `offline_access` grant outlives sign-out and session expiry
69
+ * (as in Better Auth), but a successful password reset, a password change that signs out other devices, an admin ban
70
+ * (`/admin/ban-user`, or `/admin/update-user` setting `banned: true`), an admin session revocation or an admin password
71
+ * change ends every OAuth grant of that user: tokens, consents and unredeemed codes. So does an email OTP or magic-link
72
+ * sign-in attempt on an unverified user: Better Auth strips what was set up before that proof, possibly by someone else
73
+ * who registered the address first, and Mantle ends that party's grants before the attempt is checked.
74
+ */
75
+ export function oauthGrantRevocation() {
76
+ return {
77
+ id: OAUTH_GRANT_REVOCATION,
78
+ hooks: {
79
+ // Better Auth consumes the reset token, so its user rides this request's own context to the after hook.
80
+ before: [{ matcher: ctx => ctx.path === "/reset-password", handler: createAuthMiddleware(async (ctx) => {
81
+ // Better Auth reads the token from the body or the query, in that order.
82
+ const token = ctx.body?.token || ctx.query?.token;
83
+ const value = typeof token === "string" && token ? await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`) : null;
84
+ if (value)
85
+ return { context: { [RESET_USER]: value.value } };
86
+ }) }, { matcher: ctx => PROVING.includes(ctx.path ?? ""), handler: createAuthMiddleware(async (ctx) => {
87
+ // Before the endpoint, while the user is still unverified and the magic-link token is unconsumed: once Better Auth
88
+ // marks the address verified, no later request would tell the pre-registrant's grants apart. A failure here fails
89
+ // the request before verification, so a retry ends them. A failed attempt ends them too: an unverified user's grants
90
+ // are exactly the pre-hijack risk.
91
+ const email = ctx.path === "/sign-in/email-otp" ? ctx.body?.email : await magicLinkEmail(ctx.context, ctx.query?.token);
92
+ const found = typeof email === "string" && email ? await ctx.context.internalAdapter.findUserByEmail(email.toLowerCase()) : null;
93
+ if (found && !found.user.emailVerified)
94
+ await endGrants(ctx.context.adapter, found.user.id);
95
+ }) }],
96
+ // After the endpoint: its own validation and permission checks have passed and it succeeded.
97
+ after: [{ matcher: ctx => ENDING.includes(ctx.path ?? ""), handler: createAuthMiddleware(async (ctx) => {
98
+ const returned = ctx.context.returned, path = ctx.path ?? "";
99
+ if (returned instanceof Error || returned instanceof Response && !returned.ok)
100
+ return;
101
+ const body = ctx.body;
102
+ const userId = path === "/reset-password" ? ctx[RESET_USER]
103
+ : path === "/email-otp/reset-password" ? typeof body?.email === "string" && (await ctx.context.internalAdapter.findUserByEmail(body.email.toLowerCase()))?.user.id
104
+ : path === "/phone-number/reset-password" ? typeof body?.phoneNumber === "string" && (await ctx.context.adapter.findOne({ model: "user", where: [pair("phoneNumber", body.phoneNumber)] }))?.id
105
+ // Better Auth itself ends the other sessions and keeps the current one.
106
+ : path === "/change-password" ? body?.revokeOtherSessions === true && (ctx.context.session?.user.id ?? returned?.user?.id)
107
+ : path === "/admin/update-user" ? body?.data?.banned === true && body.userId
108
+ : body?.userId;
109
+ if (typeof userId !== "string" || !userId)
110
+ return;
111
+ // Sessions first: a live session (perhaps the attacker's) could otherwise authorize a new grant after endGrants.
112
+ if (RESETS.includes(path))
113
+ await ctx.context.internalAdapter.deleteUserSessions(userId);
114
+ await endGrants(ctx.context.adapter, userId);
115
+ }) }],
116
+ },
117
+ };
118
+ }
119
+ /** The email a magic-link token names, read without consuming it; the token is stored as the magic-link plugin's `storeToken` says. */
120
+ async function magicLinkEmail(context, token) {
121
+ if (typeof token !== "string" || !token)
122
+ return;
123
+ const store = context.options.plugins?.find(plugin => plugin.id === "magic-link")?.options?.storeToken ?? "plain";
124
+ const hash = typeof store === "object" && store && "hash" in store && typeof store.hash === "function" ? store.hash : undefined;
125
+ const stored = store === "plain" ? token : hash ? await hash(token)
126
+ : store === "hashed" ? btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token))))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") : undefined;
127
+ const value = stored === undefined ? null : await context.internalAdapter.findVerificationValue(`magic-link:${stored}`);
128
+ try {
129
+ return value ? JSON.parse(value.value).email : undefined;
130
+ }
131
+ catch {
132
+ return;
133
+ }
134
+ }
135
+ /** Ends a user's grants (for one client, or all): consents, tokens and unredeemed authorization codes. */
136
+ async function endGrants(db, userId, clientId) {
137
+ const where = [pair("userId", userId), ...clientId ? [pair("clientId", clientId)] : []];
138
+ // Delete first: even if cleanup fails, every issued JWT fails the live-grant check immediately.
139
+ await db.deleteMany({ model: "oauthConsent", where });
140
+ for (const model of ["oauthRefreshToken", "oauthAccessToken"])
141
+ await db.updateMany({ model, where: [...where, { field: "revoked", value: null }], update: { revoked: new Date() } });
142
+ // An unredeemed code would mint a new token lineage after reconsent.
143
+ const code = (v) => { const x = authCode(v); return x?.userId === userId && (!clientId || x.query.client_id === clientId); };
144
+ const codes = [userId, clientId].filter(x => x !== undefined).map(x => ({ field: "value", operator: "contains", value: JSON.stringify(x) }));
145
+ for (let offset = 0;;) {
146
+ const page = await db.findMany({ model: "verification", where: codes, limit: 100, offset, sortBy: { field: "id", direction: "asc" } });
147
+ const ids = page.filter(v => code(v.value)).map(v => v.id);
148
+ if (ids.length)
149
+ await db.deleteMany({ model: "verification", where: [{ field: "id", operator: "in", value: ids }] });
150
+ if (page.length < 100)
151
+ break;
152
+ offset += page.length - ids.length;
153
+ }
154
+ }
46
155
  /** A verification value that is an authorization code, with its user and client; else undefined. */
47
156
  function authCode(text) {
48
157
  let value;
@@ -93,23 +202,7 @@ export function oauthConsents(auth) {
93
202
  const consent = await db.findOne({ model: "oauthConsent", where: [pair("id", consentId), pair("userId", userId)] });
94
203
  if (!consent)
95
204
  return false;
96
- const where = [pair("userId", userId), pair("clientId", consent.clientId)];
97
- // Delete first: even if cleanup fails, every issued JWT fails the live-grant check immediately.
98
- await db.deleteMany({ model: "oauthConsent", where });
99
- for (const model of ["oauthRefreshToken", "oauthAccessToken"])
100
- await db.updateMany({ model, where: [...where, { field: "revoked", value: null }], update: { revoked: new Date() } });
101
- // An unredeemed code would mint a new token lineage after reconsent.
102
- const code = (v) => { const x = authCode(v); return x?.userId === userId && x.query.client_id === consent.clientId; };
103
- const codes = [{ field: "value", operator: "contains", value: JSON.stringify(userId) }, { field: "value", operator: "contains", value: JSON.stringify(consent.clientId) }];
104
- for (let offset = 0;;) {
105
- const page = await db.findMany({ model: "verification", where: codes, limit: 100, offset, sortBy: { field: "id", direction: "asc" } });
106
- const ids = page.filter(v => code(v.value)).map(v => v.id);
107
- if (ids.length)
108
- await db.deleteMany({ model: "verification", where: [{ field: "id", operator: "in", value: ids }] });
109
- if (page.length < 100)
110
- break;
111
- offset += page.length - ids.length;
112
- }
205
+ await endGrants(db, userId, consent.clientId);
113
206
  return true;
114
207
  },
115
208
  };
@@ -1,5 +1,6 @@
1
1
  /** Optional app-mounted auth routes; consent and raw admin routes resolve fresh authority. Better Auth owns tokens. */
2
2
  import { checkOrigin, surfaceFailure, ContractError } from "../kernel/contracts.js";
3
+ import { draining } from "../kernel/drain.js";
3
4
  import { guardAuthAdminRequest } from "./admin.js";
4
5
  import { resolveCaller } from "./caller.js";
5
6
  const PRIVATE = { "cache-control": "private, no-store" };
@@ -17,7 +18,7 @@ export function authRoutes(loader, options) {
17
18
  if (options.connectedAppsPage && (!options.connectedAppsPage.startsWith("/") || options.connectedAppsPage.startsWith("//") || /[\\\r\n]/.test(options.connectedAppsPage)))
18
19
  throw new Error("connectedAppsPage must be a same-origin absolute path.");
19
20
  const resolve = options.resolver ?? ((request) => resolveCaller(request, loader));
20
- return async (request) => {
21
+ return draining(async (request) => {
21
22
  const { pathname } = new URL(request.url);
22
23
  const route = `${request.method} ${pathname}`;
23
24
  if (pathname === `${base}/methods` && request.method === "GET")
@@ -47,7 +48,7 @@ export function authRoutes(loader, options) {
47
48
  catch (error) {
48
49
  return surfaceFailure(error, undefined, `auth ${route}`);
49
50
  }
50
- };
51
+ });
51
52
  }
52
53
  function consentSurface(auth, options) {
53
54
  const { listOAuthConsents: list, revokeOAuthConsent: revoke } = auth;
@@ -1,119 +1,3 @@
1
- export interface AdminNavGroup {
2
- readonly id: string;
3
- readonly title: LocalizedText;
4
- readonly order?: number;
5
- }
6
- export type LocalizedText = string | Readonly<Record<string, string>>;
7
- export interface AdminExtension {
8
- readonly apiVersion?: 1;
9
- readonly id: string;
10
- readonly title?: LocalizedText;
11
- readonly module?: string;
12
- readonly integrity?: string;
13
- readonly source?: unknown;
14
- readonly contributes: AdminExtensionContributions;
15
- }
16
- export interface AdminExtensionContributions {
17
- readonly pages?: readonly AdminExtensionPage[];
18
- readonly settings?: readonly AdminExtensionSettings[];
19
- readonly actions?: readonly AdminExtensionAction[];
20
- readonly panels?: readonly AdminExtensionPanel[];
21
- readonly fields?: readonly AdminExtensionField[];
22
- }
23
- export interface AdminExtensionField {
24
- readonly id: string;
25
- readonly target: AdminFieldTarget;
26
- readonly role?: AdminRole;
27
- readonly when?: AdminExtensionWhen;
28
- readonly optionsSchema?: JsonSchema;
29
- }
30
- export type JsonSchema = {
31
- readonly $defs?: Readonly<Record<string, JsonSchema>>;
32
- readonly $ref?: string;
33
- readonly oneOf?: readonly JsonSchema[];
34
- readonly const?: unknown;
35
- readonly type?: string | readonly string[];
36
- readonly properties?: Readonly<Record<string, JsonSchema>>;
37
- readonly required?: readonly string[];
38
- readonly items?: JsonSchema;
39
- readonly enum?: readonly unknown[];
40
- readonly format?: string;
41
- readonly pattern?: string;
42
- readonly minLength?: number;
43
- readonly maxLength?: number;
44
- readonly minimum?: number;
45
- readonly maximum?: number;
46
- readonly minItems?: number;
47
- readonly maxItems?: number;
48
- readonly nullable?: boolean;
49
- readonly readOnly?: boolean;
50
- readonly default?: unknown;
51
- readonly additionalProperties?: boolean | JsonSchema;
52
- readonly description?: LocalizedText;
53
- readonly title?: LocalizedText;
54
- readonly "x-mantle-ref"?: string | MantleRefTarget;
55
- readonly "x-mcp-hint"?: string;
56
- readonly [key: string]: unknown;
57
- };
58
- export interface MantleRefTarget {
59
- readonly schema: string;
60
- readonly field: string;
61
- }
62
- export interface AdminExtensionWhen {
63
- readonly schema?: readonly string[];
64
- readonly field?: readonly string[];
65
- readonly format?: readonly string[];
66
- }
67
- export type AdminRole = string;
68
- export type AdminFieldTarget = (typeof ADMIN_EXTENSION_TARGETS.field)[number];
69
- export declare const ADMIN_EXTENSION_TARGETS: {
70
- readonly action: readonly [
71
- "record/v1",
72
- "list.selection/v1",
73
- "list.toolbar/v1"
74
- ];
75
- readonly panel: readonly [
76
- "record.sidebar/v1",
77
- "home/v1"
78
- ];
79
- readonly field: readonly [
80
- "field.input/v1",
81
- "field.cell/v1"
82
- ];
83
- };
84
- export interface AdminExtensionPanel {
85
- readonly id: string;
86
- readonly title: LocalizedText;
87
- readonly role: AdminRole;
88
- readonly target: AdminPanelTarget;
89
- readonly when?: AdminExtensionWhen;
90
- }
91
- export type AdminPanelTarget = (typeof ADMIN_EXTENSION_TARGETS.panel)[number];
92
- export interface AdminExtensionAction {
93
- readonly id: string;
94
- readonly title: LocalizedText;
95
- readonly role: AdminRole;
96
- readonly target: AdminActionTarget;
97
- readonly presentation: "run" | "confirm" | "dialog";
98
- readonly destructive?: boolean;
99
- readonly when?: AdminExtensionWhen;
100
- }
101
- export type AdminActionTarget = (typeof ADMIN_EXTENSION_TARGETS.action)[number];
102
- export interface AdminExtensionSettings {
103
- readonly id: string;
104
- readonly title: LocalizedText;
105
- readonly role: AdminRole;
106
- readonly schema: JsonSchema;
107
- }
108
- export interface AdminExtensionPage {
109
- readonly id: string;
110
- readonly title: LocalizedText;
111
- readonly role: AdminRole;
112
- readonly nav?: {
113
- readonly group: string;
114
- readonly order?: number;
115
- };
116
- }
117
1
  export interface SiteDefaults {
118
2
  readonly locales?: ReadonlyArray<string>;
119
3
  readonly brand?: string;
@@ -293,10 +177,6 @@ export declare const DIAGNOSTIC_CODES: readonly [
293
177
  "SCHEMA_SEARCH_FIELD_UNKNOWN",
294
178
  "SCHEMA_TTL_INVALID",
295
179
  "SCHEMA_UI_INVALID",
296
- "UI_EXTENSION_INVALID",
297
- "UI_EXTENSION_UNKNOWN",
298
- "UI_EXTENSION_TARGET",
299
- "UI_EXTENSION_OPTIONS",
300
180
  "SCHEMA_NAME_CASE_COLLISION",
301
181
  "SCHEMA_READER_NAME_COLLISION",
302
182
  "FIELD_NAME_CASE_COLLISION",
@@ -534,6 +414,39 @@ export interface CtxAuthScopePredicate {
534
414
  }
535
415
  export type CtxAuthPredicate = "ctx.auth";
536
416
  export type CtxUserPredicate = "ctx.user";
417
+ export type JsonSchema = {
418
+ readonly $defs?: Readonly<Record<string, JsonSchema>>;
419
+ readonly $ref?: string;
420
+ readonly oneOf?: readonly JsonSchema[];
421
+ readonly const?: unknown;
422
+ readonly type?: string | readonly string[];
423
+ readonly properties?: Readonly<Record<string, JsonSchema>>;
424
+ readonly required?: readonly string[];
425
+ readonly items?: JsonSchema;
426
+ readonly enum?: readonly unknown[];
427
+ readonly format?: string;
428
+ readonly pattern?: string;
429
+ readonly minLength?: number;
430
+ readonly maxLength?: number;
431
+ readonly minimum?: number;
432
+ readonly maximum?: number;
433
+ readonly minItems?: number;
434
+ readonly maxItems?: number;
435
+ readonly nullable?: boolean;
436
+ readonly readOnly?: boolean;
437
+ readonly default?: unknown;
438
+ readonly additionalProperties?: boolean | JsonSchema;
439
+ readonly description?: LocalizedText;
440
+ readonly title?: LocalizedText;
441
+ readonly "x-mantle-ref"?: string | MantleRefTarget;
442
+ readonly "x-mcp-hint"?: string;
443
+ readonly [key: string]: unknown;
444
+ };
445
+ export interface MantleRefTarget {
446
+ readonly schema: string;
447
+ readonly field: string;
448
+ }
449
+ export type LocalizedText = string | Readonly<Record<string, string>>;
537
450
  export type PlanRestrict = Readonly<Record<string, {
538
451
  readonly owner?: string;
539
452
  readonly terms: readonly UserScope[];
@@ -586,6 +499,13 @@ export interface UserFieldSpec {
586
499
  export declare const RESERVED_PROCEDURE_INPUT_NAMES: readonly [
587
500
  "expectedVersion"
588
501
  ];
502
+ export declare const INPUT_RULE_KEYWORDS: readonly [
503
+ "dependentRequired",
504
+ "anyOf",
505
+ "if",
506
+ "then",
507
+ "else"
508
+ ];
589
509
  export declare const EXPECTED_VERSION_PROPERTY: "expectedVersion";
590
510
  export declare const RESERVED_ENTRY_COLUMNS: readonly [
591
511
  "id",
@@ -680,6 +600,7 @@ export interface TranslatesBinding {
680
600
  readonly on: string;
681
601
  }
682
602
  export type Manifest = SchemaManifest | ViewManifest | ProcedureManifest | TriggerManifest;
603
+ export type AdminRole = string;
683
604
  export declare function isAdminRole(s: string): s is AdminRole;
684
605
  export declare const PROCEDURE_MCP_ANNOTATION_KEYS: readonly [
685
606
  "readOnlyHint",
@@ -696,6 +617,7 @@ export declare const VIEW_INPUT_RESERVED: readonly [
696
617
  "cursor"
697
618
  ];
698
619
  export declare function resolveLocalizedText(value: LocalizedText | null | undefined, preferred: string, canonical?: string | null): string | null;
620
+ export declare const isLocalizedText: (v: unknown) => v is LocalizedText;
699
621
  export declare const MANTLE_BIND_KEYWORD: "x-mantle-bind";
700
622
  export declare const MCP_HINT_KEYWORD: "x-mcp-hint";
701
623
  export declare function resolveMantleRef(property: unknown): MantleRefTarget | null;
@@ -706,8 +628,6 @@ export declare function enumOptions(property: JsonSchema | undefined): {
706
628
  export declare const MANTLE_REF_KEYWORD: "x-mantle-ref";
707
629
  export type MediaMcpHint = "media" | "media-image" | "media-video" | "media-file";
708
630
  export declare function isMediaMcpHint(value: unknown): value is MediaMcpHint;
709
- export declare const isUiExtensionRef: (value: unknown) => value is string;
710
- export declare const UI_EXTENSION_REF: RegExp;
711
631
  export interface Diagnostic {
712
632
  readonly code: DiagnosticCode;
713
633
  readonly phase: Phase;
@@ -769,6 +689,7 @@ export declare const CHECK_FUNCTIONS: ReadonlyMap<string, {
769
689
  }>;
770
690
  export declare function storageColumnClash(fields: Readonly<Record<string, string>>): string | undefined;
771
691
  export declare function notNullColumns(s: SqlSchemaDef): Set<string>;
692
+ export declare const NATIVE_COLUMNS: Readonly<Record<string, string>>;
772
693
  export declare const NATIVE_NOT_NULL: ReadonlySet<string>;
773
694
  export declare function storageColumns(s: {
774
695
  readonly fields?: Readonly<Record<string, string>>;
@@ -787,4 +708,5 @@ export interface RowTemplate {
787
708
  export declare function parseRowTemplate(text: unknown): RowTemplate | string;
788
709
  export declare const NATIVE_OUTPUT_TYPES: Readonly<Record<string, string>>;
789
710
  export declare function planFingerprint(plan: Omit<RuntimePlan, "fingerprint">): Promise<string>;
711
+ export declare const fnv: (s: string) => string;
790
712
  export declare function canonical(v: unknown): string;