@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.
Files changed (126) hide show
  1. package/README.md +27 -5
  2. package/dist/auth/adapter.js +9 -6
  3. package/dist/auth/caller.js +2 -1
  4. package/dist/auth/identity.d.ts +2 -2
  5. package/dist/auth/oauth.d.ts +33 -1
  6. package/dist/auth/oauth.js +121 -28
  7. package/dist/auth/routes.js +6 -4
  8. package/dist/build/compiler.d.ts +44 -122
  9. package/dist/build/compiler.js +202 -277
  10. package/dist/build/spec/domain/model/ManifestGrammar.d.ts +6 -5
  11. package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
  12. package/dist/build/spec/domain/service/PlanFingerprint.d.ts +2 -0
  13. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  14. package/dist/domain/describe/{chunk-ZIQVLA7X.js → chunk-ZVXBGP7Y.js} +69 -8
  15. package/dist/domain/describe/index.js +1 -1
  16. package/dist/domain/describe/target-hash.js +1 -1
  17. package/dist/domain/describe/targets.d.ts +1 -1
  18. package/dist/domain/generated-tools.js +2 -0
  19. package/dist/domain/paging.js +41 -15
  20. package/dist/infrastructure/bundle.js +118 -15
  21. package/dist/infrastructure/cli.js +11 -15
  22. package/dist/infrastructure/generate.d.ts +1 -3
  23. package/dist/infrastructure/generate.js +11 -3
  24. package/dist/infrastructure/generateAuth.js +57 -0
  25. package/dist/infrastructure/inputs.js +8 -3
  26. package/dist/infrastructure/wrangler.js +50 -12
  27. package/dist/kernel/binds.d.ts +0 -3
  28. package/dist/kernel/contracts.d.ts +1 -1
  29. package/dist/kernel/contracts.js +4 -4
  30. package/dist/kernel/dialect/common.d.ts +0 -1
  31. package/dist/kernel/drain.d.ts +5 -0
  32. package/dist/kernel/drain.js +35 -0
  33. package/dist/kernel/driver/postgres.js +6 -2
  34. package/dist/kernel/host-caller.d.ts +1 -0
  35. package/dist/kernel/host-caller.js +1 -0
  36. package/dist/kernel/mcp-apps.d.ts +29 -9
  37. package/dist/kernel/mcp-apps.js +3 -3
  38. package/dist/kernel/mcp-handler.js +101 -18
  39. package/dist/kernel/mcp.d.ts +12 -3
  40. package/dist/kernel/mcp.js +31 -6
  41. package/dist/kernel/rest-router.js +3 -2
  42. package/dist/kernel/scalars.js +3 -2
  43. package/dist/kernel/site.d.ts +1 -3
  44. package/dist/kernel/site.js +2 -2
  45. package/dist/kernel/web-index.js +40 -0
  46. package/dist/kernel/web.d.ts +3 -1
  47. package/dist/kernel/web.js +6 -37
  48. package/dist/usecase/adminBindings.js +2 -3
  49. package/dist/usecase/adminClient.js +87 -0
  50. package/dist/usecase/adminDescriptors.js +19 -65
  51. package/dist/usecase/generate.js +5 -4
  52. package/dist/usecase/mcp-media.js +2 -2
  53. package/dist/usecase/mcp.js +16 -12
  54. package/dist/usecase/paging.js +2 -3
  55. package/dist/usecase/publishing.js +3 -3
  56. package/dist/usecase/site.js +0 -1
  57. package/dist/usecase/tool-version.js +9 -0
  58. package/dist/usecase/translations.js +1 -1
  59. package/dist/usecase/validation.js +128 -19
  60. package/dist/usecase/web.js +3 -2
  61. package/docs/examples/README.md +1 -1
  62. package/docs/examples/app-shell/README.md +45 -3
  63. package/docs/examples/app-shell/package.json +14 -6
  64. package/docs/examples/app-shell/smoke-kit.mjs +54 -0
  65. package/docs/examples/app-shell/src/auth.ts +6 -3
  66. package/docs/examples/app-shell/src/service.ts +3 -3
  67. package/docs/examples/app-shell/tsconfig.json +4 -1
  68. package/docs/examples/app-shell/worker-configuration.d.ts +2 -2
  69. package/docs/examples/app-shell/wrangler.jsonc +5 -4
  70. package/docs/examples/club-events/manifests/club-events.yaml +7 -0
  71. package/docs/examples/club-events/src/handlers.ts +1 -1
  72. package/docs/examples/generated-web/README.md +6 -2
  73. package/docs/examples/generated-web/wrangler.jsonc +1 -0
  74. package/docs/examples/purchasing/README.md +1 -1
  75. package/docs/examples/purchasing/manifests/purchasing.yaml +17 -1
  76. package/docs/examples/purchasing/smoke.mjs +10 -4
  77. package/docs/examples/purchasing/src/admin.tsx +13 -0
  78. package/docs/examples/purchasing/src/handlers.ts +1 -1
  79. package/docs/examples/purchasing/wrangler.jsonc +5 -4
  80. package/docs/examples/recipes/wrangler.jsonc +5 -4
  81. package/docs/handbook/cloudflare/authentication.md +43 -6
  82. package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
  83. package/docs/handbook/cloudflare/deploy-and-operate.md +45 -1
  84. package/docs/handbook/cloudflare/media-r2.md +2 -3
  85. package/docs/handbook/cloudflare/service-entry.md +68 -31
  86. package/docs/handbook/concepts/authorization.md +1 -1
  87. package/docs/handbook/concepts/mcp-and-agents.md +19 -0
  88. package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
  89. package/docs/handbook/examples/hub.md +1 -1
  90. package/docs/handbook/guides/admin-ui.md +107 -1
  91. package/docs/handbook/guides/agent-setup.md +27 -9
  92. package/docs/handbook/guides/agent-workflow-acceptance.md +222 -0
  93. package/docs/handbook/guides/mcp-apps.md +522 -6
  94. package/docs/handbook/guides/native-admin.md +300 -51
  95. package/docs/handbook/guides/oauth.md +119 -5
  96. package/docs/handbook/guides/public-pages.md +2 -2
  97. package/docs/handbook/guides/size-and-startup.md +1 -1
  98. package/docs/handbook/guides/typed-queries.md +35 -0
  99. package/docs/handbook/navigation.json +0 -4
  100. package/docs/handbook/reference/admin-descriptors.md +0 -35
  101. package/docs/handbook/reference/procedure.md +6 -2
  102. package/docs/handbook/reference/schema.md +13 -3
  103. package/docs/handbook/reference/surface.md +129 -15
  104. package/docs/handbook/reference/trigger.md +43 -1
  105. package/docs/handbook/reference/view.md +1 -1
  106. package/docs/handbook/releases/index.md +74 -2
  107. package/docs/handbook/start/overview.md +1 -1
  108. package/docs/handbook/start/project-and-cli.md +16 -8
  109. package/docs/handbook/start/quickstart-worker.md +1 -1
  110. package/docs/skills/develop/SKILL.md +46 -18
  111. package/docs/skills/integrate/SKILL.md +4 -9
  112. package/docs/skills/media-gc/SKILL.md +7 -5
  113. package/docs/skills/provision/SKILL.md +18 -12
  114. package/docs/skills/update/SKILL.md +19 -18
  115. package/docs/upgrade-0.1-to-0.2.md +6 -6
  116. package/package.json +2 -1
  117. package/skills/README.md +11 -16
  118. package/skills/mantle/SKILL.md +39 -17
  119. package/skills/mantle/references/local-testing.md +9 -0
  120. package/skills/mantle/references/web.md +1 -1
  121. package/skills/mantle/references/website.md +26 -22
  122. package/docs/examples/app-shell/src/admin.ts +0 -10
  123. package/docs/examples/purchasing/src/admin.ts +0 -12
  124. package/docs/handbook/guides/admin-extensions.md +0 -20
  125. package/docs/skills/develop/mcp-app.md +0 -39
  126. package/skills/.keep +0 -0
@@ -1,7 +1,98 @@
1
- import { q } from "../domain/entry.js";
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
- let value = root;
30
- try {
31
- if (typeof ref !== "string" || !ref.startsWith("#/"))
32
- throw Error();
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(`!${q(schema.enum)}.some(option=>equal(value,option))`, "Value is not in enum.");
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
- check(`${guard("string", 'typeof value === "string"')}!new RegExp(${q(schema.pattern)}).test(value)`, "String does not match pattern.");
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;
@@ -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]) => !v.input?.required?.length).map(([n]) => q(n)).join(" | ") || "never"} };`,
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
  "}"
@@ -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](./reference-service/README.md) is the release smoke test for packed packages, not a starting point.
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.ts` | Admin presentation for `--admin-extensions`: `navGroups` (sidebar groups) and `adminExtensions`. Empty by default. |
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. Delete the blocks marked `MCP / OAuth (optional)` in `auth.ts`
38
- and `service.ts` if the app has no MCP.
42
+ `src/service.ts` and `src/index.ts` rarely change. The default shell mounts REST and MCP together. If the app deliberately
43
+ chooses asymmetric exposure, delete the blocks marked `MCP / OAuth (deliberate exception)` in `auth.ts` and `service.ts`;
44
+ HTTP-only Procedures will keep generating with a warning.
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 --admin-extensions src/admin.ts",
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 --admin-extensions src/admin.ts --check && wrangler types --include-runtime=false --check && tsc --noEmit",
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.7",
16
- "@aotter/mantle-ui": "0.2.0-alpha.7",
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.1.0",
25
+ "@modelcontextprotocol/server": "2.2.0",
22
26
  "better-auth": "1.7.7",
23
- "@aotter/mantle-admin": "0.2.0-alpha.7"
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 (optional): delete these three plugins, `disabledPaths` and the MCP block in service.ts if the app has no MCP.
37
+ // --- MCP / OAuth (deliberate exception): remove only when the app intentionally chooses asymmetric exposure.
38
38
  jwt({ disableSettingJwtHeader: true }),
39
- mcp({ loginPage: "/admin/sign-in", consentPage: "/admin/oauth/consent", resource: `${origin}/mcp`, scopes: ["mcp"], extensions: [oauthGrantExtension], allowPublicClientPrelogin: true, clientRegistrationClientSecretExpiration: "90d" }),
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 (optional): delete this branch with the MCP plugins in auth.ts if the app has no MCP.
60
- serveMcpHandler ??= serveMcp(tools, { ...options, apps: { admin: adminMcpApps({ catalogue: mcpAppCatalogues.admin, html: async () => (await import("@aotter/mantle-admin/mcp-app")).mantleAppHtml }) } });
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: a915f363a88b93fd3b80de7926237051)
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": "node_modules/@aotter/mantle-admin/dist/admin",
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.message.startsWith("A check of the Schema failed"))
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. Small SQL LIMITs
69
- can truncate sooner. Translation-aware web indexes/hreflang remain a separate
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.ts` declares an Approvals group (`navGroups`) above Admin's built-in
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