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

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 (126) hide show
  1. package/README.md +27 -5
  2. package/dist/auth/adapter.js +9 -6
  3. package/dist/auth/caller.js +2 -1
  4. package/dist/auth/identity.d.ts +2 -2
  5. package/dist/auth/oauth.d.ts +33 -1
  6. package/dist/auth/oauth.js +121 -28
  7. package/dist/auth/routes.js +6 -4
  8. package/dist/build/compiler.d.ts +44 -122
  9. package/dist/build/compiler.js +202 -277
  10. package/dist/build/spec/domain/model/ManifestGrammar.d.ts +6 -5
  11. package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
  12. package/dist/build/spec/domain/service/PlanFingerprint.d.ts +2 -0
  13. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  14. package/dist/domain/describe/{chunk-ZIQVLA7X.js → chunk-ZVXBGP7Y.js} +69 -8
  15. package/dist/domain/describe/index.js +1 -1
  16. package/dist/domain/describe/target-hash.js +1 -1
  17. package/dist/domain/describe/targets.d.ts +1 -1
  18. package/dist/domain/generated-tools.js +2 -0
  19. package/dist/domain/paging.js +41 -15
  20. package/dist/infrastructure/bundle.js +118 -15
  21. package/dist/infrastructure/cli.js +11 -15
  22. package/dist/infrastructure/generate.d.ts +1 -3
  23. package/dist/infrastructure/generate.js +11 -3
  24. package/dist/infrastructure/generateAuth.js +57 -0
  25. package/dist/infrastructure/inputs.js +8 -3
  26. package/dist/infrastructure/wrangler.js +50 -12
  27. package/dist/kernel/binds.d.ts +0 -3
  28. package/dist/kernel/contracts.d.ts +1 -1
  29. package/dist/kernel/contracts.js +4 -4
  30. package/dist/kernel/dialect/common.d.ts +0 -1
  31. package/dist/kernel/drain.d.ts +5 -0
  32. package/dist/kernel/drain.js +35 -0
  33. package/dist/kernel/driver/postgres.js +6 -2
  34. package/dist/kernel/host-caller.d.ts +1 -0
  35. package/dist/kernel/host-caller.js +1 -0
  36. package/dist/kernel/mcp-apps.d.ts +29 -9
  37. package/dist/kernel/mcp-apps.js +3 -3
  38. package/dist/kernel/mcp-handler.js +101 -18
  39. package/dist/kernel/mcp.d.ts +12 -3
  40. package/dist/kernel/mcp.js +31 -6
  41. package/dist/kernel/rest-router.js +3 -2
  42. package/dist/kernel/scalars.js +3 -2
  43. package/dist/kernel/site.d.ts +1 -3
  44. package/dist/kernel/site.js +2 -2
  45. package/dist/kernel/web-index.js +40 -0
  46. package/dist/kernel/web.d.ts +3 -1
  47. package/dist/kernel/web.js +6 -37
  48. package/dist/usecase/adminBindings.js +2 -3
  49. package/dist/usecase/adminClient.js +87 -0
  50. package/dist/usecase/adminDescriptors.js +19 -65
  51. package/dist/usecase/generate.js +5 -4
  52. package/dist/usecase/mcp-media.js +2 -2
  53. package/dist/usecase/mcp.js +16 -12
  54. package/dist/usecase/paging.js +2 -3
  55. package/dist/usecase/publishing.js +3 -3
  56. package/dist/usecase/site.js +0 -1
  57. package/dist/usecase/tool-version.js +9 -0
  58. package/dist/usecase/translations.js +1 -1
  59. package/dist/usecase/validation.js +128 -19
  60. package/dist/usecase/web.js +3 -2
  61. package/docs/examples/README.md +1 -1
  62. package/docs/examples/app-shell/README.md +45 -3
  63. package/docs/examples/app-shell/package.json +14 -6
  64. package/docs/examples/app-shell/smoke-kit.mjs +54 -0
  65. package/docs/examples/app-shell/src/auth.ts +6 -3
  66. package/docs/examples/app-shell/src/service.ts +3 -3
  67. package/docs/examples/app-shell/tsconfig.json +4 -1
  68. package/docs/examples/app-shell/worker-configuration.d.ts +2 -2
  69. package/docs/examples/app-shell/wrangler.jsonc +5 -4
  70. package/docs/examples/club-events/manifests/club-events.yaml +7 -0
  71. package/docs/examples/club-events/src/handlers.ts +1 -1
  72. package/docs/examples/generated-web/README.md +6 -2
  73. package/docs/examples/generated-web/wrangler.jsonc +1 -0
  74. package/docs/examples/purchasing/README.md +1 -1
  75. package/docs/examples/purchasing/manifests/purchasing.yaml +17 -1
  76. package/docs/examples/purchasing/smoke.mjs +10 -4
  77. package/docs/examples/purchasing/src/admin.tsx +13 -0
  78. package/docs/examples/purchasing/src/handlers.ts +1 -1
  79. package/docs/examples/purchasing/wrangler.jsonc +5 -4
  80. package/docs/examples/recipes/wrangler.jsonc +5 -4
  81. package/docs/handbook/cloudflare/authentication.md +43 -6
  82. package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
  83. package/docs/handbook/cloudflare/deploy-and-operate.md +45 -1
  84. package/docs/handbook/cloudflare/media-r2.md +2 -3
  85. package/docs/handbook/cloudflare/service-entry.md +68 -31
  86. package/docs/handbook/concepts/authorization.md +1 -1
  87. package/docs/handbook/concepts/mcp-and-agents.md +19 -0
  88. package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
  89. package/docs/handbook/examples/hub.md +1 -1
  90. package/docs/handbook/guides/admin-ui.md +107 -1
  91. package/docs/handbook/guides/agent-setup.md +27 -9
  92. package/docs/handbook/guides/agent-workflow-acceptance.md +222 -0
  93. package/docs/handbook/guides/mcp-apps.md +522 -6
  94. package/docs/handbook/guides/native-admin.md +300 -51
  95. package/docs/handbook/guides/oauth.md +119 -5
  96. package/docs/handbook/guides/public-pages.md +2 -2
  97. package/docs/handbook/guides/size-and-startup.md +1 -1
  98. package/docs/handbook/guides/typed-queries.md +35 -0
  99. package/docs/handbook/navigation.json +0 -4
  100. package/docs/handbook/reference/admin-descriptors.md +0 -35
  101. package/docs/handbook/reference/procedure.md +6 -2
  102. package/docs/handbook/reference/schema.md +13 -3
  103. package/docs/handbook/reference/surface.md +129 -15
  104. package/docs/handbook/reference/trigger.md +43 -1
  105. package/docs/handbook/reference/view.md +1 -1
  106. package/docs/handbook/releases/index.md +74 -2
  107. package/docs/handbook/start/overview.md +1 -1
  108. package/docs/handbook/start/project-and-cli.md +16 -8
  109. package/docs/handbook/start/quickstart-worker.md +1 -1
  110. package/docs/skills/develop/SKILL.md +46 -18
  111. package/docs/skills/integrate/SKILL.md +4 -9
  112. package/docs/skills/media-gc/SKILL.md +7 -5
  113. package/docs/skills/provision/SKILL.md +18 -12
  114. package/docs/skills/update/SKILL.md +19 -18
  115. package/docs/upgrade-0.1-to-0.2.md +6 -6
  116. package/package.json +2 -1
  117. package/skills/README.md +11 -16
  118. package/skills/mantle/SKILL.md +39 -17
  119. package/skills/mantle/references/local-testing.md +9 -0
  120. package/skills/mantle/references/web.md +1 -1
  121. package/skills/mantle/references/website.md +26 -22
  122. package/docs/examples/app-shell/src/admin.ts +0 -10
  123. package/docs/examples/purchasing/src/admin.ts +0 -12
  124. package/docs/handbook/guides/admin-extensions.md +0 -20
  125. package/docs/skills/develop/mcp-app.md +0 -39
  126. package/skills/.keep +0 -0
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
 
@@ -46,6 +46,15 @@ state column names, value types and the writable columns, and the Store enforces
46
46
  **not** a View's or Procedure's `requires`: authorize before calling it. Design, limits and what the runtime still refuses are in
47
47
  [Store guide](./docs/handbook/guides/typed-queries.md).
48
48
 
49
+ For simple creates and partial updates, prefer a declared Store-backed ref
50
+ Procedure: await the caller-bound insert/update, return an allowlist with the
51
+ actual returned `id`/`version`, and preserve omitted fields. Keep Procedure
52
+ `requires` and boundary validation; Store row policy is not operation
53
+ permission. Retain SQL Procedures for atomic conditional/multi-entity writes
54
+ such as stock/order changes and additive upserts. D1 batches cannot consume a
55
+ prior step's result and have no interactive transaction callback. See the
56
+ [write design guidance](./docs/handbook/guides/typed-queries.md#prefer-store-backed-ref-procedures-for-simple-writes).
57
+
49
58
  Importing generated modules does not initialize the Store. The first `store()`
50
59
  call prepares its metadata factory once; every call binds its own database,
51
60
  Caller and handlers. Unused Stores still tree-shake out of direct operations.
@@ -66,6 +75,14 @@ Rank-ordered exposed Views still need supported keyset ordering; use an internal
66
75
  View for arbitrary rank ordering. Applying changed search DDL remains the app's
67
76
  migration responsibility; no request runs DDL or rebuilds indexes.
68
77
 
78
+ ## Independently accept user-facing work
79
+
80
+ Before reporting any user-facing deliverable as done, follow the version-matched
81
+ [independent acceptance guide](./docs/handbook/guides/agent-workflow-acceptance.md)
82
+ with a fresh agent on the surface its user uses. For MCP work, also run the
83
+ guide's design audit before implementing tools; scripted checks remain separate
84
+ evidence.
85
+
69
86
  ## Publishing
70
87
 
71
88
  Schemas default to `lifecycle: operational`. Opt-in `lifecycle: publishing` adds status
@@ -97,16 +114,21 @@ For `cloudflare-d1` and `cloudflare-postgres`, generation configures Wrangler to
97
114
  `mantle bundle <entry> <outdir>` automatically. Node 22.22 or newer is required. Run from the project root;
98
115
  regenerate after manifest or auth option changes.
99
116
 
100
- `--entry` defaults to `src/worker.ts`, `--outdir` to `dist`. `--wrangler` defaults to the
117
+ `--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
118
  existing `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`, otherwise `wrangler.jsonc`.
102
119
  JSONC edits preserve comments and unrelated settings and are idempotent. A new config
103
120
  needs its `name`, `compatibility_date` and bindings filled in. TOML only prints instructions.
104
121
 
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.
122
+ esbuild emits minified split ESM into `<outdir>/worker`; Wrangler uploads it with `no_bundle`,
123
+ `find_additional_modules`, `base_dir` and an ESModule rule. Generation ensures
124
+ `nodejs_compat` and defaults to `global_fetch_strictly_public`, including explicit
125
+ environment flag overrides; see [Workers deployment](./docs/handbook/cloudflare/deploy-and-operate.md).
107
126
  The metafile guard rejects `better-auth` / `@better-auth/*` in the static startup graph:
108
127
  use `authLoader(() => import(...))` ([Auth](./docs/handbook/cloudflare/authentication.md#worker-entry)).
109
- The output directory is cleaned first; keep source and generated code elsewhere.
128
+ The Worker directory is cleaned first; keep source and generated code elsewhere. A Worker that imports
129
+ `@aotter/mantle-admin/server` also gets Admin, built with Vite into `<assets>/admin` (default `<outdir>/client/admin`)
130
+ from `admin.tsx` beside the entry or the built-in entry. The built-in entry is skipped while the installed
131
+ Admin and its direct dependencies are unchanged; an `admin.tsx` entry builds every time.
110
132
 
111
133
  ## D1 Sessions
112
134
 
@@ -70,19 +70,22 @@ function customAdapter(d, exec, seed, insertOnly = false, options) {
70
70
  const { adminRoles: roles, adminUserIds } = options ? adminOptionsOf({ options }) : { adminRoles: [], adminUserIds: [] };
71
71
  const holdsAdmin = (role) => typeof role === "string" && role.split(",").some(role => roles.includes(role));
72
72
  const field = (name) => getFieldName({ model: "user", field: name });
73
- const protects = (modelKey, values) => modelKey === "user" && roles.length > 0 &&
73
+ const protects = (modelKey, values) => modelKey === "user" && (roles.length > 0 || adminUserIds.length > 0) &&
74
74
  (!values || values[field("banned")] === true || values[field("banned")] === 1 || Object.hasOwn(values, field("role")) && !holdsAdmin(values[field("role")]));
75
- const membership = (s, alias) => '(' + roles.map(role => `${d === sqlite ? "instr" : "strpos"}(',' || COALESCE(${alias}.${ident(field("role"))}, '') || ',', ${s.bind(',' + role + ',')}) > 0`).join(' OR ') + ')';
76
- const lastAdminGuard = (s, model) => {
75
+ const membership = (s, alias) => '(' + [...roles.map(role => `${d === sqlite ? "instr" : "strpos"}(',' || COALESCE(${alias}.${ident(field("role"))}, '') || ',', ${s.bind(',' + role + ',')}) > 0`), d === sqlite ? "0" : "FALSE"].join(' OR ') + ')';
76
+ const allowlisted = (s, alias) => `(${[...adminUserIds.map(id => `${alias}.${idOf("user")} = ${s.bind(id)}`), d === sqlite ? "0" : "FALSE"].join(" OR ")})`;
77
+ const administrator = (s, alias) => `(${membership(s, alias)} OR ${allowlisted(s, alias)})`;
78
+ const lastAdminGuard = (s, model, keepsAllowlisted = false) => {
77
79
  const table = ident(model), other = '"_mantle_other_admin"', no = d === sqlite ? '0' : 'FALSE';
78
- return `(NOT ${membership(s, table)} OR EXISTS (SELECT 1 FROM ${table} ${other} WHERE (${membership(s, other)}${adminUserIds.map(id => ` OR ${other}.${idOf("user")} = ${s.bind(id)}`).join("")}) AND ${other}.${idOf("user")} <> ${table}.${idOf("user")} AND COALESCE(${other}.${ident(field("banned"))}, ${no}) = ${no}))`;
80
+ return `(${keepsAllowlisted ? `${allowlisted(s, table)} OR ` : ""}NOT ${administrator(s, table)} OR EXISTS (SELECT 1 FROM ${table} ${other} WHERE ${administrator(s, other)} AND ${other}.${idOf("user")} <> ${table}.${idOf("user")} AND COALESCE(${other}.${ident(field("banned"))}, ${no}) = ${no}))`;
79
81
  };
80
82
  const mutation = async (model, modelKey, where, values) => {
81
83
  const protect = protects(modelKey, values);
84
+ const keepsAllowlisted = protect && !!values && values[field("banned")] !== true && values[field("banned")] !== 1;
82
85
  if (protect && (where.length !== 1 || where[0].field !== field("id") || where[0].operator && where[0].operator !== "eq"))
83
86
  throw new APIError("FORBIDDEN", { code: "AUTH_DENIED", message: "Destructive admin identity writes require one user id." });
84
- const result = await run(s => `${values ? `UPDATE ${ident(model)} SET ${assign(s, values)}` : `DELETE FROM ${ident(model)}`}${s.where(modelKey, where, protect ? lastAdminGuard(s, model) : undefined)} RETURNING *`, protect);
85
- if (protect && !result.changes && (await run(s => `SELECT ${idOf(modelKey)} FROM ${ident(model)}${s.where(modelKey, where, `NOT ${lastAdminGuard(s, model)}`)} LIMIT 1`)).rows.length)
87
+ const result = await run(s => `${values ? `UPDATE ${ident(model)} SET ${assign(s, values)}` : `DELETE FROM ${ident(model)}`}${s.where(modelKey, where, protect ? lastAdminGuard(s, model, keepsAllowlisted) : undefined)} RETURNING *`, protect);
88
+ if (protect && !result.changes && (await run(s => `SELECT ${idOf(modelKey)} FROM ${ident(model)}${s.where(modelKey, where, `NOT ${lastAdminGuard(s, model, keepsAllowlisted)}`)} LIMIT 1`)).rows.length)
86
89
  throw new APIError("CONFLICT", { code: "CONFLICT", message: "This is the last admin; promote another admin before removing or banning this one." });
87
90
  return result;
88
91
  };
@@ -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")
@@ -27,8 +28,9 @@ export function authRoutes(loader, options) {
27
28
  if (basePathOf(auth.options) !== base)
28
29
  throw new Error("authRoutes basePath differs from betterAuth options.");
29
30
  try {
30
- await guardAuthAdminRequest(request, () => resolve(request), base);
31
- const registration = request.method === "POST" && pathname === `${base}/oauth2/register` ? await loopbackClientsAreNative(request) : request;
31
+ const bounded = request.body ? new Request(request, { body: await readBody(request) }) : request;
32
+ await guardAuthAdminRequest(bounded, () => resolve(bounded), base);
33
+ const registration = bounded.method === "POST" && pathname === `${base}/oauth2/register` ? await loopbackClientsAreNative(bounded) : bounded;
32
34
  return await normalizeAuthResponseCookies(await auth.handler(registration));
33
35
  }
34
36
  catch (error) {
@@ -47,7 +49,7 @@ export function authRoutes(loader, options) {
47
49
  catch (error) {
48
50
  return surfaceFailure(error, undefined, `auth ${route}`);
49
51
  }
50
- };
52
+ });
51
53
  }
52
54
  function consentSurface(auth, options) {
53
55
  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;