@aotter/mantle 0.2.0-alpha.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +9 -3
  2. package/dist/auth/adapter.js +1 -28
  3. package/dist/auth/admin.js +2 -5
  4. package/dist/auth/caller.js +0 -27
  5. package/dist/auth/consent.js +100 -0
  6. package/dist/auth/hardening.js +0 -7
  7. package/dist/auth/identity.js +0 -11
  8. package/dist/auth/oauth.js +0 -32
  9. package/dist/auth/resource-seeds.js +0 -2
  10. package/dist/auth/route-http.js +33 -0
  11. package/dist/auth/routes.js +5 -151
  12. package/dist/build/compiler.d.ts +19 -609
  13. package/dist/build/compiler.js +3 -6896
  14. package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
  15. package/dist/build/spec/domain/model/UserFields.d.ts +1 -0
  16. package/dist/bun/assets.js +0 -2
  17. package/dist/domain/adminOrder.js +0 -2
  18. package/dist/domain/columns.js +7 -20
  19. package/dist/domain/describe/chunk-SGMRVDGE.js +1 -0
  20. package/dist/domain/describe/index.js +1 -115
  21. package/dist/domain/describe/target-hash.js +1 -12
  22. package/dist/domain/entry.js +0 -14
  23. package/dist/domain/generated-tools.js +0 -1
  24. package/dist/domain/input.js +0 -2
  25. package/dist/domain/paging.js +0 -17
  26. package/dist/domain/recipes.js +10 -7
  27. package/dist/domain/userReferences.js +0 -1
  28. package/dist/domain/wireScalars.js +0 -6
  29. package/dist/infrastructure/adminEntries.js +10 -7
  30. package/dist/infrastructure/adminPaging.js +55 -57
  31. package/dist/infrastructure/artifacts.js +0 -4
  32. package/dist/infrastructure/authSchema.js +0 -9
  33. package/dist/infrastructure/bundle.js +2 -30
  34. package/dist/infrastructure/cli.js +25 -11
  35. package/dist/infrastructure/generate.js +9 -16
  36. package/dist/infrastructure/generateAuth.js +1 -9
  37. package/dist/infrastructure/help.js +55 -0
  38. package/dist/infrastructure/init.js +50 -0
  39. package/dist/infrastructure/inputs.js +0 -9
  40. package/dist/infrastructure/mounts.js +22 -0
  41. package/dist/infrastructure/wrangler.js +0 -6
  42. package/dist/kernel/authorization.js +0 -1
  43. package/dist/kernel/binds.d.ts +16 -4
  44. package/dist/kernel/binds.js +4 -10
  45. package/dist/kernel/caller-identity.d.ts +0 -3
  46. package/dist/kernel/caller-identity.js +0 -15
  47. package/dist/kernel/caller-mode.d.ts +4 -0
  48. package/dist/kernel/caller-mode.js +14 -0
  49. package/dist/kernel/contracts.d.ts +6 -10
  50. package/dist/kernel/contracts.js +5 -41
  51. package/dist/kernel/cron.js +0 -4
  52. package/dist/kernel/dialect/common.js +0 -3
  53. package/dist/kernel/dialect/postgres.js +0 -2
  54. package/dist/kernel/dialect/sqlite.js +1 -6
  55. package/dist/kernel/drain.js +2 -8
  56. package/dist/kernel/driver/bun-sqlite.js +0 -4
  57. package/dist/kernel/driver/d1.js +0 -1
  58. package/dist/kernel/driver/postgres.js +2 -17
  59. package/dist/kernel/driver/transaction-context.js +0 -1
  60. package/dist/kernel/email.js +0 -4
  61. package/dist/kernel/host-access.js +2 -3
  62. package/dist/kernel/host-caller.d.ts +1 -1
  63. package/dist/kernel/host-caller.js +3 -6
  64. package/dist/kernel/json-body.js +0 -2
  65. package/dist/kernel/mcp-apps.js +0 -5
  66. package/dist/kernel/mcp-call.js +0 -1
  67. package/dist/kernel/mcp-handler.js +2 -26
  68. package/dist/kernel/mcp.js +0 -5
  69. package/dist/kernel/media.js +0 -18
  70. package/dist/kernel/paging.js +1 -9
  71. package/dist/kernel/publishing.js +3 -4
  72. package/dist/kernel/r2-media.js +0 -7
  73. package/dist/kernel/rest-dispatch.js +0 -1
  74. package/dist/kernel/rest-router.js +2 -3
  75. package/dist/kernel/rest.js +0 -3
  76. package/dist/kernel/scalars.js +1 -9
  77. package/dist/kernel/search.js +0 -1
  78. package/dist/kernel/site.js +0 -2
  79. package/dist/kernel/store.d.ts +11 -1
  80. package/dist/kernel/store.js +21 -34
  81. package/dist/kernel/surface-failure.d.ts +10 -0
  82. package/dist/kernel/surface-failure.js +29 -0
  83. package/dist/kernel/surface.js +0 -3
  84. package/dist/kernel/sweep.js +0 -1
  85. package/dist/kernel/translations.js +0 -1
  86. package/dist/kernel/user-caller.js +2 -8
  87. package/dist/kernel/user-fields.d.ts +3 -2
  88. package/dist/kernel/user-fields.js +9 -9
  89. package/dist/kernel/validation.js +0 -8
  90. package/dist/kernel/web-gateway.js +0 -2
  91. package/dist/kernel/web-head.js +0 -1
  92. package/dist/kernel/web-index.js +0 -1
  93. package/dist/kernel/web-response.js +0 -1
  94. package/dist/kernel/web-serve.js +97 -0
  95. package/dist/kernel/web.d.ts +1 -1
  96. package/dist/kernel/web.js +10 -91
  97. package/dist/kernel/where.js +0 -6
  98. package/dist/usecase/adminBindings.js +30 -21
  99. package/dist/usecase/adminClient.js +0 -21
  100. package/dist/usecase/adminDescriptors.js +2 -9
  101. package/dist/usecase/generate.js +7 -12
  102. package/dist/usecase/handlers.js +0 -3
  103. package/dist/usecase/mcp-media.js +0 -1
  104. package/dist/usecase/mcp.js +0 -4
  105. package/dist/usecase/paging.js +1 -3
  106. package/dist/usecase/publishing.js +0 -4
  107. package/dist/usecase/rest.js +0 -1
  108. package/dist/usecase/site.js +0 -1
  109. package/dist/usecase/stores.js +4 -14
  110. package/dist/usecase/tool-version.js +0 -3
  111. package/dist/usecase/translations.js +0 -2
  112. package/dist/usecase/validation.js +0 -20
  113. package/docs/examples/README.md +13 -5
  114. package/docs/examples/app-shell/README.md +38 -7
  115. package/docs/examples/app-shell/accept.mjs +91 -0
  116. package/docs/examples/app-shell/gitignore +7 -0
  117. package/docs/examples/app-shell/package.json +7 -6
  118. package/docs/examples/app-shell/smoke-kit.mjs +29 -15
  119. package/docs/examples/app-shell/src/routes.ts +6 -3
  120. package/docs/examples/app-shell/src/service.ts +2 -1
  121. package/docs/examples/club-events/README.md +7 -2
  122. package/docs/examples/club-events/manifests/club-events.yaml +15 -0
  123. package/docs/examples/club-events/smoke.mjs +7 -1
  124. package/docs/examples/purchasing/README.md +12 -3
  125. package/docs/examples/purchasing/manifests/purchasing.yaml +21 -4
  126. package/docs/examples/purchasing/smoke.mjs +14 -0
  127. package/docs/examples/recipes/README.md +5 -3
  128. package/docs/handbook/cloudflare/service-entry.md +3 -0
  129. package/docs/handbook/examples/hub.md +1 -1
  130. package/docs/handbook/guides/agent-setup.md +15 -10
  131. package/docs/handbook/guides/agent-workflow-acceptance.md +113 -103
  132. package/docs/handbook/guides/local-testing.md +40 -0
  133. package/docs/handbook/guides/native-admin.md +25 -6
  134. package/docs/handbook/guides/native-execution.md +5 -4
  135. package/docs/handbook/guides/public-pages.md +44 -4
  136. package/docs/handbook/guides/sql-patterns.md +266 -0
  137. package/docs/handbook/guides/typed-queries.md +138 -0
  138. package/docs/handbook/navigation.json +16 -0
  139. package/docs/handbook/reference/admin-descriptors.md +4 -1
  140. package/docs/handbook/reference/authorization.md +1 -1
  141. package/docs/handbook/reference/diagnostics.md +6 -1
  142. package/docs/handbook/reference/procedure.md +5 -5
  143. package/docs/handbook/reference/schema.md +10 -4
  144. package/docs/handbook/reference/sql.md +64 -0
  145. package/docs/handbook/reference/surface.md +5 -2
  146. package/docs/handbook/reference/view.md +11 -3
  147. package/docs/handbook/releases/index.md +27 -6
  148. package/docs/handbook/start/agents.md +45 -0
  149. package/docs/handbook/start/project-and-cli.md +6 -0
  150. package/docs/handbook/start/quickstart-worker.md +1 -1
  151. package/docs/skills/develop/SKILL.md +6 -5
  152. package/docs/skills/update/SKILL.md +2 -0
  153. package/package.json +23 -3
  154. package/skills/README.md +1 -1
  155. package/skills/mantle/SKILL.md +39 -120
  156. package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
  157. package/skills/mantle/references/local-testing.md +0 -20
  158. package/skills/mantle/references/web.md +0 -41
  159. package/skills/mantle/references/website.md +0 -51
@@ -1,4 +1,3 @@
1
- /** Compile boundary checks into each operation; no schema enters the request bundle. */
2
1
  import { INPUT_RULE_KEYWORDS, lookupLocalRef as lookup, rootRefChain } from "../build/compiler.js";
3
2
  import { literal, q } from "../domain/entry.js";
4
3
  import { publishingInput } from "./publishing.js";
@@ -6,14 +5,9 @@ import { equal } from "../kernel/validation.js";
6
5
  const pointer = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
7
6
  const supported = new Set([..."$defs $ref $schema $id type nullable properties required additionalProperties items enum const format pattern minLength maxLength minimum maximum exclusiveMinimum exclusiveMaximum multipleOf minItems maxItems title description default readOnly writeOnly examples deprecated x-mantle-ref x-mcp-hint".split(" "), ...INPUT_RULE_KEYWORDS]);
8
7
  const shapes = "see docs/handbook/reference/surface.md";
9
- // YAML .inf and .nan parse to numbers that JSON writes as null.
10
8
  const finite = (value) => typeof value === "number" ? Number.isFinite(value) : !!value && typeof value === "object" ? Object.values(value).every(finite) : true;
11
9
  const has = (key) => `Object.hasOwn(object,${q(key)})`;
12
10
  const exactly = (value, keys) => !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).sort().join(" ") === keys;
13
- /**
14
- * An object schema's rules, each failing `when` (JavaScript on `object`) with its sentence at the missing field `key`, or the
15
- * reason a rule has an unsupported shape.
16
- */
17
11
  function rules(schema) {
18
12
  const declared = (names) => Array.isArray(names) && names.length > 0 && names.every(name => typeof name === "string" && Object.hasOwn(schema.properties ?? {}, name));
19
13
  const list = (names) => names.map(name => `'${name}'`).join(" and ");
@@ -34,7 +28,6 @@ function rules(schema) {
34
28
  if (condition !== undefined || then !== undefined || otherwise !== undefined) {
35
29
  const [key] = exactly(condition, "properties required") ? Object.keys(condition.properties) : [];
36
30
  const value = key === undefined ? undefined : condition.properties[key];
37
- // Without required: [key], an absent key satisfies `if`.
38
31
  if (key === undefined || !exactly(condition.properties, key) || !exactly(value, "const") || !(["string", "boolean"].includes(typeof value.const) || Number.isFinite(value.const) || value.const === null) || q(condition.required) !== q([key]) || !declared([key]))
39
32
  return `if is supported only as exactly { properties: { <field>: { const: <string, finite number, boolean or null> } }, required: [<field>] }, so an absent field does not satisfy it; ${shapes}.`;
40
33
  if (then === undefined && otherwise === undefined || [then, otherwise].some(branch => branch !== undefined && !(exactly(branch, "required") && declared(branch.required))))
@@ -47,19 +40,12 @@ function rules(schema) {
47
40
  }
48
41
  return found;
49
42
  }
50
- /** Whether an input schema accepts `{}`: a list View is read with no input, so web indexes need this. */
51
43
  export function acceptsEmpty(input) {
52
44
  if (input === undefined)
53
45
  return true;
54
- // Validation applies a root `$ref`'s target too, so each schema along the chain must accept `{}`; an unresolved or cyclic chain does not.
55
- // dependentRequired needs a present key; an if needs its field, so `{}` meets only an else. An enum or const must admit `{}`.
56
46
  return rootRefChain(input)?.every(schema => !schema.required?.length && schema.anyOf === undefined && !schema.else?.required?.length
57
47
  && (schema.enum === undefined || schema.enum.some(option => equal({}, option))) && (schema.const === undefined || equal({}, schema.const)) && (schema.type === undefined || [schema.type].flat().includes("object"))) ?? false;
58
48
  }
59
- /**
60
- * An input schema as an AI tool catalogue advertises it: some model APIs refuse rules (a root `anyOf`, `if`), so each rule leaves
61
- * the schema and becomes a sentence of the description.
62
- */
63
49
  export function advertised(input, description) {
64
50
  if (!input)
65
51
  return { inputSchema: input, description };
@@ -68,7 +54,6 @@ export function advertised(input, description) {
68
54
  if (!node || typeof node !== "object")
69
55
  return;
70
56
  const schema = node;
71
- // An MCP View tool keeps each rule set of its root `$ref` chain as an `allOf` branch over the merged properties.
72
57
  const branches = Array.isArray(schema.allOf) ? schema.allOf : [];
73
58
  for (const branch of [schema, ...branches.map(branch => ({ ...branch, properties: schema.properties }))]) {
74
59
  const found = rules(branch);
@@ -111,9 +96,7 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
111
96
  files[file] = `import { transition } from "@aotter/mantle/validation";\nexport const validate = (raw: unknown) => transition(${q(base)}, raw);\n`;
112
97
  continue;
113
98
  }
114
- // Only a local `$ref`, or more than 100 nested objects and arrays, can reach the nesting limit: only then is `depth` passed.
115
99
  const recursive = json.includes('"$ref"') || json.split(/[[{]/).length > 101, depth = recursive ? ",depth+1" : "";
116
- // Identical bodies share one function.
117
100
  const nodes = new Map(), bodies = new Map(), functions = [], formats = new Set(), patterns = new Map();
118
101
  let equality = false;
119
102
  const resolve = (ref) => {
@@ -142,7 +125,6 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
142
125
  refuse(base, `Unsupported input type '${type}'.`);
143
126
  if (types.length)
144
127
  lines.push(typeCheck(types));
145
- // After a single type check `fail` (never) has narrowed `value`: that type's guard is implied.
146
128
  const only = types.length === 1 ? types[0] : undefined;
147
129
  const guard = (type, test = `(${condition(type)})`) => only === type || type === "number" && only === "integer" ? "" : test + " && ";
148
130
  if (schema.enum) {
@@ -197,7 +179,6 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
197
179
  }
198
180
  if (schema.items)
199
181
  lines.push(`${only === "array" ? "" : "if (Array.isArray(value)) "}for (let i=0;i<value.length;i++) ${emit(schema.items)}(value[i],path+"/"+i${depth});`);
200
- // Several keys that need no JSON-pointer escaping are checked by one loop, in the same order.
201
182
  const required = schema.required ?? [], checks = Object.entries(schema.properties ?? {}).map(([key, child]) => [key, emit(child)]);
202
183
  const loop = (keys) => keys.length > 1 && keys.every(key => pointer(key) === key);
203
184
  const object = loop(required) ? [`for (const key of ${q(required)}) if (!Object.hasOwn(object,key)) fail(path+"/"+key,"Required field is missing.");`] : required.map(key => `if (!Object.hasOwn(object,${q(key)})) fail(path+${q("/" + pointer(key))},"Required field is missing.");`);
@@ -217,7 +198,6 @@ export function emitValidation(plan, refuse = (path, message) => { throw Error(`
217
198
  if (object.length)
218
199
  lines.push(`${only === "object" ? "" : `if (${condition("object")}) `}{ const object = value as Record<string,unknown>;\n${object.join("\n")}\n}`);
219
200
  const body = lines.join("\n");
220
- // Without recursion nothing calls a check before its body is complete, so an identical body's function serves it.
221
201
  const fn = recursive ? named : bodies.get(body) ?? named;
222
202
  if (fn === named) {
223
203
  bodies.set(body, fn);
@@ -1,7 +1,8 @@
1
1
  # Examples
2
2
 
3
- Start from the **app shell**, then copy the example closest to your app on top of it. Each example shows a few
4
- capabilities the others do not; combine them for anything else.
3
+ Start an app with `mantle init`, which writes the **app shell** once. The examples are references: each shows a few
4
+ capabilities the others do not. Find each need in **Combining** below, read that example's manifest and source, and
5
+ take the pattern into your own manifests and `src/`. Do not copy an example directory over your app.
5
6
 
6
7
  | Example | Shows |
7
8
  |---|---|
@@ -16,20 +17,27 @@ capabilities the others do not; combine them for anything else.
16
17
  | You need | Take |
17
18
  |---|---|
18
19
  | Stock that never oversells (orders, tickets, bookings) | club-events' capacity: a Schema `checks` constraint plus the decrement in the same program |
20
+ | An order, cart or import with several rows that must all commit or none | one SQL Procedure over `json_each(input.items)` with a counted check that refuses unknown lines (`mantle help typed-queries`, "Several rows that commit together") |
19
21
  | Employees or customers who see only their own records | club-events' `restrict: { <schema>: "author_id = auth.uid()" }` |
20
22
  | An approval step, or any state machine | purchasing's guarded `UPDATE ... WHERE requestStatus = ...` Procedures |
23
+ | Approve first, then reserve or spend | purchasing's `approve-request-write`: the guarded status change and the budget deduction in one program (`mantle help sql-patterns`, "Approve before reserving") |
24
+ | A status chosen while writing (auto-approve small requests) | purchasing's `submit-request`: `CASE` in the INSERT ("Choose a status while writing: CASE") |
25
+ | Edit only the fields given | `COALESCE(input.field, field)` ("Change only the fields given") |
26
+ | Create or add (restock, counters) | `INSERT ... ON CONFLICT (...) DO UPDATE` ("Create or add") |
27
+ | Refuse while something still refers to it | a `NOT EXISTS` guard in the UPDATE (purchasing's `submit-request` ledger row; "Refuse while something refers to it") |
28
+ | A record with its children in one read | club-events' `event-roster`: a JSON array per row ("A row with its children in one read") |
29
+ | The latest row per group, rankings | `row_number() OVER (PARTITION BY ...)` ("The latest row of each group") |
21
30
  | A third-party API that answers later (webhooks, callbacks) | purchasing's `src/routes.ts`: verify the raw request, then call a Procedure |
22
31
  | Public, multilingual, search- and AI-friendly pages | recipes' `src/routes.ts` |
23
32
  | Invite-only or members-only sign-up | club-events' `members` allowlist + `src/signup.ts` |
24
33
  | Notifications and reminders | purchasing's `EmailSender` call and `src/schedule.ts` |
25
34
 
26
- When you copy an example only for one of its patterns, delete what your app does not need. Copying club-events, for
27
- example, also brings its `members` allowlist and `src/signup.ts` (members-only sign-up); remove both, or restore the
35
+ If you do copy an example's files, delete what your app does not need. Copying club-events, for example, also brings its `members` allowlist and `src/signup.ts` (members-only sign-up); remove both, or restore the
28
36
  shell's allow-all `src/signup.ts`, unless sign-up should be invite-only. Purchasing brings its supplier callback
29
37
  (`src/routes.ts`, `src/supplier.ts`), email (`src/notify.ts`), cron (`src/schedule.ts` and `triggers.crons`) and its
30
38
  `wrangler.jsonc` secrets. `pnpm generate` and `tsc` point at leftovers that reference removed Schemas.
31
39
 
32
40
  Every example uses two roles: `admin` (the admin layer) and `user`.
33
41
 
34
- [Reference service](https://github.com/aotter/mantle/tree/v0.2.0-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.
42
+ [Reference service](https://github.com/aotter/mantle/tree/v0.2.0/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
35
43
  Projects on 0.1.x move with [the upgrade guide](../upgrade-0.1-to-0.2.md).
@@ -2,22 +2,23 @@
2
2
 
3
3
  The wiring every Mantle Cloudflare app needs, ready to copy: Better Auth (email OTP, admin + user roles),
4
4
  one Worker fetch (auth routes → your routes → Admin → MCP → generated REST), the Admin console assets, and a
5
- local acceptance kit. It has no business manifests: add your own, or copy one of the examples on top of it.
5
+ local acceptance kit. It has no business manifests: add your own, taking patterns from the examples (`mantle help examples`).
6
6
 
7
7
  ## Start an app
8
8
 
9
9
  ```sh
10
- cp -R node_modules/@aotter/mantle/docs/examples/app-shell/. my-app/ # or from this repository
11
- cp -R node_modules/@aotter/mantle/docs/examples/club-events/. my-app/ # optional: an example on top
10
+ npx @aotter/mantle@<exact version> init my-app # writes this shell once, Mantle packages pinned to that version
12
11
  cd my-app
13
- # package.json: pin the exact installed Mantle version for @aotter/mantle, -admin and -ui
14
12
  pnpm install
13
+ # add manifests/*.yaml (mantle help four-atoms; patterns: mantle help examples)
15
14
  pnpm generate # writes generated/ and lists any `handler: { ref }` to implement in src/handlers.ts
16
15
  pnpm types && pnpm check
17
16
  cp .dev.vars.example .dev.vars
18
- pnpm db:init && pnpm db:seed # db:seed only when the example has seed.sql
17
+ pnpm db:init # add seed.sql and run pnpm db:seed for fixtures
19
18
  pnpm dev # one-time codes print to this log
20
- pnpm smoke # when the copied example has smoke.mjs
19
+ pnpm smoke # your smoke.mjs on smoke-kit.mjs (see Files you edit)
20
+ pnpm accept # hand each role to a fresh evaluator (see Independent acceptance)
21
+ pnpm test # Admin MCP App check: needs Chrome (npx playwright-core install chromium, or MANTLE_TEST_CHROMIUM=<executable>)
21
22
  ```
22
23
 
23
24
  npm works too (`npm install`, `npm run …`). `package.json` pins `@better-auth/core` and `@better-auth/utils` even though
@@ -38,6 +39,7 @@ on one version.
38
39
  | `src/signup.ts` | `allowSignUp(env, email)`: who may create an account (allow-all by default). `auth.ts` calls it before Better Auth creates a user; `ADMIN_EMAIL` is always allowed. |
39
40
  | `src/auth.ts` | Better Auth options. Two roles only: `admin` (the admin layer) and `user`. Replace the console OTP sender before deploying. |
40
41
  | `smoke.mjs` | Your acceptance steps on `smoke-kit.mjs` (fresh local D1, OTP sign-in per user, REST/MCP/Admin calls). |
42
+ | `accept.mjs` | Independent acceptance setup: one URL per role that carries its session, plus a minimal MCP client. Add a fixtures module for realistic data. |
41
43
 
42
44
  `src/service.ts` and `src/index.ts` rarely change. The default shell mounts REST and MCP together. If the app deliberately
43
45
  chooses asymmetric exposure, delete the blocks marked `MCP / OAuth (deliberate exception)` in `auth.ts` and `service.ts`;
@@ -51,10 +53,39 @@ Search for `AGENT:` to find what an app must fill in.
51
53
  `cf-connecting-ip`; when testing by hand, wait a minute or vary that header.
52
54
  - `ADMIN_EMAIL`'s first verified sign-in becomes `admin` while no admin exists. The sign-in response may still show
53
55
  the previous role; `/admin/api/me` shows the current one.
56
+ - More administrators: in Admin, **More → Administrators** invites by email and **More → Members** grants Admin to an
57
+ existing member. `docs/handbook/cloudflare/authentication.md` (Identity helpers) has the same operations as an API.
54
58
  - Admin HTTP routes are listed in `docs/handbook/guides/native-admin.md`.
55
59
 
60
+ `mantle help local-testing` lists what the smoke covers: every Procedure at its edges, each caller's isolation,
61
+ visitor pages without JavaScript, MCP discovery and Admin's MCP App.
56
62
  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).
63
+ deliverable done, follow the independent acceptance guide: `mantle help agent-workflow-acceptance`.
64
+
65
+ ### Independent acceptance
66
+
67
+ `pnpm accept` (optionally `pnpm accept --fixtures fixtures.mjs --user member@example.test`; with npm, `npm run accept -- --fixtures …`) starts the app on a fresh local D1,
68
+ signs the admin in, runs your fixtures module, signs the user in and prints one local URL per role:
69
+
70
+ ```text
71
+ visitor (anonymous): http://127.0.0.1:18741
72
+ admin (owner@example.test): http://127.0.0.1:46467 MCP http://127.0.0.1:46467/mcp/admin Admin http://127.0.0.1:46467/admin
73
+ user (member@example.test): http://127.0.0.1:42607 MCP http://127.0.0.1:42607/mcp
74
+ ```
75
+
76
+ Each URL forwards to the app with that role's session; the evaluator never sees a cookie, and the URL refuses
77
+ `/api/auth/*`, so it cannot sign out, sign in as someone else or mint credentials. Give an evaluator only its role's URL:
78
+
79
+ - MCP: configure the URL as a streamable HTTP MCP server, or, without an MCP client,
80
+ `node accept.mjs mcp <url>/mcp` lists the tools and `node accept.mjs mcp <url>/mcp <tool> '<json>'` calls one.
81
+ - Admin and member pages: open the URL in a browser (a person's, or Playwright for an agent); it is already
82
+ signed in.
83
+ - REST: send requests to the URL with no credential.
84
+
85
+ The fixtures module's default export receives `{ call, mcp, signIn, sql, sessions, user }` and runs between the two
86
+ sign-ins, so an invite-only app can admit the user first. Create records through the app's own REST/MCP as real
87
+ users (other members' records, several states, more than one page); `sql` is for what no surface may write, and returns storage column names (lower case unless aliased).
88
+ Ctrl-C stops the app and deletes its data. To accept an external MCP host's OAuth connection instead, see the guide.
58
89
 
59
90
  ### MCP acceptance
60
91
 
@@ -0,0 +1,91 @@
1
+ // Independent acceptance setup (mantle help agent-workflow-acceptance): hands each role to a fresh evaluator without a credential.
2
+ // node accept.mjs [--fixtures fixtures.mjs] [--user member@example.test]
3
+ // Starts the app on a fresh local D1 (schema.sql, then seed.sql), signs the admin in with email OTP, runs the optional
4
+ // fixtures module (default export async ({ call, mcp, signIn, sql, sessions, user }) => {...}: realistic records through
5
+ // the app's own REST/MCP as real users, e.g. other members' orders and more than one page), signs the user in, then
6
+ // serves one local proxy per role that adds that role's session to every request. Give an evaluator only its role's URL
7
+ // (a visitor gets the app's own origin):
8
+ // MCP at <proxy>/mcp (user) or <proxy>/mcp/admin (admin), Admin at <proxy>/admin, pages and REST at <proxy>/.
9
+ // The proxy refuses /api/auth/* so the evaluator cannot sign out, sign in or mint credentials. Ctrl-C stops everything.
10
+ // node accept.mjs mcp <endpoint> [tool [json-arguments]]
11
+ // A minimal MCP client for an evaluator without one: lists the tools, or calls one and prints the raw result.
12
+ import { createServer } from "node:http";
13
+ import { Readable } from "node:stream";
14
+ import { parseArgs } from "node:util";
15
+ import { pathToFileURL } from "node:url";
16
+ import { resolve } from "node:path";
17
+
18
+ if (process.argv[2] === "mcp") await client(process.argv[3], process.argv[4], process.argv[5]).catch(error => { console.error(error.message); process.exitCode = 1; });
19
+ else await setup();
20
+
21
+ async function setup() {
22
+ const { values } = parseArgs({ args: process.argv.slice(2).filter(arg => arg !== "--"), options: { fixtures: { type: "string" }, user: { type: "string", default: "member@example.test" } } });
23
+ const { startApp } = await import("./smoke-kit.mjs");
24
+ const app = await startApp();
25
+ const stop = async () => { await app.stop(); process.exit(0); };
26
+ process.on("SIGINT", stop);
27
+ process.on("SIGTERM", stop);
28
+ try {
29
+ // Fixtures run between the two sign-ins, so an invite-only app can admit the user first.
30
+ const sessions = { admin: await app.signIn(app.owner) };
31
+ if (values.fixtures) await (await import(pathToFileURL(resolve(values.fixtures)).href)).default({ ...app, sessions, user: values.user });
32
+ sessions.user = await app.signIn(values.user);
33
+ const lines = [`visitor (anonymous): ${app.origin}`];
34
+ for (const [role, cookie] of Object.entries(sessions)) {
35
+ const url = await proxy(app.origin, cookie);
36
+ lines.push(`${role} (${role === "admin" ? app.owner : values.user}): ${url} MCP ${url}/mcp${role === "admin" ? "/admin" : ""}${role === "admin" ? ` Admin ${url}/admin` : ""}`);
37
+ }
38
+ console.log(lines.join("\n") + "\nReady. Ctrl-C stops the app and deletes its data.");
39
+ } catch (error) {
40
+ console.error(app.logs().slice(-4000));
41
+ await app.stop();
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ /** A local origin that forwards to the app with one role's session; sliding Set-Cookie updates stay inside the proxy. */
47
+ function proxy(origin, cookie) {
48
+ const jar = new Map(cookie.split("; ").map(pair => [pair.slice(0, pair.indexOf("=")), pair]));
49
+ const server = createServer(async (request, response) => {
50
+ const url = new URL(request.url, origin);
51
+ if (url.pathname.startsWith("/api/auth/")) { response.writeHead(403, { "content-type": "text/plain" }).end("Sign-in is handled by setup."); return; }
52
+ const chunks = [];
53
+ for await (const chunk of request) chunks.push(chunk);
54
+ const headers = new Headers();
55
+ for (const [name, value] of Object.entries(request.headers)) if (!["host", "cookie", "origin", "connection", "content-length"].includes(name)) headers.set(name, [value].flat().join(", "));
56
+ headers.set("cookie", [...jar.values()].join("; "));
57
+ headers.set("origin", origin);
58
+ const upstream = await fetch(url, { method: request.method, headers, redirect: "manual", body: chunks.length ? Buffer.concat(chunks) : undefined });
59
+ for (const set of upstream.headers.getSetCookie()) { const pair = set.split(";")[0]; jar.set(pair.slice(0, pair.indexOf("=")), pair); }
60
+ const out = {};
61
+ upstream.headers.forEach((value, name) => { if (!["set-cookie", "content-encoding", "content-length", "transfer-encoding"].includes(name)) out[name] = value; });
62
+ if (out.location) out.location = out.location.replace(origin, `http://${request.headers.host}`);
63
+ response.writeHead(upstream.status, out);
64
+ if (upstream.body) Readable.fromWeb(upstream.body).pipe(response); else response.end();
65
+ });
66
+ return new Promise(done => server.listen(0, "127.0.0.1", () => done(`http://127.0.0.1:${server.address().port}`)));
67
+ }
68
+
69
+ async function client(endpoint, tool, json) {
70
+ if (!endpoint) throw new Error("usage: node accept.mjs mcp <endpoint> [tool [json-arguments]]");
71
+ const headers = { "content-type": "application/json", accept: "application/json, text/event-stream" };
72
+ let id = 0;
73
+ const rpc = async (method, params) => {
74
+ const response = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", ...(method.startsWith("notifications/") ? {} : { id: ++id }), method, params }) });
75
+ const session = response.headers.get("mcp-session-id");
76
+ if (session) headers["mcp-session-id"] = session;
77
+ const text = await response.text();
78
+ if (!response.ok && response.status !== 202) throw new Error(`MCP ${method}: HTTP ${response.status} ${text}`);
79
+ const data = text.split("\n").filter(line => line.startsWith("data:")).map(line => line.slice(5)).join("\n") || text;
80
+ return data ? JSON.parse(data) : null;
81
+ };
82
+ const init = await rpc("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "mantle-accept", version: "1" } });
83
+ headers["mcp-protocol-version"] = init.result?.protocolVersion ?? "2025-11-25";
84
+ await rpc("notifications/initialized");
85
+ if (!tool) {
86
+ const tools = [];
87
+ let cursor;
88
+ do { const page = (await rpc("tools/list", cursor ? { cursor } : {})).result; tools.push(...page.tools); cursor = page.nextCursor; } while (cursor);
89
+ console.log(JSON.stringify(tools, null, 2));
90
+ } else console.log(JSON.stringify(await rpc("tools/call", { name: tool, arguments: json ? JSON.parse(json) : {} }), null, 2));
91
+ }
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ .wrangler/
3
+ .dev.vars
4
+ .env
5
+ *.sqlite
6
+ *.sqlite-shm
7
+ *.sqlite-wal
@@ -5,17 +5,18 @@
5
5
  "scripts": {
6
6
  "generate": "mantle generate cloudflare-d1 generated manifests/*.yaml --auth src/auth.ts --entry src/index.ts --admin",
7
7
  "types": "wrangler types --include-runtime=false",
8
- "db:init": "wrangler d1 execute mantle-app --local --file generated/schema.sql",
9
- "db:seed": "wrangler d1 execute mantle-app --local --file seed.sql",
8
+ "db:init": "wrangler d1 execute DB --local --file generated/schema.sql",
9
+ "db:seed": "wrangler d1 execute DB --local --file seed.sql",
10
10
  "build": "mantle bundle src/index.ts dist",
11
11
  "test": "node --test generated/admin-mcp-app.check.mjs",
12
12
  "dev": "wrangler dev --local --ip 127.0.0.1 --port 8787",
13
13
  "check": "mantle generate cloudflare-d1 generated manifests/*.yaml --auth src/auth.ts --entry src/index.ts --admin --check && wrangler types --include-runtime=false --check && tsc --noEmit",
14
- "smoke": "node smoke.mjs"
14
+ "smoke": "node smoke.mjs",
15
+ "accept": "node accept.mjs"
15
16
  },
16
17
  "dependencies": {
17
- "@aotter/mantle": "0.2.0-alpha.9",
18
- "@aotter/mantle-ui": "0.2.0-alpha.9",
18
+ "@aotter/mantle": "0.2.0",
19
+ "@aotter/mantle-ui": "0.2.0",
19
20
  "@better-auth/cimd": "1.7.7",
20
21
  "@better-auth/core": "1.7.7",
21
22
  "@better-auth/mcp": "1.7.7",
@@ -24,7 +25,7 @@
24
25
  "@modelcontextprotocol/ext-apps": "2.0.0",
25
26
  "@modelcontextprotocol/server": "2.2.0",
26
27
  "better-auth": "1.7.7",
27
- "@aotter/mantle-admin": "0.2.0-alpha.9",
28
+ "@aotter/mantle-admin": "0.2.0",
28
29
  "react": "19.3.0",
29
30
  "react-dom": "19.3.0"
30
31
  },
@@ -3,6 +3,7 @@
3
3
  // import { smoke } from "./smoke-kit.mjs";
4
4
  // await smoke(async ({ call, mcp, signIn, owner, step }) => { const admin = await signIn(owner); ... step("..."); });
5
5
  // Extra Worker variables: await smoke(scenario, { vars: { SUPPLIER_API_URL: "http://127.0.0.1:9999" } });
6
+ // accept.mjs uses startApp() to keep the app running for fresh evaluators.
6
7
  import assert from "node:assert/strict";
7
8
  import { execFile, spawn, spawnSync } from "node:child_process";
8
9
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
@@ -64,11 +65,23 @@ export async function assertMcpDiscovery(endpoint, { tools, headers = {} }) {
64
65
  return names;
65
66
  }
66
67
 
67
- const database = "mantle-app"; // wrangler.jsonc d1_databases[0].database_name
68
+ const database = "DB"; // the D1 binding (wrangler.jsonc d1_databases[0].binding), so renaming the database changes nothing here
68
69
  const env = { ...process.env, WRANGLER_SEND_METRICS: "false" };
69
70
 
70
- export async function smoke(scenario, { vars = {} } = {}) {
71
- const port = 18000 + Math.floor(Math.random() * 1000), origin = `http://127.0.0.1:${port}`, owner = "owner@example.test";
71
+ export async function smoke(scenario, options) {
72
+ const app = await startApp(options);
73
+ try {
74
+ await scenario(app);
75
+ console.log("smoke passed");
76
+ } catch (error) {
77
+ console.error(app.logs().slice(-4000));
78
+ throw error;
79
+ } finally { await app.stop(); }
80
+ }
81
+
82
+ /** Start the app on a fresh local D1 (schema.sql, then seed.sql when present); stop() ends wrangler and deletes the state. */
83
+ export async function startApp({ vars = {}, port = 18000 + Math.floor(Math.random() * 1000) } = {}) {
84
+ const origin = `http://127.0.0.1:${port}`, owner = "owner@example.test";
72
85
  const state = mkdtempSync(join(tmpdir(), "mantle-smoke-"));
73
86
  // Deployment DDL is explicit: nothing at runtime creates tables.
74
87
  for (const file of ["generated/schema.sql", ...(existsSync("seed.sql") ? ["seed.sql"] : [])]) {
@@ -128,6 +141,17 @@ export async function smoke(scenario, { vars = {} } = {}) {
128
141
  assert.fail(`no log line matching ${pattern}`);
129
142
  };
130
143
  const step = name => console.log(`ok - ${name}`);
144
+ const stop = async () => {
145
+ const kill = signal => { try { process.kill(-wrangler.pid, signal); } catch { /* already gone */ } };
146
+ kill("SIGTERM");
147
+ await new Promise(resolve => {
148
+ if (wrangler.exitCode !== null) return resolve();
149
+ const timer = setTimeout(() => { kill("SIGKILL"); resolve(); }, 5_000);
150
+ wrangler.once("exit", () => { clearTimeout(timer); resolve(); });
151
+ });
152
+ kill("SIGKILL");
153
+ rmSync(state, { recursive: true, force: true });
154
+ };
131
155
  try {
132
156
  for (let attempt = 0; ; attempt++) {
133
157
  if (wrangler.exitCode !== null) throw new Error(`wrangler exited ${wrangler.exitCode}`);
@@ -135,20 +159,10 @@ export async function smoke(scenario, { vars = {} } = {}) {
135
159
  if (attempt > 240) throw new Error("the Worker did not start");
136
160
  await new Promise(resolve => setTimeout(resolve, 250));
137
161
  }
138
- await scenario({ origin, owner, call, mcp, signIn, sql, cron, waitForLog, step, logs: () => logs });
139
- console.log("smoke passed");
140
162
  } catch (error) {
141
163
  console.error(logs.slice(-4000));
164
+ await stop();
142
165
  throw error;
143
- } finally {
144
- const stop = signal => { try { process.kill(-wrangler.pid, signal); } catch { /* already gone */ } };
145
- stop("SIGTERM");
146
- await new Promise(resolve => {
147
- if (wrangler.exitCode !== null) return resolve();
148
- const timer = setTimeout(() => { stop("SIGKILL"); resolve(); }, 5_000);
149
- wrangler.once("exit", () => { clearTimeout(timer); resolve(); });
150
- });
151
- stop("SIGKILL");
152
- rmSync(state, { recursive: true, force: true });
153
166
  }
167
+ return { origin, owner, call, mcp, signIn, sql, cron, waitForLog, step, logs: () => logs, stop };
154
168
  }
@@ -2,10 +2,13 @@ import type { Caller } from "@aotter/mantle/contracts";
2
2
  import { signInPage } from "./sign-in.js";
3
3
 
4
4
  /**
5
- * App-owned routes, tried before Admin, MCP and REST. Return undefined to fall through.
5
+ * App-owned routes, tried before REST. Return undefined to fall through. Paths under /admin and /mcp never reach here, and
6
+ * neither do Better Auth's (/api/auth/*, /oauth/consent*, /.well-known/oauth-*). A root catch-all page (/:slug) must
7
+ * still return undefined for the paths of the app's own HTTP Triggers (REST), or it shadows them.
6
8
  * GET /sign-in: member sign-in (src/sign-in.ts); a member page sends anonymous visitors there with signInRedirect.
7
- * AGENT: add visitor pages (HTML, SEO, feeds) and callbacks/webhooks here. A callback must verify the raw Request
8
- * (signature, timestamp) before it reads the body as JSON or calls a generated Procedure.
9
+ * AGENT: add visitor pages and callbacks/webhooks here. Visitor pages carry everything in `mantle help public-pages`
10
+ * (canonical, Open Graph, JSON-LD, answer block, .md alternates, sitemap.xml, robots.txt, llms.txt; recipes example).
11
+ * A callback must verify the raw Request (signature, timestamp) before it reads the body as JSON or calls a generated Procedure.
9
12
  */
10
13
  export async function appRoutes(request: Request, _env: Env, _context: { caller: () => Promise<Caller>; waitUntil: (task: Promise<unknown>) => void }): Promise<Response | undefined> {
11
14
  if (new URL(request.url).pathname === "/sign-in" && request.method === "GET") return signInPage();
@@ -43,7 +43,8 @@ export async function fetchService(request: Request, env: Env, context: Executio
43
43
  const ownAuth = await withAuthTasks(context.waitUntil.bind(context), () => authRoutes(auth!, { methods: [{ kind: "email-otp" }], resolver: caller })(request));
44
44
  if (ownAuth) return respond(ownAuth);
45
45
  const url = new URL(request.url);
46
- const own = await appRoutes(request, env, { caller, waitUntil: context.waitUntil.bind(context) });
46
+ // /admin and /mcp belong to Admin and MCP: a root catch-all page in src/routes.ts (/:slug) never shadows them.
47
+ const own = /^\/(?:admin|mcp)(?:\/|$)/.test(url.pathname) ? undefined : await appRoutes(request, env, { caller, waitUntil: context.waitUntil.bind(context) });
47
48
  if (own) return respond(own);
48
49
  let response: Response | undefined;
49
50
  if (url.pathname === "/admin/api" || url.pathname.startsWith("/admin/api/")) {
@@ -23,6 +23,9 @@ over MCP. Only members the officers listed in Admin can sign up. Officers see ev
23
23
  Schema check; no View or Trigger exposes it). `src/signup.ts` replaces the shell's allow-all `allowSignUp` with a
24
24
  Store lookup as a system caller, so any other email gets 403 at sign-in and no account. To invite another officer
25
25
  through Admin, list their email in `members` first.
26
+ - **An event with its attendees in one read**: the admin View `event-roster` returns each event with an `attendees`
27
+ JSON array built by `json_group_array(json_set('{}', ...))` in a correlated subquery, so an event nobody joined has
28
+ `[]`. PostgreSQL writes it with `jsonb_agg(jsonb_build_object(...))`; see `mantle help sql-patterns`.
26
29
  - **Member MCP tool**: the `register-mcp` Trigger (`surface: public`) exposes `register` on `/mcp`, so the member's AI
27
30
  registers with the member's own sign-in.
28
31
 
@@ -36,9 +39,11 @@ over MCP. Only members the officers listed in Admin can sign up. Officers see ev
36
39
 
37
40
  ## Run it
38
41
 
42
+ This runs the example by itself, to read and try. For your own app, run `mantle init` and take only the patterns you need into its manifests and `src/` (`mantle help examples`).
43
+
39
44
  ```sh
40
- cp -R node_modules/@aotter/mantle/docs/examples/app-shell/. my-app/
41
- cp -R node_modules/@aotter/mantle/docs/examples/club-events/. my-app/
45
+ npx mantle init my-app
46
+ cp -R node_modules/@aotter/mantle/docs/examples/club-events/. my-app/ # run the example as is
42
47
  cd my-app && pnpm install && pnpm generate && pnpm types && pnpm check
43
48
  cp .dev.vars.example .dev.vars && pnpm db:init && pnpm dev
44
49
  pnpm smoke
@@ -86,6 +86,21 @@ spec:
86
86
  FROM registrations r JOIN events e ON e.id = r.eventId
87
87
  ORDER BY r.created_at DESC, r.id DESC
88
88
  ---
89
+ # Officers' roster: each event with its attendees as one JSON array, so one read answers "who is coming".
90
+ # The correlated subquery gives [] for an event nobody registered for (a LEFT JOIN + GROUP BY would give one empty object).
91
+ # SQLite/D1 builds the objects with json_set; on PostgreSQL write jsonb_agg(jsonb_build_object('memberName', r.memberName)).
92
+ apiVersion: cms.mantle.aotter.net/v2
93
+ kind: View
94
+ metadata: { name: event-roster }
95
+ spec:
96
+ title: Event roster
97
+ surface: admin
98
+ requires: { auth: { all: [ctx.admin] } }
99
+ sql: |
100
+ SELECT e.id, e.title, e.startsAt AS "startsAt", e.seats_taken AS "seatsTaken",
101
+ (SELECT json_group_array(json_set('{}', '$.memberName', r.memberName)) FROM registrations r WHERE r.eventId = e.id) AS attendees
102
+ FROM events e ORDER BY e.startsAt, e.id
103
+ ---
89
104
  # The member-facing entry: a ref handler checks the event, then runs register-write.
90
105
  apiVersion: cms.mantle.aotter.net/v2
91
106
  kind: Procedure
@@ -105,7 +105,13 @@ await smoke(async ({ call, mcp, signIn, sql, owner, step, logs }) => {
105
105
  assert.equal((await call("GET", "/admin/api/entries?collection=registrations", { cookie: bob })).status, 403);
106
106
  assert.equal((await call("GET", "/admin/api/me", { cookie: bob })).status, 403);
107
107
  assert.ok(all.body.items.every(r => r.data.memberName && r.data.memberName !== r.data.member), JSON.stringify(all.body.items));
108
- step("officers see every member's registrations, with names, through Admin's API; a member gets 403 on Admin");
108
+ const roster = await call("GET", "/admin/api/views/event-roster", { cookie: admin });
109
+ assert.equal(roster.status, 200, JSON.stringify(roster.body));
110
+ const attendees = row => (typeof row.attendees === "string" ? JSON.parse(row.attendees) : row.attendees).map(a => a.memberName).sort();
111
+ for (const row of roster.body.rows) assert.equal(attendees(row).length, row.seatsTaken, JSON.stringify(row));
112
+ assert.ok(roster.body.rows.some(row => attendees(row).length > 0), JSON.stringify(roster.body.rows));
113
+ assert.equal((await call("GET", "/admin/api/views/event-roster", { cookie: bob })).status, 403);
114
+ step("officers see every member's registrations, with names, through Admin's API; and every event with its attendees as one JSON array (event-roster); a member gets 403 on Admin");
109
115
 
110
116
  const entry = async id => (await call("GET", `/admin/api/entries/${id}?collection=events`, { cookie: admin })).body.entry;
111
117
  const before = await seatsOf(hike.id), read = await entry(hike.id);
@@ -14,6 +14,13 @@ that have been waiting too long.
14
14
  `change-amount`, `mark-ordered`, `confirm-order`). The column is `requestStatus` because `status` is reserved for
15
15
  publishing Schemas. `requests` and `ledger` set `readOnly: true`, so Admin's generic editor cannot bypass the state
16
16
  machine (for example by moving an ordered request back to `submitted` and approving it again).
17
+ - **A status chosen at submission**: `submit-request` uses `CASE WHEN input.amount <= 100 THEN 'approved' ELSE
18
+ 'submitted' END`. A small request also spends the budget and writes its ledger row in the same program, so
19
+ `remaining >= 0` refuses all three when it would overspend. Those two statements find the request by
20
+ `created_at = now()` (the instant of this program) and `NOT EXISTS` a ledger row, so the threshold is written only in
21
+ the `CASE`. Larger requests wait for an admin. An auto-approved request sends no approval email, and the daily cron
22
+ orders it from the supplier (it already retries every `approved` request); route small requests through a `ref`
23
+ handler like `approve-request` if they must be ordered and announced at once.
17
24
  - **Admin-only operations**: `approve-request` and `reject-request` declare `requires: ctx.admin` and a `target`, so
18
25
  Admin shows them as operations on a request. Their `kind: mcp, surface: admin` Triggers make them
19
26
  `approve_request` / `reject_request` tools on `/mcp/admin`. They are not listed on `/mcp`.
@@ -56,11 +63,13 @@ scheme.
56
63
  - Need another approval flow? Copy the pattern: a ref Procedure (admin-only, side effects) that wraps a guarded SQL
57
64
  write Procedure (one transaction).
58
65
 
59
- ## Copy
66
+ ## Run it
67
+
68
+ This runs the example by itself, to read and try. For your own app, run `mantle init` and take only the patterns you need into its manifests and `src/` (`mantle help examples`).
60
69
 
61
70
  ```sh
62
- cp -R node_modules/@aotter/mantle/docs/examples/app-shell/. my-app/
63
- cp -R node_modules/@aotter/mantle/docs/examples/purchasing/. my-app/
71
+ npx mantle init my-app
72
+ cp -R node_modules/@aotter/mantle/docs/examples/purchasing/. my-app/ # run the example as is
64
73
  cd my-app && pnpm install && pnpm generate && pnpm types && pnpm check
65
74
  cp .dev.vars.example .dev.vars # then set SUPPLIER_API_URL and SUPPLIER_WEBHOOK_SECRET
66
75
  pnpm db:init && pnpm db:seed && pnpm dev
@@ -48,10 +48,12 @@ spec:
48
48
  title: Budget ledger
49
49
  lifecycle: operational
50
50
  indexes: [[createdAt, id]]
51
+ # One row per spending request.
52
+ uniqueIndexes: [[requestId]]
51
53
  uiSchema: { list: { primaryField: department, columns: [amount, remainingAfter, requestId] } }
52
54
  schema:
53
55
  type: object
54
- readOnly: true # written only by approve-request-write
56
+ readOnly: true # written only by approve-request-write and submit-request
55
57
  additionalProperties: false
56
58
  required: [requestId, department, amount, remainingAfter]
57
59
  properties:
@@ -117,7 +119,7 @@ kind: Procedure
117
119
  metadata: { name: submit-request }
118
120
  spec:
119
121
  title: Submit a purchase request
120
- description: Submit a purchase request against an existing department budget.
122
+ description: Submit a purchase request against an existing department budget; a request of 100 or less is approved at once and spends the budget.
121
123
  requires: { auth: { all: [ctx.user] } }
122
124
  input:
123
125
  type: object
@@ -128,11 +130,26 @@ spec:
128
130
  amount: { type: integer, minimum: 1 }
129
131
  department: { type: string }
130
132
  output: { type: object }
133
+ # One program. CASE picks the status: up to 100 is approved now, more waits for an admin. The budget and ledger
134
+ # statements then act only on "the request this program approved": the caller's, created by this program
135
+ # (created_at = now(), the same instant Mantle writes into created_at), approved, and with no ledger row yet. So the
136
+ # threshold lives only in the CASE, and remaining >= 0 refuses all three statements when a small request would
137
+ # overspend. A pending request matches nothing there, which a set form (IN (SELECT ...), INSERT ... SELECT) allows.
131
138
  handler:
132
139
  sql: |
133
140
  INSERT INTO requests (title, amount, department, requestStatus)
134
- SELECT input.title, input.amount, b.department, 'submitted' FROM budgets b WHERE b.department = input.department
135
- RETURNING id, version
141
+ SELECT input.title, input.amount, b.department, CASE WHEN input.amount <= 100 THEN 'approved' ELSE 'submitted' END
142
+ FROM budgets b WHERE b.department = input.department
143
+ RETURNING id, version, requestStatus AS "requestStatus";
144
+ UPDATE budgets SET remaining = remaining - (SELECT SUM(r.amount) FROM requests r WHERE r.department = budgets.department
145
+ AND r.author_id = auth.uid() AND r.created_at = now() AND r.requestStatus = 'approved'
146
+ AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id))
147
+ WHERE department IN (SELECT r.department FROM requests r WHERE r.author_id = auth.uid() AND r.created_at = now()
148
+ AND r.requestStatus = 'approved' AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id));
149
+ INSERT INTO ledger (requestId, department, amount, remainingAfter)
150
+ SELECT r.id, r.department, r.amount, b.remaining FROM requests r JOIN budgets b ON b.department = r.department
151
+ WHERE r.author_id = auth.uid() AND r.created_at = now() AND r.requestStatus = 'approved'
152
+ AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id)
136
153
  ---
137
154
  # The requester may change the amount while the request is still 'submitted'. The version moves on, so an admin
138
155
  # approving the version they reviewed before the change gets a 409 and looks again.