@aotter/mantle 0.2.0-alpha.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/auth/adapter.js +1 -28
- package/dist/auth/admin.js +2 -5
- package/dist/auth/caller.js +0 -27
- package/dist/auth/consent.js +100 -0
- package/dist/auth/hardening.js +0 -7
- package/dist/auth/identity.js +0 -11
- package/dist/auth/oauth.js +0 -32
- package/dist/auth/resource-seeds.js +0 -2
- package/dist/auth/route-http.js +33 -0
- package/dist/auth/routes.js +5 -151
- package/dist/build/compiler.d.ts +19 -609
- package/dist/build/compiler.js +3 -6896
- package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
- package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
- package/dist/bun/assets.js +0 -2
- package/dist/domain/adminOrder.js +0 -2
- package/dist/domain/columns.js +7 -20
- package/dist/domain/describe/chunk-SGMRVDGE.js +1 -0
- package/dist/domain/describe/index.js +1 -115
- package/dist/domain/describe/target-hash.js +1 -12
- package/dist/domain/entry.js +0 -14
- package/dist/domain/generated-tools.js +0 -1
- package/dist/domain/input.js +0 -2
- package/dist/domain/paging.js +0 -17
- package/dist/domain/recipes.js +10 -7
- package/dist/domain/userReferences.js +0 -1
- package/dist/domain/wireScalars.js +0 -6
- package/dist/infrastructure/adminEntries.js +10 -7
- package/dist/infrastructure/adminPaging.js +55 -57
- package/dist/infrastructure/artifacts.js +0 -4
- package/dist/infrastructure/authSchema.js +0 -9
- package/dist/infrastructure/bundle.js +2 -30
- package/dist/infrastructure/cli.js +25 -11
- package/dist/infrastructure/generate.js +9 -16
- package/dist/infrastructure/generateAuth.js +1 -9
- package/dist/infrastructure/help.js +55 -0
- package/dist/infrastructure/init.js +50 -0
- package/dist/infrastructure/inputs.js +0 -9
- package/dist/infrastructure/mounts.js +22 -0
- package/dist/infrastructure/wrangler.js +0 -6
- package/dist/kernel/authorization.js +0 -1
- package/dist/kernel/binds.d.ts +16 -4
- package/dist/kernel/binds.js +4 -10
- package/dist/kernel/caller-identity.d.ts +0 -3
- package/dist/kernel/caller-identity.js +0 -15
- package/dist/kernel/caller-mode.d.ts +4 -0
- package/dist/kernel/caller-mode.js +14 -0
- package/dist/kernel/contracts.d.ts +6 -10
- package/dist/kernel/contracts.js +5 -41
- package/dist/kernel/cron.js +0 -4
- package/dist/kernel/dialect/common.js +0 -3
- package/dist/kernel/dialect/postgres.js +0 -2
- package/dist/kernel/dialect/sqlite.js +1 -6
- package/dist/kernel/drain.js +2 -8
- package/dist/kernel/driver/bun-sqlite.js +0 -4
- package/dist/kernel/driver/d1.js +0 -1
- package/dist/kernel/driver/postgres.js +2 -17
- package/dist/kernel/driver/transaction-context.js +0 -1
- package/dist/kernel/email.js +0 -4
- package/dist/kernel/host-access.js +2 -3
- package/dist/kernel/host-caller.d.ts +1 -1
- package/dist/kernel/host-caller.js +3 -6
- package/dist/kernel/json-body.js +0 -2
- package/dist/kernel/mcp-apps.js +0 -5
- package/dist/kernel/mcp-call.js +0 -1
- package/dist/kernel/mcp-handler.js +2 -26
- package/dist/kernel/mcp.js +0 -5
- package/dist/kernel/media.js +0 -18
- package/dist/kernel/paging.js +1 -9
- package/dist/kernel/publishing.js +3 -4
- package/dist/kernel/r2-media.js +0 -7
- package/dist/kernel/rest-dispatch.js +0 -1
- package/dist/kernel/rest-router.js +2 -3
- package/dist/kernel/rest.js +0 -3
- package/dist/kernel/scalars.js +1 -9
- package/dist/kernel/search.js +0 -1
- package/dist/kernel/site.js +0 -2
- package/dist/kernel/store.d.ts +11 -1
- package/dist/kernel/store.js +21 -34
- package/dist/kernel/surface-failure.d.ts +10 -0
- package/dist/kernel/surface-failure.js +29 -0
- package/dist/kernel/surface.js +0 -3
- package/dist/kernel/sweep.js +0 -1
- package/dist/kernel/translations.js +0 -1
- package/dist/kernel/user-caller.js +2 -8
- package/dist/kernel/user-fields.d.ts +3 -2
- package/dist/kernel/user-fields.js +9 -9
- package/dist/kernel/validation.js +0 -8
- package/dist/kernel/web-gateway.js +0 -2
- package/dist/kernel/web-head.js +0 -1
- package/dist/kernel/web-index.js +0 -1
- package/dist/kernel/web-response.js +0 -1
- package/dist/kernel/web-serve.js +97 -0
- package/dist/kernel/web.d.ts +1 -1
- package/dist/kernel/web.js +10 -91
- package/dist/kernel/where.js +0 -6
- package/dist/usecase/adminBindings.js +30 -21
- package/dist/usecase/adminClient.js +0 -21
- package/dist/usecase/adminDescriptors.js +2 -9
- package/dist/usecase/generate.js +7 -12
- package/dist/usecase/handlers.js +0 -3
- package/dist/usecase/mcp-media.js +0 -1
- package/dist/usecase/mcp.js +0 -4
- package/dist/usecase/paging.js +1 -3
- package/dist/usecase/publishing.js +0 -4
- package/dist/usecase/rest.js +0 -1
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/stores.js +4 -14
- package/dist/usecase/tool-version.js +0 -3
- package/dist/usecase/translations.js +0 -2
- package/dist/usecase/validation.js +0 -20
- package/docs/examples/README.md +13 -5
- package/docs/examples/app-shell/README.md +38 -7
- package/docs/examples/app-shell/accept.mjs +91 -0
- package/docs/examples/app-shell/gitignore +7 -0
- package/docs/examples/app-shell/package.json +7 -6
- package/docs/examples/app-shell/smoke-kit.mjs +29 -15
- package/docs/examples/app-shell/src/routes.ts +6 -3
- package/docs/examples/app-shell/src/service.ts +2 -1
- package/docs/examples/club-events/README.md +7 -2
- package/docs/examples/club-events/manifests/club-events.yaml +15 -0
- package/docs/examples/club-events/smoke.mjs +7 -1
- package/docs/examples/purchasing/README.md +12 -3
- package/docs/examples/purchasing/manifests/purchasing.yaml +21 -4
- package/docs/examples/purchasing/smoke.mjs +14 -0
- package/docs/examples/recipes/README.md +5 -3
- package/docs/handbook/cloudflare/service-entry.md +3 -0
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/agent-setup.md +15 -10
- package/docs/handbook/guides/agent-workflow-acceptance.md +113 -103
- package/docs/handbook/guides/local-testing.md +40 -0
- package/docs/handbook/guides/native-admin.md +25 -6
- package/docs/handbook/guides/native-execution.md +5 -4
- package/docs/handbook/guides/public-pages.md +44 -4
- package/docs/handbook/guides/sql-patterns.md +266 -0
- package/docs/handbook/guides/typed-queries.md +138 -0
- package/docs/handbook/navigation.json +16 -0
- package/docs/handbook/reference/admin-descriptors.md +4 -1
- package/docs/handbook/reference/authorization.md +1 -1
- package/docs/handbook/reference/diagnostics.md +6 -1
- package/docs/handbook/reference/procedure.md +5 -5
- package/docs/handbook/reference/schema.md +10 -4
- package/docs/handbook/reference/sql.md +64 -0
- package/docs/handbook/reference/surface.md +5 -2
- package/docs/handbook/reference/view.md +11 -3
- package/docs/handbook/releases/index.md +27 -6
- package/docs/handbook/start/agents.md +45 -0
- package/docs/handbook/start/project-and-cli.md +6 -0
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +6 -5
- package/docs/skills/update/SKILL.md +2 -0
- package/package.json +23 -3
- package/skills/README.md +1 -1
- package/skills/mantle/SKILL.md +39 -120
- package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
- package/skills/mantle/references/local-testing.md +0 -20
- package/skills/mantle/references/web.md +0 -41
- package/skills/mantle/references/website.md +0 -51
package/README.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# Mantle
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
package/dist/auth/adapter.js
CHANGED
|
@@ -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());
|
|
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) => {
|
package/dist/auth/admin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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);
|
package/dist/auth/caller.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/auth/hardening.js
CHANGED
|
@@ -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" } });
|
package/dist/auth/identity.js
CHANGED
|
@@ -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.");
|
package/dist/auth/oauth.js
CHANGED
|
@@ -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
|
+
}
|