@aotter/mantle 0.2.0-alpha.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -3
- package/dist/auth/adapter.js +10 -34
- package/dist/auth/admin.js +2 -5
- package/dist/auth/caller.js +0 -27
- package/dist/auth/consent.js +100 -0
- package/dist/auth/hardening.js +0 -7
- package/dist/auth/identity.js +0 -11
- package/dist/auth/oauth.js +0 -32
- package/dist/auth/resource-seeds.js +0 -2
- package/dist/auth/route-http.js +33 -0
- package/dist/auth/routes.js +8 -153
- package/dist/build/compiler.d.ts +19 -609
- package/dist/build/compiler.js +3 -6896
- package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
- package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
- package/dist/bun/assets.js +0 -2
- package/dist/domain/adminOrder.js +0 -2
- package/dist/domain/columns.js +7 -20
- package/dist/domain/describe/chunk-SGMRVDGE.js +1 -0
- package/dist/domain/describe/index.js +1 -115
- package/dist/domain/describe/target-hash.js +1 -12
- package/dist/domain/entry.js +0 -14
- package/dist/domain/generated-tools.js +0 -1
- package/dist/domain/input.js +0 -2
- package/dist/domain/paging.js +0 -17
- package/dist/domain/recipes.js +10 -7
- package/dist/domain/userReferences.js +0 -1
- package/dist/domain/wireScalars.js +0 -6
- package/dist/infrastructure/adminEntries.js +10 -7
- package/dist/infrastructure/adminPaging.js +55 -57
- package/dist/infrastructure/artifacts.js +0 -4
- package/dist/infrastructure/authSchema.js +0 -9
- package/dist/infrastructure/bundle.js +17 -32
- package/dist/infrastructure/cli.js +25 -11
- package/dist/infrastructure/generate.js +18 -17
- package/dist/infrastructure/generateAuth.js +48 -5
- package/dist/infrastructure/help.js +55 -0
- package/dist/infrastructure/init.js +50 -0
- package/dist/infrastructure/inputs.js +0 -9
- package/dist/infrastructure/mounts.js +22 -0
- package/dist/infrastructure/wrangler.js +18 -9
- package/dist/kernel/authorization.js +0 -1
- package/dist/kernel/binds.d.ts +16 -7
- package/dist/kernel/binds.js +4 -10
- package/dist/kernel/caller-identity.d.ts +0 -3
- package/dist/kernel/caller-identity.js +0 -15
- package/dist/kernel/caller-mode.d.ts +4 -0
- package/dist/kernel/caller-mode.js +14 -0
- package/dist/kernel/contracts.d.ts +7 -11
- package/dist/kernel/contracts.js +8 -44
- package/dist/kernel/cron.js +0 -4
- package/dist/kernel/dialect/common.d.ts +0 -1
- package/dist/kernel/dialect/common.js +0 -3
- package/dist/kernel/dialect/postgres.js +0 -2
- package/dist/kernel/dialect/sqlite.js +1 -6
- package/dist/kernel/drain.js +2 -8
- package/dist/kernel/driver/bun-sqlite.js +0 -4
- package/dist/kernel/driver/d1.js +0 -1
- package/dist/kernel/driver/postgres.js +3 -17
- package/dist/kernel/driver/transaction-context.js +0 -1
- package/dist/kernel/email.js +0 -4
- package/dist/kernel/host-access.js +2 -3
- package/dist/kernel/host-caller.d.ts +1 -1
- package/dist/kernel/host-caller.js +3 -6
- package/dist/kernel/json-body.js +0 -2
- package/dist/kernel/mcp-apps.js +1 -6
- package/dist/kernel/mcp-call.js +0 -1
- package/dist/kernel/mcp-handler.js +3 -27
- package/dist/kernel/mcp.d.ts +5 -1
- package/dist/kernel/mcp.js +2 -6
- package/dist/kernel/media.js +0 -18
- package/dist/kernel/paging.js +1 -9
- package/dist/kernel/publishing.js +3 -4
- package/dist/kernel/r2-media.js +0 -7
- package/dist/kernel/rest-dispatch.js +0 -1
- package/dist/kernel/rest-router.js +2 -3
- package/dist/kernel/rest.js +0 -3
- package/dist/kernel/scalars.js +4 -11
- package/dist/kernel/search.js +0 -1
- package/dist/kernel/site.js +0 -2
- package/dist/kernel/store.d.ts +11 -1
- package/dist/kernel/store.js +21 -34
- package/dist/kernel/surface-failure.d.ts +10 -0
- package/dist/kernel/surface-failure.js +29 -0
- package/dist/kernel/surface.js +0 -3
- package/dist/kernel/sweep.js +0 -1
- package/dist/kernel/translations.js +0 -1
- package/dist/kernel/user-caller.js +2 -8
- package/dist/kernel/user-fields.d.ts +3 -2
- package/dist/kernel/user-fields.js +9 -9
- package/dist/kernel/validation.js +0 -8
- package/dist/kernel/web-gateway.js +0 -2
- package/dist/kernel/web-head.js +0 -1
- package/dist/kernel/web-index.js +39 -0
- package/dist/kernel/web-response.js +0 -1
- package/dist/kernel/web-serve.js +97 -0
- package/dist/kernel/web.d.ts +1 -1
- package/dist/kernel/web.js +10 -125
- package/dist/kernel/where.js +0 -6
- package/dist/usecase/adminBindings.js +30 -21
- package/dist/usecase/adminClient.js +0 -21
- package/dist/usecase/adminDescriptors.js +2 -9
- package/dist/usecase/generate.js +7 -12
- package/dist/usecase/handlers.js +0 -3
- package/dist/usecase/mcp-media.js +0 -1
- package/dist/usecase/mcp.js +0 -4
- package/dist/usecase/paging.js +1 -3
- package/dist/usecase/publishing.js +1 -5
- package/dist/usecase/rest.js +0 -1
- package/dist/usecase/site.js +0 -2
- package/dist/usecase/stores.js +4 -14
- package/dist/usecase/tool-version.js +0 -3
- package/dist/usecase/translations.js +0 -2
- package/dist/usecase/validation.js +8 -20
- package/docs/examples/README.md +13 -5
- package/docs/examples/app-shell/README.md +43 -8
- package/docs/examples/app-shell/accept.mjs +91 -0
- package/docs/examples/app-shell/gitignore +7 -0
- package/docs/examples/app-shell/package.json +7 -6
- package/docs/examples/app-shell/smoke-kit.mjs +29 -15
- package/docs/examples/app-shell/src/auth.ts +3 -2
- package/docs/examples/app-shell/src/routes.ts +6 -3
- package/docs/examples/app-shell/src/service.ts +4 -3
- package/docs/examples/club-events/README.md +7 -2
- package/docs/examples/club-events/manifests/club-events.yaml +22 -0
- package/docs/examples/club-events/smoke.mjs +7 -1
- package/docs/examples/club-events/src/handlers.ts +1 -1
- package/docs/examples/purchasing/README.md +12 -3
- package/docs/examples/purchasing/manifests/purchasing.yaml +36 -3
- package/docs/examples/purchasing/smoke.mjs +14 -0
- package/docs/examples/purchasing/src/handlers.ts +1 -1
- package/docs/examples/recipes/README.md +5 -3
- 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 +17 -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 +15 -10
- package/docs/handbook/guides/agent-workflow-acceptance.md +232 -0
- package/docs/handbook/guides/local-testing.md +40 -0
- package/docs/handbook/guides/mcp-apps.md +78 -1
- package/docs/handbook/guides/native-admin.md +29 -6
- package/docs/handbook/guides/native-execution.md +5 -4
- package/docs/handbook/guides/oauth.md +58 -0
- package/docs/handbook/guides/public-pages.md +44 -4
- package/docs/handbook/guides/sql-patterns.md +266 -0
- package/docs/handbook/guides/typed-queries.md +173 -0
- package/docs/handbook/navigation.json +16 -0
- package/docs/handbook/reference/admin-descriptors.md +4 -1
- package/docs/handbook/reference/authorization.md +1 -1
- package/docs/handbook/reference/diagnostics.md +6 -1
- package/docs/handbook/reference/procedure.md +5 -5
- package/docs/handbook/reference/schema.md +10 -4
- package/docs/handbook/reference/sql.md +64 -0
- package/docs/handbook/reference/surface.md +11 -4
- package/docs/handbook/reference/trigger.md +9 -0
- package/docs/handbook/reference/view.md +11 -3
- package/docs/handbook/releases/index.md +27 -6
- package/docs/handbook/start/agents.md +45 -0
- package/docs/handbook/start/project-and-cli.md +15 -1
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +44 -16
- package/docs/skills/integrate/SKILL.md +4 -9
- package/docs/skills/provision/SKILL.md +15 -12
- package/docs/skills/update/SKILL.md +21 -18
- package/package.json +24 -4
- package/skills/README.md +1 -1
- package/skills/mantle/SKILL.md +48 -107
- package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
- package/docs/skills/develop/mcp-app.md +0 -39
- package/skills/.keep +0 -0
- package/skills/mantle/references/local-testing.md +0 -15
- package/skills/mantle/references/web.md +0 -41
- package/skills/mantle/references/website.md +0 -49
package/dist/usecase/paging.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { userReferences } from "../domain/userReferences.js";
|
|
2
|
-
/** Selected View boundary: Core prints keyset SQL once; bare operations retain native rows. */
|
|
3
2
|
import { parseNumeric } from "../build/compiler.js";
|
|
4
3
|
import { outputConversions } from "../domain/columns.js";
|
|
5
4
|
import { authLines } from "../domain/input.js";
|
|
@@ -17,10 +16,9 @@ export function emitPaging(plan, lowered, target, refuse, proofs, boundary = fal
|
|
|
17
16
|
const proof = proofs?.[name];
|
|
18
17
|
if (boundary && (!proof || !proof.types.length && !proof.single))
|
|
19
18
|
continue;
|
|
20
|
-
// Internal bare queries remain available even when they cannot be safely paged.
|
|
21
19
|
if (!proof || !proof.types.length && !proof.single) {
|
|
22
20
|
if (view.surface !== "internal")
|
|
23
|
-
refuse(path, proof?.reason ?? "Paged Views need a complete typed NOT NULL key tuple, indexed Schema order and a non-fanning relation, or a proved singleton.");
|
|
21
|
+
refuse(path, proof?.reason ?? "Paged Views need a complete typed NOT NULL key tuple, indexed Schema order and a non-fanning relation, or a proved singleton. For a lookup by a unique field, end ORDER BY with the Schema's id (`ORDER BY t.id`).");
|
|
24
22
|
continue;
|
|
25
23
|
}
|
|
26
24
|
const mode = view.surface === "public" ? "public" : "caller";
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** Opt-in lifecycle: one atomic, version-guarded statement per transition, with Core's row rules. */
|
|
2
1
|
import { rowPolicy } from "../build/compiler.js";
|
|
3
2
|
import { dialect as sqlite } from "../kernel/dialect/sqlite.js";
|
|
4
3
|
import { dialect as postgres } from "../kernel/dialect/postgres.js";
|
|
@@ -32,11 +31,9 @@ export function emitPublishing(plan, isSqlite, declarations, procedures, entries
|
|
|
32
31
|
const schema = plan.schemas[s.name];
|
|
33
32
|
const recipes = [{ k: "now" }, { k: "input", name: "id" }, { k: "input", name: "expectedVersion" }];
|
|
34
33
|
const conditions = [`id = ${d.param(2)}`, `version = ${d.param(3)}`, `status IN (${from.map(v => `'${v}'`).join(", ")})`];
|
|
35
|
-
// Same visibility as Store and Admin writes, translation parent included (a child under an out-of-set parent is missing);
|
|
36
|
-
// the status term is replaced by the transition's own `status IN (...)`.
|
|
37
34
|
for (const term of rowPolicy(schema, trusted ? "trusted" : "caller", plan.schemas))
|
|
38
35
|
if (term[1] !== "admin")
|
|
39
|
-
conditions.push(renderTerm(d, term, "", recipe => (recipes.push(recipe), d.param(recipes.length))));
|
|
36
|
+
conditions.push(renderTerm(d, term, term[1] === "parent" ? d.ident(s.name) + "." : "", recipe => (recipes.push(recipe), d.param(recipes.length))));
|
|
40
37
|
if (verb === "publish")
|
|
41
38
|
for (const field of s.schema.required ?? []) {
|
|
42
39
|
const column = Object.keys(s.fields).find(c => (s.names?.[c] ?? c) === field);
|
|
@@ -56,7 +53,6 @@ export function emitPublishing(plan, isSqlite, declarations, procedures, entries
|
|
|
56
53
|
}
|
|
57
54
|
}
|
|
58
55
|
}
|
|
59
|
-
/** Publishing lists get one index, prefixed by scope when present. */
|
|
60
56
|
export function publishingSql(schemas, isSqlite) {
|
|
61
57
|
const d = isSqlite ? sqlite : postgres;
|
|
62
58
|
return Object.values(schemas).filter(s => s.publishing).map(s => `CREATE INDEX ${d.ident(`_mantle_ix_${s.name}_published`)} ON ${d.ident(s.name)} (${[...(s.scope ? [s.scope] : []), ...(s.userScopes ?? []).map(u => u.column), "published_at", "id"].map(d.ident).join(", ")})`);
|
package/dist/usecase/rest.js
CHANGED
|
@@ -14,7 +14,6 @@ export function emitRest(plan, lowered, target, description) {
|
|
|
14
14
|
const views = [], procedures = [];
|
|
15
15
|
let hasWire = false;
|
|
16
16
|
const convert = (code) => code ? `, convert: ${code}` : "";
|
|
17
|
-
// Each segment matches in its encoded form; `{name}` segments capture.
|
|
18
17
|
const route = (method, path) => {
|
|
19
18
|
const keys = [];
|
|
20
19
|
const source = path.split("/").map(segment => segment.startsWith("{") && segment.endsWith("}") ? (keys.push(segment.slice(1, -1)), "([^/]+)") : encodeURIComponent(segment).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("/");
|
package/dist/usecase/site.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** Generate the optional site/media module and deployment DDL, never runtime preparation. */
|
|
2
1
|
import { assertSiteDefaultsCanonical, canonicalizeLocaleList, expandPolicyRequired } from "../build/compiler.js";
|
|
3
2
|
import { driverFor, isSqlite, q } from "../domain/entry.js";
|
|
4
3
|
export function emitSite(target, defaults, refuse, schemaNames) {
|
|
@@ -32,7 +31,6 @@ export function site(db: Database, caller: Caller) { return createSite({read:(sq
|
|
|
32
31
|
`, "site-seed.sql": seed + "\n", "site-schema.sql": `CREATE TABLE site_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
33
32
|
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
33
|
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
34
|
CREATE TABLE pending_media_uploads (id TEXT PRIMARY KEY, record TEXT NOT NULL, expires_at ${pg ? "BIGINT" : "INTEGER"} NOT NULL);
|
|
37
35
|
CREATE INDEX pending_media_uploads_expires_at ON pending_media_uploads (expires_at);
|
|
38
36
|
${seed}
|
package/dist/usecase/stores.js
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One typed collection per Schema, over the generic `createStore`. Core's row rules (`rowPolicy`, `writePolicy`) are evaluated
|
|
3
|
-
* here, at build time, ONCE for a placeholder scope and TTL into the shared `Rules`; a Schema adds only a `Fact` (scope, TTL,
|
|
4
|
-
* publishing, its date-time and date columns). Generation is refused if a Schema's expanded rules differ from Core's own for it
|
|
5
|
-
* (caller and trusted rows, insert fills, update sets and lock, delete lock).
|
|
6
|
-
* Column names, value types, writable columns and `orderBy` are TypeScript types only: nothing at runtime knows them.
|
|
7
|
-
*/
|
|
8
1
|
import { NATIVE_NOT_NULL, notNullColumns, rowPolicy, writePolicy } from "../build/compiler.js";
|
|
9
2
|
import { declaredType, inputType, nativeType } from "../domain/columns.js";
|
|
10
3
|
import { q } from "../domain/entry.js";
|
|
11
4
|
import { expand, SCOPE, TTL } from "../kernel/store.js";
|
|
12
|
-
import { userScopeTerm } from '../build/compiler.js';
|
|
13
|
-
// Types the typed Store compares and writes as plain bind values. Fixed numerics are scaled integers on SQLite; geo is two columns.
|
|
5
|
+
import { refChecks, userScopeTerm } from '../build/compiler.js';
|
|
14
6
|
const SCALAR = new Set(["text", "real", "float8", "integer", "int4", "int8", "bool", "timestamptz", "date"]);
|
|
15
|
-
/** Core's own rules for a Schema, in the shape of `Rules` without the id expression (a delete's lock is compared separately). */
|
|
16
7
|
const core = (s, schemas) => {
|
|
17
8
|
const update = writePolicy(s, "update", { lock: true });
|
|
18
9
|
return { caller: rowPolicy(s, "caller", schemas), trusted: rowPolicy(s, "trusted", schemas), fills: writePolicy(s, "insert").fills, sets: update.sets, lock: update.lock };
|
|
19
10
|
};
|
|
20
11
|
const probe = { scope: SCOPE, ttl: TTL, ttlSeconds: 0, publishing: true };
|
|
21
|
-
/** @internal */
|
|
22
12
|
export function emitStores(schemas, isSqlite, newId, refuse, required = false) {
|
|
23
13
|
const all = Object.values(schemas).sort((a, b) => a.name < b.name ? -1 : 1);
|
|
24
14
|
if (!all.length && !required)
|
|
@@ -28,7 +18,7 @@ export function emitStores(schemas, isSqlite, newId, refuse, required = false) {
|
|
|
28
18
|
for (const s of all) {
|
|
29
19
|
const type = (column) => declaredType(s.fields, column) ?? (column === "version" ? "integer" : column === "published_at" ? "timestamptz" : "text");
|
|
30
20
|
const declared = Object.keys(s.fields).filter(f => f !== s.scope && s.fields[f] !== "geo");
|
|
31
|
-
const geo = Object.keys(s.fields).filter(f => s.fields[f] === "geo").flatMap(f => [`${f}_lat`, `${f}_lng`]);
|
|
21
|
+
const geo = Object.keys(s.fields).filter(f => s.fields[f] === "geo").flatMap(f => [`${f}_lat`, `${f}_lng`]);
|
|
32
22
|
const names = [...["id", "version", "created_at", "updated_at", "author_id", ...(s.publishing ? ["status", "published_at"] : [])], ...declared];
|
|
33
23
|
const required = new Set(s.schema.required ?? []), notNull = notNullColumns(s);
|
|
34
24
|
const nonNull = (column) => NATIVE_NOT_NULL.has(column) || notNull.has(column);
|
|
@@ -36,18 +26,18 @@ export function emitStores(schemas, isSqlite, newId, refuse, required = false) {
|
|
|
36
26
|
const parent = s.translates && schemas[s.translates.parent.toLowerCase()];
|
|
37
27
|
const inherited = parent ? Object.keys(parent.fields).filter(c => c !== parent.scope && !Object.hasOwn(s.fields, c)).flatMap(c => parent.fields[c] === "geo" ? [`${c}_lat`, `${c}_lng`] : [c]) : [];
|
|
38
28
|
const inheritedType = (c) => parent?.fields[c] ?? "real";
|
|
29
|
+
const ref = refChecks(s, schemas);
|
|
39
30
|
const fact = {
|
|
40
31
|
...(s.userScopes?.length ? { u: s.userScopes.map(u => userScopeTerm(u, true)) } : {}),
|
|
41
32
|
...(s.scope ? { s: s.scope } : {}), ...(s.ttl ? { ttl: [s.ttl, s.ttlSeconds ?? 0] } : {}), ...(s.publishing ? { pub: 1 } : {}),
|
|
42
33
|
...(ts.length ? { ts } : {}), ...(dates.length ? { d: dates } : {}),
|
|
43
34
|
...(s.search?.length ? { search: s.search } : {}),
|
|
35
|
+
...(ref.length ? { ref } : {}),
|
|
44
36
|
...(parent ? { parent: { on: s.translates.on.toLowerCase(), ...(parent.scope ? { scope: [parent.scope, s.scope] } : {}), table: s.translates.parent.toLowerCase(), visible: rowPolicy(s, "caller", schemas).find(t => t[1] === "parent"), caller: rowPolicy(parent, "caller", schemas), trusted: rowPolicy(parent, "trusted", schemas), fields: inherited, ts: inherited.filter(c => parent.fields[c] === "timestamptz"), d: inherited.filter(c => parent.fields[c] === "date") } } : {}),
|
|
45
37
|
};
|
|
46
|
-
// the shared rules, with this Schema's scope and TTL put in, are Core's rules for it: or the runtime would enforce something else
|
|
47
38
|
const expected = q([core(s, schemas), writePolicy(s, "delete", { lock: true }).lock]), got = q([expand(rules, fact), rules.lock]);
|
|
48
39
|
if (expected !== got)
|
|
49
40
|
refuse(`schemas/${s.name}`, `Core's row rules for this Schema differ from the shared rules (${expected} vs ${got}); the generic Store cannot serve it.`);
|
|
50
|
-
// ORDER BY is allowed where the caller's scoped read can walk an index (its first column, or the one after the scope column) and the column is NOT NULL.
|
|
51
41
|
const indexed = new Set([...(s.publishing ? [[...(s.scope ? [s.scope] : []), "published_at", "id"]] : []), ...(s.indexes ?? []), ...(s.unique ?? [])].map(i => i[0] === s.scope ? i[1] : i[0]).filter((c) => !!c));
|
|
52
42
|
const key = (c) => /^[A-Za-z_]\w*$/.test(c) ? c : q(c);
|
|
53
43
|
const of = (columns, value) => `{ ${columns.map(c => `${key(c)}: ${value(c)};`).join(" ")} }`;
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
/** Generate-time tool versions: a stale host copy shows the model an older version than results carry. */
|
|
2
1
|
import { canonical, fnv } from "../build/compiler.js";
|
|
3
|
-
/** A hash of what a catalogue lists, so an unchanged definition keeps its version across deploys. */
|
|
4
2
|
export const versionOf = (value) => fnv(canonical(value));
|
|
5
|
-
/** A listed tool: its version, and its description (each locale's) ending with that version. */
|
|
6
3
|
export function versioned(tool) {
|
|
7
4
|
const v = versionOf(tool), mark = (text) => `${text} (tool version ${v})`;
|
|
8
5
|
return { ...tool, description: typeof tool.description === "string" ? mark(tool.description) : Object.fromEntries(Object.entries(tool.description).map(([locale, text]) => [locale, mark(text)])), version: v };
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { userReferences } from "../domain/userReferences.js";
|
|
2
|
-
/** Translation helpers use generated SQL policy, never a request-time manifest. */
|
|
3
2
|
import { rowPolicy } from "../build/compiler.js";
|
|
4
3
|
import { renderTerm } from "../kernel/store.js";
|
|
5
4
|
import { dialect as SQLITE } from "../kernel/dialect/sqlite.js";
|
|
6
5
|
import { dialect as POSTGRES } from "../kernel/dialect/postgres.js";
|
|
7
6
|
import { declaredType } from "../domain/columns.js";
|
|
8
7
|
import { driverFor, isSqlite, q } from "../domain/entry.js";
|
|
9
|
-
/** The parent is checked by the same atomic UPDATE that publishes the child. */
|
|
10
8
|
export function publishedParent(plan, child, d, recipes, trusted) {
|
|
11
9
|
const tr = child.translates, parent = plan.schemas[tr.parent.toLowerCase()], on = tr.on.toLowerCase();
|
|
12
10
|
const conditions = [`p.${d.ident(on)} = ${d.ident(child.name)}.${d.ident(on)}`];
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/** Compile boundary checks into each operation; no schema enters the request bundle. */
|
|
2
1
|
import { INPUT_RULE_KEYWORDS, lookupLocalRef as lookup, rootRefChain } from "../build/compiler.js";
|
|
3
2
|
import { literal, q } from "../domain/entry.js";
|
|
4
3
|
import { publishingInput } from "./publishing.js";
|
|
@@ -6,14 +5,9 @@ import { equal } from "../kernel/validation.js";
|
|
|
6
5
|
const pointer = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
7
6
|
const supported = new Set([..."$defs $ref $schema $id type nullable properties required additionalProperties items enum const format pattern minLength maxLength minimum maximum exclusiveMinimum exclusiveMaximum multipleOf minItems maxItems title description default readOnly writeOnly examples deprecated x-mantle-ref x-mcp-hint".split(" "), ...INPUT_RULE_KEYWORDS]);
|
|
8
7
|
const shapes = "see docs/handbook/reference/surface.md";
|
|
9
|
-
// YAML .inf and .nan parse to numbers that JSON writes as null.
|
|
10
8
|
const finite = (value) => typeof value === "number" ? Number.isFinite(value) : !!value && typeof value === "object" ? Object.values(value).every(finite) : true;
|
|
11
9
|
const has = (key) => `Object.hasOwn(object,${q(key)})`;
|
|
12
10
|
const exactly = (value, keys) => !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).sort().join(" ") === keys;
|
|
13
|
-
/**
|
|
14
|
-
* An object schema's rules, each failing `when` (JavaScript on `object`) with its sentence at the missing field `key`, or the
|
|
15
|
-
* reason a rule has an unsupported shape.
|
|
16
|
-
*/
|
|
17
11
|
function rules(schema) {
|
|
18
12
|
const declared = (names) => Array.isArray(names) && names.length > 0 && names.every(name => typeof name === "string" && Object.hasOwn(schema.properties ?? {}, name));
|
|
19
13
|
const list = (names) => names.map(name => `'${name}'`).join(" and ");
|
|
@@ -34,7 +28,6 @@ function rules(schema) {
|
|
|
34
28
|
if (condition !== undefined || then !== undefined || otherwise !== undefined) {
|
|
35
29
|
const [key] = exactly(condition, "properties required") ? Object.keys(condition.properties) : [];
|
|
36
30
|
const value = key === undefined ? undefined : condition.properties[key];
|
|
37
|
-
// Without required: [key], an absent key satisfies `if`.
|
|
38
31
|
if (key === undefined || !exactly(condition.properties, key) || !exactly(value, "const") || !(["string", "boolean"].includes(typeof value.const) || Number.isFinite(value.const) || value.const === null) || q(condition.required) !== q([key]) || !declared([key]))
|
|
39
32
|
return `if is supported only as exactly { properties: { <field>: { const: <string, finite number, boolean or null> } }, required: [<field>] }, so an absent field does not satisfy it; ${shapes}.`;
|
|
40
33
|
if (then === undefined && otherwise === undefined || [then, otherwise].some(branch => branch !== undefined && !(exactly(branch, "required") && declared(branch.required))))
|
|
@@ -47,19 +40,12 @@ function rules(schema) {
|
|
|
47
40
|
}
|
|
48
41
|
return found;
|
|
49
42
|
}
|
|
50
|
-
/** Whether an input schema accepts `{}`: a list View is read with no input, so web indexes need this. */
|
|
51
43
|
export function acceptsEmpty(input) {
|
|
52
44
|
if (input === undefined)
|
|
53
45
|
return true;
|
|
54
|
-
// Validation applies a root `$ref`'s target too, so each schema along the chain must accept `{}`; an unresolved or cyclic chain does not.
|
|
55
|
-
// dependentRequired needs a present key; an if needs its field, so `{}` meets only an else. An enum or const must admit `{}`.
|
|
56
46
|
return rootRefChain(input)?.every(schema => !schema.required?.length && schema.anyOf === undefined && !schema.else?.required?.length
|
|
57
47
|
&& (schema.enum === undefined || schema.enum.some(option => equal({}, option))) && (schema.const === undefined || equal({}, schema.const)) && (schema.type === undefined || [schema.type].flat().includes("object"))) ?? false;
|
|
58
48
|
}
|
|
59
|
-
/**
|
|
60
|
-
* An input schema as an AI tool catalogue advertises it: some model APIs refuse rules (a root `anyOf`, `if`), so each rule leaves
|
|
61
|
-
* the schema and becomes a sentence of the description.
|
|
62
|
-
*/
|
|
63
49
|
export function advertised(input, description) {
|
|
64
50
|
if (!input)
|
|
65
51
|
return { inputSchema: input, description };
|
|
@@ -68,7 +54,6 @@ export function advertised(input, description) {
|
|
|
68
54
|
if (!node || typeof node !== "object")
|
|
69
55
|
return;
|
|
70
56
|
const schema = node;
|
|
71
|
-
// An MCP View tool keeps each rule set of its root `$ref` chain as an `allOf` branch over the merged properties.
|
|
72
57
|
const branches = Array.isArray(schema.allOf) ? schema.allOf : [];
|
|
73
58
|
for (const branch of [schema, ...branches.map(branch => ({ ...branch, properties: schema.properties }))]) {
|
|
74
59
|
const found = rules(branch);
|
|
@@ -111,9 +96,7 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
|
|
|
111
96
|
files[file] = `import { transition } from "@aotter/mantle/validation";\nexport const validate = (raw: unknown) => transition(${q(base)}, raw);\n`;
|
|
112
97
|
continue;
|
|
113
98
|
}
|
|
114
|
-
// Only a local `$ref`, or more than 100 nested objects and arrays, can reach the nesting limit: only then is `depth` passed.
|
|
115
99
|
const recursive = json.includes('"$ref"') || json.split(/[[{]/).length > 101, depth = recursive ? ",depth+1" : "";
|
|
116
|
-
// Identical bodies share one function.
|
|
117
100
|
const nodes = new Map(), bodies = new Map(), functions = [], formats = new Set(), patterns = new Map();
|
|
118
101
|
let equality = false;
|
|
119
102
|
const resolve = (ref) => {
|
|
@@ -142,7 +125,6 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
|
|
|
142
125
|
refuse(base, `Unsupported input type '${type}'.`);
|
|
143
126
|
if (types.length)
|
|
144
127
|
lines.push(typeCheck(types));
|
|
145
|
-
// After a single type check `fail` (never) has narrowed `value`: that type's guard is implied.
|
|
146
128
|
const only = types.length === 1 ? types[0] : undefined;
|
|
147
129
|
const guard = (type, test = `(${condition(type)})`) => only === type || type === "number" && only === "integer" ? "" : test + " && ";
|
|
148
130
|
if (schema.enum) {
|
|
@@ -157,6 +139,14 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
|
|
|
157
139
|
equality = true;
|
|
158
140
|
check(`!equal(value,${literal(schema.const)})`, "Value violates const.");
|
|
159
141
|
}
|
|
142
|
+
for (const key of ["minLength", "maxLength", "minItems", "maxItems"])
|
|
143
|
+
if (schema[key] !== undefined && (!Number.isSafeInteger(schema[key]) || schema[key] < 0))
|
|
144
|
+
refuse(base, `${key} must be a non-negative safe integer.`);
|
|
145
|
+
for (const key of ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"])
|
|
146
|
+
if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key])))
|
|
147
|
+
refuse(base, `${key} must be a finite number.`);
|
|
148
|
+
if (schema.multipleOf !== undefined && (typeof schema.multipleOf !== "number" || !Number.isFinite(schema.multipleOf) || schema.multipleOf <= 0))
|
|
149
|
+
refuse(base, "multipleOf must be a positive finite number.");
|
|
160
150
|
for (const [type, measure, keywords] of bounds)
|
|
161
151
|
for (const [key, operator] of Object.entries(keywords))
|
|
162
152
|
if (schema[key] !== undefined)
|
|
@@ -189,7 +179,6 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
|
|
|
189
179
|
}
|
|
190
180
|
if (schema.items)
|
|
191
181
|
lines.push(`${only === "array" ? "" : "if (Array.isArray(value)) "}for (let i=0;i<value.length;i++) ${emit(schema.items)}(value[i],path+"/"+i${depth});`);
|
|
192
|
-
// Several keys that need no JSON-pointer escaping are checked by one loop, in the same order.
|
|
193
182
|
const required = schema.required ?? [], checks = Object.entries(schema.properties ?? {}).map(([key, child]) => [key, emit(child)]);
|
|
194
183
|
const loop = (keys) => keys.length > 1 && keys.every(key => pointer(key) === key);
|
|
195
184
|
const object = loop(required) ? [`for (const key of ${q(required)}) if (!Object.hasOwn(object,key)) fail(path+"/"+key,"Required field is missing.");`] : required.map(key => `if (!Object.hasOwn(object,${q(key)})) fail(path+${q("/" + pointer(key))},"Required field is missing.");`);
|
|
@@ -209,7 +198,6 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
|
|
|
209
198
|
if (object.length)
|
|
210
199
|
lines.push(`${only === "object" ? "" : `if (${condition("object")}) `}{ const object = value as Record<string,unknown>;\n${object.join("\n")}\n}`);
|
|
211
200
|
const body = lines.join("\n");
|
|
212
|
-
// Without recursion nothing calls a check before its body is complete, so an identical body's function serves it.
|
|
213
201
|
const fn = recursive ? named : bodies.get(body) ?? named;
|
|
214
202
|
if (fn === named) {
|
|
215
203
|
bodies.set(body, fn);
|
package/docs/examples/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Examples
|
|
2
2
|
|
|
3
|
-
Start
|
|
4
|
-
capabilities the others do not
|
|
3
|
+
Start an app with `mantle init`, which writes the **app shell** once. The examples are references: each shows a few
|
|
4
|
+
capabilities the others do not. Find each need in **Combining** below, read that example's manifest and source, and
|
|
5
|
+
take the pattern into your own manifests and `src/`. Do not copy an example directory over your app.
|
|
5
6
|
|
|
6
7
|
| Example | Shows |
|
|
7
8
|
|---|---|
|
|
@@ -16,20 +17,27 @@ capabilities the others do not; combine them for anything else.
|
|
|
16
17
|
| You need | Take |
|
|
17
18
|
|---|---|
|
|
18
19
|
| Stock that never oversells (orders, tickets, bookings) | club-events' capacity: a Schema `checks` constraint plus the decrement in the same program |
|
|
20
|
+
| An order, cart or import with several rows that must all commit or none | one SQL Procedure over `json_each(input.items)` with a counted check that refuses unknown lines (`mantle help typed-queries`, "Several rows that commit together") |
|
|
19
21
|
| Employees or customers who see only their own records | club-events' `restrict: { <schema>: "author_id = auth.uid()" }` |
|
|
20
22
|
| An approval step, or any state machine | purchasing's guarded `UPDATE ... WHERE requestStatus = ...` Procedures |
|
|
23
|
+
| Approve first, then reserve or spend | purchasing's `approve-request-write`: the guarded status change and the budget deduction in one program (`mantle help sql-patterns`, "Approve before reserving") |
|
|
24
|
+
| A status chosen while writing (auto-approve small requests) | purchasing's `submit-request`: `CASE` in the INSERT ("Choose a status while writing: CASE") |
|
|
25
|
+
| Edit only the fields given | `COALESCE(input.field, field)` ("Change only the fields given") |
|
|
26
|
+
| Create or add (restock, counters) | `INSERT ... ON CONFLICT (...) DO UPDATE` ("Create or add") |
|
|
27
|
+
| Refuse while something still refers to it | a `NOT EXISTS` guard in the UPDATE (purchasing's `submit-request` ledger row; "Refuse while something refers to it") |
|
|
28
|
+
| A record with its children in one read | club-events' `event-roster`: a JSON array per row ("A row with its children in one read") |
|
|
29
|
+
| The latest row per group, rankings | `row_number() OVER (PARTITION BY ...)` ("The latest row of each group") |
|
|
21
30
|
| A third-party API that answers later (webhooks, callbacks) | purchasing's `src/routes.ts`: verify the raw request, then call a Procedure |
|
|
22
31
|
| Public, multilingual, search- and AI-friendly pages | recipes' `src/routes.ts` |
|
|
23
32
|
| Invite-only or members-only sign-up | club-events' `members` allowlist + `src/signup.ts` |
|
|
24
33
|
| Notifications and reminders | purchasing's `EmailSender` call and `src/schedule.ts` |
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
example, also brings its `members` allowlist and `src/signup.ts` (members-only sign-up); remove both, or restore the
|
|
35
|
+
If you do copy an example's files, delete what your app does not need. Copying club-events, for example, also brings its `members` allowlist and `src/signup.ts` (members-only sign-up); remove both, or restore the
|
|
28
36
|
shell's allow-all `src/signup.ts`, unless sign-up should be invite-only. Purchasing brings its supplier callback
|
|
29
37
|
(`src/routes.ts`, `src/supplier.ts`), email (`src/notify.ts`), cron (`src/schedule.ts` and `triggers.crons`) and its
|
|
30
38
|
`wrangler.jsonc` secrets. `pnpm generate` and `tsc` point at leftovers that reference removed Schemas.
|
|
31
39
|
|
|
32
40
|
Every example uses two roles: `admin` (the admin layer) and `user`.
|
|
33
41
|
|
|
34
|
-
[Reference service](https://github.com/aotter/mantle/tree/v0.2.0
|
|
42
|
+
[Reference service](https://github.com/aotter/mantle/tree/v0.2.0/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
43
|
Projects on 0.1.x move with [the upgrade guide](../upgrade-0.1-to-0.2.md).
|
|
@@ -2,22 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
The wiring every Mantle Cloudflare app needs, ready to copy: Better Auth (email OTP, admin + user roles),
|
|
4
4
|
one Worker fetch (auth routes → your routes → Admin → MCP → generated REST), the Admin console assets, and a
|
|
5
|
-
local acceptance kit. It has no business manifests: add your own,
|
|
5
|
+
local acceptance kit. It has no business manifests: add your own, taking patterns from the examples (`mantle help examples`).
|
|
6
6
|
|
|
7
7
|
## Start an app
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
|
|
11
|
-
cp -R node_modules/@aotter/mantle/docs/examples/club-events/. my-app/ # optional: an example on top
|
|
10
|
+
npx @aotter/mantle@<exact version> init my-app # writes this shell once, Mantle packages pinned to that version
|
|
12
11
|
cd my-app
|
|
13
|
-
# package.json: pin the exact installed Mantle version for @aotter/mantle, -admin and -ui
|
|
14
12
|
pnpm install
|
|
13
|
+
# add manifests/*.yaml (mantle help four-atoms; patterns: mantle help examples)
|
|
15
14
|
pnpm generate # writes generated/ and lists any `handler: { ref }` to implement in src/handlers.ts
|
|
16
15
|
pnpm types && pnpm check
|
|
17
16
|
cp .dev.vars.example .dev.vars
|
|
18
|
-
pnpm db:init
|
|
17
|
+
pnpm db:init # add seed.sql and run pnpm db:seed for fixtures
|
|
19
18
|
pnpm dev # one-time codes print to this log
|
|
20
|
-
pnpm smoke #
|
|
19
|
+
pnpm smoke # your smoke.mjs on smoke-kit.mjs (see Files you edit)
|
|
20
|
+
pnpm accept # hand each role to a fresh evaluator (see Independent acceptance)
|
|
21
|
+
pnpm test # Admin MCP App check: needs Chrome (npx playwright-core install chromium, or MANTLE_TEST_CHROMIUM=<executable>)
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
npm works too (`npm install`, `npm run …`). `package.json` pins `@better-auth/core` and `@better-auth/utils` even though
|
|
@@ -38,9 +39,11 @@ on one version.
|
|
|
38
39
|
| `src/signup.ts` | `allowSignUp(env, email)`: who may create an account (allow-all by default). `auth.ts` calls it before Better Auth creates a user; `ADMIN_EMAIL` is always allowed. |
|
|
39
40
|
| `src/auth.ts` | Better Auth options. Two roles only: `admin` (the admin layer) and `user`. Replace the console OTP sender before deploying. |
|
|
40
41
|
| `smoke.mjs` | Your acceptance steps on `smoke-kit.mjs` (fresh local D1, OTP sign-in per user, REST/MCP/Admin calls). |
|
|
42
|
+
| `accept.mjs` | Independent acceptance setup: one URL per role that carries its session, plus a minimal MCP client. Add a fixtures module for realistic data. |
|
|
41
43
|
|
|
42
|
-
`src/service.ts` and `src/index.ts` rarely change.
|
|
43
|
-
|
|
44
|
+
`src/service.ts` and `src/index.ts` rarely change. The default shell mounts REST and MCP together. If the app deliberately
|
|
45
|
+
chooses asymmetric exposure, delete the blocks marked `MCP / OAuth (deliberate exception)` in `auth.ts` and `service.ts`;
|
|
46
|
+
HTTP-only Procedures will keep generating with a warning.
|
|
44
47
|
|
|
45
48
|
Search for `AGENT:` to find what an app must fill in.
|
|
46
49
|
|
|
@@ -50,8 +53,40 @@ Search for `AGENT:` to find what an app must fill in.
|
|
|
50
53
|
`cf-connecting-ip`; when testing by hand, wait a minute or vary that header.
|
|
51
54
|
- `ADMIN_EMAIL`'s first verified sign-in becomes `admin` while no admin exists. The sign-in response may still show
|
|
52
55
|
the previous role; `/admin/api/me` shows the current one.
|
|
56
|
+
- More administrators: in Admin, **More → Administrators** invites by email and **More → Members** grants Admin to an
|
|
57
|
+
existing member. `docs/handbook/cloudflare/authentication.md` (Identity helpers) has the same operations as an API.
|
|
53
58
|
- Admin HTTP routes are listed in `docs/handbook/guides/native-admin.md`.
|
|
54
59
|
|
|
60
|
+
`mantle help local-testing` lists what the smoke covers: every Procedure at its edges, each caller's isolation,
|
|
61
|
+
visitor pages without JavaScript, MCP discovery and Admin's MCP App.
|
|
62
|
+
Scripted smoke is not independent acceptance; before reporting a user-facing
|
|
63
|
+
deliverable done, follow the independent acceptance guide: `mantle help agent-workflow-acceptance`.
|
|
64
|
+
|
|
65
|
+
### Independent acceptance
|
|
66
|
+
|
|
67
|
+
`pnpm accept` (optionally `pnpm accept --fixtures fixtures.mjs --user member@example.test`; with npm, `npm run accept -- --fixtures …`) starts the app on a fresh local D1,
|
|
68
|
+
signs the admin in, runs your fixtures module, signs the user in and prints one local URL per role:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
visitor (anonymous): http://127.0.0.1:18741
|
|
72
|
+
admin (owner@example.test): http://127.0.0.1:46467 MCP http://127.0.0.1:46467/mcp/admin Admin http://127.0.0.1:46467/admin
|
|
73
|
+
user (member@example.test): http://127.0.0.1:42607 MCP http://127.0.0.1:42607/mcp
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Each URL forwards to the app with that role's session; the evaluator never sees a cookie, and the URL refuses
|
|
77
|
+
`/api/auth/*`, so it cannot sign out, sign in as someone else or mint credentials. Give an evaluator only its role's URL:
|
|
78
|
+
|
|
79
|
+
- MCP: configure the URL as a streamable HTTP MCP server, or, without an MCP client,
|
|
80
|
+
`node accept.mjs mcp <url>/mcp` lists the tools and `node accept.mjs mcp <url>/mcp <tool> '<json>'` calls one.
|
|
81
|
+
- Admin and member pages: open the URL in a browser (a person's, or Playwright for an agent); it is already
|
|
82
|
+
signed in.
|
|
83
|
+
- REST: send requests to the URL with no credential.
|
|
84
|
+
|
|
85
|
+
The fixtures module's default export receives `{ call, mcp, signIn, sql, sessions, user }` and runs between the two
|
|
86
|
+
sign-ins, so an invite-only app can admit the user first. Create records through the app's own REST/MCP as real
|
|
87
|
+
users (other members' records, several states, more than one page); `sql` is for what no surface may write, and returns storage column names (lower case unless aliased).
|
|
88
|
+
Ctrl-C stops the app and deletes its data. To accept an external MCP host's OAuth connection instead, see the guide.
|
|
89
|
+
|
|
55
90
|
### MCP acceptance
|
|
56
91
|
|
|
57
92
|
Generation declares tools; the app owns whether and where a remote endpoint is mounted.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Independent acceptance setup (mantle help agent-workflow-acceptance): hands each role to a fresh evaluator without a credential.
|
|
2
|
+
// node accept.mjs [--fixtures fixtures.mjs] [--user member@example.test]
|
|
3
|
+
// Starts the app on a fresh local D1 (schema.sql, then seed.sql), signs the admin in with email OTP, runs the optional
|
|
4
|
+
// fixtures module (default export async ({ call, mcp, signIn, sql, sessions, user }) => {...}: realistic records through
|
|
5
|
+
// the app's own REST/MCP as real users, e.g. other members' orders and more than one page), signs the user in, then
|
|
6
|
+
// serves one local proxy per role that adds that role's session to every request. Give an evaluator only its role's URL
|
|
7
|
+
// (a visitor gets the app's own origin):
|
|
8
|
+
// MCP at <proxy>/mcp (user) or <proxy>/mcp/admin (admin), Admin at <proxy>/admin, pages and REST at <proxy>/.
|
|
9
|
+
// The proxy refuses /api/auth/* so the evaluator cannot sign out, sign in or mint credentials. Ctrl-C stops everything.
|
|
10
|
+
// node accept.mjs mcp <endpoint> [tool [json-arguments]]
|
|
11
|
+
// A minimal MCP client for an evaluator without one: lists the tools, or calls one and prints the raw result.
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { Readable } from "node:stream";
|
|
14
|
+
import { parseArgs } from "node:util";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
if (process.argv[2] === "mcp") await client(process.argv[3], process.argv[4], process.argv[5]).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
19
|
+
else await setup();
|
|
20
|
+
|
|
21
|
+
async function setup() {
|
|
22
|
+
const { values } = parseArgs({ args: process.argv.slice(2).filter(arg => arg !== "--"), options: { fixtures: { type: "string" }, user: { type: "string", default: "member@example.test" } } });
|
|
23
|
+
const { startApp } = await import("./smoke-kit.mjs");
|
|
24
|
+
const app = await startApp();
|
|
25
|
+
const stop = async () => { await app.stop(); process.exit(0); };
|
|
26
|
+
process.on("SIGINT", stop);
|
|
27
|
+
process.on("SIGTERM", stop);
|
|
28
|
+
try {
|
|
29
|
+
// Fixtures run between the two sign-ins, so an invite-only app can admit the user first.
|
|
30
|
+
const sessions = { admin: await app.signIn(app.owner) };
|
|
31
|
+
if (values.fixtures) await (await import(pathToFileURL(resolve(values.fixtures)).href)).default({ ...app, sessions, user: values.user });
|
|
32
|
+
sessions.user = await app.signIn(values.user);
|
|
33
|
+
const lines = [`visitor (anonymous): ${app.origin}`];
|
|
34
|
+
for (const [role, cookie] of Object.entries(sessions)) {
|
|
35
|
+
const url = await proxy(app.origin, cookie);
|
|
36
|
+
lines.push(`${role} (${role === "admin" ? app.owner : values.user}): ${url} MCP ${url}/mcp${role === "admin" ? "/admin" : ""}${role === "admin" ? ` Admin ${url}/admin` : ""}`);
|
|
37
|
+
}
|
|
38
|
+
console.log(lines.join("\n") + "\nReady. Ctrl-C stops the app and deletes its data.");
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error(app.logs().slice(-4000));
|
|
41
|
+
await app.stop();
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A local origin that forwards to the app with one role's session; sliding Set-Cookie updates stay inside the proxy. */
|
|
47
|
+
function proxy(origin, cookie) {
|
|
48
|
+
const jar = new Map(cookie.split("; ").map(pair => [pair.slice(0, pair.indexOf("=")), pair]));
|
|
49
|
+
const server = createServer(async (request, response) => {
|
|
50
|
+
const url = new URL(request.url, origin);
|
|
51
|
+
if (url.pathname.startsWith("/api/auth/")) { response.writeHead(403, { "content-type": "text/plain" }).end("Sign-in is handled by setup."); return; }
|
|
52
|
+
const chunks = [];
|
|
53
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
54
|
+
const headers = new Headers();
|
|
55
|
+
for (const [name, value] of Object.entries(request.headers)) if (!["host", "cookie", "origin", "connection", "content-length"].includes(name)) headers.set(name, [value].flat().join(", "));
|
|
56
|
+
headers.set("cookie", [...jar.values()].join("; "));
|
|
57
|
+
headers.set("origin", origin);
|
|
58
|
+
const upstream = await fetch(url, { method: request.method, headers, redirect: "manual", body: chunks.length ? Buffer.concat(chunks) : undefined });
|
|
59
|
+
for (const set of upstream.headers.getSetCookie()) { const pair = set.split(";")[0]; jar.set(pair.slice(0, pair.indexOf("=")), pair); }
|
|
60
|
+
const out = {};
|
|
61
|
+
upstream.headers.forEach((value, name) => { if (!["set-cookie", "content-encoding", "content-length", "transfer-encoding"].includes(name)) out[name] = value; });
|
|
62
|
+
if (out.location) out.location = out.location.replace(origin, `http://${request.headers.host}`);
|
|
63
|
+
response.writeHead(upstream.status, out);
|
|
64
|
+
if (upstream.body) Readable.fromWeb(upstream.body).pipe(response); else response.end();
|
|
65
|
+
});
|
|
66
|
+
return new Promise(done => server.listen(0, "127.0.0.1", () => done(`http://127.0.0.1:${server.address().port}`)));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function client(endpoint, tool, json) {
|
|
70
|
+
if (!endpoint) throw new Error("usage: node accept.mjs mcp <endpoint> [tool [json-arguments]]");
|
|
71
|
+
const headers = { "content-type": "application/json", accept: "application/json, text/event-stream" };
|
|
72
|
+
let id = 0;
|
|
73
|
+
const rpc = async (method, params) => {
|
|
74
|
+
const response = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", ...(method.startsWith("notifications/") ? {} : { id: ++id }), method, params }) });
|
|
75
|
+
const session = response.headers.get("mcp-session-id");
|
|
76
|
+
if (session) headers["mcp-session-id"] = session;
|
|
77
|
+
const text = await response.text();
|
|
78
|
+
if (!response.ok && response.status !== 202) throw new Error(`MCP ${method}: HTTP ${response.status} ${text}`);
|
|
79
|
+
const data = text.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5)).join("\n") || text;
|
|
80
|
+
return data ? JSON.parse(data) : null;
|
|
81
|
+
};
|
|
82
|
+
const init = await rpc("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "mantle-accept", version: "1" } });
|
|
83
|
+
headers["mcp-protocol-version"] = init.result?.protocolVersion ?? "2025-11-25";
|
|
84
|
+
await rpc("notifications/initialized");
|
|
85
|
+
if (!tool) {
|
|
86
|
+
const tools = [];
|
|
87
|
+
let cursor;
|
|
88
|
+
do { const page = (await rpc("tools/list", cursor ? { cursor } : {})).result; tools.push(...page.tools); cursor = page.nextCursor; } while (cursor);
|
|
89
|
+
console.log(JSON.stringify(tools, null, 2));
|
|
90
|
+
} else console.log(JSON.stringify(await rpc("tools/call", { name: tool, arguments: json ? JSON.parse(json) : {} }), null, 2));
|
|
91
|
+
}
|
|
@@ -5,17 +5,18 @@
|
|
|
5
5
|
"scripts": {
|
|
6
6
|
"generate": "mantle generate cloudflare-d1 generated manifests/*.yaml --auth src/auth.ts --entry src/index.ts --admin",
|
|
7
7
|
"types": "wrangler types --include-runtime=false",
|
|
8
|
-
"db:init": "wrangler d1 execute
|
|
9
|
-
"db:seed": "wrangler d1 execute
|
|
8
|
+
"db:init": "wrangler d1 execute DB --local --file generated/schema.sql",
|
|
9
|
+
"db:seed": "wrangler d1 execute DB --local --file seed.sql",
|
|
10
10
|
"build": "mantle bundle src/index.ts dist",
|
|
11
11
|
"test": "node --test generated/admin-mcp-app.check.mjs",
|
|
12
12
|
"dev": "wrangler dev --local --ip 127.0.0.1 --port 8787",
|
|
13
13
|
"check": "mantle generate cloudflare-d1 generated manifests/*.yaml --auth src/auth.ts --entry src/index.ts --admin --check && wrangler types --include-runtime=false --check && tsc --noEmit",
|
|
14
|
-
"smoke": "node smoke.mjs"
|
|
14
|
+
"smoke": "node smoke.mjs",
|
|
15
|
+
"accept": "node accept.mjs"
|
|
15
16
|
},
|
|
16
17
|
"dependencies": {
|
|
17
|
-
"@aotter/mantle": "0.2.0
|
|
18
|
-
"@aotter/mantle-ui": "0.2.0
|
|
18
|
+
"@aotter/mantle": "0.2.0",
|
|
19
|
+
"@aotter/mantle-ui": "0.2.0",
|
|
19
20
|
"@better-auth/cimd": "1.7.7",
|
|
20
21
|
"@better-auth/core": "1.7.7",
|
|
21
22
|
"@better-auth/mcp": "1.7.7",
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
"@modelcontextprotocol/ext-apps": "2.0.0",
|
|
25
26
|
"@modelcontextprotocol/server": "2.2.0",
|
|
26
27
|
"better-auth": "1.7.7",
|
|
27
|
-
"@aotter/mantle-admin": "0.2.0
|
|
28
|
+
"@aotter/mantle-admin": "0.2.0",
|
|
28
29
|
"react": "19.3.0",
|
|
29
30
|
"react-dom": "19.3.0"
|
|
30
31
|
},
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// import { smoke } from "./smoke-kit.mjs";
|
|
4
4
|
// await smoke(async ({ call, mcp, signIn, owner, step }) => { const admin = await signIn(owner); ... step("..."); });
|
|
5
5
|
// Extra Worker variables: await smoke(scenario, { vars: { SUPPLIER_API_URL: "http://127.0.0.1:9999" } });
|
|
6
|
+
// accept.mjs uses startApp() to keep the app running for fresh evaluators.
|
|
6
7
|
import assert from "node:assert/strict";
|
|
7
8
|
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
8
9
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
@@ -64,11 +65,23 @@ export async function assertMcpDiscovery(endpoint, { tools, headers = {} }) {
|
|
|
64
65
|
return names;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
const database = "
|
|
68
|
+
const database = "DB"; // the D1 binding (wrangler.jsonc d1_databases[0].binding), so renaming the database changes nothing here
|
|
68
69
|
const env = { ...process.env, WRANGLER_SEND_METRICS: "false" };
|
|
69
70
|
|
|
70
|
-
export async function smoke(scenario,
|
|
71
|
-
const
|
|
71
|
+
export async function smoke(scenario, options) {
|
|
72
|
+
const app = await startApp(options);
|
|
73
|
+
try {
|
|
74
|
+
await scenario(app);
|
|
75
|
+
console.log("smoke passed");
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(app.logs().slice(-4000));
|
|
78
|
+
throw error;
|
|
79
|
+
} finally { await app.stop(); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Start the app on a fresh local D1 (schema.sql, then seed.sql when present); stop() ends wrangler and deletes the state. */
|
|
83
|
+
export async function startApp({ vars = {}, port = 18000 + Math.floor(Math.random() * 1000) } = {}) {
|
|
84
|
+
const origin = `http://127.0.0.1:${port}`, owner = "owner@example.test";
|
|
72
85
|
const state = mkdtempSync(join(tmpdir(), "mantle-smoke-"));
|
|
73
86
|
// Deployment DDL is explicit: nothing at runtime creates tables.
|
|
74
87
|
for (const file of ["generated/schema.sql", ...(existsSync("seed.sql") ? ["seed.sql"] : [])]) {
|
|
@@ -128,6 +141,17 @@ export async function smoke(scenario, { vars = {} } = {}) {
|
|
|
128
141
|
assert.fail(`no log line matching ${pattern}`);
|
|
129
142
|
};
|
|
130
143
|
const step = name => console.log(`ok - ${name}`);
|
|
144
|
+
const stop = async () => {
|
|
145
|
+
const kill = signal => { try { process.kill(-wrangler.pid, signal); } catch { /* already gone */ } };
|
|
146
|
+
kill("SIGTERM");
|
|
147
|
+
await new Promise(resolve => {
|
|
148
|
+
if (wrangler.exitCode !== null) return resolve();
|
|
149
|
+
const timer = setTimeout(() => { kill("SIGKILL"); resolve(); }, 5_000);
|
|
150
|
+
wrangler.once("exit", () => { clearTimeout(timer); resolve(); });
|
|
151
|
+
});
|
|
152
|
+
kill("SIGKILL");
|
|
153
|
+
rmSync(state, { recursive: true, force: true });
|
|
154
|
+
};
|
|
131
155
|
try {
|
|
132
156
|
for (let attempt = 0; ; attempt++) {
|
|
133
157
|
if (wrangler.exitCode !== null) throw new Error(`wrangler exited ${wrangler.exitCode}`);
|
|
@@ -135,20 +159,10 @@ export async function smoke(scenario, { vars = {} } = {}) {
|
|
|
135
159
|
if (attempt > 240) throw new Error("the Worker did not start");
|
|
136
160
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
137
161
|
}
|
|
138
|
-
await scenario({ origin, owner, call, mcp, signIn, sql, cron, waitForLog, step, logs: () => logs });
|
|
139
|
-
console.log("smoke passed");
|
|
140
162
|
} catch (error) {
|
|
141
163
|
console.error(logs.slice(-4000));
|
|
164
|
+
await stop();
|
|
142
165
|
throw error;
|
|
143
|
-
} finally {
|
|
144
|
-
const stop = signal => { try { process.kill(-wrangler.pid, signal); } catch { /* already gone */ } };
|
|
145
|
-
stop("SIGTERM");
|
|
146
|
-
await new Promise(resolve => {
|
|
147
|
-
if (wrangler.exitCode !== null) return resolve();
|
|
148
|
-
const timer = setTimeout(() => { stop("SIGKILL"); resolve(); }, 5_000);
|
|
149
|
-
wrangler.once("exit", () => { clearTimeout(timer); resolve(); });
|
|
150
|
-
});
|
|
151
|
-
stop("SIGKILL");
|
|
152
|
-
rmSync(state, { recursive: true, force: true });
|
|
153
166
|
}
|
|
167
|
+
return { origin, owner, call, mcp, signIn, sql, cron, waitForLog, step, logs: () => logs, stop };
|
|
154
168
|
}
|
|
@@ -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
|
];
|