@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.
Files changed (61) hide show
  1. package/README.md +17 -0
  2. package/dist/auth/adapter.js +9 -6
  3. package/dist/auth/routes.js +3 -2
  4. package/dist/build/compiler.js +1 -1
  5. package/dist/infrastructure/bundle.js +16 -3
  6. package/dist/infrastructure/generate.js +9 -1
  7. package/dist/infrastructure/generateAuth.js +51 -0
  8. package/dist/infrastructure/wrangler.js +19 -4
  9. package/dist/kernel/binds.d.ts +0 -3
  10. package/dist/kernel/contracts.d.ts +1 -1
  11. package/dist/kernel/contracts.js +4 -4
  12. package/dist/kernel/dialect/common.d.ts +0 -1
  13. package/dist/kernel/driver/postgres.js +3 -2
  14. package/dist/kernel/mcp-apps.js +1 -1
  15. package/dist/kernel/mcp-handler.js +1 -1
  16. package/dist/kernel/mcp.d.ts +5 -1
  17. package/dist/kernel/mcp.js +2 -1
  18. package/dist/kernel/scalars.js +3 -2
  19. package/dist/kernel/web-index.js +40 -0
  20. package/dist/kernel/web.js +3 -37
  21. package/dist/usecase/publishing.js +1 -1
  22. package/dist/usecase/site.js +0 -1
  23. package/dist/usecase/validation.js +8 -0
  24. package/docs/examples/README.md +1 -1
  25. package/docs/examples/app-shell/README.md +6 -2
  26. package/docs/examples/app-shell/package.json +3 -3
  27. package/docs/examples/app-shell/src/auth.ts +3 -2
  28. package/docs/examples/app-shell/src/service.ts +2 -2
  29. package/docs/examples/club-events/manifests/club-events.yaml +7 -0
  30. package/docs/examples/club-events/src/handlers.ts +1 -1
  31. package/docs/examples/purchasing/manifests/purchasing.yaml +16 -0
  32. package/docs/examples/purchasing/src/handlers.ts +1 -1
  33. package/docs/handbook/cloudflare/authentication.md +21 -4
  34. package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
  35. package/docs/handbook/cloudflare/deploy-and-operate.md +24 -1
  36. package/docs/handbook/cloudflare/media-r2.md +2 -3
  37. package/docs/handbook/cloudflare/service-entry.md +14 -4
  38. package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
  39. package/docs/handbook/examples/hub.md +1 -1
  40. package/docs/handbook/guides/agent-setup.md +4 -4
  41. package/docs/handbook/guides/agent-workflow-acceptance.md +222 -0
  42. package/docs/handbook/guides/mcp-apps.md +78 -1
  43. package/docs/handbook/guides/native-admin.md +4 -0
  44. package/docs/handbook/guides/oauth.md +58 -0
  45. package/docs/handbook/guides/typed-queries.md +35 -0
  46. package/docs/handbook/reference/procedure.md +1 -1
  47. package/docs/handbook/reference/surface.md +6 -2
  48. package/docs/handbook/reference/trigger.md +9 -0
  49. package/docs/handbook/releases/index.md +3 -3
  50. package/docs/handbook/start/project-and-cli.md +9 -1
  51. package/docs/skills/develop/SKILL.md +43 -16
  52. package/docs/skills/integrate/SKILL.md +4 -9
  53. package/docs/skills/provision/SKILL.md +15 -12
  54. package/docs/skills/update/SKILL.md +19 -18
  55. package/package.json +2 -2
  56. package/skills/README.md +1 -1
  57. package/skills/mantle/SKILL.md +35 -13
  58. package/skills/mantle/references/local-testing.md +5 -0
  59. package/skills/mantle/references/website.md +6 -4
  60. package/docs/skills/develop/mcp-app.md +0 -39
  61. 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
@@ -70,19 +70,22 @@ function customAdapter(d, exec, seed, insertOnly = false, options) {
70
70
  const { adminRoles: roles, adminUserIds } = options ? adminOptionsOf({ options }) : { adminRoles: [], adminUserIds: [] };
71
71
  const holdsAdmin = (role) => typeof role === "string" && role.split(",").some(role => roles.includes(role));
72
72
  const field = (name) => getFieldName({ model: "user", field: name });
73
- const protects = (modelKey, values) => modelKey === "user" && roles.length > 0 &&
73
+ const protects = (modelKey, values) => modelKey === "user" && (roles.length > 0 || adminUserIds.length > 0) &&
74
74
  (!values || values[field("banned")] === true || values[field("banned")] === 1 || Object.hasOwn(values, field("role")) && !holdsAdmin(values[field("role")]));
75
- const membership = (s, alias) => '(' + roles.map(role => `${d === sqlite ? "instr" : "strpos"}(',' || COALESCE(${alias}.${ident(field("role"))}, '') || ',', ${s.bind(',' + role + ',')}) > 0`).join(' OR ') + ')';
76
- const lastAdminGuard = (s, model) => {
75
+ const membership = (s, alias) => '(' + [...roles.map(role => `${d === sqlite ? "instr" : "strpos"}(',' || COALESCE(${alias}.${ident(field("role"))}, '') || ',', ${s.bind(',' + role + ',')}) > 0`), d === sqlite ? "0" : "FALSE"].join(' OR ') + ')';
76
+ const allowlisted = (s, alias) => `(${[...adminUserIds.map(id => `${alias}.${idOf("user")} = ${s.bind(id)}`), d === sqlite ? "0" : "FALSE"].join(" OR ")})`;
77
+ const administrator = (s, alias) => `(${membership(s, alias)} OR ${allowlisted(s, alias)})`;
78
+ const lastAdminGuard = (s, model, keepsAllowlisted = false) => {
77
79
  const table = ident(model), other = '"_mantle_other_admin"', no = d === sqlite ? '0' : 'FALSE';
78
- return `(NOT ${membership(s, table)} OR EXISTS (SELECT 1 FROM ${table} ${other} WHERE (${membership(s, other)}${adminUserIds.map(id => ` OR ${other}.${idOf("user")} = ${s.bind(id)}`).join("")}) AND ${other}.${idOf("user")} <> ${table}.${idOf("user")} AND COALESCE(${other}.${ident(field("banned"))}, ${no}) = ${no}))`;
80
+ return `(${keepsAllowlisted ? `${allowlisted(s, table)} OR ` : ""}NOT ${administrator(s, table)} OR EXISTS (SELECT 1 FROM ${table} ${other} WHERE ${administrator(s, other)} AND ${other}.${idOf("user")} <> ${table}.${idOf("user")} AND COALESCE(${other}.${ident(field("banned"))}, ${no}) = ${no}))`;
79
81
  };
80
82
  const mutation = async (model, modelKey, where, values) => {
81
83
  const protect = protects(modelKey, values);
84
+ const keepsAllowlisted = protect && !!values && values[field("banned")] !== true && values[field("banned")] !== 1;
82
85
  if (protect && (where.length !== 1 || where[0].field !== field("id") || where[0].operator && where[0].operator !== "eq"))
83
86
  throw new APIError("FORBIDDEN", { code: "AUTH_DENIED", message: "Destructive admin identity writes require one user id." });
84
- const result = await run(s => `${values ? `UPDATE ${ident(model)} SET ${assign(s, values)}` : `DELETE FROM ${ident(model)}`}${s.where(modelKey, where, protect ? lastAdminGuard(s, model) : undefined)} RETURNING *`, protect);
85
- if (protect && !result.changes && (await run(s => `SELECT ${idOf(modelKey)} FROM ${ident(model)}${s.where(modelKey, where, `NOT ${lastAdminGuard(s, model)}`)} LIMIT 1`)).rows.length)
87
+ const result = await run(s => `${values ? `UPDATE ${ident(model)} SET ${assign(s, values)}` : `DELETE FROM ${ident(model)}`}${s.where(modelKey, where, protect ? lastAdminGuard(s, model, keepsAllowlisted) : undefined)} RETURNING *`, protect);
88
+ if (protect && !result.changes && (await run(s => `SELECT ${idOf(modelKey)} FROM ${ident(model)}${s.where(modelKey, where, `NOT ${lastAdminGuard(s, model, keepsAllowlisted)}`)} LIMIT 1`)).rows.length)
86
89
  throw new APIError("CONFLICT", { code: "CONFLICT", message: "This is the last admin; promote another admin before removing or banning this one." });
87
90
  return result;
88
91
  };
@@ -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
- await guardAuthAdminRequest(request, () => resolve(request), base);
32
- const registration = request.method === "POST" && pathname === `${base}/oauth2/register` ? await loopbackClientsAreNative(request) : request;
31
+ const bounded = request.body ? new Request(request, { body: await readBody(request) }) : request;
32
+ await guardAuthAdminRequest(bounded, () => resolve(bounded), base);
33
+ const registration = bounded.method === "POST" && pathname === `${base}/oauth2/register` ? await loopbackClientsAreNative(bounded) : bounded;
33
34
  return await normalizeAuthResponseCookies(await auth.handler(registration));
34
35
  }
35
36
  catch (error) {
@@ -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.8";
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(resolve(outer), resolve(inner));
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 (resolve(dir) === process.cwd())
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
- // Only the directory: the binding, run_worker_first and html_handling stay the application's.
84
- if (assets !== undefined)
85
- edit(["assets", "directory"], local(assets));
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;
@@ -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, violated?: boolean) => ContractError | undefined;
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;
@@ -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, violated = /MANTLE_CHECK /.test(String(error?.message))) => {
58
- if (!violated)
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
- const message = String(error?.message ?? "");
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 { checkConflict, conflict, ContractError } from "../contracts.js";
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) : checkConflict(path, error, sqlstate(error) === "23514") ?? 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);
@@ -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 ??= requiredScopes.join(" ");
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;
@@ -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
- export { callTool, callToolLazy } from "./mcp-call.js";
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[];
@@ -1,5 +1,6 @@
1
1
  import { linkApps, linkSurface, resourcesOf } from "./mcp-apps.js";
2
- export { callTool, callToolLazy } from "./mcp-call.js";
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;
@@ -6,9 +6,10 @@
6
6
  import { ContractError } from "./contracts.js";
7
7
  export function encodeTimestamptz(v) {
8
8
  if (v instanceof Date) {
9
- if (Number.isNaN(v.getTime()))
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 v.getTime() * 1000;
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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
+ }
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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", cap = sitemap ? 50000 : 5000, limit = sitemap ? 50 * 1024 * 1024 : 1024 * 1024;
80
- 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";
81
- const links = [];
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);
@@ -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)
@@ -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.8/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.
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. Delete the blocks marked `MCP / OAuth (optional)` in `auth.ts`
43
- and `service.ts` if the app has no MCP.
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.8",
18
- "@aotter/mantle-ui": "0.2.0-alpha.8",
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.8",
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 (optional): delete these plugins, `disabledPaths` and the MCP block in service.ts if the app has no MCP.
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
- mcp({ loginPage: "/admin/sign-in", consentPage: "/admin/oauth/consent", resource: `${origin}/mcp`, scopes: ["mcp"], extensions: [oauthGrantExtension], allowPublicClientPrelogin: true, clientRegistrationClientSecretExpiration: "90d" }),
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 (optional): delete this branch with the MCP plugins in auth.ts if the app has no 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);