@aotter/mantle 0.2.0-alpha.8 → 0.2.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/auth/adapter.js +9 -6
- package/dist/auth/routes.js +3 -2
- package/dist/build/compiler.js +1 -1
- package/dist/infrastructure/bundle.js +16 -3
- package/dist/infrastructure/generate.js +9 -1
- package/dist/infrastructure/generateAuth.js +51 -0
- package/dist/infrastructure/wrangler.js +19 -4
- package/dist/kernel/binds.d.ts +0 -3
- package/dist/kernel/contracts.d.ts +1 -1
- package/dist/kernel/contracts.js +4 -4
- package/dist/kernel/dialect/common.d.ts +0 -1
- package/dist/kernel/driver/postgres.js +3 -2
- package/dist/kernel/mcp-apps.js +1 -1
- package/dist/kernel/mcp-handler.js +1 -1
- package/dist/kernel/mcp.d.ts +5 -1
- package/dist/kernel/mcp.js +2 -1
- package/dist/kernel/scalars.js +3 -2
- package/dist/kernel/web-index.js +40 -0
- package/dist/kernel/web.js +3 -37
- package/dist/usecase/publishing.js +1 -1
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/validation.js +8 -0
- package/docs/examples/README.md +1 -1
- package/docs/examples/app-shell/README.md +6 -2
- package/docs/examples/app-shell/package.json +3 -3
- package/docs/examples/app-shell/src/auth.ts +3 -2
- package/docs/examples/app-shell/src/service.ts +2 -2
- package/docs/examples/club-events/manifests/club-events.yaml +7 -0
- package/docs/examples/club-events/src/handlers.ts +1 -1
- package/docs/examples/purchasing/manifests/purchasing.yaml +16 -0
- package/docs/examples/purchasing/src/handlers.ts +1 -1
- package/docs/handbook/cloudflare/authentication.md +21 -4
- package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
- package/docs/handbook/cloudflare/deploy-and-operate.md +24 -1
- package/docs/handbook/cloudflare/media-r2.md +2 -3
- package/docs/handbook/cloudflare/service-entry.md +14 -4
- package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/agent-setup.md +4 -4
- package/docs/handbook/guides/agent-workflow-acceptance.md +222 -0
- package/docs/handbook/guides/mcp-apps.md +78 -1
- package/docs/handbook/guides/native-admin.md +4 -0
- package/docs/handbook/guides/oauth.md +58 -0
- package/docs/handbook/guides/typed-queries.md +35 -0
- package/docs/handbook/reference/procedure.md +1 -1
- package/docs/handbook/reference/surface.md +6 -2
- package/docs/handbook/reference/trigger.md +9 -0
- package/docs/handbook/releases/index.md +3 -3
- package/docs/handbook/start/project-and-cli.md +9 -1
- package/docs/skills/develop/SKILL.md +43 -16
- package/docs/skills/integrate/SKILL.md +4 -9
- package/docs/skills/provision/SKILL.md +15 -12
- package/docs/skills/update/SKILL.md +19 -18
- package/package.json +2 -2
- package/skills/README.md +1 -1
- package/skills/mantle/SKILL.md +35 -13
- package/skills/mantle/references/local-testing.md +5 -0
- package/skills/mantle/references/website.md +6 -4
- package/docs/skills/develop/mcp-app.md +0 -39
- package/skills/.keep +0 -0
package/README.md
CHANGED
|
@@ -46,6 +46,15 @@ state column names, value types and the writable columns, and the Store enforces
|
|
|
46
46
|
**not** a View's or Procedure's `requires`: authorize before calling it. Design, limits and what the runtime still refuses are in
|
|
47
47
|
[Store guide](./docs/handbook/guides/typed-queries.md).
|
|
48
48
|
|
|
49
|
+
For simple creates and partial updates, prefer a declared Store-backed ref
|
|
50
|
+
Procedure: await the caller-bound insert/update, return an allowlist with the
|
|
51
|
+
actual returned `id`/`version`, and preserve omitted fields. Keep Procedure
|
|
52
|
+
`requires` and boundary validation; Store row policy is not operation
|
|
53
|
+
permission. Retain SQL Procedures for atomic conditional/multi-entity writes
|
|
54
|
+
such as stock/order changes and additive upserts. D1 batches cannot consume a
|
|
55
|
+
prior step's result and have no interactive transaction callback. See the
|
|
56
|
+
[write design guidance](./docs/handbook/guides/typed-queries.md#prefer-store-backed-ref-procedures-for-simple-writes).
|
|
57
|
+
|
|
49
58
|
Importing generated modules does not initialize the Store. The first `store()`
|
|
50
59
|
call prepares its metadata factory once; every call binds its own database,
|
|
51
60
|
Caller and handlers. Unused Stores still tree-shake out of direct operations.
|
|
@@ -66,6 +75,14 @@ Rank-ordered exposed Views still need supported keyset ordering; use an internal
|
|
|
66
75
|
View for arbitrary rank ordering. Applying changed search DDL remains the app's
|
|
67
76
|
migration responsibility; no request runs DDL or rebuilds indexes.
|
|
68
77
|
|
|
78
|
+
## Independently accept user-facing work
|
|
79
|
+
|
|
80
|
+
Before reporting any user-facing deliverable as done, follow the version-matched
|
|
81
|
+
[independent acceptance guide](./docs/handbook/guides/agent-workflow-acceptance.md)
|
|
82
|
+
with a fresh agent on the surface its user uses. For MCP work, also run the
|
|
83
|
+
guide's design audit before implementing tools; scripted checks remain separate
|
|
84
|
+
evidence.
|
|
85
|
+
|
|
69
86
|
## Publishing
|
|
70
87
|
|
|
71
88
|
Schemas default to `lifecycle: operational`. Opt-in `lifecycle: publishing` adds status
|
package/dist/auth/adapter.js
CHANGED
|
@@ -70,19 +70,22 @@ function customAdapter(d, exec, seed, insertOnly = false, options) {
|
|
|
70
70
|
const { adminRoles: roles, adminUserIds } = options ? adminOptionsOf({ options }) : { adminRoles: [], adminUserIds: [] };
|
|
71
71
|
const holdsAdmin = (role) => typeof role === "string" && role.split(",").some(role => roles.includes(role));
|
|
72
72
|
const field = (name) => getFieldName({ model: "user", field: name });
|
|
73
|
-
const protects = (modelKey, values) => modelKey === "user" && roles.length > 0 &&
|
|
73
|
+
const protects = (modelKey, values) => modelKey === "user" && (roles.length > 0 || adminUserIds.length > 0) &&
|
|
74
74
|
(!values || values[field("banned")] === true || values[field("banned")] === 1 || Object.hasOwn(values, field("role")) && !holdsAdmin(values[field("role")]));
|
|
75
|
-
const membership = (s, alias) => '(' + roles.map(role => `${d === sqlite ? "instr" : "strpos"}(',' || COALESCE(${alias}.${ident(field("role"))}, '') || ',', ${s.bind(',' + role + ',')}) > 0`).join(' OR ') + ')';
|
|
76
|
-
const
|
|
75
|
+
const membership = (s, alias) => '(' + [...roles.map(role => `${d === sqlite ? "instr" : "strpos"}(',' || COALESCE(${alias}.${ident(field("role"))}, '') || ',', ${s.bind(',' + role + ',')}) > 0`), d === sqlite ? "0" : "FALSE"].join(' OR ') + ')';
|
|
76
|
+
const allowlisted = (s, alias) => `(${[...adminUserIds.map(id => `${alias}.${idOf("user")} = ${s.bind(id)}`), d === sqlite ? "0" : "FALSE"].join(" OR ")})`;
|
|
77
|
+
const administrator = (s, alias) => `(${membership(s, alias)} OR ${allowlisted(s, alias)})`;
|
|
78
|
+
const lastAdminGuard = (s, model, keepsAllowlisted = false) => {
|
|
77
79
|
const table = ident(model), other = '"_mantle_other_admin"', no = d === sqlite ? '0' : 'FALSE';
|
|
78
|
-
return `(NOT ${
|
|
80
|
+
return `(${keepsAllowlisted ? `${allowlisted(s, table)} OR ` : ""}NOT ${administrator(s, table)} OR EXISTS (SELECT 1 FROM ${table} ${other} WHERE ${administrator(s, other)} AND ${other}.${idOf("user")} <> ${table}.${idOf("user")} AND COALESCE(${other}.${ident(field("banned"))}, ${no}) = ${no}))`;
|
|
79
81
|
};
|
|
80
82
|
const mutation = async (model, modelKey, where, values) => {
|
|
81
83
|
const protect = protects(modelKey, values);
|
|
84
|
+
const keepsAllowlisted = protect && !!values && values[field("banned")] !== true && values[field("banned")] !== 1;
|
|
82
85
|
if (protect && (where.length !== 1 || where[0].field !== field("id") || where[0].operator && where[0].operator !== "eq"))
|
|
83
86
|
throw new APIError("FORBIDDEN", { code: "AUTH_DENIED", message: "Destructive admin identity writes require one user id." });
|
|
84
|
-
const result = await run(s => `${values ? `UPDATE ${ident(model)} SET ${assign(s, values)}` : `DELETE FROM ${ident(model)}`}${s.where(modelKey, where, protect ? lastAdminGuard(s, model) : undefined)} RETURNING *`, protect);
|
|
85
|
-
if (protect && !result.changes && (await run(s => `SELECT ${idOf(modelKey)} FROM ${ident(model)}${s.where(modelKey, where, `NOT ${lastAdminGuard(s, model)}`)} LIMIT 1`)).rows.length)
|
|
87
|
+
const result = await run(s => `${values ? `UPDATE ${ident(model)} SET ${assign(s, values)}` : `DELETE FROM ${ident(model)}`}${s.where(modelKey, where, protect ? lastAdminGuard(s, model, keepsAllowlisted) : undefined)} RETURNING *`, protect);
|
|
88
|
+
if (protect && !result.changes && (await run(s => `SELECT ${idOf(modelKey)} FROM ${ident(model)}${s.where(modelKey, where, `NOT ${lastAdminGuard(s, model, keepsAllowlisted)}`)} LIMIT 1`)).rows.length)
|
|
86
89
|
throw new APIError("CONFLICT", { code: "CONFLICT", message: "This is the last admin; promote another admin before removing or banning this one." });
|
|
87
90
|
return result;
|
|
88
91
|
};
|
package/dist/auth/routes.js
CHANGED
|
@@ -28,8 +28,9 @@ export function authRoutes(loader, options) {
|
|
|
28
28
|
if (basePathOf(auth.options) !== base)
|
|
29
29
|
throw new Error("authRoutes basePath differs from betterAuth options.");
|
|
30
30
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const bounded = request.body ? new Request(request, { body: await readBody(request) }) : request;
|
|
32
|
+
await guardAuthAdminRequest(bounded, () => resolve(bounded), base);
|
|
33
|
+
const registration = bounded.method === "POST" && pathname === `${base}/oauth2/register` ? await loopbackClientsAreNative(bounded) : bounded;
|
|
33
34
|
return await normalizeAuthResponseCookies(await auth.handler(registration));
|
|
34
35
|
}
|
|
35
36
|
catch (error) {
|
package/dist/build/compiler.js
CHANGED
|
@@ -5094,7 +5094,7 @@ function lifecycleHookSets(plan) {
|
|
|
5094
5094
|
}
|
|
5095
5095
|
|
|
5096
5096
|
// src/build/core/version.ts
|
|
5097
|
-
var MANTLE_VERSION = "0.2.0-alpha.
|
|
5097
|
+
var MANTLE_VERSION = "0.2.0-alpha.9";
|
|
5098
5098
|
|
|
5099
5099
|
// src/build/core/sql/ast.ts
|
|
5100
5100
|
var S = (s) => ({ String: { sval: s } });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { build } from "esbuild";
|
|
6
6
|
import { INPUTS_FILE, authTableSql, extraRoles, generatedTarget, staleInputs } from "./inputs.js";
|
|
@@ -41,14 +41,27 @@ export function guardStartup(metafile, entry) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
/** The physical spelling of a path, including a nonexistent tail below its nearest existing ancestor. */
|
|
45
|
+
const physical = (input) => {
|
|
46
|
+
let found = resolve(input);
|
|
47
|
+
const tail = [];
|
|
48
|
+
while (!existsSync(found)) {
|
|
49
|
+
const parent = dirname(found);
|
|
50
|
+
if (parent === found)
|
|
51
|
+
return found;
|
|
52
|
+
tail.unshift(basename(found));
|
|
53
|
+
found = parent;
|
|
54
|
+
}
|
|
55
|
+
return resolve(realpathSync(found), ...tail);
|
|
56
|
+
};
|
|
44
57
|
/** Whether `inner` is `outer` or inside it. */
|
|
45
58
|
const within = (outer, inner) => {
|
|
46
|
-
const path = relative(
|
|
59
|
+
const path = relative(physical(outer), physical(inner));
|
|
47
60
|
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
48
61
|
};
|
|
49
62
|
/** A directory a build empties must not be the working directory or hold the source entry. */
|
|
50
63
|
function assertCleanable(label, dir, entry) {
|
|
51
|
-
if (
|
|
64
|
+
if (physical(dir) === physical(process.cwd()))
|
|
52
65
|
throw new Error(`${label} must not be the working directory: it is cleaned before building.`);
|
|
53
66
|
if (within(dir, entry))
|
|
54
67
|
throw new Error(`${label} must not contain the source entry: it is cleaned before building.`);
|
|
@@ -90,6 +90,14 @@ function outputNameWarnings(plan) {
|
|
|
90
90
|
inspect("Procedure", name, procedure.handler.sql.stmts);
|
|
91
91
|
return [...new Set(warnings)];
|
|
92
92
|
}
|
|
93
|
+
function missingMcpWarnings(plan) {
|
|
94
|
+
const mcp = new Set(Object.values(plan.triggers).flatMap(trigger => trigger.source.kind === "mcp" ? [trigger.procedure] : []));
|
|
95
|
+
const http = new Set(Object.values(plan.triggers).flatMap(trigger => trigger.source.kind === "http" ? [trigger.procedure] : []));
|
|
96
|
+
return [...http].filter(name => !mcp.has(name)).map(name => {
|
|
97
|
+
const surface = plan.procedures[name]?.requires?.auth?.all.includes("ctx.admin") ? "admin" : "public";
|
|
98
|
+
return `Procedure '${name}' has an http Trigger but no mcp Trigger, so the user's AI cannot reach this operation. Add a Trigger with spec.source: { kind: mcp, surface: ${surface} } and spec.target: { procedure: ${name} }.`;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
93
101
|
export async function generateFromSources(request, schema) {
|
|
94
102
|
const postgres = request.target.endsWith("postgres");
|
|
95
103
|
const compiler = postgres ? postgresCompiler : sqliteCompiler;
|
|
@@ -170,5 +178,5 @@ export async function generateFromSources(request, schema) {
|
|
|
170
178
|
const gatewayFiles = generatedFiles({ "access-targets.ts": `import type { AccessTarget } from "@aotter/mantle/contracts";\nexport const targets: Readonly<Record<string, AccessTarget>> = ${JSON.stringify(accessTargets)};\n` });
|
|
171
179
|
const ddl = [...(postgres ? postgresSchema : sqliteSchema)(compiled.plan.schemas), ...publishingSql(compiled.plan.schemas, !postgres), ...(request.admin ? adminIndexes(compiled.plan.schemas, !postgres) : []), ...(schema?.() ?? [])];
|
|
172
180
|
const { "site-schema.sql": siteSql = "", ...files } = result.files;
|
|
173
|
-
return { ...result, warnings: [...outputNameWarnings(compiled.plan), ...(request.admin ? adminCreateWarnings(compiled.plan) : []), ...(compiled.plan.access?.warnings ?? [])], files: { ...files, ...gatewayFiles, ...generatedFiles({ "schema.sql": ddl.join(";\n\n") + ";\n" + siteSql }) } };
|
|
181
|
+
return { ...result, warnings: [...missingMcpWarnings(compiled.plan), ...outputNameWarnings(compiled.plan), ...(request.admin ? adminCreateWarnings(compiled.plan) : []), ...(compiled.plan.access?.warnings ?? [])], files: { ...files, ...gatewayFiles, ...generatedFiles({ "schema.sql": ddl.join(";\n\n") + ";\n" + siteSql }) } };
|
|
174
182
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { generateFromSources as generate } from "./generate.js";
|
|
2
|
+
import { admin, anonymous, jwt, phoneNumber, siwe, twoFactor } from "better-auth/plugins";
|
|
2
3
|
import { authSchemaSql } from "./authSchema.js";
|
|
3
4
|
/** One or more Better Auth instances (each with its own table names); their DDL is merged. */
|
|
4
5
|
export function generateFromSources(request) {
|
|
@@ -12,6 +13,8 @@ function authSql(instances, dialect) {
|
|
|
12
13
|
if (ids.includes("oauth-provider") && !ids.includes("mantle-oauth-grant-revocation") /* oauthGrantRevocation().id */)
|
|
13
14
|
throw new Error("Better Auth has an OAuth provider (oauthProvider or mcp) without oauthGrantRevocation(): add it from @aotter/mantle/auth/oauth to the plugins, so a password reset, an admin ban or session revocation ends that user's OAuth grants.");
|
|
14
15
|
}
|
|
16
|
+
if (instances.length > 1)
|
|
17
|
+
refuseSharedPluginMappings(instances);
|
|
15
18
|
const statements = [...new Set(instances.flatMap(options => authSchemaSql(options, dialect)))];
|
|
16
19
|
// CREATE TABLE IF NOT EXISTS would silently keep the first of two different definitions.
|
|
17
20
|
// Identical statements are already merged, so a repeated table name means two different definitions.
|
|
@@ -21,3 +24,51 @@ function authSql(instances, dialect) {
|
|
|
21
24
|
throw new Error(`Better Auth instances define table ${twice} differently; give each instance its own table names (user.modelName, session.modelName, ...).`);
|
|
22
25
|
return statements;
|
|
23
26
|
}
|
|
27
|
+
// Plugins whose factory hands back one module-level schema object (mergeSchema writes `schema` options into it); no options leaves it untouched.
|
|
28
|
+
const sharedSchema = {
|
|
29
|
+
jwt: () => jwt().schema, admin: () => admin().schema, anonymous: () => anonymous().schema,
|
|
30
|
+
"phone-number": () => phoneNumber({}).schema, siwe: () => siwe({}).schema, "two-factor": () => twoFactor().schema,
|
|
31
|
+
};
|
|
32
|
+
// Plugins that share field objects (device-authorization) or copy per call (oauth-provider, listed on request) are only checked for equal resolved names.
|
|
33
|
+
const checkedPlugins = new Set([...Object.keys(sharedSchema), "device-authorization", "oauth-provider"]);
|
|
34
|
+
/** Names a plugin's tables and fields resolve to, keyed `table` / `table.field`; only mappings that differ from the key are listed. */
|
|
35
|
+
function mapped(schema) {
|
|
36
|
+
const names = new Map();
|
|
37
|
+
for (const [table, model] of Object.entries(schema)) {
|
|
38
|
+
if (model.modelName && model.modelName !== table)
|
|
39
|
+
names.set(table, model.modelName);
|
|
40
|
+
for (const [field, type] of Object.entries(model.fields ?? {})) {
|
|
41
|
+
const name = type.fieldName;
|
|
42
|
+
if (name && name !== field)
|
|
43
|
+
names.set(`${table}.${field}`, name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return names;
|
|
47
|
+
}
|
|
48
|
+
/** Better Auth plugin factories share their schema object, so differing plugin field/model mappings across instances in one Worker override each other. */
|
|
49
|
+
function refuseSharedPluginMappings(instances) {
|
|
50
|
+
const byPlugin = new Map();
|
|
51
|
+
instances.forEach((options, index) => (options.plugins ?? []).forEach(plugin => {
|
|
52
|
+
const schema = plugin.schema;
|
|
53
|
+
if (schema && checkedPlugins.has(plugin.id))
|
|
54
|
+
byPlugin.set(plugin.id, [...(byPlugin.get(plugin.id) ?? []), { index, schema }]);
|
|
55
|
+
}));
|
|
56
|
+
for (const [id, uses] of byPlugin) {
|
|
57
|
+
const names = uses.map(use => mapped(use.schema));
|
|
58
|
+
if (uses.length < 2 || !names.some(own => own.size))
|
|
59
|
+
continue;
|
|
60
|
+
const fail = (reason, which) => { throw new Error(`Better Auth plugin ${id} is used by instances ${which.join(", ")} (--auth order, from 0) and ${reason}. Plugin factories share their schema object, so differing plugin field/model mappings across instances in one Worker override each other: pass an explicit \`schema\` mapping to the ${id} plugin in every instance, with names that differ between instances, and give each instance its own copy right after constructing it, \`{ ...plugin, schema: structuredClone(plugin.schema) }\`; or keep the ${id} plugin at its defaults and separate the instances by core table names.`); };
|
|
61
|
+
const shared = sharedSchema[id]?.();
|
|
62
|
+
const holders = uses.filter(use => use.schema === shared).map(use => use.index);
|
|
63
|
+
if (holders.length)
|
|
64
|
+
fail("a non-default mapping exists while an instance holds the plugin's shared schema object (no own copy)", holders);
|
|
65
|
+
for (const [i, a] of uses.entries())
|
|
66
|
+
for (const [j, b] of uses.entries()) {
|
|
67
|
+
if (j <= i)
|
|
68
|
+
continue;
|
|
69
|
+
for (const [key, name] of names[i])
|
|
70
|
+
if (names[j].get(key) === name)
|
|
71
|
+
fail(`both resolve ${key} to "${name}"; non-default plugin names must differ between instances, because generate cannot tell an identical mapping from an inherited one`, [a.index, b.index]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -36,7 +36,7 @@ export function configureWrangler(configPath, entry, outdir, generated, check =
|
|
|
36
36
|
throw new Error("--check requires a JSON or JSONC Wrangler config; TOML generation only prints manual edits.");
|
|
37
37
|
console.log(`Leave ${configPath} unchanged. Add these Wrangler TOML keys (include nodejs_compat and global_fetch_strictly_public in every compatibility_flags override):\n` +
|
|
38
38
|
`main = ${JSON.stringify(keys.main)}\nno_bundle = true\nfind_additional_modules = true\nbase_dir = ${JSON.stringify(worker)}\ncompatibility_flags = ${JSON.stringify(requiredFlags)}\n\n` +
|
|
39
|
-
(assets === undefined ? "" : `[assets]\ndirectory = ${JSON.stringify(local(assets))}\n\n`) +
|
|
39
|
+
(assets === undefined ? "" : `[assets]\ndirectory = ${JSON.stringify(local(assets))}\nbinding = "ASSETS"\nrun_worker_first = true\nhtml_handling = "none"\n\n`) +
|
|
40
40
|
`[[rules]]\ntype = "ESModule"\nglobs = ["**/*.js"]\nfallthrough = true\n\n[build]\ncommand = ${JSON.stringify(keys.build.command)}\nwatch_dir = ${JSON.stringify(keys.build.watch_dir)}`);
|
|
41
41
|
return false;
|
|
42
42
|
}
|
|
@@ -80,9 +80,24 @@ export function configureWrangler(configPath, entry, outdir, generated, check =
|
|
|
80
80
|
edit([key], value);
|
|
81
81
|
for (const [key, value] of Object.entries(buildKeys))
|
|
82
82
|
edit(["build", key], value);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
if (assets !== undefined) {
|
|
84
|
+
const wireAssets = (path, current) => {
|
|
85
|
+
edit([...path, "directory"], local(assets));
|
|
86
|
+
const ownsDefaults = current === undefined || current?.binding === undefined;
|
|
87
|
+
if (ownsDefaults && current?.binding === undefined)
|
|
88
|
+
edit([...path, "binding"], "ASSETS");
|
|
89
|
+
if (ownsDefaults && current?.run_worker_first === undefined)
|
|
90
|
+
edit([...path, "run_worker_first"], true);
|
|
91
|
+
// The shell fetches index.html itself; asset canonicalization would redirect it back to /admin/ forever.
|
|
92
|
+
if (current?.html_handling === undefined && (current?.run_worker_first === true || (ownsDefaults && current?.run_worker_first === undefined)))
|
|
93
|
+
edit([...path, "html_handling"], "none");
|
|
94
|
+
};
|
|
95
|
+
wireAssets(["assets"], config.assets);
|
|
96
|
+
for (const [name, environment] of Object.entries(config.env ?? {})) {
|
|
97
|
+
if (environment?.assets !== undefined)
|
|
98
|
+
wireAssets(["env", name, "assets"], environment.assets);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
86
101
|
if (!(config.rules ?? []).some((r) => isDeepStrictEqual(r, rule)))
|
|
87
102
|
edit(config.rules === undefined ? ["rules"] : ["rules", -1], config.rules === undefined ? [rule] : rule);
|
|
88
103
|
const changed = !exists || text !== original;
|
package/dist/kernel/binds.d.ts
CHANGED
|
@@ -36,9 +36,6 @@ export type Bound = {
|
|
|
36
36
|
};
|
|
37
37
|
/** The dialect functions the recipes in use call; generated code passes only those. */
|
|
38
38
|
export type Values = Partial<Pick<Dialect, "now" | "cutoff" | "timestamp" | "date">> & {
|
|
39
|
-
readonly user?: (recipe: Extract<Recipe, {
|
|
40
|
-
k: "user";
|
|
41
|
-
}>, caller: Caller) => unknown;
|
|
42
39
|
readonly scopeInsert?: Dialect['scopeInsert'];
|
|
43
40
|
readonly userRefs?: (recipe: Recipe, caller: Caller) => unknown;
|
|
44
41
|
/** Present only in apps with user fields: keeps, per caller, one printed form of each Schema scope term. */
|
|
@@ -111,7 +111,7 @@ export declare function deny(caller: Caller, allowed: boolean, path: string): vo
|
|
|
111
111
|
/** CONFLICT for a write the database refused: a unique-index clash, or a row statement that did not change exactly one row. */
|
|
112
112
|
export declare const conflict: (path: string, unique: boolean, cause?: unknown) => ContractError;
|
|
113
113
|
/** A Schema `checks` violation (Mantle's SQLite check trigger, or PostgreSQL SQLSTATE 23514) is a CONFLICT naming the check. */
|
|
114
|
-
export declare const checkConflict: (path: string, error: unknown
|
|
114
|
+
export declare const checkConflict: (path: string, error: unknown) => ContractError | undefined;
|
|
115
115
|
/** One result of `unsafe`: the rows (typed by the generated function), plus the affected count. */
|
|
116
116
|
export type PostgresResult = any[] & {
|
|
117
117
|
readonly count: number | null;
|
package/dist/kernel/contracts.js
CHANGED
|
@@ -54,11 +54,11 @@ export function deny(caller, allowed, path) {
|
|
|
54
54
|
/** CONFLICT for a write the database refused: a unique-index clash, or a row statement that did not change exactly one row. */
|
|
55
55
|
export const conflict = (path, unique, cause) => new ContractError("CONFLICT", unique ? "A unique constraint of the Schema was violated." : "Expected one affected row.", path, { cause });
|
|
56
56
|
/** A Schema `checks` violation (Mantle's SQLite check trigger, or PostgreSQL SQLSTATE 23514) is a CONFLICT naming the check. */
|
|
57
|
-
export const checkConflict = (path, error
|
|
58
|
-
|
|
57
|
+
export const checkConflict = (path, error) => {
|
|
58
|
+
const message = error?.message ?? "", marker = /MANTLE_CHECK ([^:\n]+): (.+)/.exec(message);
|
|
59
|
+
if (!/MANTLE_CHECK /.test(message))
|
|
59
60
|
return undefined;
|
|
60
|
-
|
|
61
|
-
return new ContractError("CONFLICT", `A check of the Schema failed: ${/MANTLE_CHECK [^\n'"]+/.exec(message)?.[0] ?? message}`, path, { cause: error });
|
|
61
|
+
return new ContractError("CONFLICT", `A check of the Schema failed: ${marker?.[0] ?? message}`, path, { cause: error, fields: { kind: "check", schema: marker?.[1], check: marker?.[2]?.replace(/: SQLITE_CONSTRAINT.*$/, "") } });
|
|
62
62
|
};
|
|
63
63
|
/** A cookie is ambient authority; bearer credentials deliberately supplied by the app are not. */
|
|
64
64
|
export function checkOrigin(request, caller) {
|
|
@@ -10,7 +10,6 @@ export declare const likeEscape = " ESCAPE '\\'";
|
|
|
10
10
|
*/
|
|
11
11
|
export interface Dialect {
|
|
12
12
|
readonly userRefs?: import('../binds.js').Values['userRefs'];
|
|
13
|
-
readonly user?: import('../binds.js').Values['user'];
|
|
14
13
|
readonly anyOf?: (column: string, placeholder: string, type: 'string' | 'number') => string;
|
|
15
14
|
readonly scopeInsert?: (scopes: readonly {
|
|
16
15
|
readonly column: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { conflict, ContractError } from "../contracts.js";
|
|
2
2
|
import { context, guard } from "./transaction-context.js";
|
|
3
3
|
const transactions = new WeakSet();
|
|
4
4
|
const ISOLATION = ["serializable", "repeatable read", "read committed", "read uncommitted"];
|
|
@@ -9,6 +9,7 @@ export async function run(db, sql, binds) {
|
|
|
9
9
|
}
|
|
10
10
|
/** SQLSTATE of a server error: `errno` on Bun.SQL, `code` on postgres.js. */
|
|
11
11
|
const sqlstate = (error) => { const e = error; return typeof e?.errno === "string" ? e.errno : e?.code; };
|
|
12
|
+
const check = (path, e) => new ContractError("CONFLICT", `A check of the Schema failed: ${e.message}`, path, { cause: e, fields: { kind: "check", schema: e.table ?? e.table_name, check: e.constraint ?? e.constraint_name } });
|
|
12
13
|
/** A structured server ErrorResponse proves rollback; missing evidence is uncertain. */
|
|
13
14
|
export function definitive(error) {
|
|
14
15
|
if (error instanceof ContractError)
|
|
@@ -35,7 +36,7 @@ export async function procedure(db, steps, path) {
|
|
|
35
36
|
const execute = async (handle) => {
|
|
36
37
|
const results = [];
|
|
37
38
|
for (const s of steps) {
|
|
38
|
-
const { rows, changes } = await run(handle, s.sql, s.binds).catch(error => { throw sqlstate(error) === "23505" ? conflict(path, true, error) :
|
|
39
|
+
const { rows, changes } = await run(handle, s.sql, s.binds).catch(error => { throw sqlstate(error) === "23505" ? conflict(path, true, error) : sqlstate(error) === "23514" ? check(path, error) : error; });
|
|
39
40
|
if (s.row && changes !== 1)
|
|
40
41
|
throw conflict(path, false);
|
|
41
42
|
results.push(rows);
|
package/dist/kernel/mcp-apps.js
CHANGED
|
@@ -35,6 +35,6 @@ export function linkApps(apps, tools, views) {
|
|
|
35
35
|
}
|
|
36
36
|
/** One endpoint's Apps bound to its tool table; `previous` is reused while the table's names are unchanged. */
|
|
37
37
|
export function linkSurface(apps, selected, previous) {
|
|
38
|
-
const key = JSON.stringify(selected.map(tool => tool.name).sort());
|
|
38
|
+
const key = JSON.stringify(selected.map(tool => [tool.name, !!tool.page]).sort(([a], [b]) => a.localeCompare(b)));
|
|
39
39
|
return previous?.key === key ? previous : { key, links: linkApps(apps, new Set(selected.map(tool => tool.name)), new Set(selected.filter(tool => tool.page).map(tool => tool.name))) };
|
|
40
40
|
}
|
|
@@ -94,7 +94,7 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
94
94
|
observe(options.onRefusal, { at: Date.now(), status: result.status, reason: "credential" });
|
|
95
95
|
}
|
|
96
96
|
if (result.status === 401)
|
|
97
|
-
scope
|
|
97
|
+
scope = union(scope === undefined ? requiredScopes : scope.split(" "), options.authorizationScopes ?? []);
|
|
98
98
|
return (await import("./host-caller.js")).hostFailure(error, resolved, `mcp ${path}`, bare ? undefined : 'Bearer ' + (scope ? (result.status === 403 ? 'error="insufficient_scope", ' : '') + 'scope="' + scope + '", ' : '') + 'resource_metadata="' + (options.resourceMetadata ?? url.origin + '/.well-known/oauth-protected-resource/mcp') + '"');
|
|
99
99
|
};
|
|
100
100
|
let caller;
|
package/dist/kernel/mcp.d.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import type { Caller, Target, Authorize, Gateway, RequestContext, GatewayOptions } from "./contracts.js";
|
|
3
3
|
import type { Conversions } from "./surface.js";
|
|
4
4
|
import { type McpAppsSource } from "./mcp-apps.js";
|
|
5
|
-
|
|
5
|
+
type Call = typeof import("./mcp-call.js");
|
|
6
|
+
export declare const callTool: Call["callTool"];
|
|
7
|
+
export declare const callToolLazy: Call["callToolLazy"];
|
|
6
8
|
export type { McpApps, McpAppsSource, McpAppResource, McpAppRender, McpAppPage, McpAppCsp } from "./mcp-apps.js";
|
|
7
9
|
/** Options of a generated `mcp(...)`; it adds required `handlers` when the app declares ref Procedures. */
|
|
8
10
|
export interface Options<Env, Database> extends GatewayOptions {
|
|
@@ -28,6 +30,8 @@ export interface Options<Env, Database> extends GatewayOptions {
|
|
|
28
30
|
}[];
|
|
29
31
|
};
|
|
30
32
|
requiredScopes?: readonly string[];
|
|
33
|
+
/** Additional scopes requested by an unauthenticated OAuth challenge (for example offline_access), not an API scope floor. */
|
|
34
|
+
authorizationScopes?: readonly string[];
|
|
31
35
|
resourceMetadata?: string;
|
|
32
36
|
/** Browser origins MCP answers besides loopback, compared by hostname: `https://console.example` or the official SDK's bare `console.example`. The site's own origin is not implied. */
|
|
33
37
|
allowedOrigins?: readonly string[];
|
package/dist/kernel/mcp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { linkApps, linkSurface, resourcesOf } from "./mcp-apps.js";
|
|
2
|
-
export
|
|
2
|
+
export const callTool = async (...args) => (await import("./mcp-call.js")).callTool(...args);
|
|
3
|
+
export const callToolLazy = async (...args) => (await import("./mcp-call.js")).callToolLazy(...args);
|
|
3
4
|
/** The selected MCP routes; all request work loads only when an MCP request arrives. */
|
|
4
5
|
export function mcp(tools, options, reads = []) {
|
|
5
6
|
let serve;
|
package/dist/kernel/scalars.js
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
import { ContractError } from "./contracts.js";
|
|
7
7
|
export function encodeTimestamptz(v) {
|
|
8
8
|
if (v instanceof Date) {
|
|
9
|
-
|
|
9
|
+
const micros = v.getTime() * 1000;
|
|
10
|
+
if (!Number.isSafeInteger(micros))
|
|
10
11
|
throw new ContractError("SQL_TYPE", "Invalid Date is not a timestamp");
|
|
11
|
-
return
|
|
12
|
+
return micros;
|
|
12
13
|
}
|
|
13
14
|
// hours 00-23: Date.parse would roll T24:00 over to the next day
|
|
14
15
|
const m = /^(\d{4}-\d\d-\d\d)[T ]((?:[01]\d|2[0-3]):\d\d(?::\d\d)?)(?:\.(\d{1,6}))?(Z|[+-]\d\d(?::?\d\d)?)$/.exec(v.trim());
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** /llms.txt and /sitemap.xml bodies; loaded only when an index is requested, so page Workers do not pay for it at startup. */
|
|
2
|
+
const escape = (v) => String(v).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
3
|
+
const label = (v) => String(v).replace(/[\r\n]/g, " ").replace(/[\\[\]]/g, "\\$&");
|
|
4
|
+
const size = (s) => new TextEncoder().encode(s).length;
|
|
5
|
+
export async function webIndex(sitemap, options, pages, list, validUrl) {
|
|
6
|
+
const cap = sitemap ? 50000 : 5000, limit = sitemap ? 50 * 1024 * 1024 : 1024 * 1024;
|
|
7
|
+
const start = sitemap ? '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' : "# " + label(options.title) + "\n\n> " + label(options.description) + "\n\n## Pages\n\n", end = sitemap ? "</urlset>" : "\n";
|
|
8
|
+
const links = [];
|
|
9
|
+
let visited = 0, requests = 0, bytes = size(start + end);
|
|
10
|
+
if (bytes > limit)
|
|
11
|
+
throw new TypeError("Web index header exceeds its byte limit.");
|
|
12
|
+
pagesLoop: for (const entry of pages) {
|
|
13
|
+
if (!entry.page.list)
|
|
14
|
+
continue;
|
|
15
|
+
let cursor;
|
|
16
|
+
do {
|
|
17
|
+
if (requests >= (sitemap ? 100 : 10))
|
|
18
|
+
break pagesLoop;
|
|
19
|
+
requests++;
|
|
20
|
+
const page = await list(entry.page.list, cursor);
|
|
21
|
+
for (const row of page.rows) {
|
|
22
|
+
if (visited++ >= cap)
|
|
23
|
+
break pagesLoop;
|
|
24
|
+
const url = validUrl(entry.path, row, entry.page.view);
|
|
25
|
+
if (!url)
|
|
26
|
+
continue;
|
|
27
|
+
const line = sitemap ? "<url><loc>" + escape(url) + "</loc></url>" : "- [" + label(row.title ?? url) + "](" + url + "): " + label(row.description ?? "") + "\n";
|
|
28
|
+
const length = size(line);
|
|
29
|
+
if (bytes + length > limit)
|
|
30
|
+
break pagesLoop;
|
|
31
|
+
bytes += length;
|
|
32
|
+
links.push(line);
|
|
33
|
+
}
|
|
34
|
+
if (visited >= cap)
|
|
35
|
+
break pagesLoop;
|
|
36
|
+
cursor = page.nextCursor;
|
|
37
|
+
} while (cursor);
|
|
38
|
+
}
|
|
39
|
+
return start + links.join("") + end;
|
|
40
|
+
}
|
package/dist/kernel/web.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/** Web output for a generated public View table: /llms.txt, /sitemap.xml, Markdown pages and head tags. SQL owns visibility. */
|
|
2
2
|
import { forwarded, wire, ContractError } from "./contracts.js";
|
|
3
3
|
import { convert, decode } from "./surface.js";
|
|
4
|
-
const escape = (v) => String(v).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
5
|
-
const label = (v) => String(v).replace(/[\r\n]/g, " ").replace(/[\\[\]]/g, "\\$&");
|
|
6
|
-
const size = (s) => new TextEncoder().encode(s).length;
|
|
7
4
|
const NO_STORE = { "cache-control": "no-store" };
|
|
8
5
|
const notFound = () => new Response(null, { status: 404, headers: NO_STORE });
|
|
9
6
|
export function web(options, table) {
|
|
@@ -76,40 +73,9 @@ export function web(options, table) {
|
|
|
76
73
|
const ctx = await context(request, env, execution);
|
|
77
74
|
caller = ctx.caller;
|
|
78
75
|
if (index) {
|
|
79
|
-
const sitemap = path === "/sitemap.xml"
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
let visited = 0, requests = 0, bytes = size(start + end);
|
|
83
|
-
if (bytes > limit)
|
|
84
|
-
throw new TypeError("Web index header exceeds its byte limit.");
|
|
85
|
-
pagesLoop: for (const entry of pages) {
|
|
86
|
-
if (!entry.page.list)
|
|
87
|
-
continue;
|
|
88
|
-
let cursor;
|
|
89
|
-
do {
|
|
90
|
-
if (requests >= (sitemap ? 100 : 10))
|
|
91
|
-
break pagesLoop;
|
|
92
|
-
requests++;
|
|
93
|
-
const page = await query(entry.page.list, ctx.db, ctx.caller, new URLSearchParams(), cursor);
|
|
94
|
-
for (const row of page.rows) {
|
|
95
|
-
if (visited++ >= cap)
|
|
96
|
-
break pagesLoop;
|
|
97
|
-
const url = validUrl(entry.path, row, entry.page.view);
|
|
98
|
-
if (!url)
|
|
99
|
-
continue;
|
|
100
|
-
const line = sitemap ? "<url><loc>" + escape(url) + "</loc></url>" : "- [" + label(row.title ?? url) + "](" + url + "): " + label(row.description ?? "") + "\n";
|
|
101
|
-
const length = size(line);
|
|
102
|
-
if (bytes + length > limit)
|
|
103
|
-
break pagesLoop;
|
|
104
|
-
bytes += length;
|
|
105
|
-
links.push(line);
|
|
106
|
-
}
|
|
107
|
-
if (visited >= cap)
|
|
108
|
-
break pagesLoop;
|
|
109
|
-
cursor = page.nextCursor;
|
|
110
|
-
} while (cursor);
|
|
111
|
-
}
|
|
112
|
-
return response(request, start + links.join("") + end, sitemap ? "application/xml" : "text/plain", ctx.caller);
|
|
76
|
+
const sitemap = path === "/sitemap.xml";
|
|
77
|
+
const body = await (await import("./web-index.js")).webIndex(sitemap, options, pages, (list, cursor) => query(list, ctx.db, ctx.caller, new URLSearchParams(), cursor), validUrl);
|
|
78
|
+
return response(request, body, sitemap ? "application/xml" : "text/plain", ctx.caller);
|
|
113
79
|
}
|
|
114
80
|
const exact = match(path);
|
|
115
81
|
if (exact)
|
|
@@ -36,7 +36,7 @@ export function emitPublishing(plan, isSqlite, declarations, procedures, entries
|
|
|
36
36
|
// the status term is replaced by the transition's own `status IN (...)`.
|
|
37
37
|
for (const term of rowPolicy(schema, trusted ? "trusted" : "caller", plan.schemas))
|
|
38
38
|
if (term[1] !== "admin")
|
|
39
|
-
conditions.push(renderTerm(d, term, "", recipe => (recipes.push(recipe), d.param(recipes.length))));
|
|
39
|
+
conditions.push(renderTerm(d, term, term[1] === "parent" ? d.ident(s.name) + "." : "", recipe => (recipes.push(recipe), d.param(recipes.length))));
|
|
40
40
|
if (verb === "publish")
|
|
41
41
|
for (const field of s.schema.required ?? []) {
|
|
42
42
|
const column = Object.keys(s.fields).find(c => (s.names?.[c] ?? c) === field);
|
package/dist/usecase/site.js
CHANGED
|
@@ -32,7 +32,6 @@ export function site(db: Database, caller: Caller) { return createSite({read:(sq
|
|
|
32
32
|
`, "site-seed.sql": seed + "\n", "site-schema.sql": `CREATE TABLE site_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
33
33
|
CREATE TABLE media_assets (id TEXT PRIMARY KEY, created_at ${pg ? "BIGINT" : "INTEGER"} NOT NULL, owner_id TEXT, alt TEXT, caption TEXT, variants TEXT NOT NULL, metadata TEXT);
|
|
34
34
|
CREATE INDEX media_assets_by_created_id ON media_assets (created_at DESC, id DESC);
|
|
35
|
-
CREATE INDEX media_assets_by_owner_created ON media_assets (owner_id, created_at DESC);
|
|
36
35
|
CREATE TABLE pending_media_uploads (id TEXT PRIMARY KEY, record TEXT NOT NULL, expires_at ${pg ? "BIGINT" : "INTEGER"} NOT NULL);
|
|
37
36
|
CREATE INDEX pending_media_uploads_expires_at ON pending_media_uploads (expires_at);
|
|
38
37
|
${seed}
|
|
@@ -157,6 +157,14 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
|
|
|
157
157
|
equality = true;
|
|
158
158
|
check(`!equal(value,${literal(schema.const)})`, "Value violates const.");
|
|
159
159
|
}
|
|
160
|
+
for (const key of ["minLength", "maxLength", "minItems", "maxItems"])
|
|
161
|
+
if (schema[key] !== undefined && (!Number.isSafeInteger(schema[key]) || schema[key] < 0))
|
|
162
|
+
refuse(base, `${key} must be a non-negative safe integer.`);
|
|
163
|
+
for (const key of ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"])
|
|
164
|
+
if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key])))
|
|
165
|
+
refuse(base, `${key} must be a finite number.`);
|
|
166
|
+
if (schema.multipleOf !== undefined && (typeof schema.multipleOf !== "number" || !Number.isFinite(schema.multipleOf) || schema.multipleOf <= 0))
|
|
167
|
+
refuse(base, "multipleOf must be a positive finite number.");
|
|
160
168
|
for (const [type, measure, keywords] of bounds)
|
|
161
169
|
for (const [key, operator] of Object.entries(keywords))
|
|
162
170
|
if (schema[key] !== undefined)
|
package/docs/examples/README.md
CHANGED
|
@@ -31,5 +31,5 @@ shell's allow-all `src/signup.ts`, unless sign-up should be invite-only. Purchas
|
|
|
31
31
|
|
|
32
32
|
Every example uses two roles: `admin` (the admin layer) and `user`.
|
|
33
33
|
|
|
34
|
-
[Reference service](https://github.com/aotter/mantle/tree/v0.2.0-alpha.
|
|
34
|
+
[Reference service](https://github.com/aotter/mantle/tree/v0.2.0-alpha.9/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
|
|
35
35
|
Projects on 0.1.x move with [the upgrade guide](../upgrade-0.1-to-0.2.md).
|
|
@@ -39,8 +39,9 @@ on one version.
|
|
|
39
39
|
| `src/auth.ts` | Better Auth options. Two roles only: `admin` (the admin layer) and `user`. Replace the console OTP sender before deploying. |
|
|
40
40
|
| `smoke.mjs` | Your acceptance steps on `smoke-kit.mjs` (fresh local D1, OTP sign-in per user, REST/MCP/Admin calls). |
|
|
41
41
|
|
|
42
|
-
`src/service.ts` and `src/index.ts` rarely change.
|
|
43
|
-
|
|
42
|
+
`src/service.ts` and `src/index.ts` rarely change. The default shell mounts REST and MCP together. If the app deliberately
|
|
43
|
+
chooses asymmetric exposure, delete the blocks marked `MCP / OAuth (deliberate exception)` in `auth.ts` and `service.ts`;
|
|
44
|
+
HTTP-only Procedures will keep generating with a warning.
|
|
44
45
|
|
|
45
46
|
Search for `AGENT:` to find what an app must fill in.
|
|
46
47
|
|
|
@@ -52,6 +53,9 @@ Search for `AGENT:` to find what an app must fill in.
|
|
|
52
53
|
the previous role; `/admin/api/me` shows the current one.
|
|
53
54
|
- Admin HTTP routes are listed in `docs/handbook/guides/native-admin.md`.
|
|
54
55
|
|
|
56
|
+
Scripted smoke is not independent acceptance; before reporting a user-facing
|
|
57
|
+
deliverable done, follow the [independent acceptance guide](../../handbook/guides/agent-workflow-acceptance.md).
|
|
58
|
+
|
|
55
59
|
### MCP acceptance
|
|
56
60
|
|
|
57
61
|
Generation declares tools; the app owns whether and where a remote endpoint is mounted.
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"smoke": "node smoke.mjs"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@aotter/mantle": "0.2.0-alpha.
|
|
18
|
-
"@aotter/mantle-ui": "0.2.0-alpha.
|
|
17
|
+
"@aotter/mantle": "0.2.0-alpha.9",
|
|
18
|
+
"@aotter/mantle-ui": "0.2.0-alpha.9",
|
|
19
19
|
"@better-auth/cimd": "1.7.7",
|
|
20
20
|
"@better-auth/core": "1.7.7",
|
|
21
21
|
"@better-auth/mcp": "1.7.7",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@modelcontextprotocol/ext-apps": "2.0.0",
|
|
25
25
|
"@modelcontextprotocol/server": "2.2.0",
|
|
26
26
|
"better-auth": "1.7.7",
|
|
27
|
-
"@aotter/mantle-admin": "0.2.0-alpha.
|
|
27
|
+
"@aotter/mantle-admin": "0.2.0-alpha.9",
|
|
28
28
|
"react": "19.3.0",
|
|
29
29
|
"react-dom": "19.3.0"
|
|
30
30
|
},
|
|
@@ -34,11 +34,12 @@ export function authOptions(env: Env) {
|
|
|
34
34
|
if (!/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(origin)) throw new Error("Configure a production email sender.");
|
|
35
35
|
console.log(`${email}: one-time code is ${otp}`);
|
|
36
36
|
} }),
|
|
37
|
-
// --- MCP / OAuth (
|
|
37
|
+
// --- MCP / OAuth (deliberate exception): remove only when the app intentionally chooses asymmetric exposure.
|
|
38
38
|
jwt({ disableSettingJwtHeader: true }),
|
|
39
39
|
// Ends a user's OAuth grants (offline ones too) on password reset, admin ban or session revocation.
|
|
40
40
|
oauthGrantRevocation(),
|
|
41
|
-
|
|
41
|
+
// The client must request and consent to offline_access to receive a rotating refresh token.
|
|
42
|
+
mcp({ loginPage: "/admin/sign-in", consentPage: "/admin/oauth/consent", resource: `${origin}/mcp`, scopes: ["mcp", "offline_access"], clientRegistrationDefaultScopes: ["mcp", "offline_access"], extensions: [oauthGrantExtension], allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, allowPublicClientPrelogin: true, clientRegistrationClientSecretExpiration: "90d" }),
|
|
42
43
|
cimd({ fetchClientMetadataResource: (input, init) => fetch(input, { ...init, redirect: "manual" }), metadataProfile: "mcp-2026-07-28" }),
|
|
43
44
|
// --- end MCP / OAuth
|
|
44
45
|
];
|
|
@@ -56,8 +56,8 @@ export async function fetchService(request: Request, env: Env, context: Executio
|
|
|
56
56
|
}
|
|
57
57
|
response = await serveAdmin(request, env);
|
|
58
58
|
} else if (/^\/mcp(?:\/admin)?\/*$/.test(url.pathname)) {
|
|
59
|
-
// --- MCP (
|
|
60
|
-
serveMcpHandler ??= serveMcp(tools, { ...options, apps: { admin: adminMcpApps({ catalogue: mcpAppCatalogues.admin }) } });
|
|
59
|
+
// --- MCP / OAuth (deliberate exception): remove with the auth plugins only for intentional asymmetric exposure.
|
|
60
|
+
serveMcpHandler ??= serveMcp(tools, { ...options, authorizationScopes: ["offline_access"], apps: { admin: adminMcpApps({ catalogue: mcpAppCatalogues.admin }) } });
|
|
61
61
|
response = await serveMcpHandler(request, env);
|
|
62
62
|
} else {
|
|
63
63
|
response = await serveRest(request, env);
|