@aotter/mantle 0.2.0-alpha.9 → 0.2.1-alpha.1

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 (180) hide show
  1. package/README.md +15 -5
  2. package/dist/auth/adapter.js +1 -28
  3. package/dist/auth/admin.js +2 -5
  4. package/dist/auth/caller.js +0 -27
  5. package/dist/auth/consent.js +100 -0
  6. package/dist/auth/hardening.js +0 -7
  7. package/dist/auth/identity.js +0 -11
  8. package/dist/auth/oauth.js +0 -32
  9. package/dist/auth/resource-seeds.js +0 -2
  10. package/dist/auth/route-http.js +33 -0
  11. package/dist/auth/routes.js +5 -151
  12. package/dist/build/compiler.d.ts +19 -609
  13. package/dist/build/compiler.js +6 -6896
  14. package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
  15. package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
  16. package/dist/build/spec/domain/model/SqlIr.d.ts +32 -0
  17. package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
  18. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  19. package/dist/bun/assets.js +0 -2
  20. package/dist/domain/adminOrder.js +0 -2
  21. package/dist/domain/columns.js +7 -20
  22. package/dist/domain/describe/chunk-P6PP6VVV.js +1 -0
  23. package/dist/domain/describe/index.d.ts +1 -0
  24. package/dist/domain/describe/index.js +1 -115
  25. package/dist/domain/describe/match.d.ts +19 -0
  26. package/dist/domain/describe/target-hash.js +1 -12
  27. package/dist/domain/entry.js +0 -14
  28. package/dist/domain/generated-tools.js +0 -1
  29. package/dist/domain/input.js +0 -2
  30. package/dist/domain/paging.js +0 -17
  31. package/dist/domain/recipes.js +10 -7
  32. package/dist/domain/userReferences.js +0 -1
  33. package/dist/domain/wireScalars.js +0 -6
  34. package/dist/infrastructure/adminEntries.js +27 -8
  35. package/dist/infrastructure/adminPaging.js +55 -57
  36. package/dist/infrastructure/artifacts.js +0 -4
  37. package/dist/infrastructure/authSchema.js +0 -9
  38. package/dist/infrastructure/bundle.js +45 -38
  39. package/dist/infrastructure/cli.js +26 -12
  40. package/dist/infrastructure/generate.js +26 -19
  41. package/dist/infrastructure/generateAuth.js +1 -9
  42. package/dist/infrastructure/help.js +55 -0
  43. package/dist/infrastructure/init.js +50 -0
  44. package/dist/infrastructure/inputs.js +0 -9
  45. package/dist/infrastructure/mounts.js +22 -0
  46. package/dist/infrastructure/wrangler.js +0 -6
  47. package/dist/kernel/authorization.js +0 -1
  48. package/dist/kernel/binds.d.ts +34 -7
  49. package/dist/kernel/binds.js +5 -11
  50. package/dist/kernel/caller-identity.d.ts +0 -3
  51. package/dist/kernel/caller-identity.js +0 -15
  52. package/dist/kernel/caller-mode.d.ts +4 -0
  53. package/dist/kernel/caller-mode.js +14 -0
  54. package/dist/kernel/contracts.d.ts +13 -10
  55. package/dist/kernel/contracts.js +14 -41
  56. package/dist/kernel/cron.js +0 -4
  57. package/dist/kernel/dialect/common.js +0 -3
  58. package/dist/kernel/dialect/postgres.js +0 -2
  59. package/dist/kernel/dialect/sqlite.js +1 -6
  60. package/dist/kernel/drain.js +2 -8
  61. package/dist/kernel/driver/bun-sqlite.js +3 -7
  62. package/dist/kernel/driver/d1.d.ts +1 -1
  63. package/dist/kernel/driver/d1.js +6 -4
  64. package/dist/kernel/driver/postgres.d.ts +2 -1
  65. package/dist/kernel/driver/postgres.js +12 -22
  66. package/dist/kernel/driver/transaction-context.js +0 -1
  67. package/dist/kernel/email.js +0 -4
  68. package/dist/kernel/host-access.js +2 -3
  69. package/dist/kernel/host-caller.d.ts +1 -1
  70. package/dist/kernel/host-caller.js +3 -6
  71. package/dist/kernel/json-body.js +0 -2
  72. package/dist/kernel/mcp-apps.js +0 -5
  73. package/dist/kernel/mcp-call.js +0 -1
  74. package/dist/kernel/mcp-handler.js +4 -27
  75. package/dist/kernel/mcp.js +0 -5
  76. package/dist/kernel/media.js +0 -18
  77. package/dist/kernel/paging.js +1 -9
  78. package/dist/kernel/publishing.js +3 -4
  79. package/dist/kernel/r2-media.js +0 -7
  80. package/dist/kernel/rest-dispatch.js +0 -1
  81. package/dist/kernel/rest-router.js +5 -8
  82. package/dist/kernel/rest.d.ts +2 -1
  83. package/dist/kernel/rest.js +1 -3
  84. package/dist/kernel/route-pattern.d.ts +26 -0
  85. package/dist/kernel/route-pattern.js +34 -0
  86. package/dist/kernel/scalars.js +1 -9
  87. package/dist/kernel/search.js +0 -1
  88. package/dist/kernel/site.js +0 -2
  89. package/dist/kernel/store.d.ts +26 -2
  90. package/dist/kernel/store.js +69 -49
  91. package/dist/kernel/surface-failure.d.ts +10 -0
  92. package/dist/kernel/surface-failure.js +29 -0
  93. package/dist/kernel/surface.js +0 -3
  94. package/dist/kernel/sweep.js +0 -1
  95. package/dist/kernel/translations.js +0 -1
  96. package/dist/kernel/user-caller.js +2 -8
  97. package/dist/kernel/user-fields.d.ts +3 -2
  98. package/dist/kernel/user-fields.js +9 -9
  99. package/dist/kernel/validation.js +0 -8
  100. package/dist/kernel/web-gateway.js +0 -2
  101. package/dist/kernel/web-head.js +0 -1
  102. package/dist/kernel/web-index.js +0 -1
  103. package/dist/kernel/web-response.js +0 -1
  104. package/dist/kernel/web-serve.js +97 -0
  105. package/dist/kernel/web.d.ts +1 -1
  106. package/dist/kernel/web.js +10 -91
  107. package/dist/kernel/where.js +0 -6
  108. package/dist/usecase/adminBindings.js +32 -23
  109. package/dist/usecase/adminClient.js +4 -22
  110. package/dist/usecase/adminDescriptors.js +2 -9
  111. package/dist/usecase/generate.js +14 -17
  112. package/dist/usecase/handlers.js +1 -4
  113. package/dist/usecase/mcp-media.js +0 -1
  114. package/dist/usecase/mcp.js +0 -4
  115. package/dist/usecase/paging.js +1 -3
  116. package/dist/usecase/publishing.js +20 -11
  117. package/dist/usecase/rest.js +5 -6
  118. package/dist/usecase/site.js +0 -1
  119. package/dist/usecase/stores.js +12 -17
  120. package/dist/usecase/tool-version.js +0 -3
  121. package/dist/usecase/translations.js +0 -2
  122. package/dist/usecase/validation.js +0 -20
  123. package/docs/examples/README.md +13 -5
  124. package/docs/examples/app-shell/README.md +91 -12
  125. package/docs/examples/app-shell/accept.mjs +91 -0
  126. package/docs/examples/app-shell/gitignore +7 -0
  127. package/docs/examples/app-shell/package.json +10 -9
  128. package/docs/examples/app-shell/smoke-kit.mjs +29 -15
  129. package/docs/examples/app-shell/src/routes.ts +6 -3
  130. package/docs/examples/app-shell/src/service.ts +2 -1
  131. package/docs/examples/club-events/README.md +7 -2
  132. package/docs/examples/club-events/manifests/club-events.yaml +17 -0
  133. package/docs/examples/club-events/smoke.mjs +7 -1
  134. package/docs/examples/generated-web/seed.sql +5 -4
  135. package/docs/examples/purchasing/README.md +12 -3
  136. package/docs/examples/purchasing/manifests/purchasing.yaml +24 -4
  137. package/docs/examples/purchasing/smoke.mjs +14 -0
  138. package/docs/examples/recipes/README.md +5 -3
  139. package/docs/handbook/cloudflare/media-r2.md +6 -3
  140. package/docs/handbook/cloudflare/service-entry.md +5 -0
  141. package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
  142. package/docs/handbook/examples/hub.md +1 -1
  143. package/docs/handbook/guides/admin-ui.md +1 -1
  144. package/docs/handbook/guides/agent-setup.md +20 -10
  145. package/docs/handbook/guides/agent-workflow-acceptance.md +225 -105
  146. package/docs/handbook/guides/local-testing.md +85 -0
  147. package/docs/handbook/guides/native-admin.md +242 -16
  148. package/docs/handbook/guides/native-execution.md +10 -8
  149. package/docs/handbook/guides/public-pages.md +44 -4
  150. package/docs/handbook/guides/revisions.md +584 -0
  151. package/docs/handbook/guides/size-and-startup.md +140 -0
  152. package/docs/handbook/guides/sql-patterns.md +312 -0
  153. package/docs/handbook/guides/typed-queries.md +146 -3
  154. package/docs/handbook/navigation.json +20 -0
  155. package/docs/handbook/reference/admin-descriptors.md +4 -1
  156. package/docs/handbook/reference/authorization.md +3 -1
  157. package/docs/handbook/reference/describe.md +42 -0
  158. package/docs/handbook/reference/diagnostics.md +16 -2
  159. package/docs/handbook/reference/procedure.md +6 -6
  160. package/docs/handbook/reference/schema.md +79 -9
  161. package/docs/handbook/reference/sql.md +65 -0
  162. package/docs/handbook/reference/surface.md +8 -4
  163. package/docs/handbook/reference/trigger.md +12 -0
  164. package/docs/handbook/reference/view.md +11 -3
  165. package/docs/handbook/releases/index.md +71 -7
  166. package/docs/handbook/start/agents.md +49 -0
  167. package/docs/handbook/start/project-and-cli.md +10 -0
  168. package/docs/handbook/start/quickstart-worker.md +1 -1
  169. package/docs/skills/develop/SKILL.md +19 -6
  170. package/docs/skills/integrate/SKILL.md +3 -1
  171. package/docs/skills/performance/SKILL.md +169 -0
  172. package/docs/skills/update/SKILL.md +10 -4
  173. package/docs/upgrade-0.1-to-0.2.md +151 -39
  174. package/package.json +31 -11
  175. package/skills/README.md +6 -2
  176. package/skills/mantle/SKILL.md +53 -120
  177. package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
  178. package/skills/mantle/references/local-testing.md +0 -20
  179. package/skills/mantle/references/web.md +0 -41
  180. package/skills/mantle/references/website.md +0 -51
package/README.md CHANGED
@@ -1,8 +1,14 @@
1
1
  # Mantle
2
2
 
3
- Generate native SQL for Bun SQLite/PostgreSQL and Cloudflare D1/PostgreSQL.
4
- The build-time compiler uses the closed v2 grammar and canonical SQL policy.
5
- Applications import contract primitives and official drivers.
3
+ Mantle is an agent-native application SDK and backend compiler. Declare Schemas, Views, Procedures and Triggers in
4
+ YAML manifests; `mantle generate` compiles them at build time into native SQL and typed TypeScript for SQLite (Bun or
5
+ Cloudflare D1) and PostgreSQL (Bun or Cloudflare Workers). The compiler adds row policy to every query, and the closed v2
6
+ grammar reports actionable diagnostics. Your app keeps its own entry, routes and auth; Mantle adds only the surfaces
7
+ you select: REST, MCP, a web module, Better Auth sign-in and the native Admin.
8
+
9
+ **Start here:** `npx mantle help` prints the installed docs one topic at a time. `mantle init <directory>` writes a
10
+ complete app shell once (email OTP, `admin` + `user`, Admin, MCP, REST); [examples](./docs/examples/README.md) show
11
+ how to combine stock, own-records, approvals, callbacks, public pages and email into it.
6
12
 
7
13
  Three packages: `@aotter/mantle` is the SDK with bin `mantle`; `@aotter/mantle-ui` is pure UI without an SDK dependency; `@aotter/mantle-admin` is the optional product with the SDK as a peer. Use matching installed versions and their own docs.
8
14
 
@@ -153,8 +159,12 @@ Writes are lazy thenables: await `collection.insert/update/delete(...)` to execu
153
159
  or pass unused Writes from the same Store to `store.batch([...])` for atomic results in input order;
154
160
  a stale lock, excluded row or unique clash rolls back the whole batch with `CONFLICT`.
155
161
  A batch step cannot use a previous step's result. D1 supports only batch.
156
- On bun:sqlite and PostgreSQL, `store.transaction(async tx => { ... })` binds `collection`, `views`
157
- and `procedures` to one transaction: resolve commits, throw rolls back; nested transactions and `tx.batch` are refused.
162
+ On bun:sqlite and PostgreSQL, `store.transaction(async tx => { ... })` binds `collection`, `batch`, `views`
163
+ and `procedures` to one transaction: resolve commits, throw rolls back; nested transactions are refused.
164
+ `tx.batch` and a ref handler's `ctx.store.batch` join that same transaction, without a savepoint or another BEGIN.
165
+ A rejected batch makes the whole outer transaction rollback-only: even if the callback catches the error and returns,
166
+ its transaction rejects with the first batch failure and rolls back every write. This includes batch validation errors.
167
+ Ref handlers can use `ctx.store.batch` on every target, including D1, without creating another Store.
158
168
  PostgreSQL defaults to serializable; `{ isolation: "read committed" }` selects another level. There is no automatic retry.
159
169
  SQLite uses BEGIN IMMEDIATE and queues all Store/generated driver calls on the same Database until COMMIT/ROLLBACK;
160
170
  Inside a transaction callback the outer Store or handle passed to a Mantle driver throws `INPUT_VALIDATION_FAILED`; use `tx`.
@@ -1,10 +1,3 @@
1
- /**
2
- * Better Auth `database` adapters on the same native handle as the data layer: a Cloudflare D1 binding, or PostgreSQL through Bun.SQL or postgres.js.
3
- * No Kysely. Rules (where semantics, joins, field/model mapping, value transforms) come from Better Auth's own
4
- * `createAdapterFactory`; this file only turns its already-normalised calls into parameterised SQL.
5
- * `consumeOne` and `incrementOne` are single statements: D1 has no transactions, and they guard OTP consumption and
6
- * attempt counting.
7
- */
8
1
  import { createAdapterFactory } from "@better-auth/core/db/adapter";
9
2
  import { APIError } from "@better-auth/core/error";
10
3
  import { getAuthTables } from "@better-auth/core/db";
@@ -16,8 +9,6 @@ import * as sqlite from "../kernel/dialect/sqlite.js";
16
9
  import * as bunSql from "../kernel/driver/postgres.js";
17
10
  import { condition as spell } from "../kernel/where.js";
18
11
  const nativeReaders = new WeakMap();
19
- /** Selected auth queries share this adapter's handle, dialect and Better Auth's physical model/field mapping. */
20
- /** Better Auth's table and column names for these options (modelName/fieldName mapping); needs no database. */
21
12
  export function authNames(options) {
22
13
  const schema = getAuthTables(options);
23
14
  return {
@@ -45,7 +36,6 @@ function customAdapter(d, exec, seed, insertOnly = false, options) {
45
36
  }
46
37
  return spell(d, col, operator === "not_in" ? "notIn" : operator, value, bind, fold);
47
38
  };
48
- /** Builds one statement (its own parameters) and runs it. `where` is `WHERE (and...) AND (or...)`, the Kysely adapter's grouping; empty without conditions. */
49
39
  const run = (build, protectAdmin = false) => {
50
40
  const values = [];
51
41
  const bind = (value) => (values.push(value === undefined ? null : value), d.param(values.length));
@@ -60,11 +50,8 @@ function customAdapter(d, exec, seed, insertOnly = false, options) {
60
50
  return exec(build({ bind, where }), values, protectAdmin);
61
51
  };
62
52
  const idOf = (modelKey) => ident(getFieldName({ model: modelKey, field: "id" }));
63
- /** The WHERE plus `<id> IN (SELECT <id> FROM t WHERE ... LIMIT 1)`: the statement touches at most one row. */
64
53
  const oneRow = (s, table, modelKey, where) => s.where(modelKey, where, `${idOf(modelKey)} IN (SELECT ${idOf(modelKey)} FROM ${ident(table)}${s.where(modelKey, where)} LIMIT 1)`);
65
- /** The one row a `RETURNING *` statement changed, else null. */
66
54
  const one = async (build) => ((await run(s => `${build(s)} RETURNING *`)).rows[0] ?? null);
67
- /** The columns a write gives (an `undefined` is no value). */
68
55
  const cols = (values) => Object.entries(values).filter(([, v]) => v !== undefined);
69
56
  const assign = (s, values, increment = {}) => [...cols(values).map(([c, v]) => `${ident(c)} = ${s.bind(v)}`), ...Object.entries(increment).map(([c, n]) => `${ident(c)} = ${ident(c)} + ${s.bind(n)}`)].join(", ");
70
57
  const { adminRoles: roles, adminUserIds } = options ? adminOptionsOf({ options }) : { adminRoles: [], adminUserIds: [] };
@@ -90,7 +77,6 @@ function customAdapter(d, exec, seed, insertOnly = false, options) {
90
77
  return result;
91
78
  };
92
79
  const deleteMany = async ({ model, modelKey = model, where }) => (await mutation(model, modelKey, where)).changes;
93
- /** Joined rows come back as flat `__j<i>__<column>` aliases; fold them into `row[joinModel]` (object or array). */
94
80
  const joinColumns = (join) => Object.entries(join).map(([model, config]) => {
95
81
  const fields = schema[config.modelKey ?? getDefaultModelName(model)]?.fields ?? {};
96
82
  return [...new Set(["id", ...Object.entries(fields).map(([field, attr]) => attr.fieldName || field)])];
@@ -134,7 +120,6 @@ function customAdapter(d, exec, seed, insertOnly = false, options) {
134
120
  const column = (field) => getFieldName({ model: modelKey, field });
135
121
  const sortColumn = o.sortBy?.field ? ident(column(o.sortBy.field)) : null;
136
122
  const order = (prefix = "") => (sortColumn ? ` ORDER BY ${prefix}${sortColumn} ${o.sortBy.direction === "desc" ? "DESC" : "ASC"}` : "");
137
- // With a join the primary subquery also needs the id (rows fold by it), every join key and the sort column; they are dropped again below.
138
123
  const wanted = o.select?.length ? o.select.map(column) : null;
139
124
  const needed = o.join ? [column("id"), ...Object.values(o.join).map(c => c.on.from), ...(o.sortBy ? [column(o.sortBy.field)] : [])] : [];
140
125
  const { rows } = await run(s => {
@@ -145,7 +130,6 @@ function customAdapter(d, exec, seed, insertOnly = false, options) {
145
130
  return primary;
146
131
  const entries = Object.entries(o.join);
147
132
  const names = joinColumns(o.join);
148
- // The CTE is MATERIALIZED so an unordered LIMIT picks the same parents in both of its uses. One-to-many: number each parent's joined rows in SQL and keep the first `limit`, so only that many rows ever leave the database.
149
133
  const joinSql = entries.map(([joined, c], i) => {
150
134
  const source = c.relation === "one-to-one" ? ident(joined)
151
135
  : `(SELECT *, ROW_NUMBER() OVER (PARTITION BY ${ident(c.on.to)} ORDER BY ${idOf(c.modelKey ?? getDefaultModelName(joined))}) AS "__rn" FROM ${ident(joined)} WHERE ${ident(c.on.to)} IN (SELECT ${ident(c.on.from)} FROM "primary"))`;
@@ -207,8 +191,7 @@ function build(d, native, adapterId, adapterName, exec, transaction, options) {
207
191
  const resources = new Map((options.plugins ?? []).flatMap(plugin => plugin.id === "oauth-provider" ? resourceSeeds(plugin.options).map(input => {
208
192
  const resource = typeof input === "string" ? { identifier: input } : input;
209
193
  return [resource.identifier, { resource, options: plugin.options }];
210
- }) : []).reverse()); // first configured entry wins, as upstream insertOnly does
211
- // ponytail: one seed per identifier per isolate (upstream seeds once per init); disable a configured resource rather than deleting it.
194
+ }) : []).reverse());
212
195
  const seeded = new Set();
213
196
  let writer;
214
197
  const pending = new Map();
@@ -220,7 +203,6 @@ function build(d, native, adapterId, adapterName, exec, transaction, options) {
220
203
  if (!work) {
221
204
  work = (async () => {
222
205
  const { resource, options: oauth } = configured;
223
- // Match upstream identifier validation and seed row defaults.
224
206
  let valid;
225
207
  try {
226
208
  valid = oauth.identifierValidator ? !!(await oauth.identifierValidator(identifier)) : !new URL(identifier).hash;
@@ -238,7 +220,6 @@ function build(d, native, adapterId, adapterName, exec, transaction, options) {
238
220
  dpopBoundAccessTokensRequired: resource.dpopBoundAccessTokensRequired ?? false, disabled: resource.disabled ?? false,
239
221
  policyVersion: 1, createdAt: now, updatedAt: now,
240
222
  };
241
- // The factory owns ID generation, field mapping and native date/JSON/boolean encoding, including for seeds.
242
223
  writer ??= createAdapterFactory({ config, adapter: customAdapter(d, exec, async () => false, true) })(options);
243
224
  await writer.create({ model: "oauthResource", data });
244
225
  seeded.add(identifier);
@@ -250,14 +231,11 @@ function build(d, native, adapterId, adapterName, exec, transaction, options) {
250
231
  };
251
232
  const config = {
252
233
  adapterId, adapterName, supportsBooleans: native, supportsDates: native, supportsJSON: native, supportsUUIDs: native, transaction,
253
- // Bun.SQL returns int8/numeric as strings; a `number` field must reach Better Auth as a number (the rate limiter adds to `lastRequest`). Ids stay strings.
254
234
  customTransformOutput: ({ data, fieldAttributes, field }) => (fieldAttributes.type === "number" && typeof data === "string" && field !== "id" && !fieldAttributes.references ? Number(data) : data),
255
235
  };
256
236
  const factory = createAdapterFactory({ config, adapter: customAdapter(d, exec, seed, false, options) });
257
- // Both native adapters implement joins; Better Auth otherwise emulates each joined read with another statement.
258
237
  return (options) => factory({ ...options, advanced: { ...options.advanced, database: { ...options.advanced?.database, joins: options.advanced?.database?.joins ?? true } } });
259
238
  }
260
- /** Cloudflare D1 (SQLite). D1 has no interactive transactions, so `transaction` is off and the atomic operations are single statements. */
261
239
  export function d1Adapter(db) {
262
240
  const exec = async (text, values) => {
263
241
  const { results, meta } = await db.prepare(text).bind(...values).all();
@@ -267,11 +245,6 @@ export function d1Adapter(db) {
267
245
  nativeReaders.set(factory, { exec, param: sqlite.param, now: ms => new Date(ms).toISOString() });
268
246
  return factory;
269
247
  }
270
- /**
271
- * PostgreSQL through Bun.SQL or postgres.js (the `Postgres` contract the data layer uses). Transactions use `begin`,
272
- * which pins every statement to one connection. On Workers, create the postgres.js client per request (or per isolate
273
- * with `max: 1`), as for the data layer.
274
- */
275
248
  export function postgresAdapter(sql) {
276
249
  const executor = (handle) => async (text, values, protectAdmin) => {
277
250
  const run = async (tx) => {
@@ -1,5 +1,5 @@
1
- import { checkOrigin, isAdmin, deny, ContractError } from "../kernel/contracts.js";
2
- /** Shared selected-boundary guard. Resolve fresh database authority only for Better Auth's raw admin endpoints. */
1
+ import { isAdmin, deny, ContractError } from "../kernel/contracts.js";
2
+ import { checkOrigin } from "../kernel/surface-failure.js";
3
3
  export async function guardAuthAdminRequest(request, resolve, basePath = "/api/auth") {
4
4
  let path;
5
5
  try {
@@ -12,9 +12,6 @@ export async function guardAuthAdminRequest(request, resolve, basePath = "/api/a
12
12
  if (path !== admin && !path.startsWith(`${admin}/`))
13
13
  return;
14
14
  const caller = await resolve();
15
- // Mantle policy: only the admin layer reaches Better Auth admin endpoints (oauth-provider's /admin/oauth2/* admits any
16
- // session by default); Better Auth access control then decides each action.
17
- // Any signed-in user may ask about their own permissions or end an impersonation.
18
15
  const own = path === `${admin}/has-permission` || path === `${admin}/stop-impersonating`;
19
16
  deny(caller, caller.kind === "user" && caller.credential === "session" && (own || isAdmin(caller)), "auth:admin");
20
17
  checkOrigin(request, caller);
@@ -2,29 +2,19 @@ import { ContractError } from "../kernel/contracts.js";
2
2
  import { userCaller } from "../kernel/user-caller.js";
3
3
  const users = new WeakMap();
4
4
  const withUser = (caller, user) => (users.set(caller, user), caller);
5
- /** Display identity from the session this Caller already resolved; never an authority source. */
6
- /** The Better Auth user record the caller was resolved from (session, OAuth or service credential): name, email, image. */
7
5
  export const userOf = (caller) => users.get(caller);
8
- /** @deprecated Use `userOf`; it covers every credential, not only sessions. */
9
6
  export const sessionUserOf = userOf;
10
- /** The admin plugin's ban, lapsed once `banExpires` has passed (as the plugin reads it at sign-in). */
11
7
  export const isBanned = (user) => !!user.banned && (user.banExpires == null || new Date(user.banExpires).getTime() > Date.now());
12
- /** Better Auth owns the role model; absence of its admin plugin grants no admin layer. */
13
8
  export function adminOptionsOf(auth) {
14
9
  const plugin = auth.options.plugins?.find(plugin => plugin.id === "admin");
15
10
  const configured = plugin?.options?.adminRoles ?? ["admin"];
16
11
  return { adminRoles: plugin ? (typeof configured === "string" ? configured.split(",") : [...configured]).map(role => role.trim()) : [], adminUserIds: plugin?.options?.adminUserIds ?? [], defaultRole: plugin?.options?.defaultRole ?? "user" };
17
12
  }
18
- /**
19
- * `load` should be a dynamic import of the module that builds `betterAuth()`, so the bundler keeps it (and Better Auth)
20
- * out of the entry module: `authLoader(() => import("./auth.js").then(m => m.auth))`.
21
- */
22
13
  export function authLoader(load, cookiePrefix = "better-auth") {
23
14
  let instance;
24
15
  return {
25
16
  cookiePrefix,
26
17
  get: () => instance ??= load().then((auth) => {
27
- // Better Auth's getSessionCookie looks the cookie up by prefix only: any other spelling would make every signed-in user anonymous.
28
18
  if ((auth.options.advanced?.cookiePrefix ?? "better-auth") !== cookiePrefix)
29
19
  throw new Error(`authLoader cookiePrefix '${cookiePrefix}' differs from the auth options' advanced.cookiePrefix.`);
30
20
  if (auth.options.advanced?.cookies?.session_token?.name)
@@ -43,7 +33,6 @@ function checkFieldSources(instance, fields, session = false) {
43
33
  throw new ContractError("AUTH_CONFIGURATION_ERROR", `User field '${field}' requires an input: false additional field; customSession-only fields are available only to session credentials.`, "auth:user");
44
34
  }
45
35
  }
46
- /** All credentials read current authority from the same Better Auth user record. */
47
36
  async function loadSubject(instance, id, fields, sessionUser) {
48
37
  checkFieldSources(instance, fields, !!sessionUser);
49
38
  const context = await instance.$context;
@@ -57,17 +46,6 @@ async function loadSubject(instance, id, fields, sessionUser) {
57
46
  }
58
47
  return user;
59
48
  }
60
- /**
61
- * The Caller for a request that is not public. Public routes must not call it.
62
- * - `Authorization: Bearer`: an OAuth access token verified by Better Auth's resource client for `audience`; an invalid
63
- * token is UNAUTHENTICATED, never anonymous. The user (role, ban) is read from the database on every request.
64
- * - Otherwise a session cookie, validated by `auth.api.getSession` without the cookie cache, so revocation and role
65
- * changes apply on the next request. `readOnly: true` explicitly permits a bounded official cookie snapshot
66
- * only with no secondaryStorage and cookieCache {enabled:true,maxAge:1..60,refreshCache:false}.
67
- * The application classifies reads; HTTP method alone is not sufficient. No cookie means anonymous without loading Better Auth.
68
- * When Better Auth extends the session (`updateAge`) it sets a fresh cookie: pass the outgoing response's headers as
69
- * `responseHeaders` and those Set-Cookie lines are appended, so the browser cookie slides with the session.
70
- */
71
49
  export async function resolveCaller(request, auth, options = {}) {
72
50
  const lacks = (have) => options.scopes?.some(scope => !have.includes(scope));
73
51
  const supplied = await options.resolveCredential?.(request);
@@ -91,7 +69,6 @@ export async function resolveCaller(request, auth, options = {}) {
91
69
  const { oauthProviderResourceClient } = await import("@better-auth/oauth-provider/resource-client");
92
70
  const { createDpopReplayStore } = await import("better-auth/oauth2");
93
71
  const context = await instance.$context;
94
- // Issuance uses the normalized auth context URL, not the raw origin-only baseURL option.
95
72
  const verifier = oauthProviderResourceClient({ options: { ...instance.options, baseURL: context.baseURL, basePath: "" }, $context: instance.$context }).getActions();
96
73
  let claims;
97
74
  try {
@@ -113,7 +90,6 @@ export async function resolveCaller(request, auth, options = {}) {
113
90
  }
114
91
  if (authorization !== null)
115
92
  throw new ContractError("UNAUTHENTICATED", "The authorization header is not a supported credential.");
116
- // With no Cookie header at all getSessionCookie returns null: skip even loading the helper.
117
93
  if (!request.headers.has("cookie"))
118
94
  return { kind: "anonymous" };
119
95
  const { getSessionCookie, splitSetCookieHeader } = await import("better-auth/cookies");
@@ -123,7 +99,6 @@ export async function resolveCaller(request, auth, options = {}) {
123
99
  checkFieldSources(instance, options.fields, true);
124
100
  const mirror = !!instance.options.secondaryStorage;
125
101
  const cache = instance.options.session?.cookieCache;
126
- // Only an application-classified read may use Better Auth's bounded signed snapshot. KV remains primary-guarded.
127
102
  const cachedRead = options.readOnly === true && !mirror && cache?.enabled === true && cache.refreshCache === false &&
128
103
  typeof cache.maxAge === "number" && cache.maxAge >= 1 && cache.maxAge <= 60;
129
104
  const initial = await instance.api.getSession({ headers: request.headers, query: { disableCookieCache: !cachedRead, disableRefresh: mirror || !options.responseHeaders }, returnHeaders: true });
@@ -131,7 +106,6 @@ export async function resolveCaller(request, auth, options = {}) {
131
106
  if (!found)
132
107
  return { kind: "anonymous" };
133
108
  if (mirror) {
134
- // disableCookieCache bypasses browser cookies, not KV. Only mirrors need a primary authority re-read.
135
109
  if (!instance.options.session?.storeSessionInDatabase)
136
110
  throw new ContractError("AUTH_CONFIGURATION_ERROR", "Caller resolution with secondaryStorage requires session.storeSessionInDatabase: true.", "auth:session");
137
111
  const context = await instance.$context;
@@ -142,7 +116,6 @@ export async function resolveCaller(request, auth, options = {}) {
142
116
  const user = await loadSubject(instance, found.user.id, options.fields, found.user);
143
117
  if (!user)
144
118
  return { kind: "anonymous" };
145
- // Never let a stale secondary snapshot extend an already-expired primary session before the check above.
146
119
  if (options.responseHeaders) {
147
120
  const refreshed = mirror ? await instance.api.getSession({ headers: request.headers, query: { disableCookieCache: true }, returnHeaders: true }) : initial;
148
121
  if (!refreshed.response || refreshed.response.session.id !== found.session.id || refreshed.response.user.id !== user.id)
@@ -0,0 +1,100 @@
1
+ import { PRIVATE, json, readBody, redirect, text } from "./route-http.js";
2
+ export function consentSurface(auth, options) {
3
+ const { listOAuthConsents: list, revokeOAuthConsent: revoke } = auth;
4
+ return async (request, caller, route) => {
5
+ const person = caller.kind === "user" && caller.credential === "session" ? caller.subject : null;
6
+ const locale = /zh[-_]tw/i.test(request.headers.get("accept-language") ?? "") ? "zh-TW" : "en";
7
+ if (route === "GET /oauth/consent/data") {
8
+ if (!person)
9
+ return json({ consent: null }, 401);
10
+ const model = await auth.getOAuthConsentRequest(request).catch(() => null);
11
+ return json({ consent: model }, model ? 200 : 400);
12
+ }
13
+ if (route === "GET /oauth/consent") {
14
+ const model = await auth.getOAuthConsentRequest(request).catch(() => null);
15
+ return html(consentHtml(locale, model), model ? 200 : 400);
16
+ }
17
+ if (route === "POST /oauth/consent") {
18
+ if (!person)
19
+ return text("sign in to answer this request", 401);
20
+ const body = await readBody(request);
21
+ const decision = new URLSearchParams(body).get("decision");
22
+ if (decision !== "approve" && decision !== "deny")
23
+ return text("invalid consent decision", 400);
24
+ const location = await auth.completeOAuthConsent(new Request(request, { body }), decision === "approve").catch(() => null);
25
+ const target = location && URL.canParse(location) ? new URL(location) : null;
26
+ if (!target || /^(javascript|data|vbscript|blob):$/i.test(target.protocol))
27
+ return text("invalid authorization request", 400);
28
+ return redirect(target.href);
29
+ }
30
+ if (!list || !revoke)
31
+ return text("not found", 404);
32
+ if (route === "GET /oauth/consents") {
33
+ if (options.connectedAppsPage)
34
+ return redirect(options.connectedAppsPage);
35
+ if (!person)
36
+ return text("sign in to see your connected apps", 401);
37
+ return html(connectedAppsHtml(locale, await list(person)), 200);
38
+ }
39
+ if (route === "GET /oauth/consents/data") {
40
+ if (!person)
41
+ return json({ consents: [] }, 401);
42
+ return json({ consents: await list(person) });
43
+ }
44
+ if (route === "POST /oauth/consents/revoke") {
45
+ if (!person)
46
+ return text("sign in to revoke a connection", 401);
47
+ const body = await readBody(request);
48
+ const id = new URLSearchParams(body).get("consent_id");
49
+ if (!id)
50
+ return text("invalid consent id", 400);
51
+ if (!await revoke(person, id))
52
+ return text("consent not found", 404);
53
+ return redirect("/oauth/consents", 303);
54
+ }
55
+ return text("not found", 404);
56
+ };
57
+ }
58
+ function html(body, status) {
59
+ return new Response(body, {
60
+ status,
61
+ headers: {
62
+ ...PRIVATE, "content-type": "text/html; charset=UTF-8", "referrer-policy": "same-origin", "x-content-type-options": "nosniff", "x-frame-options": "DENY",
63
+ "content-security-policy": "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",
64
+ },
65
+ });
66
+ }
67
+ const T = {
68
+ en: {
69
+ lang: "en", title: "Authorize", eyebrow: "Connect an MCP client", heading: (c) => `Connect ${c}?`,
70
+ body: (c) => `${c} will be able to use this site's tools through MCP. What it can view or change is still limited by your account permissions.`,
71
+ returnsTo: "Returns to", scopes: "Requested access", approve: "Connect", deny: "Cancel", invalid: "Invalid authorization request", invalidBody: "Missing or malformed consent payload. Return to your MCP client and try again.",
72
+ apps: "MCP connections", appsBody: "These MCP clients can use this site's tools. Every action is still checked against your current account permissions.", empty: "No MCP clients are connected.", revoke: "Disconnect",
73
+ },
74
+ "zh-TW": {
75
+ lang: "zh-Hant-TW", title: "授權", eyebrow: "連結 MCP 客戶端", heading: (c) => `要連結 ${c} 嗎?`,
76
+ body: (c) => `${c} 將能透過 MCP 使用這個網站提供的工具;它能查看或變更哪些內容,仍會依照你的帳號權限決定。`,
77
+ returnsTo: "完成後返回", scopes: "要求的權限", approve: "連結", deny: "取消", invalid: "無效的授權請求", invalidBody: "缺少或格式錯誤的授權資訊,請返回 MCP 客戶端重試。",
78
+ apps: "MCP 連線", appsBody: "這些 MCP 客戶端可以使用網站提供的工具;每次操作仍會依照你當下的帳號權限檢查。", empty: "目前沒有已連結的 MCP 客戶端。", revoke: "中斷連線",
79
+ },
80
+ };
81
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
82
+ const page = (l, title, main) => `<!doctype html><html lang="${T[l].lang}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>${title}</title>` +
83
+ `<style>:root{color-scheme:light dark;font:1rem/1.5 system-ui,sans-serif}body{margin:0;padding:2rem 1rem}main{max-width:32rem;margin:10vh auto}code{overflow-wrap:anywhere}button{font:inherit;padding:.5rem 1rem;cursor:pointer}</style></head><body><main>${main}</main></body></html>`;
84
+ function consentHtml(l, model) {
85
+ const t = T[l];
86
+ if (!model)
87
+ return page(l, t.title, `<p>${t.eyebrow}</p><h1>${t.invalid}</h1><p>${t.invalidBody}</p>`);
88
+ const client = esc(model.clientName);
89
+ const host = URL.canParse(model.redirectUri) ? new URL(model.redirectUri).host || new URL(model.redirectUri).protocol : model.redirectUri;
90
+ return page(l, t.title, `<p>${t.eyebrow}</p><h1>${t.heading(client)}</h1><p>${t.body(client)}</p>` +
91
+ `<p>${t.returnsTo} <code>${esc(host)}</code></p><p>${t.scopes}</p><ul>${model.scopes.map((sc) => `<li><code>${esc(sc)}</code></li>`).join("")}</ul><form method="post" action="/oauth/consent">` +
92
+ `<input type="hidden" name="oauth_query" value="${esc(model.oauthQuery)}"/><button type="submit" name="decision" value="approve">${t.approve}</button> ` +
93
+ `<button type="submit" name="decision" value="deny">${t.deny}</button></form>`);
94
+ }
95
+ function connectedAppsHtml(l, consents) {
96
+ const t = T[l];
97
+ const apps = consents.length === 0 ? `<p>${t.empty}</p>` : consents.map((c) => `<section><h2>${esc(c.clientName)}</h2><code>${esc(c.clientId)}</code><form method="post" action="/oauth/consents/revoke">` +
98
+ `<input type="hidden" name="consent_id" value="${esc(c.id)}"/><button type="submit">${t.revoke}</button></form></section>`).join("");
99
+ return page(l, t.apps, `<h1>${t.apps}</h1><p>${t.appsBody}</p>${apps}`);
100
+ }
@@ -1,13 +1,10 @@
1
- /** Optional app-owned Better Auth configuration; no Better Auth runtime import. */
2
1
  import { AsyncLocalStorage } from "node:async_hooks";
3
2
  const retention = new AsyncLocalStorage();
4
- /** Wrap the app's auth handler call with the current Worker's ctx.waitUntil.bind(ctx). Concurrent requests stay isolated. */
5
3
  export function withAuthTasks(waitUntil, run) { return retention.run(waitUntil, run); }
6
4
  export function retainAuthTask(task) {
7
5
  const settled = task.catch(() => { console.error("[mantle auth] background task failed"); });
8
6
  retention.getStore()?.(settled);
9
7
  }
10
- /** Keyed HMAC prevents enumeration of the six-digit OTP space after a database leak. */
11
8
  export async function hashEmailOtp(secret, otp) {
12
9
  if (!secret)
13
10
  throw new Error("AUTH_SECRET is required for OTP hashing.");
@@ -15,7 +12,6 @@ export async function hashEmailOtp(secret, otp) {
15
12
  const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, bytes.encode(otp)));
16
13
  return btoa(String.fromCharCode(...mac)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
17
14
  }
18
- /** Never default to a client-controlled X-Forwarded-For. The host must overwrite the configured headers. */
19
15
  export function authHardening({ ipAddressHeaders, baseURL, errorURL = "/" }) {
20
16
  const headers = ipAddressHeaders.map(h => h.trim()).filter(Boolean);
21
17
  if (!headers.length || headers.some(h => !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(h)))
@@ -24,10 +20,8 @@ export function authHardening({ ipAddressHeaders, baseURL, errorURL = "/" }) {
24
20
  if (target.origin !== new URL(baseURL).origin)
25
21
  throw new Error("Auth errorURL must be same-origin.");
26
22
  return { advanced: { ipAddress: { ipAddressHeaders: headers }, backgroundTasks: { handler: retainAuthTask } },
27
- // Shared across isolates; Better Auth's defaults and plugin rules (e.g. 3/min per OTP path) set the limits.
28
23
  rateLimit: { enabled: true, storage: "database" }, onAPIError: { errorURL: `${target.pathname}${target.search}` } };
29
24
  }
30
- /** Accept the provider-owned profile only on explicitly trusted callbacks, never sign-up/update JSON. */
31
25
  export function guardGithubLogin(user, ctx, trustedProviders = ["github"]) {
32
26
  if (!("githubLogin" in user))
33
27
  return;
@@ -36,5 +30,4 @@ export function guardGithubLogin(user, ctx, trustedProviders = ["github"]) {
36
30
  return;
37
31
  return { data: { githubLogin: null } };
38
32
  }
39
- /** First-deploy facade: failed setup never falls back to an anonymous/successful auth response. */
40
33
  export const authSetupIncomplete = () => Response.json({ error: { code: "SERVICE_UNAVAILABLE", message: "Authentication setup is incomplete.", path: "auth" } }, { status: 503, headers: { "cache-control": "no-store", "retry-after": "60" } });
@@ -1,4 +1,3 @@
1
- /** Optional identity operations. Better Auth owns access control, writes and user hooks. */
2
1
  import { adminOptionsOf, isBanned } from "./caller.js";
3
2
  import { authNames } from "./adapter.js";
4
3
  import { APIError } from "@better-auth/core/error";
@@ -16,7 +15,6 @@ const adminSql = (d, auth) => {
16
15
  const col = (field) => column("user", field);
17
16
  return '(' + [...adminRoles.map(role => `${d === sqlite.dialect ? "instr" : "strpos"}(',' || COALESCE(${col("role")}, '') || ',', ${literal(',' + role + ',')}) > 0`), ...adminUserIds.map(id => `${col("id")} = ${literal(id)}`), 'FALSE'].join(' OR ') + ')';
18
17
  };
19
- /** Translate Better Auth errors at the selected transport boundary, retaining its status and code. */
20
18
  async function api(run) {
21
19
  try {
22
20
  return await run();
@@ -53,11 +51,9 @@ function directory(auth, _caller, headers, d, read) {
53
51
  const remove = async (id) => {
54
52
  try {
55
53
  const found = await find(id);
56
- // Better Auth deletes sessions and accounts before the adapter refuses the last admin row; refuse first.
57
54
  if (found && adminLayerOf(found, options) && !(await read(`SELECT 1 FROM ${table("user")} WHERE ${privileged} AND ${col("id")} <> ${literal(id)} AND COALESCE(${col("banned")}, ${d === sqlite.dialect ? "0" : "FALSE"}) = ${d === sqlite.dialect ? "0" : "FALSE"} LIMIT 1`, [])).length)
58
55
  throw new ContractError("CONFLICT", "This is the last admin; promote another admin before removing this one.", "identity", { status: 409 });
59
56
  await api(() => auth.api.removeUser({ headers, body: { userId: id } }));
60
- // The admin plugin removes accounts and sessions, but email-OTP proofs are keyed by email.
61
57
  if (found)
62
58
  await (await context()).adapter.deleteMany({ model: "verification", where: [{ field: "identifier", operator: "in", value: ["sign-in", "email-verification", "forget-password"].map(t => `${t}-otp-${found.email.toLowerCase()}`) }] });
63
59
  return true;
@@ -148,17 +144,11 @@ function directory(auth, _caller, headers, d, read) {
148
144
  }
149
145
  export const d1Identity = (db, auth, caller, headers) => directory(auth, caller, headers, sqlite.dialect, (sql, v) => d1Read(db, sql, v));
150
146
  export const postgresIdentity = (db, auth, caller, headers) => directory(auth, caller, headers, postgres.dialect, (sql, v) => pgRead(db, sql, v));
151
- /**
152
- * Server-side reads of Better Auth users for code without a session (cron, system callers): no SQL against Better Auth's
153
- * tables, each instance's own table mapping, banned users excluded. Read-only; writes stay with the caller-bound directory.
154
- */
155
147
  export function readIdentity(auth) {
156
148
  const options = adminOptionsOf(auth), context = async () => await auth.$context;
157
149
  const active = (user) => user && !isBanned(user) ? project(user) : null;
158
150
  return {
159
- /** The active user with this id, or null. */
160
151
  user: async (id) => active(await (await context()).internalAdapter.findUserById(id)),
161
- /** Active users in the admin layer (`adminRoles` and `adminUserIds`). ponytail: reads up to 10,000 per role; page it if an app has more admins. */
162
152
  admins: async () => {
163
153
  const { adapter } = await context();
164
154
  const found = new Map();
@@ -180,7 +170,6 @@ const promote = (d, param, source) => {
180
170
  const { table, column } = authNames(auth.options), role = column("user", "role"), id = column("user", "id");
181
171
  return `UPDATE ${table("user")} SET ${role}=${literal(roles[0])} || CASE WHEN COALESCE(${role}, '') = '' THEN '' ELSE ',' || ${role} END WHERE ${id}=${param} AND NOT EXISTS (SELECT 1 FROM ${table("user")} WHERE ${adminSql(d, auth)}) RETURNING ${id}`;
182
172
  };
183
- /** First-admin bootstrap has no authorized session for setRole; one conditional write elects a single admin. */
184
173
  function bootstrap(rule, update) {
185
174
  if (!rule.value.trim())
186
175
  throw invalid("Bootstrap target cannot be empty.");
@@ -11,10 +11,6 @@ const array = (value) => {
11
11
  return null;
12
12
  }
13
13
  };
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
- */
18
14
  export async function assertActiveUserGrant(auth, claims, audience) {
19
15
  const { sub, azp, sid, mantle_consent_id: id, mantle_consent_reference: reference, mantle_skip_consent: skip } = claims;
20
16
  const tokenScopes = typeof claims.scope === "string" ? claims.scope.split(/\s+/).filter(Boolean) : array(claims.scope);
@@ -42,10 +38,8 @@ export async function assertActiveUserGrant(auth, claims, audience) {
42
38
  throw new ContractError("UNAUTHENTICATED", "OAuth authorization grant is no longer active.");
43
39
  return { id: sub };
44
40
  }
45
- /** Add first to oauthProvider/mcp extensions; Better Auth retains ownership of referenceId. */
46
41
  export const oauthGrantExtension = {
47
42
  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
43
  if (grantType === "refresh_token" && !scopes.includes("offline_access")) {
50
44
  const session = sessionId ? await ctx.context.adapter.findOne({ model: "session", where: [pair("id", sessionId)] }) : null;
51
45
  if (!session || new Date(session.expiresAt).getTime() <= Date.now())
@@ -58,42 +52,25 @@ export const oauthGrantExtension = {
58
52
  } },
59
53
  };
60
54
  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
55
  const RESETS = ["/reset-password", "/email-otp/reset-password", "/phone-number/reset-password", "/admin/set-user-password"];
63
56
  const ENDING = [...RESETS, "/change-password", "/admin/ban-user", "/admin/update-user", "/admin/revoke-user-sessions"];
64
57
  const RESET_USER = "mantleResetPasswordUser";
65
- /** Better Auth's email-proof sign-ins: on an unverified user they delete its accounts and sessions (revokeUnprovenAccountAccess). */
66
58
  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
59
  export function oauthGrantRevocation() {
76
60
  return {
77
61
  id: OAUTH_GRANT_REVOCATION,
78
62
  hooks: {
79
- // Better Auth consumes the reset token, so its user rides this request's own context to the after hook.
80
63
  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
64
  const token = ctx.body?.token || ctx.query?.token;
83
65
  const value = typeof token === "string" && token ? await ctx.context.internalAdapter.findVerificationValue(`reset-password:${token}`) : null;
84
66
  if (value)
85
67
  return { context: { [RESET_USER]: value.value } };
86
68
  }) }, { 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
69
  const email = ctx.path === "/sign-in/email-otp" ? ctx.body?.email : await magicLinkEmail(ctx.context, ctx.query?.token);
92
70
  const found = typeof email === "string" && email ? await ctx.context.internalAdapter.findUserByEmail(email.toLowerCase()) : null;
93
71
  if (found && !found.user.emailVerified)
94
72
  await endGrants(ctx.context.adapter, found.user.id);
95
73
  }) }],
96
- // After the endpoint: its own validation and permission checks have passed and it succeeded.
97
74
  after: [{ matcher: ctx => ENDING.includes(ctx.path ?? ""), handler: createAuthMiddleware(async (ctx) => {
98
75
  const returned = ctx.context.returned, path = ctx.path ?? "";
99
76
  if (returned instanceof Error || returned instanceof Response && !returned.ok)
@@ -102,13 +79,11 @@ export function oauthGrantRevocation() {
102
79
  const userId = path === "/reset-password" ? ctx[RESET_USER]
103
80
  : path === "/email-otp/reset-password" ? typeof body?.email === "string" && (await ctx.context.internalAdapter.findUserByEmail(body.email.toLowerCase()))?.user.id
104
81
  : 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
82
  : path === "/change-password" ? body?.revokeOtherSessions === true && (ctx.context.session?.user.id ?? returned?.user?.id)
107
83
  : path === "/admin/update-user" ? body?.data?.banned === true && body.userId
108
84
  : body?.userId;
109
85
  if (typeof userId !== "string" || !userId)
110
86
  return;
111
- // Sessions first: a live session (perhaps the attacker's) could otherwise authorize a new grant after endGrants.
112
87
  if (RESETS.includes(path))
113
88
  await ctx.context.internalAdapter.deleteUserSessions(userId);
114
89
  await endGrants(ctx.context.adapter, userId);
@@ -116,7 +91,6 @@ export function oauthGrantRevocation() {
116
91
  },
117
92
  };
118
93
  }
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
94
  async function magicLinkEmail(context, token) {
121
95
  if (typeof token !== "string" || !token)
122
96
  return;
@@ -132,14 +106,11 @@ async function magicLinkEmail(context, token) {
132
106
  return;
133
107
  }
134
108
  }
135
- /** Ends a user's grants (for one client, or all): consents, tokens and unredeemed authorization codes. */
136
109
  async function endGrants(db, userId, clientId) {
137
110
  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
111
  await db.deleteMany({ model: "oauthConsent", where });
140
112
  for (const model of ["oauthRefreshToken", "oauthAccessToken"])
141
113
  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
114
  const code = (v) => { const x = authCode(v); return x?.userId === userId && (!clientId || x.query.client_id === clientId); };
144
115
  const codes = [userId, clientId].filter(x => x !== undefined).map(x => ({ field: "value", operator: "contains", value: JSON.stringify(x) }));
145
116
  for (let offset = 0;;) {
@@ -152,7 +123,6 @@ async function endGrants(db, userId, clientId) {
152
123
  offset += page.length - ids.length;
153
124
  }
154
125
  }
155
- /** A verification value that is an authorization code, with its user and client; else undefined. */
156
126
  function authCode(text) {
157
127
  let value;
158
128
  try {
@@ -163,7 +133,6 @@ function authCode(text) {
163
133
  }
164
134
  return value?.type === "authorization_code" && typeof value.userId === "string" && typeof value.query?.client_id === "string" ? value : undefined;
165
135
  }
166
- /** Signed-query and consent APIs over the same application-owned instance and adapter (including renamed tables). */
167
136
  export function oauthConsents(auth) {
168
137
  const adapter = async () => (await auth.$context).adapter;
169
138
  return {
@@ -207,7 +176,6 @@ export function oauthConsents(auth) {
207
176
  },
208
177
  };
209
178
  }
210
- /** Optional scheduled storage hygiene, not boot work or an authorization rule. Owned/CIMD clients survive. */
211
179
  export async function pruneLegacyOAuthClients(auth) {
212
180
  const { adapter } = await auth.$context;
213
181
  return adapter.deleteMany({ model: "oauthClient", where: [
@@ -1,5 +1,3 @@
1
- /** Keep runtime inputs while disabling the provider's eager and bulk lazy seeding. No schema or database work. */
2
- // OAuth provider 1.7.7 shares plugin.options with its eager/lazy seed closures; its seedResources/checkIdentifier helpers are not exported.
3
1
  const seeds = new WeakMap();
4
2
  export function resourceSeeds(options) {
5
3
  const inputs = (seeds.get(options) ?? options.resources ?? []);
@@ -0,0 +1,33 @@
1
+ import { ContractError } from "../kernel/contracts.js";
2
+ export const PRIVATE = { "cache-control": "private, no-store" };
3
+ export const CONSENT = new Set(["GET /oauth/consent", "GET /oauth/consent/data", "POST /oauth/consent", "GET /oauth/consents", "GET /oauth/consents/data", "POST /oauth/consents/revoke"]);
4
+ export const MAX_BODY = 1_048_576;
5
+ export const text = (body, status) => new Response(body, { status, headers: PRIVATE });
6
+ export const json = (body, status = 200) => Response.json(body, { status, headers: PRIVATE });
7
+ export const redirect = (location, status = 302) => new Response(null, { status, headers: { location, ...PRIVATE } });
8
+ export const tooLarge = () => new ContractError("INPUT_VALIDATION_FAILED", "Request body exceeds 1 MiB.");
9
+ export async function readBody(request) {
10
+ if (Number(request.headers.get("content-length")) > MAX_BODY)
11
+ throw tooLarge();
12
+ const reader = request.body?.getReader();
13
+ if (!reader)
14
+ return "";
15
+ const decoder = new TextDecoder();
16
+ let size = 0, text = "";
17
+ try {
18
+ for (;;) {
19
+ const { value, done } = await reader.read();
20
+ if (done)
21
+ return text + decoder.decode();
22
+ size += value.byteLength;
23
+ if (size > MAX_BODY) {
24
+ void reader.cancel();
25
+ throw tooLarge();
26
+ }
27
+ text += decoder.decode(value, { stream: true });
28
+ }
29
+ }
30
+ finally {
31
+ reader.releaseLock();
32
+ }
33
+ }