@aotter/mantle 0.2.0-alpha.7 → 0.2.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -5
- package/dist/auth/adapter.js +9 -6
- package/dist/auth/caller.js +2 -1
- package/dist/auth/identity.d.ts +2 -2
- package/dist/auth/oauth.d.ts +33 -1
- package/dist/auth/oauth.js +121 -28
- package/dist/auth/routes.js +6 -4
- package/dist/build/compiler.d.ts +44 -122
- package/dist/build/compiler.js +202 -277
- package/dist/build/spec/domain/model/ManifestGrammar.d.ts +6 -5
- package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
- package/dist/build/spec/domain/service/PlanFingerprint.d.ts +2 -0
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/domain/describe/{chunk-ZIQVLA7X.js → chunk-ZVXBGP7Y.js} +69 -8
- package/dist/domain/describe/index.js +1 -1
- package/dist/domain/describe/target-hash.js +1 -1
- package/dist/domain/describe/targets.d.ts +1 -1
- package/dist/domain/generated-tools.js +2 -0
- package/dist/domain/paging.js +41 -15
- package/dist/infrastructure/bundle.js +118 -15
- package/dist/infrastructure/cli.js +11 -15
- package/dist/infrastructure/generate.d.ts +1 -3
- package/dist/infrastructure/generate.js +11 -3
- package/dist/infrastructure/generateAuth.js +57 -0
- package/dist/infrastructure/inputs.js +8 -3
- package/dist/infrastructure/wrangler.js +50 -12
- 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/drain.d.ts +5 -0
- package/dist/kernel/drain.js +35 -0
- package/dist/kernel/driver/postgres.js +6 -2
- package/dist/kernel/host-caller.d.ts +1 -0
- package/dist/kernel/host-caller.js +1 -0
- package/dist/kernel/mcp-apps.d.ts +29 -9
- package/dist/kernel/mcp-apps.js +3 -3
- package/dist/kernel/mcp-handler.js +101 -18
- package/dist/kernel/mcp.d.ts +12 -3
- package/dist/kernel/mcp.js +31 -6
- package/dist/kernel/rest-router.js +3 -2
- package/dist/kernel/scalars.js +3 -2
- package/dist/kernel/site.d.ts +1 -3
- package/dist/kernel/site.js +2 -2
- package/dist/kernel/web-index.js +40 -0
- package/dist/kernel/web.d.ts +3 -1
- package/dist/kernel/web.js +6 -37
- package/dist/usecase/adminBindings.js +2 -3
- package/dist/usecase/adminClient.js +87 -0
- package/dist/usecase/adminDescriptors.js +19 -65
- package/dist/usecase/generate.js +5 -4
- package/dist/usecase/mcp-media.js +2 -2
- package/dist/usecase/mcp.js +16 -12
- package/dist/usecase/paging.js +2 -3
- package/dist/usecase/publishing.js +3 -3
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/tool-version.js +9 -0
- package/dist/usecase/translations.js +1 -1
- package/dist/usecase/validation.js +128 -19
- package/dist/usecase/web.js +3 -2
- package/docs/examples/README.md +1 -1
- package/docs/examples/app-shell/README.md +45 -3
- package/docs/examples/app-shell/package.json +14 -6
- package/docs/examples/app-shell/smoke-kit.mjs +54 -0
- package/docs/examples/app-shell/src/auth.ts +6 -3
- package/docs/examples/app-shell/src/service.ts +3 -3
- package/docs/examples/app-shell/tsconfig.json +4 -1
- package/docs/examples/app-shell/worker-configuration.d.ts +2 -2
- package/docs/examples/app-shell/wrangler.jsonc +5 -4
- package/docs/examples/club-events/manifests/club-events.yaml +7 -0
- package/docs/examples/club-events/src/handlers.ts +1 -1
- package/docs/examples/generated-web/README.md +6 -2
- package/docs/examples/generated-web/wrangler.jsonc +1 -0
- package/docs/examples/purchasing/README.md +1 -1
- package/docs/examples/purchasing/manifests/purchasing.yaml +17 -1
- package/docs/examples/purchasing/smoke.mjs +10 -4
- package/docs/examples/purchasing/src/admin.tsx +13 -0
- package/docs/examples/purchasing/src/handlers.ts +1 -1
- package/docs/examples/purchasing/wrangler.jsonc +5 -4
- package/docs/examples/recipes/wrangler.jsonc +5 -4
- package/docs/handbook/cloudflare/authentication.md +43 -6
- package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
- package/docs/handbook/cloudflare/deploy-and-operate.md +45 -1
- package/docs/handbook/cloudflare/media-r2.md +2 -3
- package/docs/handbook/cloudflare/service-entry.md +68 -31
- package/docs/handbook/concepts/authorization.md +1 -1
- package/docs/handbook/concepts/mcp-and-agents.md +19 -0
- package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/admin-ui.md +107 -1
- package/docs/handbook/guides/agent-setup.md +27 -9
- package/docs/handbook/guides/agent-workflow-acceptance.md +222 -0
- package/docs/handbook/guides/mcp-apps.md +522 -6
- package/docs/handbook/guides/native-admin.md +300 -51
- package/docs/handbook/guides/oauth.md +119 -5
- package/docs/handbook/guides/public-pages.md +2 -2
- package/docs/handbook/guides/size-and-startup.md +1 -1
- package/docs/handbook/guides/typed-queries.md +35 -0
- package/docs/handbook/navigation.json +0 -4
- package/docs/handbook/reference/admin-descriptors.md +0 -35
- package/docs/handbook/reference/procedure.md +6 -2
- package/docs/handbook/reference/schema.md +13 -3
- package/docs/handbook/reference/surface.md +129 -15
- package/docs/handbook/reference/trigger.md +43 -1
- package/docs/handbook/reference/view.md +1 -1
- package/docs/handbook/releases/index.md +74 -2
- package/docs/handbook/start/overview.md +1 -1
- package/docs/handbook/start/project-and-cli.md +16 -8
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +46 -18
- package/docs/skills/integrate/SKILL.md +4 -9
- package/docs/skills/media-gc/SKILL.md +7 -5
- package/docs/skills/provision/SKILL.md +18 -12
- package/docs/skills/update/SKILL.md +19 -18
- package/docs/upgrade-0.1-to-0.2.md +6 -6
- package/package.json +2 -1
- package/skills/README.md +11 -16
- package/skills/mantle/SKILL.md +39 -17
- package/skills/mantle/references/local-testing.md +9 -0
- package/skills/mantle/references/web.md +1 -1
- package/skills/mantle/references/website.md +26 -22
- package/docs/examples/app-shell/src/admin.ts +0 -10
- package/docs/examples/purchasing/src/admin.ts +0 -12
- package/docs/handbook/guides/admin-extensions.md +0 -20
- package/docs/skills/develop/mcp-app.md +0 -39
- package/skills/.keep +0 -0
|
@@ -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;
|
|
@@ -109,7 +117,7 @@ export async function generateFromSources(request, schema) {
|
|
|
109
117
|
// a trusted all-scope read does not acquire a fictional scope predicate.
|
|
110
118
|
if (!Object.hasOwn(paging, name))
|
|
111
119
|
paging[name] = provePaging(compiled.plan, ast);
|
|
112
|
-
return
|
|
120
|
+
return ast;
|
|
113
121
|
},
|
|
114
122
|
project(ast, kind, name, page) {
|
|
115
123
|
if (kind === "procedure")
|
|
@@ -155,7 +163,7 @@ export async function generateFromSources(request, schema) {
|
|
|
155
163
|
const adminReads = request.admin ? await adminBindingsOf(compiled.plan, dialect, paging, wire, refusals) : undefined;
|
|
156
164
|
const { adminPages, adminEntries } = adminReads ?? {};
|
|
157
165
|
const report = await describeApp(compiled.plan);
|
|
158
|
-
const result = generate({ description: report, target: request.target, admin: request.admin, adminPages, adminEntries, site: request.site, plan: compiled.plan, lowered, wireLowered, viewKeys, newId: coreNewId(dialect, compiled.plan.schemas),
|
|
166
|
+
const result = generate({ description: report, target: request.target, admin: request.admin, adminPages, adminEntries, site: request.site, plan: compiled.plan, lowered, wireLowered, viewKeys, newId: coreNewId(dialect, compiled.plan.schemas), paging });
|
|
159
167
|
if (refusals.length) {
|
|
160
168
|
const diagnostics = result.ok ? [] : result.diagnostics.filter(d => !searchProblems.some(p => p.path === d.path) ||
|
|
161
169
|
!["Every reachable visibility mode must have canonical lowered SQL.", "Both caller and trusted SQL must be lowered."].includes(d.message));
|
|
@@ -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) {
|
|
@@ -6,6 +7,14 @@ export function generateFromSources(request) {
|
|
|
6
7
|
return generate(request, instances.length ? () => authSql(instances, request.target.endsWith("postgres") ? "postgres" : "sqlite") : undefined);
|
|
7
8
|
}
|
|
8
9
|
function authSql(instances, dialect) {
|
|
10
|
+
for (const options of instances) {
|
|
11
|
+
const ids = (options.plugins ?? []).map(plugin => plugin.id);
|
|
12
|
+
// Offline OAuth grants outlive sessions; without the revocation plugin a password reset or ban would not end them. mcp() spreads oauthProvider, id included.
|
|
13
|
+
if (ids.includes("oauth-provider") && !ids.includes("mantle-oauth-grant-revocation") /* oauthGrantRevocation().id */)
|
|
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.");
|
|
15
|
+
}
|
|
16
|
+
if (instances.length > 1)
|
|
17
|
+
refuseSharedPluginMappings(instances);
|
|
9
18
|
const statements = [...new Set(instances.flatMap(options => authSchemaSql(options, dialect)))];
|
|
10
19
|
// CREATE TABLE IF NOT EXISTS would silently keep the first of two different definitions.
|
|
11
20
|
// Identical statements are already merged, so a repeated table name means two different definitions.
|
|
@@ -15,3 +24,51 @@ function authSql(instances, dialect) {
|
|
|
15
24
|
throw new Error(`Better Auth instances define table ${twice} differently; give each instance its own table names (user.modelName, session.modelName, ...).`);
|
|
16
25
|
return statements;
|
|
17
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
|
+
}
|
|
@@ -7,10 +7,15 @@ export const INPUTS_FILE = ".mantle-inputs.json";
|
|
|
7
7
|
const MANTLE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
8
8
|
// Line endings are normalized so a CRLF checkout (core.autocrlf) matches the LF fingerprint.
|
|
9
9
|
const digest = (path) => existsSync(path) ? createHash("sha256").update(readFileSync(path, "utf8").replaceAll("\r\n", "\n")).digest("hex") : null;
|
|
10
|
-
/** The manifests and option modules (`--auth`, `--site
|
|
11
|
-
export function inputsFingerprint(destination, paths) {
|
|
10
|
+
/** The target and the manifests and option modules (`--auth`, `--site`) one generate run read, by path relative to its output. */
|
|
11
|
+
export function inputsFingerprint(destination, paths, target) {
|
|
12
12
|
const inputs = Object.fromEntries(paths.map(path => [relative(resolve(destination), resolve(path)).replaceAll("\\", "/"), digest(path)]));
|
|
13
|
-
return JSON.stringify({ mantle: MANTLE_VERSION, inputs }, null, 2) + "\n";
|
|
13
|
+
return JSON.stringify({ mantle: MANTLE_VERSION, target, inputs }, null, 2) + "\n";
|
|
14
|
+
}
|
|
15
|
+
/** The generate target recorded beside `generatedModule` (undefined without a fingerprint, or one written before targets were recorded). */
|
|
16
|
+
export function generatedTarget(generatedModule) {
|
|
17
|
+
const file = resolve(dirname(generatedModule), INPUTS_FILE);
|
|
18
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")).target : undefined;
|
|
14
19
|
}
|
|
15
20
|
/** Why the generated output that holds `generatedModule` no longer matches its inputs (empty when it does, or when it has no fingerprint). */
|
|
16
21
|
export function staleInputs(generatedModule) {
|
|
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
5
|
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
6
|
+
const requiredFlags = ["nodejs_compat", "global_fetch_strictly_public"];
|
|
6
7
|
/** Newest date the project's installed workerd supports, as create-cloudflare picks it; a fixed date before Wrangler is installed. */
|
|
7
8
|
function compatibilityDate(dir) {
|
|
8
9
|
try {
|
|
@@ -16,22 +17,26 @@ function compatibilityDate(dir) {
|
|
|
16
17
|
catch { }
|
|
17
18
|
return "2026-09-28";
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
+
/** `assets`: with `--admin`, the static assets directory `mantle bundle` builds Admin into (`<assets>/admin`). */
|
|
21
|
+
export function configureWrangler(configPath, entry, outdir, generated, check = false, assets, publicFetchRequired = false) {
|
|
20
22
|
const configDir = dirname(resolve(configPath));
|
|
21
23
|
const local = (path) => relative(configDir, resolve(path)).replaceAll("\\", "/") || ".";
|
|
22
24
|
const output = local(outdir);
|
|
23
25
|
const quote = (arg) => /^[\w./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`;
|
|
26
|
+
const worker = `${output}/worker`;
|
|
27
|
+
const assetsFlag = assets !== undefined && resolve(assets) !== resolve(outdir, "client") ? ` --assets ${quote(assets)}` : "";
|
|
24
28
|
const keys = {
|
|
25
|
-
main: `${
|
|
26
|
-
find_additional_modules: true, base_dir:
|
|
29
|
+
main: `${worker}/${basename(entry).replace(/\.[^.]+$/, "")}.js`, no_bundle: true,
|
|
30
|
+
find_additional_modules: true, base_dir: worker,
|
|
27
31
|
rules: [{ type: "ESModule", globs: ["**/*.js"], fallthrough: true }],
|
|
28
|
-
build: { command: `mantle bundle ${quote(entry)} ${quote(outdir)}`, watch_dir: [local(dirname(entry)), local(generated)] },
|
|
32
|
+
build: { command: `mantle bundle ${quote(entry)} ${quote(outdir)}${assetsFlag}`, watch_dir: [local(dirname(entry)), local(generated)] },
|
|
29
33
|
};
|
|
30
34
|
if (configPath.endsWith(".toml")) {
|
|
31
35
|
if (check)
|
|
32
36
|
throw new Error("--check requires a JSON or JSONC Wrangler config; TOML generation only prints manual edits.");
|
|
33
|
-
console.log(`Leave ${configPath} unchanged. Add these Wrangler TOML keys (include nodejs_compat in
|
|
34
|
-
`main = ${JSON.stringify(keys.main)}\nno_bundle = true\nfind_additional_modules = true\nbase_dir = ${JSON.stringify(
|
|
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
|
+
`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))}\nbinding = "ASSETS"\nrun_worker_first = true\nhtml_handling = "none"\n\n`) +
|
|
35
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)}`);
|
|
36
41
|
return false;
|
|
37
42
|
}
|
|
@@ -42,10 +47,6 @@ export function configureWrangler(configPath, entry, outdir, generated, check =
|
|
|
42
47
|
const config = parse(text, errors, { allowTrailingComma: true });
|
|
43
48
|
if (errors.length || !config || typeof config !== "object" || Array.isArray(config))
|
|
44
49
|
throw new Error(`${configPath} must be a valid JSONC object.`);
|
|
45
|
-
if (config.compatibility_flags !== undefined && (!Array.isArray(config.compatibility_flags) || !config.compatibility_flags.every((flag) => typeof flag === "string"))) {
|
|
46
|
-
throw new Error(`${configPath}: compatibility_flags must be an array of strings.`);
|
|
47
|
-
}
|
|
48
|
-
const flags = config.compatibility_flags ?? [];
|
|
49
50
|
const indent = text.match(/\n([ \t]+)"/)?.[1] ?? " ";
|
|
50
51
|
const formattingOptions = { insertSpaces: !indent.includes("\t"), tabSize: indent.length, eol: text.includes("\r\n") ? "\r\n" : "\n" };
|
|
51
52
|
// Merge, never replace: the user's own module rules and build options survive.
|
|
@@ -54,14 +55,51 @@ export function configureWrangler(configPath, entry, outdir, generated, check =
|
|
|
54
55
|
if (!isDeepStrictEqual(path.reduce((v, k) => v?.[k], config), value))
|
|
55
56
|
text = applyEdits(text, modify(text, path, value, { formattingOptions }));
|
|
56
57
|
};
|
|
58
|
+
// Public routing is the default; an explicit private origin remains app-owned without OAuth/CIMD.
|
|
59
|
+
const ensureFlags = (path, flags) => {
|
|
60
|
+
if (flags !== undefined && (!Array.isArray(flags) || !flags.every(flag => typeof flag === "string")))
|
|
61
|
+
throw new Error(`${configPath}: ${path.join(".")} must be an array of strings.`);
|
|
62
|
+
const privateFetch = Array.isArray(flags) && flags.includes("global_fetch_private_origin");
|
|
63
|
+
if (privateFetch && (publicFetchRequired || flags.includes("global_fetch_strictly_public")))
|
|
64
|
+
throw new Error(`${configPath}: ${path.join(".")} includes global_fetch_private_origin, which conflicts with public OAuth/CIMD fetch routing. Remove it and use global_fetch_strictly_public.`);
|
|
65
|
+
const required = privateFetch ? ["nodejs_compat"] : requiredFlags;
|
|
66
|
+
if (flags === undefined)
|
|
67
|
+
edit(path, required);
|
|
68
|
+
else
|
|
69
|
+
for (const flag of required)
|
|
70
|
+
if (!flags.includes(flag))
|
|
71
|
+
edit([...path, -1], flag);
|
|
72
|
+
};
|
|
73
|
+
ensureFlags(["compatibility_flags"], config.compatibility_flags);
|
|
74
|
+
// Wrangler environment arrays replace the top-level flags rather than inheriting them.
|
|
75
|
+
for (const [name, environment] of Object.entries(config.env ?? {})) {
|
|
76
|
+
if (environment?.compatibility_flags !== undefined)
|
|
77
|
+
ensureFlags(["env", name, "compatibility_flags"], environment.compatibility_flags);
|
|
78
|
+
}
|
|
57
79
|
for (const [key, value] of Object.entries(top))
|
|
58
80
|
edit([key], value);
|
|
59
81
|
for (const [key, value] of Object.entries(buildKeys))
|
|
60
82
|
edit(["build", key], value);
|
|
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
|
+
}
|
|
61
101
|
if (!(config.rules ?? []).some((r) => isDeepStrictEqual(r, rule)))
|
|
62
102
|
edit(config.rules === undefined ? ["rules"] : ["rules", -1], config.rules === undefined ? [rule] : rule);
|
|
63
|
-
if (!flags.includes("nodejs_compat"))
|
|
64
|
-
edit(config.compatibility_flags === undefined ? ["compatibility_flags"] : ["compatibility_flags", -1], config.compatibility_flags === undefined ? ["nodejs_compat"] : "nodejs_compat");
|
|
65
103
|
const changed = !exists || text !== original;
|
|
66
104
|
if (changed && !check)
|
|
67
105
|
writeFileSync(configPath, text);
|
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;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Request-time only: no Worker startup path imports this. */
|
|
2
|
+
/** A request handler that drains the body once it has answered or thrown. `undefined` and `null` mean the request is not this
|
|
3
|
+
* handler's, so the next handler can still read it. A handler that answers and reads the request later (in `waitUntil`) must
|
|
4
|
+
* read or lock the body before answering. */
|
|
5
|
+
export declare const draining: <A extends [Request, ...any[]], R>(handle: (...args: A) => Promise<R>) => (...args: A) => Promise<R>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Request-time only: no Worker startup path imports this. */
|
|
2
|
+
/** Read and discard what is left of a request body, at most 1 MiB and 1 s. A response sent over an unread POST body makes local
|
|
3
|
+
* `wrangler dev` break the connection, and the next request fails with "Network connection lost". */
|
|
4
|
+
async function drain(request) {
|
|
5
|
+
let reader;
|
|
6
|
+
const timer = setTimeout(() => reader?.cancel().catch(() => { }), 1000);
|
|
7
|
+
try {
|
|
8
|
+
reader = request.body?.getReader(); // a body the app consumed is done; one it still holds is locked and throws
|
|
9
|
+
for (let read = 0, chunk; reader && !(chunk = await reader.read()).done;)
|
|
10
|
+
if ((read += chunk.value.byteLength) > 1024 * 1024) {
|
|
11
|
+
await reader.cancel();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch { /* the client went away */ }
|
|
16
|
+
finally {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** A request handler that drains the body once it has answered or thrown. `undefined` and `null` mean the request is not this
|
|
21
|
+
* handler's, so the next handler can still read it. A handler that answers and reads the request later (in `waitUntil`) must
|
|
22
|
+
* read or lock the body before answering. */
|
|
23
|
+
export const draining = (handle) => async (...args) => {
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await handle(...args);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
await drain(args[0]);
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
if (response != null)
|
|
33
|
+
await drain(args[0]);
|
|
34
|
+
return response;
|
|
35
|
+
};
|
|
@@ -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);
|
|
@@ -59,6 +60,9 @@ export async function transaction(db, fn, options = {}, path = "store/transactio
|
|
|
59
60
|
guard(db);
|
|
60
61
|
if (transactions.has(db))
|
|
61
62
|
throw new Error("Nested transactions are not supported.");
|
|
63
|
+
// a transaction handle the app opened (postgres.js types it without `begin`) only suits reads and single statements
|
|
64
|
+
if (typeof db.begin !== "function")
|
|
65
|
+
throw new Error("This PostgreSQL handle is a transaction the app opened and cannot begin another: open the transaction with store(pool, caller).transaction and call Procedures through its tx.");
|
|
62
66
|
let committing = false;
|
|
63
67
|
const state = { outer: db };
|
|
64
68
|
try {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Request-only host contract validation. */
|
|
2
|
+
export { draining } from "./drain.js";
|
|
2
3
|
import { type Caller, type Gateway, type RequestContext } from "./contracts.js";
|
|
3
4
|
import type { UserFields, UserValue } from "./user-fields.js";
|
|
4
5
|
/** The complete JSON supplied as ctx.props.mantle by a trusted gateway. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Request-only host contract validation. */
|
|
2
|
+
export { draining } from "./drain.js";
|
|
2
3
|
import { forwarded, ContractError, surfaceError, surfaceFailure } from "./contracts.js";
|
|
3
4
|
import { userCaller } from "./user-caller.js";
|
|
4
5
|
import { identities, isHostCaller } from "./caller-identity.js";
|
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
/** MCP Apps (ADR-0029 D7): UI resources served beside the tools that render in them. */
|
|
2
2
|
export declare const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
3
|
+
/** What `resources/read` passes an HTML loader. */
|
|
4
|
+
export interface McpAppRender {
|
|
5
|
+
/** The tools this caller's `tools/list` shows on the endpoint, so the HTML names none it cannot see. */
|
|
6
|
+
readonly tools: ReadonlySet<string>;
|
|
7
|
+
/** The request's environment as the handler received it: Worker bindings such as `ASSETS`, or whatever a Bun host passes. */
|
|
8
|
+
readonly env: unknown;
|
|
9
|
+
/** The `resources/read` request, for its origin. */
|
|
10
|
+
readonly request: Request;
|
|
11
|
+
}
|
|
12
|
+
/** The CSP a resource asks its host for, as `_meta.ui.csp`. */
|
|
13
|
+
export type McpAppCsp = {
|
|
14
|
+
readonly connectDomains?: readonly string[];
|
|
15
|
+
readonly resourceDomains?: readonly string[];
|
|
16
|
+
readonly frameDomains?: readonly string[];
|
|
17
|
+
readonly baseUriDomains?: readonly string[];
|
|
18
|
+
};
|
|
19
|
+
/** What a loader may return beside a plain string: the HTML and a CSP known only when it is read. It is sent on `resources/read` and replaces the resource's static `csp` (lists are not merged). */
|
|
20
|
+
export interface McpAppPage {
|
|
21
|
+
readonly html: string;
|
|
22
|
+
readonly csp?: McpAppCsp;
|
|
23
|
+
}
|
|
3
24
|
/** One `ui://` resource. The HTML is a reusable asset and never carries caller data: that travels in tool results. */
|
|
4
25
|
export interface McpAppResource {
|
|
5
26
|
readonly uri: string;
|
|
6
27
|
readonly name: string;
|
|
7
28
|
readonly title?: string;
|
|
8
29
|
readonly description?: string;
|
|
9
|
-
readonly html: string | (() => string | Promise<string>);
|
|
10
|
-
readonly csp?:
|
|
11
|
-
readonly connectDomains?: readonly string[];
|
|
12
|
-
readonly resourceDomains?: readonly string[];
|
|
13
|
-
readonly frameDomains?: readonly string[];
|
|
14
|
-
readonly baseUriDomains?: readonly string[];
|
|
15
|
-
};
|
|
30
|
+
readonly html: string | ((render: McpAppRender) => string | McpAppPage | Promise<string | McpAppPage>);
|
|
31
|
+
readonly csp?: McpAppCsp;
|
|
16
32
|
readonly permissions?: {
|
|
17
33
|
readonly camera?: Record<string, never>;
|
|
18
34
|
readonly microphone?: Record<string, never>;
|
|
@@ -20,6 +36,8 @@ export interface McpAppResource {
|
|
|
20
36
|
readonly clipboardWrite?: Record<string, never>;
|
|
21
37
|
};
|
|
22
38
|
readonly prefersBorder?: boolean;
|
|
39
|
+
/** `_meta.ui.domain`: the dedicated sandbox origin a host should serve this App from, in the form that host documents. */
|
|
40
|
+
readonly domain?: string;
|
|
23
41
|
/** Tool names whose results this resource renders. */
|
|
24
42
|
readonly renders?: readonly string[];
|
|
25
43
|
/** Views only this App may call (`visibility: ["app"]`); a client without MCP Apps does not see them. A Procedure cannot be hidden from the model. */
|
|
@@ -28,9 +46,11 @@ export interface McpAppResource {
|
|
|
28
46
|
export interface McpApps {
|
|
29
47
|
readonly resources: readonly McpAppResource[];
|
|
30
48
|
}
|
|
31
|
-
/**
|
|
49
|
+
/** An application-owned contributor resolved after the endpoint's caller gate. */
|
|
50
|
+
export type McpAppsSource = McpApps | ((context: Pick<McpAppRender, "env" | "request">) => McpApps | Promise<McpApps>);
|
|
51
|
+
/** Flatten contributors; `mcp(...)` reads static resources getters once at construction. */
|
|
32
52
|
export declare const resourcesOf: (apps: McpApps | readonly McpApps[]) => McpApps;
|
|
33
|
-
/**
|
|
53
|
+
/** Static contributors are checked at construction; resolved factories are checked per request. A request-bound tool table defers tool-link checks until its first request. */
|
|
34
54
|
export declare function linkApps(apps: McpApps | readonly McpApps[], tools?: ReadonlySet<string>, views?: ReadonlySet<string>): {
|
|
35
55
|
resources: McpAppResource[];
|
|
36
56
|
renders: Map<string, string>;
|
package/dist/kernel/mcp-apps.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { ContractError } from "./contracts.js";
|
|
2
2
|
/** MCP Apps (ADR-0029 D7): UI resources served beside the tools that render in them. */
|
|
3
3
|
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
4
|
-
/**
|
|
4
|
+
/** Flatten contributors; `mcp(...)` reads static resources getters once at construction. */
|
|
5
5
|
export const resourcesOf = (apps) => ({ resources: (Array.isArray(apps) ? apps : [apps]).flatMap(app => app.resources) });
|
|
6
|
-
/**
|
|
6
|
+
/** Static contributors are checked at construction; resolved factories are checked per request. A request-bound tool table defers tool-link checks until its first request. */
|
|
7
7
|
export function linkApps(apps, tools, views) {
|
|
8
8
|
const resources = resourcesOf(apps).resources;
|
|
9
9
|
const uris = new Set(), names = new Set(), renders = new Map(), appOnly = new Set();
|
|
@@ -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
|
}
|