@aotter/mantle 0.2.0-alpha.9 → 0.2.1-alpha.1
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 +15 -5
- package/dist/auth/adapter.js +1 -28
- package/dist/auth/admin.js +2 -5
- package/dist/auth/caller.js +0 -27
- package/dist/auth/consent.js +100 -0
- package/dist/auth/hardening.js +0 -7
- package/dist/auth/identity.js +0 -11
- package/dist/auth/oauth.js +0 -32
- package/dist/auth/resource-seeds.js +0 -2
- package/dist/auth/route-http.js +33 -0
- package/dist/auth/routes.js +5 -151
- package/dist/build/compiler.d.ts +19 -609
- package/dist/build/compiler.js +6 -6896
- package/dist/build/spec/domain/model/ManifestGrammar.d.ts +39 -1
- package/dist/build/spec/domain/model/RuntimePlan.d.ts +16 -3
- package/dist/build/spec/domain/model/SqlIr.d.ts +32 -0
- package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- 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-P6PP6VVV.js +1 -0
- package/dist/domain/describe/index.d.ts +1 -0
- package/dist/domain/describe/index.js +1 -115
- package/dist/domain/describe/match.d.ts +19 -0
- 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 +27 -8
- 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 +45 -38
- package/dist/infrastructure/cli.js +26 -12
- package/dist/infrastructure/generate.js +26 -19
- package/dist/infrastructure/generateAuth.js +1 -9
- package/dist/infrastructure/help.js +55 -0
- package/dist/infrastructure/init.js +50 -0
- package/dist/infrastructure/inputs.js +0 -9
- package/dist/infrastructure/mounts.js +22 -0
- package/dist/infrastructure/wrangler.js +0 -6
- package/dist/kernel/authorization.js +0 -1
- package/dist/kernel/binds.d.ts +34 -7
- package/dist/kernel/binds.js +5 -11
- 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 +13 -10
- package/dist/kernel/contracts.js +14 -41
- package/dist/kernel/cron.js +0 -4
- package/dist/kernel/dialect/common.js +0 -3
- package/dist/kernel/dialect/postgres.js +0 -2
- package/dist/kernel/dialect/sqlite.js +1 -6
- package/dist/kernel/drain.js +2 -8
- package/dist/kernel/driver/bun-sqlite.js +3 -7
- package/dist/kernel/driver/d1.d.ts +1 -1
- package/dist/kernel/driver/d1.js +6 -4
- package/dist/kernel/driver/postgres.d.ts +2 -1
- package/dist/kernel/driver/postgres.js +12 -22
- package/dist/kernel/driver/transaction-context.js +0 -1
- package/dist/kernel/email.js +0 -4
- package/dist/kernel/host-access.js +2 -3
- package/dist/kernel/host-caller.d.ts +1 -1
- package/dist/kernel/host-caller.js +3 -6
- package/dist/kernel/json-body.js +0 -2
- package/dist/kernel/mcp-apps.js +0 -5
- package/dist/kernel/mcp-call.js +0 -1
- package/dist/kernel/mcp-handler.js +4 -27
- package/dist/kernel/mcp.js +0 -5
- package/dist/kernel/media.js +0 -18
- package/dist/kernel/paging.js +1 -9
- package/dist/kernel/publishing.js +3 -4
- package/dist/kernel/r2-media.js +0 -7
- package/dist/kernel/rest-dispatch.js +0 -1
- package/dist/kernel/rest-router.js +5 -8
- package/dist/kernel/rest.d.ts +2 -1
- package/dist/kernel/rest.js +1 -3
- package/dist/kernel/route-pattern.d.ts +26 -0
- package/dist/kernel/route-pattern.js +34 -0
- package/dist/kernel/scalars.js +1 -9
- package/dist/kernel/search.js +0 -1
- package/dist/kernel/site.js +0 -2
- package/dist/kernel/store.d.ts +26 -2
- package/dist/kernel/store.js +69 -49
- package/dist/kernel/surface-failure.d.ts +10 -0
- package/dist/kernel/surface-failure.js +29 -0
- package/dist/kernel/surface.js +0 -3
- package/dist/kernel/sweep.js +0 -1
- package/dist/kernel/translations.js +0 -1
- package/dist/kernel/user-caller.js +2 -8
- package/dist/kernel/user-fields.d.ts +3 -2
- package/dist/kernel/user-fields.js +9 -9
- package/dist/kernel/validation.js +0 -8
- package/dist/kernel/web-gateway.js +0 -2
- package/dist/kernel/web-head.js +0 -1
- package/dist/kernel/web-index.js +0 -1
- package/dist/kernel/web-response.js +0 -1
- package/dist/kernel/web-serve.js +97 -0
- package/dist/kernel/web.d.ts +1 -1
- package/dist/kernel/web.js +10 -91
- package/dist/kernel/where.js +0 -6
- package/dist/usecase/adminBindings.js +32 -23
- package/dist/usecase/adminClient.js +4 -22
- package/dist/usecase/adminDescriptors.js +2 -9
- package/dist/usecase/generate.js +14 -17
- package/dist/usecase/handlers.js +1 -4
- 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 +20 -11
- package/dist/usecase/rest.js +5 -6
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/stores.js +12 -17
- package/dist/usecase/tool-version.js +0 -3
- package/dist/usecase/translations.js +0 -2
- package/dist/usecase/validation.js +0 -20
- package/docs/examples/README.md +13 -5
- package/docs/examples/app-shell/README.md +91 -12
- package/docs/examples/app-shell/accept.mjs +91 -0
- package/docs/examples/app-shell/gitignore +7 -0
- package/docs/examples/app-shell/package.json +10 -9
- package/docs/examples/app-shell/smoke-kit.mjs +29 -15
- package/docs/examples/app-shell/src/routes.ts +6 -3
- package/docs/examples/app-shell/src/service.ts +2 -1
- package/docs/examples/club-events/README.md +7 -2
- package/docs/examples/club-events/manifests/club-events.yaml +17 -0
- package/docs/examples/club-events/smoke.mjs +7 -1
- package/docs/examples/generated-web/seed.sql +5 -4
- package/docs/examples/purchasing/README.md +12 -3
- package/docs/examples/purchasing/manifests/purchasing.yaml +24 -4
- package/docs/examples/purchasing/smoke.mjs +14 -0
- package/docs/examples/recipes/README.md +5 -3
- package/docs/handbook/cloudflare/media-r2.md +6 -3
- package/docs/handbook/cloudflare/service-entry.md +5 -0
- package/docs/handbook/concepts/lifecycle-and-locales.md +21 -1
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/admin-ui.md +1 -1
- package/docs/handbook/guides/agent-setup.md +20 -10
- package/docs/handbook/guides/agent-workflow-acceptance.md +225 -105
- package/docs/handbook/guides/local-testing.md +85 -0
- package/docs/handbook/guides/native-admin.md +242 -16
- package/docs/handbook/guides/native-execution.md +10 -8
- package/docs/handbook/guides/public-pages.md +44 -4
- package/docs/handbook/guides/revisions.md +584 -0
- package/docs/handbook/guides/size-and-startup.md +140 -0
- package/docs/handbook/guides/sql-patterns.md +312 -0
- package/docs/handbook/guides/typed-queries.md +146 -3
- package/docs/handbook/navigation.json +20 -0
- package/docs/handbook/reference/admin-descriptors.md +4 -1
- package/docs/handbook/reference/authorization.md +3 -1
- package/docs/handbook/reference/describe.md +42 -0
- package/docs/handbook/reference/diagnostics.md +16 -2
- package/docs/handbook/reference/procedure.md +6 -6
- package/docs/handbook/reference/schema.md +79 -9
- package/docs/handbook/reference/sql.md +65 -0
- package/docs/handbook/reference/surface.md +8 -4
- package/docs/handbook/reference/trigger.md +12 -0
- package/docs/handbook/reference/view.md +11 -3
- package/docs/handbook/releases/index.md +71 -7
- package/docs/handbook/start/agents.md +49 -0
- package/docs/handbook/start/project-and-cli.md +10 -0
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +19 -6
- package/docs/skills/integrate/SKILL.md +3 -1
- package/docs/skills/performance/SKILL.md +169 -0
- package/docs/skills/update/SKILL.md +10 -4
- package/docs/upgrade-0.1-to-0.2.md +151 -39
- package/package.json +31 -11
- package/skills/README.md +6 -2
- package/skills/mantle/SKILL.md +53 -120
- package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
- package/skills/mantle/references/local-testing.md +0 -20
- package/skills/mantle/references/web.md +0 -41
- package/skills/mantle/references/website.md +0 -51
|
@@ -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,36 +5,37 @@
|
|
|
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.
|
|
18
|
-
"@aotter/mantle-ui": "0.2.
|
|
18
|
+
"@aotter/mantle": "0.2.1-alpha.1",
|
|
19
|
+
"@aotter/mantle-ui": "0.2.1-alpha.1",
|
|
19
20
|
"@better-auth/cimd": "1.7.7",
|
|
20
21
|
"@better-auth/core": "1.7.7",
|
|
21
22
|
"@better-auth/mcp": "1.7.7",
|
|
22
23
|
"@better-auth/oauth-provider": "1.7.7",
|
|
23
24
|
"@better-auth/utils": "0.4.2",
|
|
24
25
|
"@modelcontextprotocol/ext-apps": "2.0.0",
|
|
25
|
-
"@modelcontextprotocol/server": "2.
|
|
26
|
+
"@modelcontextprotocol/server": "2.3.1",
|
|
26
27
|
"better-auth": "1.7.7",
|
|
27
|
-
"@aotter/mantle-admin": "0.2.
|
|
28
|
+
"@aotter/mantle-admin": "0.2.1-alpha.1",
|
|
28
29
|
"react": "19.3.0",
|
|
29
30
|
"react-dom": "19.3.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
|
-
"@cloudflare/workers-types": "5.
|
|
33
|
+
"@cloudflare/workers-types": "5.20261027.1",
|
|
33
34
|
"@types/node": "^26",
|
|
34
35
|
"@types/react": "^19.3.0",
|
|
35
36
|
"playwright-core": "1.62.1",
|
|
36
37
|
"typescript": "^6.0.3",
|
|
37
|
-
"wrangler": "4.
|
|
38
|
+
"wrangler": "4.153.0"
|
|
38
39
|
},
|
|
39
40
|
"engines": {
|
|
40
41
|
"node": ">=22.22"
|
|
@@ -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
|
}
|
|
@@ -2,10 +2,13 @@ import type { Caller } from "@aotter/mantle/contracts";
|
|
|
2
2
|
import { signInPage } from "./sign-in.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* App-owned routes, tried before
|
|
5
|
+
* App-owned routes, tried before REST. Return undefined to fall through. Paths under /admin and /mcp never reach here, and
|
|
6
|
+
* neither do Better Auth's (/api/auth/*, /oauth/consent*, /.well-known/oauth-*). A root catch-all page (/:slug) must
|
|
7
|
+
* still return undefined for the paths of the app's own HTTP Triggers (REST), or it shadows them.
|
|
6
8
|
* GET /sign-in: member sign-in (src/sign-in.ts); a member page sends anonymous visitors there with signInRedirect.
|
|
7
|
-
* AGENT: add visitor pages
|
|
8
|
-
* (
|
|
9
|
+
* AGENT: add visitor pages and callbacks/webhooks here. Visitor pages carry everything in `mantle help public-pages`
|
|
10
|
+
* (canonical, Open Graph, JSON-LD, answer block, .md alternates, sitemap.xml, robots.txt, llms.txt; recipes example).
|
|
11
|
+
* A callback must verify the raw Request (signature, timestamp) before it reads the body as JSON or calls a generated Procedure.
|
|
9
12
|
*/
|
|
10
13
|
export async function appRoutes(request: Request, _env: Env, _context: { caller: () => Promise<Caller>; waitUntil: (task: Promise<unknown>) => void }): Promise<Response | undefined> {
|
|
11
14
|
if (new URL(request.url).pathname === "/sign-in" && request.method === "GET") return signInPage();
|
|
@@ -43,7 +43,8 @@ export async function fetchService(request: Request, env: Env, context: Executio
|
|
|
43
43
|
const ownAuth = await withAuthTasks(context.waitUntil.bind(context), () => authRoutes(auth!, { methods: [{ kind: "email-otp" }], resolver: caller })(request));
|
|
44
44
|
if (ownAuth) return respond(ownAuth);
|
|
45
45
|
const url = new URL(request.url);
|
|
46
|
-
|
|
46
|
+
// /admin and /mcp belong to Admin and MCP: a root catch-all page in src/routes.ts (/:slug) never shadows them.
|
|
47
|
+
const own = /^\/(?:admin|mcp)(?:\/|$)/.test(url.pathname) ? undefined : await appRoutes(request, env, { caller, waitUntil: context.waitUntil.bind(context) });
|
|
47
48
|
if (own) return respond(own);
|
|
48
49
|
let response: Response | undefined;
|
|
49
50
|
if (url.pathname === "/admin/api" || url.pathname.startsWith("/admin/api/")) {
|
|
@@ -23,6 +23,9 @@ over MCP. Only members the officers listed in Admin can sign up. Officers see ev
|
|
|
23
23
|
Schema check; no View or Trigger exposes it). `src/signup.ts` replaces the shell's allow-all `allowSignUp` with a
|
|
24
24
|
Store lookup as a system caller, so any other email gets 403 at sign-in and no account. To invite another officer
|
|
25
25
|
through Admin, list their email in `members` first.
|
|
26
|
+
- **An event with its attendees in one read**: the admin View `event-roster` returns each event with an `attendees`
|
|
27
|
+
JSON array built by `json_group_array(json_set('{}', ...))` in a correlated subquery, so an event nobody joined has
|
|
28
|
+
`[]`. PostgreSQL writes it with `jsonb_agg(jsonb_build_object(...))`; see `mantle help sql-patterns`.
|
|
26
29
|
- **Member MCP tool**: the `register-mcp` Trigger (`surface: public`) exposes `register` on `/mcp`, so the member's AI
|
|
27
30
|
registers with the member's own sign-in.
|
|
28
31
|
|
|
@@ -36,9 +39,11 @@ over MCP. Only members the officers listed in Admin can sign up. Officers see ev
|
|
|
36
39
|
|
|
37
40
|
## Run it
|
|
38
41
|
|
|
42
|
+
This runs the example by itself, to read and try. For your own app, run `mantle init` and take only the patterns you need into its manifests and `src/` (`mantle help examples`).
|
|
43
|
+
|
|
39
44
|
```sh
|
|
40
|
-
|
|
41
|
-
cp -R node_modules/@aotter/mantle/docs/examples/club-events/. my-app/
|
|
45
|
+
npx mantle init my-app
|
|
46
|
+
cp -R node_modules/@aotter/mantle/docs/examples/club-events/. my-app/ # run the example as is
|
|
42
47
|
cd my-app && pnpm install && pnpm generate && pnpm types && pnpm check
|
|
43
48
|
cp .dev.vars.example .dev.vars && pnpm db:init && pnpm dev
|
|
44
49
|
pnpm smoke
|
|
@@ -9,6 +9,7 @@ spec:
|
|
|
9
9
|
# It also refuses an officer lowering capacity below the seats already taken.
|
|
10
10
|
checks: ["seats_taken <= capacity"]
|
|
11
11
|
indexes: [[startsAt, id]]
|
|
12
|
+
searchableFields: [title]
|
|
12
13
|
uiSchema: { list: { primaryField: title, columns: [startsAt, capacity, seats_taken, closed] } }
|
|
13
14
|
schema:
|
|
14
15
|
type: object
|
|
@@ -54,6 +55,7 @@ spec:
|
|
|
54
55
|
uniqueIndexes: [[email]]
|
|
55
56
|
# Better Auth lowercases emails; the check refuses a mixed-case entry that would never match.
|
|
56
57
|
checks: ["email = lower(email)"]
|
|
58
|
+
searchableFields: [email, name]
|
|
57
59
|
uiSchema: { list: { primaryField: email, columns: [name] } }
|
|
58
60
|
schema:
|
|
59
61
|
type: object
|
|
@@ -86,6 +88,21 @@ spec:
|
|
|
86
88
|
FROM registrations r JOIN events e ON e.id = r.eventId
|
|
87
89
|
ORDER BY r.created_at DESC, r.id DESC
|
|
88
90
|
---
|
|
91
|
+
# Officers' roster: each event with its attendees as one JSON array, so one read answers "who is coming".
|
|
92
|
+
# The correlated subquery gives [] for an event nobody registered for (a LEFT JOIN + GROUP BY would give one empty object).
|
|
93
|
+
# SQLite/D1 builds the objects with json_set; on PostgreSQL write jsonb_agg(jsonb_build_object('memberName', r.memberName)).
|
|
94
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
95
|
+
kind: View
|
|
96
|
+
metadata: { name: event-roster }
|
|
97
|
+
spec:
|
|
98
|
+
title: Event roster
|
|
99
|
+
surface: admin
|
|
100
|
+
requires: { auth: { all: [ctx.admin] } }
|
|
101
|
+
sql: |
|
|
102
|
+
SELECT e.id, e.title, e.startsAt AS "startsAt", e.seats_taken AS "seatsTaken",
|
|
103
|
+
(SELECT json_group_array(json_set('{}', '$.memberName', r.memberName)) FROM registrations r WHERE r.eventId = e.id) AS attendees
|
|
104
|
+
FROM events e ORDER BY e.startsAt, e.id
|
|
105
|
+
---
|
|
89
106
|
# The member-facing entry: a ref handler checks the event, then runs register-write.
|
|
90
107
|
apiVersion: cms.mantle.aotter.net/v2
|
|
91
108
|
kind: Procedure
|
|
@@ -105,7 +105,13 @@ await smoke(async ({ call, mcp, signIn, sql, owner, step, logs }) => {
|
|
|
105
105
|
assert.equal((await call("GET", "/admin/api/entries?collection=registrations", { cookie: bob })).status, 403);
|
|
106
106
|
assert.equal((await call("GET", "/admin/api/me", { cookie: bob })).status, 403);
|
|
107
107
|
assert.ok(all.body.items.every(r => r.data.memberName && r.data.memberName !== r.data.member), JSON.stringify(all.body.items));
|
|
108
|
-
|
|
108
|
+
const roster = await call("GET", "/admin/api/views/event-roster", { cookie: admin });
|
|
109
|
+
assert.equal(roster.status, 200, JSON.stringify(roster.body));
|
|
110
|
+
const attendees = row => (typeof row.attendees === "string" ? JSON.parse(row.attendees) : row.attendees).map(a => a.memberName).sort();
|
|
111
|
+
for (const row of roster.body.rows) assert.equal(attendees(row).length, row.seatsTaken, JSON.stringify(row));
|
|
112
|
+
assert.ok(roster.body.rows.some(row => attendees(row).length > 0), JSON.stringify(roster.body.rows));
|
|
113
|
+
assert.equal((await call("GET", "/admin/api/views/event-roster", { cookie: bob })).status, 403);
|
|
114
|
+
step("officers see every member's registrations, with names, through Admin's API; and every event with its attendees as one JSON array (event-roster); a member gets 403 on Admin");
|
|
109
115
|
|
|
110
116
|
const entry = async id => (await call("GET", `/admin/api/entries/${id}?collection=events`, { cookie: admin })).body.entry;
|
|
111
117
|
const before = await seatsOf(hike.id), read = await entry(hike.id);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
-- Local fixture only. Production publishing goes through the generated Procedures.
|
|
2
|
+
-- Every system column is set: timestamps are microseconds since the Unix epoch (2026-01-01T00:00:00Z); 0 would show as 1970.
|
|
2
3
|
INSERT INTO posts (id, version, created_at, updated_at, title, body, status, published_at) VALUES
|
|
3
|
-
('home', 1,
|
|
4
|
-
('hello', 1,
|
|
5
|
-
('second', 1,
|
|
6
|
-
('draft', 1,
|
|
4
|
+
('home', 1, 1767225600000000, 1767225600000000, 'Posts', '# Posts — browse the HTML index for articles.', 'published', 1767225600000000),
|
|
5
|
+
('hello', 1, 1767225600000000, 1767225600000000, 'Hello <world>', '# Hello world', 'published', 1767225600000000),
|
|
6
|
+
('second', 1, 1767225600000000, 1767225600000000, 'Second post', '# Second post', 'published', 1767225600000000),
|
|
7
|
+
('draft', 1, 1767225600000000, 1767225600000000, 'Hidden draft', '# Draft', 'draft', NULL);
|
|
@@ -14,6 +14,13 @@ that have been waiting too long.
|
|
|
14
14
|
`change-amount`, `mark-ordered`, `confirm-order`). The column is `requestStatus` because `status` is reserved for
|
|
15
15
|
publishing Schemas. `requests` and `ledger` set `readOnly: true`, so Admin's generic editor cannot bypass the state
|
|
16
16
|
machine (for example by moving an ordered request back to `submitted` and approving it again).
|
|
17
|
+
- **A status chosen at submission**: `submit-request` uses `CASE WHEN input.amount <= 100 THEN 'approved' ELSE
|
|
18
|
+
'submitted' END`. A small request also spends the budget and writes its ledger row in the same program, so
|
|
19
|
+
`remaining >= 0` refuses all three when it would overspend. Those two statements find the request by
|
|
20
|
+
`created_at = now()` (the instant of this program) and `NOT EXISTS` a ledger row, so the threshold is written only in
|
|
21
|
+
the `CASE`. Larger requests wait for an admin. An auto-approved request sends no approval email, and the daily cron
|
|
22
|
+
orders it from the supplier (it already retries every `approved` request); route small requests through a `ref`
|
|
23
|
+
handler like `approve-request` if they must be ordered and announced at once.
|
|
17
24
|
- **Admin-only operations**: `approve-request` and `reject-request` declare `requires: ctx.admin` and a `target`, so
|
|
18
25
|
Admin shows them as operations on a request. Their `kind: mcp, surface: admin` Triggers make them
|
|
19
26
|
`approve_request` / `reject_request` tools on `/mcp/admin`. They are not listed on `/mcp`.
|
|
@@ -56,11 +63,13 @@ scheme.
|
|
|
56
63
|
- Need another approval flow? Copy the pattern: a ref Procedure (admin-only, side effects) that wraps a guarded SQL
|
|
57
64
|
write Procedure (one transaction).
|
|
58
65
|
|
|
59
|
-
##
|
|
66
|
+
## Run it
|
|
67
|
+
|
|
68
|
+
This runs the example by itself, to read and try. For your own app, run `mantle init` and take only the patterns you need into its manifests and `src/` (`mantle help examples`).
|
|
60
69
|
|
|
61
70
|
```sh
|
|
62
|
-
|
|
63
|
-
cp -R node_modules/@aotter/mantle/docs/examples/purchasing/. my-app/
|
|
71
|
+
npx mantle init my-app
|
|
72
|
+
cp -R node_modules/@aotter/mantle/docs/examples/purchasing/. my-app/ # run the example as is
|
|
64
73
|
cd my-app && pnpm install && pnpm generate && pnpm types && pnpm check
|
|
65
74
|
cp .dev.vars.example .dev.vars # then set SUPPLIER_API_URL and SUPPLIER_WEBHOOK_SECRET
|
|
66
75
|
pnpm db:init && pnpm db:seed && pnpm dev
|
|
@@ -9,6 +9,7 @@ spec:
|
|
|
9
9
|
uniqueIndexes: [[department]]
|
|
10
10
|
# An approval that would overspend fails this check and the whole approval rolls back.
|
|
11
11
|
checks: ["remaining >= 0"]
|
|
12
|
+
searchableFields: [department]
|
|
12
13
|
uiSchema: { list: { primaryField: department, columns: [remaining] } }
|
|
13
14
|
schema:
|
|
14
15
|
type: object
|
|
@@ -25,6 +26,7 @@ spec:
|
|
|
25
26
|
title: Purchase requests
|
|
26
27
|
lifecycle: operational
|
|
27
28
|
indexes: [[authorId, createdAt, id], [requestStatus, createdAt, id]]
|
|
29
|
+
searchableFields: [title, department]
|
|
28
30
|
# Sidebar: in the Approvals group (src/admin.tsx), after the queue of requests waiting for a decision.
|
|
29
31
|
uiSchema: { list: { primaryField: title, columns: [department, amount, requestStatus], filterField: requestStatus }, nav: { group: approvals, order: 2 } }
|
|
30
32
|
schema:
|
|
@@ -48,10 +50,13 @@ spec:
|
|
|
48
50
|
title: Budget ledger
|
|
49
51
|
lifecycle: operational
|
|
50
52
|
indexes: [[createdAt, id]]
|
|
53
|
+
# One row per spending request.
|
|
54
|
+
uniqueIndexes: [[requestId]]
|
|
55
|
+
searchableFields: [department]
|
|
51
56
|
uiSchema: { list: { primaryField: department, columns: [amount, remainingAfter, requestId] } }
|
|
52
57
|
schema:
|
|
53
58
|
type: object
|
|
54
|
-
readOnly: true # written only by approve-request-write
|
|
59
|
+
readOnly: true # written only by approve-request-write and submit-request
|
|
55
60
|
additionalProperties: false
|
|
56
61
|
required: [requestId, department, amount, remainingAfter]
|
|
57
62
|
properties:
|
|
@@ -117,7 +122,7 @@ kind: Procedure
|
|
|
117
122
|
metadata: { name: submit-request }
|
|
118
123
|
spec:
|
|
119
124
|
title: Submit a purchase request
|
|
120
|
-
description: Submit a purchase request against an existing department budget.
|
|
125
|
+
description: Submit a purchase request against an existing department budget; a request of 100 or less is approved at once and spends the budget.
|
|
121
126
|
requires: { auth: { all: [ctx.user] } }
|
|
122
127
|
input:
|
|
123
128
|
type: object
|
|
@@ -128,11 +133,26 @@ spec:
|
|
|
128
133
|
amount: { type: integer, minimum: 1 }
|
|
129
134
|
department: { type: string }
|
|
130
135
|
output: { type: object }
|
|
136
|
+
# One program. CASE picks the status: up to 100 is approved now, more waits for an admin. The budget and ledger
|
|
137
|
+
# statements then act only on "the request this program approved": the caller's, created by this program
|
|
138
|
+
# (created_at = now(), the same instant Mantle writes into created_at), approved, and with no ledger row yet. So the
|
|
139
|
+
# threshold lives only in the CASE, and remaining >= 0 refuses all three statements when a small request would
|
|
140
|
+
# overspend. A pending request matches nothing there, which a set form (IN (SELECT ...), INSERT ... SELECT) allows.
|
|
131
141
|
handler:
|
|
132
142
|
sql: |
|
|
133
143
|
INSERT INTO requests (title, amount, department, requestStatus)
|
|
134
|
-
SELECT input.title, input.amount, b.department,
|
|
135
|
-
|
|
144
|
+
SELECT input.title, input.amount, b.department, CASE WHEN input.amount <= 100 THEN 'approved' ELSE 'submitted' END
|
|
145
|
+
FROM budgets b WHERE b.department = input.department
|
|
146
|
+
RETURNING id, version, requestStatus AS "requestStatus";
|
|
147
|
+
UPDATE budgets SET remaining = remaining - (SELECT SUM(r.amount) FROM requests r WHERE r.department = budgets.department
|
|
148
|
+
AND r.author_id = auth.uid() AND r.created_at = now() AND r.requestStatus = 'approved'
|
|
149
|
+
AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id))
|
|
150
|
+
WHERE department IN (SELECT r.department FROM requests r WHERE r.author_id = auth.uid() AND r.created_at = now()
|
|
151
|
+
AND r.requestStatus = 'approved' AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id));
|
|
152
|
+
INSERT INTO ledger (requestId, department, amount, remainingAfter)
|
|
153
|
+
SELECT r.id, r.department, r.amount, b.remaining FROM requests r JOIN budgets b ON b.department = r.department
|
|
154
|
+
WHERE r.author_id = auth.uid() AND r.created_at = now() AND r.requestStatus = 'approved'
|
|
155
|
+
AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id)
|
|
136
156
|
---
|
|
137
157
|
# The requester may change the amount while the request is still 'submitted'. The version moves on, so an admin
|
|
138
158
|
# approving the version they reviewed before the change gets a 409 and looks again.
|
|
@@ -171,6 +171,20 @@ try {
|
|
|
171
171
|
assert.deepEqual(received.map(order => order.reference), [laptop, projector]);
|
|
172
172
|
assert.deepEqual(await statusAndOrder(projector), { status: "ordered", supplierOrderId: "SUP-2" });
|
|
173
173
|
step("daily cron retries the failed supplier order (system caller) and emails every admin about requests pending more than 3 days, and only those");
|
|
174
|
+
|
|
175
|
+
const small = await call("POST", "/api/requests", { cookie: bob, body: { title: "Pens", amount: 50, department: "Engineering" } });
|
|
176
|
+
assert.equal(small.status, 200, JSON.stringify(small.body));
|
|
177
|
+
const pens = small.body.results[0][0];
|
|
178
|
+
assert.equal(pens.requestStatus, "approved");
|
|
179
|
+
const spent = await remaining("Engineering");
|
|
180
|
+
assert.deepEqual((await ledgerRows()).filter(r => r.requestId === pens.id), [{ requestId: pens.id, amount: 50, remainingAfter: spent }]);
|
|
181
|
+
await query(`UPDATE budgets SET remaining = 20 WHERE department = 'Design'`);
|
|
182
|
+
const ledgerBefore = (await ledgerRows()).length, requestsBefore = (await query("SELECT count(*) AS n FROM requests"))[0].n;
|
|
183
|
+
assert.equal((await call("POST", "/api/requests", { cookie: alice, body: { title: "Tape", amount: 30, department: "Design" } })).status, 409);
|
|
184
|
+
assert.equal(await remaining("Design"), 20);
|
|
185
|
+
assert.equal((await ledgerRows()).length, ledgerBefore);
|
|
186
|
+
assert.equal((await query("SELECT count(*) AS n FROM requests"))[0].n, requestsBefore);
|
|
187
|
+
step("CASE approves a request of 100 or less at submission: the status, the budget and the ledger row commit together, and one that would overspend is refused with nothing written");
|
|
174
188
|
}, { vars: { EMPLOYEE_DOMAIN: "example.test", SUPPLIER_API_URL: `http://127.0.0.1:${supplier.address().port}/orders`, SUPPLIER_WEBHOOK_SECRET: SECRET } });
|
|
175
189
|
} finally {
|
|
176
190
|
supplier.close();
|
|
@@ -25,11 +25,13 @@ Search for `AGENT:` for what an app must still provide: layout and styling, cach
|
|
|
25
25
|
- Need members to see or edit only their own rows? Add club-events' `restrict: { recipes: "author_id = auth.uid()" }` on a View.
|
|
26
26
|
- Need review before publishing, or an AI that approves? Add purchasing's guarded status transitions and admin MCP.
|
|
27
27
|
|
|
28
|
-
##
|
|
28
|
+
## Run it
|
|
29
|
+
|
|
30
|
+
This runs the example by itself, to read and try. For your own app, run `mantle init` and take only the patterns you need into its manifests and `src/` (`mantle help examples`).
|
|
29
31
|
|
|
30
32
|
```sh
|
|
31
|
-
|
|
32
|
-
cp -R node_modules/@aotter/mantle/docs/examples/recipes/. my-app/
|
|
33
|
+
npx mantle init my-app
|
|
34
|
+
cp -R node_modules/@aotter/mantle/docs/examples/recipes/. my-app/ # run the example as is
|
|
33
35
|
cd my-app && pnpm install && pnpm generate && pnpm types && pnpm check
|
|
34
36
|
```
|
|
35
37
|
|
|
@@ -44,8 +44,11 @@ An existing S3 signer (for example an application already using aws4fetch) fits
|
|
|
44
44
|
this port. This package adds no AWS SDK. The signer must return a SigV4
|
|
45
45
|
query-signed PUT URL and preserve `X-Amz-Expires`; never give its credentials to
|
|
46
46
|
the client. Configure bucket CORS to allow PUT from the application's origins
|
|
47
|
-
and the returned Content-Type header.
|
|
48
|
-
|
|
47
|
+
and the returned Content-Type header. Uploads go to `uploads/<purpose>/<group>/<role>.<ext>` and each commit copies them
|
|
48
|
+
to `committed/<group>/<attempt>/<role>.<ext>`. Serve the `committed/` prefix on the public domain. Keep `uploads/`
|
|
49
|
+
private/unserved on the public domain and add a bucket lifecycle rule for abandoned upload keys. Mantle 0.1.x wrote
|
|
50
|
+
`<purpose>/<group>/<role>.<ext>` keys (`<group>/<role>.<ext>` when the purpose was not a plain slug); an asset reads its keys from the stored `storageKey` and `publicUrl` of each
|
|
51
|
+
variant, so 0.1.x assets keep working while their prefixes stay served.
|
|
49
52
|
|
|
50
53
|
Commit checks every object's MIME and size, then rechecks the exact GET
|
|
51
54
|
response used for the copy. Public keys cannot be reached by an upload URL. Each commit attempt has a separate
|
|
@@ -54,7 +57,7 @@ loser can neither overwrite nor remove the winner's objects. After SQL selects
|
|
|
54
57
|
the durable asset, the losing attempt removes only its own unselected copies.
|
|
55
58
|
Upload sources remain until the database commit succeeds, so an uncertain DB
|
|
56
59
|
write can be reconciled/retried with the same UUID. Failed object deletion keeps
|
|
57
|
-
the media row; retry the same id. An upload group expires after 15 minutes. Asset `createdAt` is the upload-group creation time
|
|
60
|
+
the media row; retry the same id. An upload group expires after 15 minutes. Asset `createdAt` (`media_assets.created_at`) is the upload-group creation time in epoch milliseconds, not the later commit time; entry timestamps on D1 are microseconds ([reserved entry columns](../reference/schema.md#reserved-entry-columns)).
|
|
58
61
|
No R2 upload or deployment is performed by generation.
|
|
59
62
|
|
|
60
63
|
A positively identified native rollback deletes only that attempt's copied
|
|
@@ -22,6 +22,8 @@ under `secrets.required`, run `pnpm exec wrangler types --include-runtime=false`
|
|
|
22
22
|
add `worker-configuration.d.ts` to `tsconfig.json` and commit it.
|
|
23
23
|
`@cloudflare/workers-types` still supplies runtime types. Rerun after every binding
|
|
24
24
|
change; `wrangler types --include-runtime=false --check` fails on drift.
|
|
25
|
+
Both run the custom build (`mantle bundle`), because Wrangler reads the built entry's exports
|
|
26
|
+
for Durable Object and service binding types; Wrangler has no option to skip it.
|
|
25
27
|
Once `secrets.required` is set, `wrangler dev` loads only those keys from
|
|
26
28
|
`.dev.vars`/`.env`, so declare every secret the app reads.
|
|
27
29
|
|
|
@@ -81,6 +83,9 @@ export default {
|
|
|
81
83
|
if (shell) return shell;
|
|
82
84
|
// Your public frontend/web.ts routes can use an anonymous Caller without
|
|
83
85
|
// loading auth; their generated SQL still controls publication visibility.
|
|
86
|
+
// Mounted here, after every Mantle surface, a root catch-all page (/:slug)
|
|
87
|
+
// cannot shadow /admin, /mcp or REST. Mount them earlier only behind a
|
|
88
|
+
// guard that skips those paths, as the app shell does.
|
|
84
89
|
return new Response('Not found', { status: 404, headers: { 'cache-control': 'no-store' } });
|
|
85
90
|
} catch (error) { return surfaceFailure(error); }
|
|
86
91
|
});
|
|
@@ -2,7 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
Schemas default to `operational`: rows are live immediately and edit in place. Opt-in `publishing` adds `status` (draft/published/archived), nullable `published_at` and a scope-aware `(published_at, id)` index.
|
|
4
4
|
|
|
5
|
-
Generated `<table>.publish`, `.unpublish` and `.archive` Procedures take `{ id, expectedVersion }`, require admin/system and perform one atomic UPDATE. Publish checks required fields, visibility, TTL, source state and OCC. A failed guard is `CONFLICT`. Store cannot write status/published_at directly. Admin
|
|
5
|
+
Generated `<table>.publish`, `.unpublish` and `.archive` Procedures take `{ id, expectedVersion }`, require admin/system and perform one atomic UPDATE. Publish checks required fields, visibility, TTL, source state and OCC. A failed guard is `CONFLICT`. Store cannot write status/published_at directly. By default Admin's generic patch edits drafts only (published and archived entries are unpublished first), and a published entry is unpublished before it is deleted; both are an implicit lock named `publishing` that Admin shows before the edit, and neither applies to the transitions. A Store update may edit a published row in place but cannot clear a field `publish` requires: setting one to null is `INPUT_VALIDATION_FAILED` for every caller. Fields it leaves alone are not re-checked, so adding a field to `required` later does not block updates of posts published before it.
|
|
6
|
+
|
|
7
|
+
To let admins correct a published entry in place, opt in with `publishing: { liveEdits: true }`. A personal blog needs nothing more than one Schema:
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
11
|
+
kind: Schema
|
|
12
|
+
metadata: { name: posts }
|
|
13
|
+
spec:
|
|
14
|
+
title: Posts
|
|
15
|
+
lifecycle: publishing
|
|
16
|
+
publishing: { liveEdits: true }
|
|
17
|
+
schema:
|
|
18
|
+
type: object
|
|
19
|
+
required: [title, body]
|
|
20
|
+
properties:
|
|
21
|
+
title: { type: string }
|
|
22
|
+
body: { type: string }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Admin then edits a published post with a "changes go live on save" notice. The save goes live on commit, `published_at` is kept and `updated_at` moves. The merged post must still pass `publish`'s checks, so emptying its title is refused with `INPUT_VALIDATION_FAILED`. An archived post stays draft-only: restore it to edit it. Deleting a published post still requires unpublishing, and [declared locks](../reference/schema.md#locks) still apply. `publishing` is refused on an operational Schema, which already edits in place. To prepare changes to published content without exposing them, split it into a publishing parent and operational revisions instead ([edit published content without downtime](../guides/revisions.md)).
|
|
6
26
|
|
|
7
27
|
Public Views see published rows only. Other reads expose drafts only to admin/system. This policy applies to joins and nested queries too.
|
|
8
28
|
|
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
|
|
3
3
|
Use the [Worker quickstart](../start/quickstart-worker.md) for a complete native manifest and entry, [typed queries](../guides/typed-queries.md) for embedding, and [service composition](../cloudflare/service-entry.md) for optional surfaces.
|
|
4
4
|
|
|
5
|
-
The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.
|
|
5
|
+
The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.1-alpha.1/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
|
|
@@ -6,7 +6,7 @@ Schema `uiSchema` selects fields, widgets, list columns, enum tabs and child nav
|
|
|
6
6
|
|
|
7
7
|
Admin Views provide custom read lists; their SELECT outputs, column lineage and `uiSchema.list` define display/search/filter metadata. Procedures and MCP admin Triggers define operations with declared authorization, input checks and optional row targets. A hidden button never authorizes an operation.
|
|
8
8
|
|
|
9
|
-
Generic publishing edits apply to drafts; publishing transitions retain their atomic guards and OCC. Read-only collections refuse generic edits while declared Procedures can still run. Ref operations receive the caller/transaction-bound Store.
|
|
9
|
+
Generic publishing edits apply to drafts unless the Schema sets `publishing: { liveEdits: true }` ([lifecycle](../concepts/lifecycle-and-locales.md)); publishing transitions retain their atomic guards and OCC. Read-only collections refuse generic edits while declared Procedures can still run. Ref operations receive the caller/transaction-bound Store.
|
|
10
10
|
|
|
11
11
|
Use [`defineAdmin` components](native-admin.md#customising-admin) for app-owned cells, inputs, record asides and actions; server work is a Procedure, a View or an application route. Keep exact conflict/uncertain-outcome handling in the shared UI controller.
|
|
12
12
|
|