@aotter/mantle 0.2.0-alpha.7 → 0.2.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -5
- package/dist/auth/adapter.js +9 -6
- package/dist/auth/caller.js +2 -1
- package/dist/auth/identity.d.ts +2 -2
- package/dist/auth/oauth.d.ts +33 -1
- package/dist/auth/oauth.js +121 -28
- package/dist/auth/routes.js +6 -4
- package/dist/build/compiler.d.ts +44 -122
- package/dist/build/compiler.js +202 -277
- package/dist/build/spec/domain/model/ManifestGrammar.d.ts +6 -5
- package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
- package/dist/build/spec/domain/service/PlanFingerprint.d.ts +2 -0
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/domain/describe/{chunk-ZIQVLA7X.js → chunk-ZVXBGP7Y.js} +69 -8
- package/dist/domain/describe/index.js +1 -1
- package/dist/domain/describe/target-hash.js +1 -1
- package/dist/domain/describe/targets.d.ts +1 -1
- package/dist/domain/generated-tools.js +2 -0
- package/dist/domain/paging.js +41 -15
- package/dist/infrastructure/bundle.js +118 -15
- package/dist/infrastructure/cli.js +11 -15
- package/dist/infrastructure/generate.d.ts +1 -3
- package/dist/infrastructure/generate.js +11 -3
- package/dist/infrastructure/generateAuth.js +57 -0
- package/dist/infrastructure/inputs.js +8 -3
- package/dist/infrastructure/wrangler.js +50 -12
- package/dist/kernel/binds.d.ts +0 -3
- package/dist/kernel/contracts.d.ts +1 -1
- package/dist/kernel/contracts.js +4 -4
- package/dist/kernel/dialect/common.d.ts +0 -1
- package/dist/kernel/drain.d.ts +5 -0
- package/dist/kernel/drain.js +35 -0
- package/dist/kernel/driver/postgres.js +6 -2
- package/dist/kernel/host-caller.d.ts +1 -0
- package/dist/kernel/host-caller.js +1 -0
- package/dist/kernel/mcp-apps.d.ts +29 -9
- package/dist/kernel/mcp-apps.js +3 -3
- package/dist/kernel/mcp-handler.js +101 -18
- package/dist/kernel/mcp.d.ts +12 -3
- package/dist/kernel/mcp.js +31 -6
- package/dist/kernel/rest-router.js +3 -2
- package/dist/kernel/scalars.js +3 -2
- package/dist/kernel/site.d.ts +1 -3
- package/dist/kernel/site.js +2 -2
- package/dist/kernel/web-index.js +40 -0
- package/dist/kernel/web.d.ts +3 -1
- package/dist/kernel/web.js +6 -37
- package/dist/usecase/adminBindings.js +2 -3
- package/dist/usecase/adminClient.js +87 -0
- package/dist/usecase/adminDescriptors.js +19 -65
- package/dist/usecase/generate.js +5 -4
- package/dist/usecase/mcp-media.js +2 -2
- package/dist/usecase/mcp.js +16 -12
- package/dist/usecase/paging.js +2 -3
- package/dist/usecase/publishing.js +3 -3
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/tool-version.js +9 -0
- package/dist/usecase/translations.js +1 -1
- package/dist/usecase/validation.js +128 -19
- package/dist/usecase/web.js +3 -2
- package/docs/examples/README.md +1 -1
- package/docs/examples/app-shell/README.md +45 -3
- package/docs/examples/app-shell/package.json +14 -6
- package/docs/examples/app-shell/smoke-kit.mjs +54 -0
- package/docs/examples/app-shell/src/auth.ts +6 -3
- package/docs/examples/app-shell/src/service.ts +3 -3
- package/docs/examples/app-shell/tsconfig.json +4 -1
- package/docs/examples/app-shell/worker-configuration.d.ts +2 -2
- package/docs/examples/app-shell/wrangler.jsonc +5 -4
- package/docs/examples/club-events/manifests/club-events.yaml +7 -0
- package/docs/examples/club-events/src/handlers.ts +1 -1
- package/docs/examples/generated-web/README.md +6 -2
- package/docs/examples/generated-web/wrangler.jsonc +1 -0
- package/docs/examples/purchasing/README.md +1 -1
- package/docs/examples/purchasing/manifests/purchasing.yaml +17 -1
- package/docs/examples/purchasing/smoke.mjs +10 -4
- package/docs/examples/purchasing/src/admin.tsx +13 -0
- package/docs/examples/purchasing/src/handlers.ts +1 -1
- package/docs/examples/purchasing/wrangler.jsonc +5 -4
- package/docs/examples/recipes/wrangler.jsonc +5 -4
- package/docs/handbook/cloudflare/authentication.md +43 -6
- package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
- package/docs/handbook/cloudflare/deploy-and-operate.md +45 -1
- package/docs/handbook/cloudflare/media-r2.md +2 -3
- package/docs/handbook/cloudflare/service-entry.md +68 -31
- package/docs/handbook/concepts/authorization.md +1 -1
- package/docs/handbook/concepts/mcp-and-agents.md +19 -0
- package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/admin-ui.md +107 -1
- package/docs/handbook/guides/agent-setup.md +27 -9
- package/docs/handbook/guides/agent-workflow-acceptance.md +222 -0
- package/docs/handbook/guides/mcp-apps.md +522 -6
- package/docs/handbook/guides/native-admin.md +300 -51
- package/docs/handbook/guides/oauth.md +119 -5
- package/docs/handbook/guides/public-pages.md +2 -2
- package/docs/handbook/guides/size-and-startup.md +1 -1
- package/docs/handbook/guides/typed-queries.md +35 -0
- package/docs/handbook/navigation.json +0 -4
- package/docs/handbook/reference/admin-descriptors.md +0 -35
- package/docs/handbook/reference/procedure.md +6 -2
- package/docs/handbook/reference/schema.md +13 -3
- package/docs/handbook/reference/surface.md +129 -15
- package/docs/handbook/reference/trigger.md +43 -1
- package/docs/handbook/reference/view.md +1 -1
- package/docs/handbook/releases/index.md +74 -2
- package/docs/handbook/start/overview.md +1 -1
- package/docs/handbook/start/project-and-cli.md +16 -8
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +46 -18
- package/docs/skills/integrate/SKILL.md +4 -9
- package/docs/skills/media-gc/SKILL.md +7 -5
- package/docs/skills/provision/SKILL.md +18 -12
- package/docs/skills/update/SKILL.md +19 -18
- package/docs/upgrade-0.1-to-0.2.md +6 -6
- package/package.json +2 -1
- package/skills/README.md +11 -16
- package/skills/mantle/SKILL.md +39 -17
- package/skills/mantle/references/local-testing.md +9 -0
- package/skills/mantle/references/web.md +1 -1
- package/skills/mantle/references/website.md +26 -22
- package/docs/examples/app-shell/src/admin.ts +0 -10
- package/docs/examples/purchasing/src/admin.ts +0 -12
- package/docs/handbook/guides/admin-extensions.md +0 -20
- package/docs/skills/develop/mcp-app.md +0 -39
- package/skills/.keep +0 -0
|
@@ -1,7 +1,98 @@
|
|
|
1
|
-
|
|
1
|
+
/** Compile boundary checks into each operation; no schema enters the request bundle. */
|
|
2
|
+
import { INPUT_RULE_KEYWORDS, lookupLocalRef as lookup, rootRefChain } from "../build/compiler.js";
|
|
3
|
+
import { literal, q } from "../domain/entry.js";
|
|
2
4
|
import { publishingInput } from "./publishing.js";
|
|
5
|
+
import { equal } from "../kernel/validation.js";
|
|
3
6
|
const pointer = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
4
|
-
const supported = new Set("$defs $ref $schema $id type nullable properties required additionalProperties items enum 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(" "));
|
|
7
|
+
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
|
+
const shapes = "see docs/handbook/reference/surface.md";
|
|
9
|
+
// YAML .inf and .nan parse to numbers that JSON writes as null.
|
|
10
|
+
const finite = (value) => typeof value === "number" ? Number.isFinite(value) : !!value && typeof value === "object" ? Object.values(value).every(finite) : true;
|
|
11
|
+
const has = (key) => `Object.hasOwn(object,${q(key)})`;
|
|
12
|
+
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
|
+
function rules(schema) {
|
|
18
|
+
const declared = (names) => Array.isArray(names) && names.length > 0 && names.every(name => typeof name === "string" && Object.hasOwn(schema.properties ?? {}, name));
|
|
19
|
+
const list = (names) => names.map(name => `'${name}'`).join(" and ");
|
|
20
|
+
const found = [], { dependentRequired: dependent, anyOf, if: condition, then, else: otherwise } = schema;
|
|
21
|
+
if (dependent !== undefined) {
|
|
22
|
+
if (!dependent || typeof dependent !== "object" || Array.isArray(dependent) || !declared(Object.keys(dependent)) || !Object.values(dependent).every(declared))
|
|
23
|
+
return `dependentRequired maps declared properties to lists of declared properties; ${shapes}.`;
|
|
24
|
+
for (const [key, names] of Object.entries(dependent))
|
|
25
|
+
for (const name of names)
|
|
26
|
+
found.push({ when: `${has(key)} && !${has(name)}`, key: name, message: `'${name}' is required when '${key}' is present.` });
|
|
27
|
+
}
|
|
28
|
+
if (anyOf !== undefined) {
|
|
29
|
+
if (!Array.isArray(anyOf) || !anyOf.length || !anyOf.every(branch => exactly(branch, "required") && declared(branch.required)))
|
|
30
|
+
return `anyOf is supported only as branches of exactly { required: [declared properties] }; ${shapes}.`;
|
|
31
|
+
const branches = anyOf.map(branch => branch.required);
|
|
32
|
+
found.push({ when: `!(${branches.map(names => names.map(has).join(" && ")).join(" || ")})`, message: `At least one of ${branches.map(names => names.length > 1 ? `(${list(names)})` : list(names)).join(", ")} is required.` });
|
|
33
|
+
}
|
|
34
|
+
if (condition !== undefined || then !== undefined || otherwise !== undefined) {
|
|
35
|
+
const [key] = exactly(condition, "properties required") ? Object.keys(condition.properties) : [];
|
|
36
|
+
const value = key === undefined ? undefined : condition.properties[key];
|
|
37
|
+
// Without required: [key], an absent key satisfies `if`.
|
|
38
|
+
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
|
+
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
|
+
if (then === undefined && otherwise === undefined || [then, otherwise].some(branch => branch !== undefined && !(exactly(branch, "required") && declared(branch.required))))
|
|
41
|
+
return `then and else are supported only as exactly { required: [declared properties] } after an if; ${shapes}.`;
|
|
42
|
+
const met = `${has(key)} && object[${q(key)}] === ${q(value.const)}`;
|
|
43
|
+
for (const name of then?.required ?? [])
|
|
44
|
+
found.push({ when: `${met} && !${has(name)}`, key: name, message: `'${name}' is required when '${key}' is ${q(value.const)}.` });
|
|
45
|
+
for (const name of otherwise?.required ?? [])
|
|
46
|
+
found.push({ when: `!(${met}) && !${has(name)}`, key: name, message: `'${name}' is required unless '${key}' is ${q(value.const)}.` });
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
/** Whether an input schema accepts `{}`: a list View is read with no input, so web indexes need this. */
|
|
51
|
+
export function acceptsEmpty(input) {
|
|
52
|
+
if (input === undefined)
|
|
53
|
+
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
|
+
return rootRefChain(input)?.every(schema => !schema.required?.length && schema.anyOf === undefined && !schema.else?.required?.length
|
|
57
|
+
&& (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
|
+
}
|
|
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
|
+
export function advertised(input, description) {
|
|
64
|
+
if (!input)
|
|
65
|
+
return { inputSchema: input, description };
|
|
66
|
+
const sentences = [], copy = JSON.parse(q(input));
|
|
67
|
+
const strip = (node, at) => {
|
|
68
|
+
if (!node || typeof node !== "object")
|
|
69
|
+
return;
|
|
70
|
+
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
|
+
const branches = Array.isArray(schema.allOf) ? schema.allOf : [];
|
|
73
|
+
for (const branch of [schema, ...branches.map(branch => ({ ...branch, properties: schema.properties }))]) {
|
|
74
|
+
const found = rules(branch);
|
|
75
|
+
if (typeof found !== "string")
|
|
76
|
+
for (const rule of found)
|
|
77
|
+
sentences.push(at ? `In '${at}': ${rule.message}` : rule.message);
|
|
78
|
+
}
|
|
79
|
+
if (branches.length && branches.every(branch => Object.keys(branch).every(key => INPUT_RULE_KEYWORDS.includes(key))))
|
|
80
|
+
delete schema.allOf;
|
|
81
|
+
for (const keyword of INPUT_RULE_KEYWORDS)
|
|
82
|
+
delete schema[keyword];
|
|
83
|
+
for (const [key, child] of Object.entries((schema.properties ?? {})))
|
|
84
|
+
strip(child, `${at}/${key}`);
|
|
85
|
+
for (const [key, child] of Object.entries((schema.$defs ?? {})))
|
|
86
|
+
strip(child, `#/$defs/${key}`);
|
|
87
|
+
strip(schema.items, `${at}/*`);
|
|
88
|
+
strip(schema.additionalProperties, `${at}/*`);
|
|
89
|
+
};
|
|
90
|
+
strip(copy, "");
|
|
91
|
+
if (!sentences.length)
|
|
92
|
+
return { inputSchema: input, description };
|
|
93
|
+
const text = sentences.join(" ");
|
|
94
|
+
return { inputSchema: copy, description: typeof description === "string" ? `${description} ${text}` : Object.fromEntries(Object.entries(description).map(([locale, value]) => [locale, `${value} ${text}`])) };
|
|
95
|
+
}
|
|
5
96
|
const bounds = [
|
|
6
97
|
["string", "[...value].length", { minLength: "<", maxLength: ">" }],
|
|
7
98
|
["number", "value", { minimum: "<", maximum: ">", exclusiveMinimum: "<=", exclusiveMaximum: ">=" }],
|
|
@@ -23,21 +114,13 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
|
|
|
23
114
|
// Only a local `$ref`, or more than 100 nested objects and arrays, can reach the nesting limit: only then is `depth` passed.
|
|
24
115
|
const recursive = json.includes('"$ref"') || json.split(/[[{]/).length > 101, depth = recursive ? ",depth+1" : "";
|
|
25
116
|
// Identical bodies share one function.
|
|
26
|
-
const nodes = new Map(), bodies = new Map(), functions = [], formats = new Set();
|
|
117
|
+
const nodes = new Map(), bodies = new Map(), functions = [], formats = new Set(), patterns = new Map();
|
|
27
118
|
let equality = false;
|
|
28
119
|
const resolve = (ref) => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
for (const key of decodeURIComponent(ref.slice(2)).split("/").map(key => key.replace(/~1/g, "/").replace(/~0/g, "~")))
|
|
34
|
-
value = value && typeof value === "object" && Object.hasOwn(value, key) ? value[key] : undefined;
|
|
35
|
-
if (value && typeof value === "object")
|
|
36
|
-
return value;
|
|
37
|
-
}
|
|
38
|
-
catch { }
|
|
39
|
-
refuse(base, "Input ref must be a valid local JSON pointer.");
|
|
40
|
-
return {};
|
|
120
|
+
const found = lookup(root, ref);
|
|
121
|
+
if (!found)
|
|
122
|
+
refuse(base, "Input ref must be a valid local JSON pointer.");
|
|
123
|
+
return found ?? {};
|
|
41
124
|
};
|
|
42
125
|
const emit = (schema) => {
|
|
43
126
|
if (nodes.has(schema))
|
|
@@ -63,9 +146,25 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
|
|
|
63
146
|
const only = types.length === 1 ? types[0] : undefined;
|
|
64
147
|
const guard = (type, test = `(${condition(type)})`) => only === type || type === "number" && only === "integer" ? "" : test + " && ";
|
|
65
148
|
if (schema.enum) {
|
|
149
|
+
if (!finite(schema.enum))
|
|
150
|
+
refuse(base, "Input enum numbers must be finite; JSON has no Infinity or NaN.");
|
|
151
|
+
equality = true;
|
|
152
|
+
check(`!${literal(schema.enum)}.some(option=>equal(value,option))`, "Value is not in enum.");
|
|
153
|
+
}
|
|
154
|
+
if (schema.const !== undefined) {
|
|
155
|
+
if (!finite(schema.const))
|
|
156
|
+
refuse(base, "Input const numbers must be finite; JSON has no Infinity or NaN.");
|
|
66
157
|
equality = true;
|
|
67
|
-
check(
|
|
158
|
+
check(`!equal(value,${literal(schema.const)})`, "Value violates const.");
|
|
68
159
|
}
|
|
160
|
+
for (const key of ["minLength", "maxLength", "minItems", "maxItems"])
|
|
161
|
+
if (schema[key] !== undefined && (!Number.isSafeInteger(schema[key]) || schema[key] < 0))
|
|
162
|
+
refuse(base, `${key} must be a non-negative safe integer.`);
|
|
163
|
+
for (const key of ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"])
|
|
164
|
+
if (schema[key] !== undefined && (typeof schema[key] !== "number" || !Number.isFinite(schema[key])))
|
|
165
|
+
refuse(base, `${key} must be a finite number.`);
|
|
166
|
+
if (schema.multipleOf !== undefined && (typeof schema.multipleOf !== "number" || !Number.isFinite(schema.multipleOf) || schema.multipleOf <= 0))
|
|
167
|
+
refuse(base, "multipleOf must be a positive finite number.");
|
|
69
168
|
for (const [type, measure, keywords] of bounds)
|
|
70
169
|
for (const [key, operator] of Object.entries(keywords))
|
|
71
170
|
if (schema[key] !== undefined)
|
|
@@ -79,13 +178,17 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
|
|
|
79
178
|
catch {
|
|
80
179
|
refuse(base, "Input pattern is not a valid regular expression.");
|
|
81
180
|
}
|
|
82
|
-
|
|
181
|
+
if (!patterns.has(schema.pattern))
|
|
182
|
+
patterns.set(schema.pattern, `pattern${patterns.size}`);
|
|
183
|
+
check(`${guard("string", 'typeof value === "string"')}!${patterns.get(schema.pattern)}.test(value)`, "String does not match pattern.");
|
|
83
184
|
}
|
|
84
185
|
if (schema.format) {
|
|
85
186
|
if (schema.format === "email")
|
|
86
187
|
check(`${guard("string", 'typeof value === "string"')}!/^[^\\s@]+@[^\\s@]+$/u.test(value)`, "Expected an email with one @, non-empty local and domain parts, and no whitespace.");
|
|
188
|
+
else if (schema.format === "uri")
|
|
189
|
+
lines.push(`${only === "string" ? "" : 'if (typeof value === "string") '}{ let url: URL | undefined; try { url = new URL(value); } catch {} if (url?.protocol !== "http:" && url?.protocol !== "https:") fail(path, "Expected an absolute http or https URL."); }`);
|
|
87
190
|
else if (!["date", "date-time"].includes(schema.format))
|
|
88
|
-
refuse(base, `Generated input validation does not support format '${schema.format}'.`);
|
|
191
|
+
refuse(base, `Generated input validation does not support format '${schema.format}'; ${shapes} for the supported formats.`);
|
|
89
192
|
else {
|
|
90
193
|
const scalar = schema.format === "date" ? "encodeDate" : "encodeTimestamptz";
|
|
91
194
|
formats.add(scalar);
|
|
@@ -105,6 +208,12 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
|
|
|
105
208
|
object.push(`if (Object.hasOwn(object,${q(key)})) ${fn}(object[${q(key)}],path+${q("/" + pointer(key))}${depth});`);
|
|
106
209
|
if (schema.additionalProperties === false || typeof schema.additionalProperties === "object")
|
|
107
210
|
object.push(`for (const key of Object.keys(object)) if (!(${q(Object.keys(schema.properties ?? {}))} as readonly string[]).includes(key)) { const childPath = path+"/"+key.replace(/~/g,"~0").replace(/\\//g,"~1"); ${schema.additionalProperties === false ? 'fail(childPath,"Unknown field.");' : `${emit(schema.additionalProperties)}(object[key],childPath${depth});`} }`);
|
|
211
|
+
const checked = rules(schema);
|
|
212
|
+
if (typeof checked === "string")
|
|
213
|
+
refuse(base, checked);
|
|
214
|
+
else
|
|
215
|
+
for (const rule of checked)
|
|
216
|
+
object.push(`if (${rule.when}) fail(path${rule.key === undefined ? "" : `+${q("/" + pointer(rule.key))}`}, ${q(rule.message)});`);
|
|
108
217
|
if (object.length)
|
|
109
218
|
lines.push(`${only === "object" ? "" : `if (${condition("object")}) `}{ const object = value as Record<string,unknown>;\n${object.join("\n")}\n}`);
|
|
110
219
|
const body = lines.join("\n");
|
|
@@ -123,7 +232,7 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
|
|
|
123
232
|
return type === "boolean" || type === "integer" || type === "number" ? [key, type] : [key];
|
|
124
233
|
}) : undefined;
|
|
125
234
|
const helpers = [...(functions.length ? ["fail"] : []), ...(equality ? ["equal"] : []), ...(fields ? ["query as read"] : [])];
|
|
126
|
-
const code = `${helpers.length ? `import { ${helpers.join(", ")} } from "@aotter/mantle/validation";\n` : ""}${formats.size ? `import { ${[...formats].join(", ")} } from "@aotter/mantle/scalars";\n` : ""}${functions.join("\n")}
|
|
235
|
+
const code = `${helpers.length ? `import { ${helpers.join(", ")} } from "@aotter/mantle/validation";\n` : ""}${formats.size ? `import { ${[...formats].join(", ")} } from "@aotter/mantle/scalars";\n` : ""}${[...patterns].map(([pattern, name]) => `const ${name} = new RegExp(${q(pattern)});\n`).join("")}${functions.join("\n")}
|
|
127
236
|
export function validate(raw:unknown):Record<string,unknown> { ${validate}(raw,${q(base)}${recursive ? ",0" : ""}); return raw as Record<string,unknown>; }
|
|
128
237
|
${fields ? `export const query = (params:URLSearchParams) => read(params, validate, ${q(`${kind}/${name}`)}, ${q(fields)});\n` : ""}`;
|
|
129
238
|
files[file] = code;
|
package/dist/usecase/web.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { databaseImport, q } from "../domain/entry.js";
|
|
2
2
|
import { authLines, conversions } from "../domain/input.js";
|
|
3
|
+
import { acceptsEmpty } from "./validation.js";
|
|
3
4
|
export function emitWeb(plan, lowered, target) {
|
|
4
5
|
const views = Object.entries(plan.views).filter(([, v]) => v.surface === "public");
|
|
5
6
|
const table = views.map(([name, v], i) => {
|
|
6
7
|
const binds = lowered.views[name]?.public?.binds ?? [], convert = conversions(v.input?.properties, binds, target);
|
|
7
8
|
const booleans = Object.keys(v.input?.properties ?? {}).filter(field => binds.some(b => b.k === "input" && String(b.name).toLowerCase() === field.toLowerCase() && String(b.type) === "bool"));
|
|
8
9
|
const check = authLines(v.requires, `views/${name}`, binds, !target.endsWith("postgres"));
|
|
9
|
-
return `[${q(name)}, { ${check.length ? `check: (caller) => {${check.join("")}}, ` : ""}query: input${i}, page: async (db, caller, input, paging) => (await import("./paged/views/${encodeURIComponent(name)}.js")).default(db, caller, input, paging)${convert ? `, convert: ${convert}` : ""}${booleans.length ? `, booleans: ${q(booleans)}` : ""} }]`;
|
|
10
|
+
return `[${q(name)}, { ${check.length ? `check: (caller) => {${check.join("")}}, ` : ""}${acceptsEmpty(v.input) ? "" : "unlisted: true, "}query: input${i}, page: async (db, caller, input, paging) => (await import("./paged/views/${encodeURIComponent(name)}.js")).default(db, caller, input, paging)${convert ? `, convert: ${convert}` : ""}${booleans.length ? `, booleans: ${q(booleans)}` : ""} }]`;
|
|
10
11
|
});
|
|
11
12
|
return { "web.ts": [
|
|
12
13
|
...(plan.access ? ['import { userBinds } from "@aotter/mantle/user-fields";'] : []),
|
|
@@ -14,7 +15,7 @@ export function emitWeb(plan, lowered, target) {
|
|
|
14
15
|
...views.flatMap(([n], i) => [`import { query as input${i} } from "./validation/views/${encodeURIComponent(n)}.js";`, `import type { Row as Row${i} } from "./views/${encodeURIComponent(n)}.js";`]),
|
|
15
16
|
'import { web as site, type Options, type Page } from "@aotter/mantle/web";',
|
|
16
17
|
databaseImport(target),
|
|
17
|
-
`type App = { Rows: {${views.map(([n], i) => `${q(n)}:Row${i}`).join(";")}}; Inputs: {${views.map(([n, v]) => `${q(n)}:${Object.keys(v.input?.properties ?? {}).map(k => q(k)).join(" | ") || "never"}`).join(";")}}; List: ${views.filter(([, v]) =>
|
|
18
|
+
`type App = { Rows: {${views.map(([n], i) => `${q(n)}:Row${i}`).join(";")}}; Inputs: {${views.map(([n, v]) => `${q(n)}:${Object.keys(v.input?.properties ?? {}).map(k => q(k)).join(" | ") || "never"}`).join(";")}}; List: ${views.filter(([, v]) => acceptsEmpty(v.input)).map(([n]) => q(n)).join(" | ") || "never"} };`,
|
|
18
19
|
"export function web<const P extends Record<string, Page<App>>, E = unknown>(options: Options<App, Database, P, E>) {",
|
|
19
20
|
` return site<Database, E>(${plan.access ? `{...options, fields: ${q(plan.access.userFields)}, accessTargets: () => import("./access-targets.js").then(m => m.targets)}` : '{...options, accessTargets: () => import("./access-targets.js").then(m => m.targets)}'}, [${table.join(", ")}]);`,
|
|
20
21
|
"}"
|
package/docs/examples/README.md
CHANGED
|
@@ -31,5 +31,5 @@ shell's allow-all `src/signup.ts`, unless sign-up should be invite-only. Purchas
|
|
|
31
31
|
|
|
32
32
|
Every example uses two roles: `admin` (the admin layer) and `user`.
|
|
33
33
|
|
|
34
|
-
[Reference service](
|
|
34
|
+
[Reference service](https://github.com/aotter/mantle/tree/v0.2.0-alpha.9/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
|
|
35
35
|
Projects on 0.1.x move with [the upgrade guide](../upgrade-0.1-to-0.2.md).
|
|
@@ -20,6 +20,11 @@ pnpm dev # one-time codes print to this log
|
|
|
20
20
|
pnpm smoke # when the copied example has smoke.mjs
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
npm works too (`npm install`, `npm run …`). `package.json` pins `@better-auth/core` and `@better-auth/utils` even though
|
|
24
|
+
the shell does not import them: they are the exact versions the SDK's Better Auth peers require, and without them a fresh
|
|
25
|
+
install resolves newer ones (npm ERESOLVE, or a second Better Auth type tree under pnpm). Keep every Better Auth package
|
|
26
|
+
on one version.
|
|
27
|
+
|
|
23
28
|
## Files you edit
|
|
24
29
|
|
|
25
30
|
| File | What it is for |
|
|
@@ -27,15 +32,16 @@ pnpm smoke # when the copied example has smoke.mjs
|
|
|
27
32
|
| `manifests/*.yaml` | Your Schemas, Views, Procedures and Triggers. `pnpm generate` compiles every file here. |
|
|
28
33
|
| `src/handlers.ts` | Code for each `handler: { ref }` Procedure. The Store it receives is bound to the same caller. |
|
|
29
34
|
| `src/routes.ts` | App-owned routes: visitor pages, SEO files, webhooks and callbacks. |
|
|
30
|
-
| `src/admin.
|
|
35
|
+
| `src/admin.tsx` | Optional, not shipped: the Admin entry (`defineAdmin(admin, { nav, pages, home, components, plugins, mcp })`) `mantle bundle` builds instead of the built-in one. Add it to group the sidebar or replace Admin regions; see docs/handbook/guides/native-admin.md. |
|
|
31
36
|
| `src/sign-in.ts` | Member sign-in page at `/sign-in` (email code, then back to `?return=`) and `signInRedirect(request)` for pages that need a signed-in visitor. Unstyled; delete it if the app has no member pages. |
|
|
32
37
|
| `src/schedule.ts` | Cron work. Add the cron to `wrangler.jsonc` `triggers.crons`. |
|
|
33
38
|
| `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. |
|
|
34
39
|
| `src/auth.ts` | Better Auth options. Two roles only: `admin` (the admin layer) and `user`. Replace the console OTP sender before deploying. |
|
|
35
40
|
| `smoke.mjs` | Your acceptance steps on `smoke-kit.mjs` (fresh local D1, OTP sign-in per user, REST/MCP/Admin calls). |
|
|
36
41
|
|
|
37
|
-
`src/service.ts` and `src/index.ts` rarely change.
|
|
38
|
-
|
|
42
|
+
`src/service.ts` and `src/index.ts` rarely change. The default shell mounts REST and MCP together. If the app deliberately
|
|
43
|
+
chooses asymmetric exposure, delete the blocks marked `MCP / OAuth (deliberate exception)` in `auth.ts` and `service.ts`;
|
|
44
|
+
HTTP-only Procedures will keep generating with a warning.
|
|
39
45
|
|
|
40
46
|
Search for `AGENT:` to find what an app must fill in.
|
|
41
47
|
|
|
@@ -46,3 +52,39 @@ Search for `AGENT:` to find what an app must fill in.
|
|
|
46
52
|
- `ADMIN_EMAIL`'s first verified sign-in becomes `admin` while no admin exists. The sign-in response may still show
|
|
47
53
|
the previous role; `/admin/api/me` shows the current one.
|
|
48
54
|
- Admin HTTP routes are listed in `docs/handbook/guides/native-admin.md`.
|
|
55
|
+
|
|
56
|
+
Scripted smoke is not independent acceptance; before reporting a user-facing
|
|
57
|
+
deliverable done, follow the [independent acceptance guide](../../handbook/guides/agent-workflow-acceptance.md).
|
|
58
|
+
|
|
59
|
+
### MCP acceptance
|
|
60
|
+
|
|
61
|
+
Generation declares tools; the app owns whether and where a remote endpoint is mounted.
|
|
62
|
+
In your `smoke.mjs`, reuse the generated static tool names rather than maintaining a second list:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
import assert from "node:assert/strict";
|
|
66
|
+
import { assertMcpDiscovery, smoke } from "./smoke-kit.mjs";
|
|
67
|
+
import { developerConsole } from "./generated/admin-descriptors.ts";
|
|
68
|
+
|
|
69
|
+
await smoke(async ({ origin, owner, signIn, mcp }) => {
|
|
70
|
+
const cookie = await signIn(owner);
|
|
71
|
+
const tools = developerConsole.interfaces.callable.filter(tool => tool.surface === "admin");
|
|
72
|
+
await assertMcpDiscovery(`${origin}/mcp/admin`, { tools, headers: { cookie, origin } });
|
|
73
|
+
// Denial is a separate assertion, not successful discovery.
|
|
74
|
+
assert.equal((await mcp("/mcp/admin")).status, 401);
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Node 22.22+ can read this pure static
|
|
79
|
+
TypeScript descriptor directly; no generated request handlers are executed to derive expected names.
|
|
80
|
+
A TypeScript smoke scenario can instead import `tools` from generated `mcp.ts`.
|
|
81
|
+
Explicitly select the permitted subset if the app narrows authorization, and include any app-composed tools.
|
|
82
|
+
The assertion initializes MCP, forwards supplied identity/session headers, reads all tools/list pages,
|
|
83
|
+
and compares exact names without calling business tools. JSON/SSE protocol failures, missing routes,
|
|
84
|
+
unexpected/missing tools and repeated/excessive pagination fail; it never obtains credentials or mounts routes.
|
|
85
|
+
|
|
86
|
+
This local cookie test does not prove OAuth Bearer or an external client's connection. Test those separately
|
|
87
|
+
with supplied credentials and record their results. For each declared interface, the existing handoff/README/test
|
|
88
|
+
report must say verified (command, identity and result), intentionally not exposed, or deferred/unverified with a
|
|
89
|
+
reason. WebMCP-only applications remain valid; an interrupted preview must retain the remote acceptance item
|
|
90
|
+
as deferred rather than silently claiming completion.
|
|
@@ -3,28 +3,36 @@
|
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"generate": "mantle generate cloudflare-d1 generated manifests/*.yaml --auth src/auth.ts --entry src/index.ts --admin
|
|
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
8
|
"db:init": "wrangler d1 execute mantle-app --local --file generated/schema.sql",
|
|
9
9
|
"db:seed": "wrangler d1 execute mantle-app --local --file seed.sql",
|
|
10
|
+
"build": "mantle bundle src/index.ts dist",
|
|
11
|
+
"test": "node --test generated/admin-mcp-app.check.mjs",
|
|
10
12
|
"dev": "wrangler dev --local --ip 127.0.0.1 --port 8787",
|
|
11
|
-
"check": "mantle generate cloudflare-d1 generated manifests/*.yaml --auth src/auth.ts --entry src/index.ts --admin --
|
|
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",
|
|
12
14
|
"smoke": "node smoke.mjs"
|
|
13
15
|
},
|
|
14
16
|
"dependencies": {
|
|
15
|
-
"@aotter/mantle": "0.2.0-alpha.
|
|
16
|
-
"@aotter/mantle-ui": "0.2.0-alpha.
|
|
17
|
+
"@aotter/mantle": "0.2.0-alpha.9",
|
|
18
|
+
"@aotter/mantle-ui": "0.2.0-alpha.9",
|
|
17
19
|
"@better-auth/cimd": "1.7.7",
|
|
20
|
+
"@better-auth/core": "1.7.7",
|
|
18
21
|
"@better-auth/mcp": "1.7.7",
|
|
19
22
|
"@better-auth/oauth-provider": "1.7.7",
|
|
23
|
+
"@better-auth/utils": "0.4.2",
|
|
20
24
|
"@modelcontextprotocol/ext-apps": "2.0.0",
|
|
21
|
-
"@modelcontextprotocol/server": "2.
|
|
25
|
+
"@modelcontextprotocol/server": "2.2.0",
|
|
22
26
|
"better-auth": "1.7.7",
|
|
23
|
-
"@aotter/mantle-admin": "0.2.0-alpha.
|
|
27
|
+
"@aotter/mantle-admin": "0.2.0-alpha.9",
|
|
28
|
+
"react": "19.3.0",
|
|
29
|
+
"react-dom": "19.3.0"
|
|
24
30
|
},
|
|
25
31
|
"devDependencies": {
|
|
26
32
|
"@cloudflare/workers-types": "5.20260904.1",
|
|
27
33
|
"@types/node": "^26",
|
|
34
|
+
"@types/react": "^19.3.0",
|
|
35
|
+
"playwright-core": "1.62.1",
|
|
28
36
|
"typescript": "^6.0.3",
|
|
29
37
|
"wrangler": "4.129.0"
|
|
30
38
|
},
|
|
@@ -10,6 +10,60 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { promisify } from "node:util";
|
|
12
12
|
|
|
13
|
+
/** Read-only remote acceptance. Pass generated tools filtered for this surface and the supplied test identity. */
|
|
14
|
+
export async function assertMcpDiscovery(endpoint, { tools, headers = {} }) {
|
|
15
|
+
const expected = tools.map(tool => tool.name).sort();
|
|
16
|
+
assert.equal(new Set(expected).size, expected.length, "expected tools contain duplicate names");
|
|
17
|
+
const requestHeaders = new Headers(headers);
|
|
18
|
+
requestHeaders.set("content-type", "application/json");
|
|
19
|
+
requestHeaders.set("accept", "application/json, text/event-stream");
|
|
20
|
+
let id = 0;
|
|
21
|
+
const rpc = async (method, params, notification = false) => {
|
|
22
|
+
const message = { jsonrpc: "2.0", ...(notification ? {} : { id: ++id }), method, ...(params === undefined ? {} : { params }) };
|
|
23
|
+
const response = await fetch(endpoint, { method: "POST", headers: requestHeaders, redirect: "error", signal: AbortSignal.timeout(10_000), body: JSON.stringify(message) });
|
|
24
|
+
assert.equal(response.status, notification ? 202 : 200, `MCP ${method}: HTTP ${response.status}`);
|
|
25
|
+
if (notification) { await response.body?.cancel(); return; }
|
|
26
|
+
const text = await response.text(), type = response.headers.get("content-type") ?? "";
|
|
27
|
+
let replies;
|
|
28
|
+
if (type.includes("text/event-stream")) {
|
|
29
|
+
replies = text.replace(/\r\n/g, "\n").split("\n\n").map(event => event.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5).trimStart()).join("\n")).filter(Boolean).map(data => JSON.parse(data));
|
|
30
|
+
} else {
|
|
31
|
+
assert.ok(type.includes("application/json"), `MCP ${method}: expected JSON or SSE`);
|
|
32
|
+
replies = [JSON.parse(text)];
|
|
33
|
+
}
|
|
34
|
+
const matches = replies.filter(reply => reply?.id === message.id);
|
|
35
|
+
assert.equal(matches.length, 1, `MCP ${method}: expected one response with the request id`);
|
|
36
|
+
const reply = matches[0];
|
|
37
|
+
assert.equal(reply.jsonrpc, "2.0", `MCP ${method}: invalid JSON-RPC version`);
|
|
38
|
+
assert.ok(!Object.hasOwn(reply, "error"), `MCP ${method}: returned a JSON-RPC error`);
|
|
39
|
+
assert.ok(reply.result && typeof reply.result === "object" && !Array.isArray(reply.result), `MCP ${method}: missing result object`);
|
|
40
|
+
const session = response.headers.get("mcp-session-id");
|
|
41
|
+
if (session) requestHeaders.set("mcp-session-id", session);
|
|
42
|
+
return reply.result;
|
|
43
|
+
};
|
|
44
|
+
const initialized = await rpc("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "mantle-smoke", version: "1" } });
|
|
45
|
+
assert.equal(initialized.protocolVersion, "2025-11-25", "MCP initialize: unsupported protocol version");
|
|
46
|
+
assert.ok(initialized.capabilities?.tools && typeof initialized.capabilities.tools === "object" && !Array.isArray(initialized.capabilities.tools), "MCP initialize: no tools capability");
|
|
47
|
+
assert.ok(typeof initialized.serverInfo?.name === "string" && typeof initialized.serverInfo?.version === "string", "MCP initialize: invalid serverInfo");
|
|
48
|
+
requestHeaders.set("mcp-protocol-version", initialized.protocolVersion);
|
|
49
|
+
await rpc("notifications/initialized", undefined, true);
|
|
50
|
+
const names = [], cursors = new Set();
|
|
51
|
+
let cursor;
|
|
52
|
+
do {
|
|
53
|
+
assert.ok(cursors.size < 100, "MCP tools/list: exceeded 100 pages");
|
|
54
|
+
const page = await rpc("tools/list", cursor === undefined ? {} : { cursor });
|
|
55
|
+
assert.ok(Array.isArray(page.tools) && page.tools.every(tool => tool && typeof tool.name === "string" && tool.name.length > 0 && tool.inputSchema && typeof tool.inputSchema === "object" && !Array.isArray(tool.inputSchema) && tool.inputSchema.type === "object"), "MCP tools/list: invalid tools");
|
|
56
|
+
names.push(...page.tools.map(tool => tool.name));
|
|
57
|
+
cursor = page.nextCursor;
|
|
58
|
+
if (cursor !== undefined) {
|
|
59
|
+
assert.ok(typeof cursor === "string" && cursor.length > 0 && !cursors.has(cursor), "MCP tools/list: invalid or repeated cursor");
|
|
60
|
+
cursors.add(cursor);
|
|
61
|
+
}
|
|
62
|
+
} while (cursor !== undefined);
|
|
63
|
+
assert.deepEqual(names.sort(), expected, "MCP tools/list: discovered tools differ from the expected permitted tools");
|
|
64
|
+
return names;
|
|
65
|
+
}
|
|
66
|
+
|
|
13
67
|
const database = "mantle-app"; // wrangler.jsonc d1_databases[0].database_name
|
|
14
68
|
const env = { ...process.env, WRANGLER_SEND_METRICS: "false" };
|
|
15
69
|
|
|
@@ -5,7 +5,7 @@ import { createAccessControl } from "better-auth/plugins/access";
|
|
|
5
5
|
import { defaultStatements } from "better-auth/plugins/admin/access";
|
|
6
6
|
import { mcp } from "@better-auth/mcp";
|
|
7
7
|
import { cimd } from "@better-auth/cimd";
|
|
8
|
-
import { oauthGrantExtension } from "@aotter/mantle/auth/oauth";
|
|
8
|
+
import { oauthGrantExtension, oauthGrantRevocation } from "@aotter/mantle/auth/oauth";
|
|
9
9
|
import { d1Adapter } from "@aotter/mantle/auth/adapter";
|
|
10
10
|
import { d1BootstrapAdmin } from "@aotter/mantle/auth/identity";
|
|
11
11
|
import { authHardening, hashEmailOtp } from "@aotter/mantle/auth/hardening";
|
|
@@ -34,9 +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
|
+
oauthGrantRevocation(),
|
|
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" }),
|
|
40
43
|
cimd({ fetchClientMetadataResource: (input, init) => fetch(input, { ...init, redirect: "manual" }), metadataProfile: "mcp-2026-07-28" }),
|
|
41
44
|
// --- end MCP / OAuth
|
|
42
45
|
];
|
|
@@ -56,14 +56,14 @@ export async function fetchService(request: Request, env: Env, context: Executio
|
|
|
56
56
|
}
|
|
57
57
|
response = await serveAdmin(request, env);
|
|
58
58
|
} else if (/^\/mcp(?:\/admin)?\/*$/.test(url.pathname)) {
|
|
59
|
-
// --- MCP (
|
|
60
|
-
serveMcpHandler ??= serveMcp(tools, { ...options, apps: { admin: adminMcpApps({ catalogue: mcpAppCatalogues.admin
|
|
59
|
+
// --- MCP / OAuth (deliberate exception): remove with the auth plugins only for intentional asymmetric exposure.
|
|
60
|
+
serveMcpHandler ??= serveMcp(tools, { ...options, authorizationScopes: ["offline_access"], apps: { admin: adminMcpApps({ catalogue: mcpAppCatalogues.admin }) } });
|
|
61
61
|
response = await serveMcpHandler(request, env);
|
|
62
62
|
} else {
|
|
63
63
|
response = await serveRest(request, env);
|
|
64
64
|
}
|
|
65
65
|
if (response) return respond(response);
|
|
66
|
-
const shell = await adminShell(request, path => env.ASSETS.fetch(new Request(new URL("/" + path, url), request)));
|
|
66
|
+
const shell = await adminShell(request, path => env.ASSETS.fetch(new Request(new URL("/admin/" + path, url), request)));
|
|
67
67
|
if (shell) return shell;
|
|
68
68
|
return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
|
|
69
69
|
} catch (error) { return surfaceFailure(error); }
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"target": "ES2022",
|
|
4
4
|
"lib": [
|
|
5
|
-
"ES2023"
|
|
5
|
+
"ES2023",
|
|
6
|
+
"DOM"
|
|
6
7
|
],
|
|
8
|
+
"jsx": "react-jsx",
|
|
7
9
|
"module": "ESNext",
|
|
8
10
|
"moduleResolution": "Bundler",
|
|
9
11
|
"strict": true,
|
|
@@ -18,6 +20,7 @@
|
|
|
18
20
|
"include": [
|
|
19
21
|
"worker-configuration.d.ts",
|
|
20
22
|
"src/**/*.ts",
|
|
23
|
+
"src/**/*.tsx",
|
|
21
24
|
"generated/**/*.ts"
|
|
22
25
|
]
|
|
23
26
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* eslint-disable */
|
|
2
|
-
// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash:
|
|
2
|
+
// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 1091fee65f95f8f0fa1b8353572138b4)
|
|
3
3
|
interface __BaseEnv_Env {
|
|
4
4
|
DB: D1Database;
|
|
5
5
|
ASSETS: Fetcher;
|
|
@@ -9,7 +9,7 @@ interface __BaseEnv_Env {
|
|
|
9
9
|
}
|
|
10
10
|
declare namespace Cloudflare {
|
|
11
11
|
interface GlobalProps {
|
|
12
|
-
mainModule: typeof import("./dist/index");
|
|
12
|
+
mainModule: typeof import("./dist/worker/index");
|
|
13
13
|
}
|
|
14
14
|
interface Env extends __BaseEnv_Env {}
|
|
15
15
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "node_modules/wrangler/config-schema.json",
|
|
3
3
|
"name": "mantle-app",
|
|
4
|
-
"main": "dist/index.js",
|
|
4
|
+
"main": "dist/worker/index.js",
|
|
5
5
|
"compatibility_date": "2026-09-01",
|
|
6
6
|
"compatibility_flags": [
|
|
7
|
-
"nodejs_compat"
|
|
7
|
+
"nodejs_compat",
|
|
8
|
+
"global_fetch_strictly_public"
|
|
8
9
|
],
|
|
9
10
|
"secrets": {
|
|
10
11
|
"required": [
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
}
|
|
32
33
|
],
|
|
33
34
|
"assets": {
|
|
34
|
-
"directory": "
|
|
35
|
+
"directory": "dist/client",
|
|
35
36
|
"binding": "ASSETS",
|
|
36
37
|
"run_worker_first": true,
|
|
37
38
|
"html_handling": "none"
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
},
|
|
42
43
|
"no_bundle": true,
|
|
43
44
|
"find_additional_modules": true,
|
|
44
|
-
"base_dir": "dist",
|
|
45
|
+
"base_dir": "dist/worker",
|
|
45
46
|
"build": {
|
|
46
47
|
"command": "mantle bundle src/index.ts dist",
|
|
47
48
|
"watch_dir": [
|
|
@@ -165,6 +165,13 @@ spec:
|
|
|
165
165
|
source: { kind: http, method: POST, path: /api/registrations/cancel }
|
|
166
166
|
target: { procedure: cancel-registration }
|
|
167
167
|
---
|
|
168
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
169
|
+
kind: Trigger
|
|
170
|
+
metadata: { name: cancel-registration-mcp }
|
|
171
|
+
spec:
|
|
172
|
+
source: { kind: mcp, surface: public }
|
|
173
|
+
target: { procedure: cancel-registration }
|
|
174
|
+
---
|
|
168
175
|
# Officers add records through declared Procedures: operational collections have no generic create in Admin.
|
|
169
176
|
# collectionAction puts the Procedure's button and input form in that collection's list header; the admin MCP
|
|
170
177
|
# Trigger is what makes it an Admin operation (and lets the officers' AI do the same).
|
|
@@ -17,7 +17,7 @@ export const handlers: Handlers = {
|
|
|
17
17
|
return await store.procedures["register-write"]({ eventId, member: caller.subject, memberName: user?.name || user?.email || caller.subject });
|
|
18
18
|
} catch (error) {
|
|
19
19
|
// register-write's only Schema check is the capacity one.
|
|
20
|
-
if (error instanceof ContractError && error.code === "CONFLICT" && error.
|
|
20
|
+
if (error instanceof ContractError && error.code === "CONFLICT" && error.fields?.kind === "check" && error.fields.schema === "events")
|
|
21
21
|
throw new ContractError("CONFLICT", "The event is full.", "procedures/register");
|
|
22
22
|
throw error;
|
|
23
23
|
}
|
|
@@ -65,8 +65,12 @@ total). Markdown may perform two View reads when checking an exact `.md` page
|
|
|
65
65
|
before its alternate.
|
|
66
66
|
|
|
67
67
|
Web indexes have deliberate ceilings: 5,000 scanned rows, 10 queries and 1 MiB
|
|
68
|
-
for `llms.txt`; 50,000 rows, 100 queries and 50 MiB for a sitemap.
|
|
69
|
-
|
|
68
|
+
for `llms.txt`; 50,000 rows, 100 queries and 50 MiB for a sitemap. An authored SQL `LIMIT 3` means at most three rows in the entire index,
|
|
69
|
+
not three rows per query. Omit SQL LIMIT to enumerate the whole authorized
|
|
70
|
+
result up to these web ceilings. The renderer requests pages of up to 500
|
|
71
|
+
through generated paging options; transport page size does not expand the
|
|
72
|
+
authored result set. Migration: remove LIMIT only when it was mistakenly
|
|
73
|
+
being used as a page-size setting; retain a business-defined top-N limit. Translation-aware web indexes/hreflang remain a separate
|
|
70
74
|
follow-up; `locales[0]` supplies the currently supported locale alternate.
|
|
71
75
|
|
|
72
76
|
The SDK runs this exact Worker against a native SQLite database in
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"name": "mantle-generated-web",
|
|
3
3
|
"main": "src/worker.ts",
|
|
4
4
|
"compatibility_date": "2026-09-28",
|
|
5
|
+
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
|
|
5
6
|
"vars": { "PUBLIC_ORIGIN": "http://localhost:8787" },
|
|
6
7
|
"d1_databases": [{ "binding": "DB", "database_name": "mantle-generated-web", "database_id": "local-example" }]
|
|
7
8
|
}
|
|
@@ -37,7 +37,7 @@ that have been waiting too long.
|
|
|
37
37
|
`wrangler.jsonc`. The cron retries failed orders (internal View `approved-requests`) and sends the reminder
|
|
38
38
|
(internal View `stale-requests`) to admins who are not banned.
|
|
39
39
|
- Employees see only their own requests through `my-requests` (`restrict: { requests: "author_id = auth.uid()" }`).
|
|
40
|
-
- **Sidebar for the approver's day**: `src/admin.
|
|
40
|
+
- **Sidebar for the approver's day**: `src/admin.tsx` declares an Approvals group (`defineAdmin` `nav.groups`) above Admin's built-in
|
|
41
41
|
groups; `pending-requests` and `requests` move into it with `uiSchema.nav: { group, order }`.
|
|
42
42
|
- **A page only signed-in employees reach**: `src/requests-page.ts` serves `GET /requests`. An anonymous visitor gets a
|
|
43
43
|
303 to the shell's `/sign-in?return=/requests` (`src/sign-in.ts`, email code, then back). Signed in, the table reads
|