@aotter/mantle 0.2.0-alpha.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +26 -3
  2. package/dist/auth/adapter.js +10 -34
  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 +8 -153
  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 +17 -32
  34. package/dist/infrastructure/cli.js +25 -11
  35. package/dist/infrastructure/generate.js +18 -17
  36. package/dist/infrastructure/generateAuth.js +48 -5
  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 +18 -9
  42. package/dist/kernel/authorization.js +0 -1
  43. package/dist/kernel/binds.d.ts +16 -7
  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 +7 -11
  50. package/dist/kernel/contracts.js +8 -44
  51. package/dist/kernel/cron.js +0 -4
  52. package/dist/kernel/dialect/common.d.ts +0 -1
  53. package/dist/kernel/dialect/common.js +0 -3
  54. package/dist/kernel/dialect/postgres.js +0 -2
  55. package/dist/kernel/dialect/sqlite.js +1 -6
  56. package/dist/kernel/drain.js +2 -8
  57. package/dist/kernel/driver/bun-sqlite.js +0 -4
  58. package/dist/kernel/driver/d1.js +0 -1
  59. package/dist/kernel/driver/postgres.js +3 -17
  60. package/dist/kernel/driver/transaction-context.js +0 -1
  61. package/dist/kernel/email.js +0 -4
  62. package/dist/kernel/host-access.js +2 -3
  63. package/dist/kernel/host-caller.d.ts +1 -1
  64. package/dist/kernel/host-caller.js +3 -6
  65. package/dist/kernel/json-body.js +0 -2
  66. package/dist/kernel/mcp-apps.js +1 -6
  67. package/dist/kernel/mcp-call.js +0 -1
  68. package/dist/kernel/mcp-handler.js +3 -27
  69. package/dist/kernel/mcp.d.ts +5 -1
  70. package/dist/kernel/mcp.js +2 -6
  71. package/dist/kernel/media.js +0 -18
  72. package/dist/kernel/paging.js +1 -9
  73. package/dist/kernel/publishing.js +3 -4
  74. package/dist/kernel/r2-media.js +0 -7
  75. package/dist/kernel/rest-dispatch.js +0 -1
  76. package/dist/kernel/rest-router.js +2 -3
  77. package/dist/kernel/rest.js +0 -3
  78. package/dist/kernel/scalars.js +4 -11
  79. package/dist/kernel/search.js +0 -1
  80. package/dist/kernel/site.js +0 -2
  81. package/dist/kernel/store.d.ts +11 -1
  82. package/dist/kernel/store.js +21 -34
  83. package/dist/kernel/surface-failure.d.ts +10 -0
  84. package/dist/kernel/surface-failure.js +29 -0
  85. package/dist/kernel/surface.js +0 -3
  86. package/dist/kernel/sweep.js +0 -1
  87. package/dist/kernel/translations.js +0 -1
  88. package/dist/kernel/user-caller.js +2 -8
  89. package/dist/kernel/user-fields.d.ts +3 -2
  90. package/dist/kernel/user-fields.js +9 -9
  91. package/dist/kernel/validation.js +0 -8
  92. package/dist/kernel/web-gateway.js +0 -2
  93. package/dist/kernel/web-head.js +0 -1
  94. package/dist/kernel/web-index.js +39 -0
  95. package/dist/kernel/web-response.js +0 -1
  96. package/dist/kernel/web-serve.js +97 -0
  97. package/dist/kernel/web.d.ts +1 -1
  98. package/dist/kernel/web.js +10 -125
  99. package/dist/kernel/where.js +0 -6
  100. package/dist/usecase/adminBindings.js +30 -21
  101. package/dist/usecase/adminClient.js +0 -21
  102. package/dist/usecase/adminDescriptors.js +2 -9
  103. package/dist/usecase/generate.js +7 -12
  104. package/dist/usecase/handlers.js +0 -3
  105. package/dist/usecase/mcp-media.js +0 -1
  106. package/dist/usecase/mcp.js +0 -4
  107. package/dist/usecase/paging.js +1 -3
  108. package/dist/usecase/publishing.js +1 -5
  109. package/dist/usecase/rest.js +0 -1
  110. package/dist/usecase/site.js +0 -2
  111. package/dist/usecase/stores.js +4 -14
  112. package/dist/usecase/tool-version.js +0 -3
  113. package/dist/usecase/translations.js +0 -2
  114. package/dist/usecase/validation.js +8 -20
  115. package/docs/examples/README.md +13 -5
  116. package/docs/examples/app-shell/README.md +43 -8
  117. package/docs/examples/app-shell/accept.mjs +91 -0
  118. package/docs/examples/app-shell/gitignore +7 -0
  119. package/docs/examples/app-shell/package.json +7 -6
  120. package/docs/examples/app-shell/smoke-kit.mjs +29 -15
  121. package/docs/examples/app-shell/src/auth.ts +3 -2
  122. package/docs/examples/app-shell/src/routes.ts +6 -3
  123. package/docs/examples/app-shell/src/service.ts +4 -3
  124. package/docs/examples/club-events/README.md +7 -2
  125. package/docs/examples/club-events/manifests/club-events.yaml +22 -0
  126. package/docs/examples/club-events/smoke.mjs +7 -1
  127. package/docs/examples/club-events/src/handlers.ts +1 -1
  128. package/docs/examples/purchasing/README.md +12 -3
  129. package/docs/examples/purchasing/manifests/purchasing.yaml +36 -3
  130. package/docs/examples/purchasing/smoke.mjs +14 -0
  131. package/docs/examples/purchasing/src/handlers.ts +1 -1
  132. package/docs/examples/recipes/README.md +5 -3
  133. package/docs/handbook/cloudflare/authentication.md +21 -4
  134. package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
  135. package/docs/handbook/cloudflare/deploy-and-operate.md +24 -1
  136. package/docs/handbook/cloudflare/media-r2.md +2 -3
  137. package/docs/handbook/cloudflare/service-entry.md +17 -4
  138. package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
  139. package/docs/handbook/examples/hub.md +1 -1
  140. package/docs/handbook/guides/agent-setup.md +15 -10
  141. package/docs/handbook/guides/agent-workflow-acceptance.md +232 -0
  142. package/docs/handbook/guides/local-testing.md +40 -0
  143. package/docs/handbook/guides/mcp-apps.md +78 -1
  144. package/docs/handbook/guides/native-admin.md +29 -6
  145. package/docs/handbook/guides/native-execution.md +5 -4
  146. package/docs/handbook/guides/oauth.md +58 -0
  147. package/docs/handbook/guides/public-pages.md +44 -4
  148. package/docs/handbook/guides/sql-patterns.md +266 -0
  149. package/docs/handbook/guides/typed-queries.md +173 -0
  150. package/docs/handbook/navigation.json +16 -0
  151. package/docs/handbook/reference/admin-descriptors.md +4 -1
  152. package/docs/handbook/reference/authorization.md +1 -1
  153. package/docs/handbook/reference/diagnostics.md +6 -1
  154. package/docs/handbook/reference/procedure.md +5 -5
  155. package/docs/handbook/reference/schema.md +10 -4
  156. package/docs/handbook/reference/sql.md +64 -0
  157. package/docs/handbook/reference/surface.md +11 -4
  158. package/docs/handbook/reference/trigger.md +9 -0
  159. package/docs/handbook/reference/view.md +11 -3
  160. package/docs/handbook/releases/index.md +27 -6
  161. package/docs/handbook/start/agents.md +45 -0
  162. package/docs/handbook/start/project-and-cli.md +15 -1
  163. package/docs/handbook/start/quickstart-worker.md +1 -1
  164. package/docs/skills/develop/SKILL.md +44 -16
  165. package/docs/skills/integrate/SKILL.md +4 -9
  166. package/docs/skills/provision/SKILL.md +15 -12
  167. package/docs/skills/update/SKILL.md +21 -18
  168. package/package.json +24 -4
  169. package/skills/README.md +1 -1
  170. package/skills/mantle/SKILL.md +48 -107
  171. package/dist/domain/describe/chunk-ZVXBGP7Y.js +0 -424
  172. package/docs/skills/develop/mcp-app.md +0 -39
  173. package/skills/.keep +0 -0
  174. package/skills/mantle/references/local-testing.md +0 -15
  175. package/skills/mantle/references/web.md +0 -41
  176. package/skills/mantle/references/website.md +0 -49
@@ -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/")) {
@@ -56,8 +57,8 @@ export async function fetchService(request: Request, env: Env, context: Executio
56
57
  }
57
58
  response = await serveAdmin(request, env);
58
59
  } 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 }) } });
60
+ // --- MCP / OAuth (deliberate exception): remove with the auth plugins only for intentional asymmetric exposure.
61
+ serveMcpHandler ??= serveMcp(tools, { ...options, authorizationScopes: ["offline_access"], apps: { admin: adminMcpApps({ catalogue: mcpAppCatalogues.admin }) } });
61
62
  response = await serveMcpHandler(request, env);
62
63
  } else {
63
64
  response = await serveRest(request, env);
@@ -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
@@ -165,6 +180,13 @@ spec:
165
180
  source: { kind: http, method: POST, path: /api/registrations/cancel }
166
181
  target: { procedure: cancel-registration }
167
182
  ---
183
+ apiVersion: cms.mantle.aotter.net/v2
184
+ kind: Trigger
185
+ metadata: { name: cancel-registration-mcp }
186
+ spec:
187
+ source: { kind: mcp, surface: public }
188
+ target: { procedure: cancel-registration }
189
+ ---
168
190
  # Officers add records through declared Procedures: operational collections have no generic create in Admin.
169
191
  # collectionAction puts the Procedure's button and input form in that collection's list header; the admin MCP
170
192
  # Trigger is what makes it an Admin operation (and lets the officers' AI do the same).
@@ -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);
@@ -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
  }
@@ -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,6 +119,7 @@ kind: Procedure
117
119
  metadata: { name: submit-request }
118
120
  spec:
119
121
  title: Submit a purchase request
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.
120
123
  requires: { auth: { all: [ctx.user] } }
121
124
  input:
122
125
  type: object
@@ -127,11 +130,26 @@ spec:
127
130
  amount: { type: integer, minimum: 1 }
128
131
  department: { type: string }
129
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.
130
138
  handler:
131
139
  sql: |
132
140
  INSERT INTO requests (title, amount, department, requestStatus)
133
- SELECT input.title, input.amount, b.department, 'submitted' FROM budgets b WHERE b.department = input.department
134
- 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)
135
153
  ---
136
154
  # The requester may change the amount while the request is still 'submitted'. The version moves on, so an admin
137
155
  # approving the version they reviewed before the change gets a 409 and looks again.
@@ -140,6 +158,7 @@ kind: Procedure
140
158
  metadata: { name: change-amount }
141
159
  spec:
142
160
  title: Change the amount of my request
161
+ description: Change the amount of the signed-in member's submitted purchase request.
143
162
  requires: { auth: { all: [ctx.user] } }
144
163
  restrict: { requests: "author_id = auth.uid()" }
145
164
  input:
@@ -291,11 +310,25 @@ spec:
291
310
  ---
292
311
  apiVersion: cms.mantle.aotter.net/v2
293
312
  kind: Trigger
313
+ metadata: { name: submit-request-mcp }
314
+ spec:
315
+ source: { kind: mcp, surface: public }
316
+ target: { procedure: submit-request }
317
+ ---
318
+ apiVersion: cms.mantle.aotter.net/v2
319
+ kind: Trigger
294
320
  metadata: { name: change-amount-http }
295
321
  spec:
296
322
  source: { kind: http, method: POST, path: '/api/requests/{id}/amount' }
297
323
  target: { procedure: change-amount }
298
324
  ---
325
+ apiVersion: cms.mantle.aotter.net/v2
326
+ kind: Trigger
327
+ metadata: { name: change-amount-mcp }
328
+ spec:
329
+ source: { kind: mcp, surface: public }
330
+ target: { procedure: change-amount }
331
+ ---
299
332
  # Admin MCP tools: an AI agent connected to /mcp/admin can approve or reject.
300
333
  apiVersion: cms.mantle.aotter.net/v2
301
334
  kind: Trigger
@@ -171,6 +171,20 @@ try {
171
171
  assert.deepEqual(received.map(order => order.reference), [laptop, projector]);
172
172
  assert.deepEqual(await statusAndOrder(projector), { status: "ordered", supplierOrderId: "SUP-2" });
173
173
  step("daily cron retries the failed supplier order (system caller) and emails every admin about requests pending more than 3 days, and only those");
174
+
175
+ const small = await call("POST", "/api/requests", { cookie: bob, body: { title: "Pens", amount: 50, department: "Engineering" } });
176
+ assert.equal(small.status, 200, JSON.stringify(small.body));
177
+ const pens = small.body.results[0][0];
178
+ assert.equal(pens.requestStatus, "approved");
179
+ const spent = await remaining("Engineering");
180
+ assert.deepEqual((await ledgerRows()).filter(r => r.requestId === pens.id), [{ requestId: pens.id, amount: 50, remainingAfter: spent }]);
181
+ await query(`UPDATE budgets SET remaining = 20 WHERE department = 'Design'`);
182
+ const ledgerBefore = (await ledgerRows()).length, requestsBefore = (await query("SELECT count(*) AS n FROM requests"))[0].n;
183
+ assert.equal((await call("POST", "/api/requests", { cookie: alice, body: { title: "Tape", amount: 30, department: "Design" } })).status, 409);
184
+ assert.equal(await remaining("Design"), 20);
185
+ assert.equal((await ledgerRows()).length, ledgerBefore);
186
+ assert.equal((await query("SELECT count(*) AS n FROM requests"))[0].n, requestsBefore);
187
+ step("CASE approves a request of 100 or less at submission: the status, the budget and the ledger row commit together, and one that would overspend is refused with nothing written");
174
188
  }, { vars: { EMPLOYEE_DOMAIN: "example.test", SUPPLIER_API_URL: `http://127.0.0.1:${supplier.address().port}/orders`, SUPPLIER_WEBHOOK_SECRET: SECRET } });
175
189
  } finally {
176
190
  supplier.close();
@@ -14,7 +14,7 @@ export const handlers: Handlers = {
14
14
  } catch (error) {
15
15
  // The declared budget check is a business refusal, not a server error. Nothing was written.
16
16
  // The approval's only Schema check is the budget one.
17
- if (error instanceof ContractError && error.code === "CONFLICT" && error.message.startsWith("A check of the Schema failed"))
17
+ if (error instanceof ContractError && error.code === "CONFLICT" && error.fields?.kind === "check" && error.fields.schema === "budgets")
18
18
  throw new ContractError("CONFLICT", "The department budget cannot cover this request.", "procedures/approve-request");
19
19
  throw error;
20
20
  }
@@ -25,11 +25,13 @@ Search for `AGENT:` for what an app must still provide: layout and styling, cach
25
25
  - Need members to see or edit only their own rows? Add club-events' `restrict: { recipes: "author_id = auth.uid()" }` on a View.
26
26
  - Need review before publishing, or an AI that approves? Add purchasing's guarded status transitions and admin MCP.
27
27
 
28
- ## Copy
28
+ ## Run it
29
+
30
+ This runs the example by itself, to read and try. For your own app, run `mantle init` and take only the patterns you need into its manifests and `src/` (`mantle help examples`).
29
31
 
30
32
  ```sh
31
- cp -R node_modules/@aotter/mantle/docs/examples/app-shell/. my-app/
32
- cp -R node_modules/@aotter/mantle/docs/examples/recipes/. my-app/
33
+ npx mantle init my-app
34
+ cp -R node_modules/@aotter/mantle/docs/examples/recipes/. my-app/ # run the example as is
33
35
  cd my-app && pnpm install && pnpm generate && pnpm types && pnpm check
34
36
  ```
35
37
 
@@ -12,6 +12,8 @@ Mantle supplies the database adapter, schema generation and caller resolution:
12
12
 
13
13
  ## `auth.ts`
14
14
 
15
+ > **Verification status:** the GitHub social-provider and magic-link recipes have not yet been exercised end to end against real deployed provider flows.
16
+
15
17
  Copy `src/auth.ts` from the [app shell](../../examples/app-shell/README.md), which CI runs from the packed packages;
16
18
  installed, it is `node_modules/@aotter/mantle/docs/examples/app-shell/src/auth.ts`. Its essentials:
17
19
 
@@ -78,6 +80,7 @@ after success; invitations still work when email is absent.
78
80
  `databaseHooks.user.create.after` (and user.update.after for a later email verification). Only the first matching signup wins (case-insensitive); email matching requires emailVerified=true (required for bootstrap).
79
81
  D1 uses one conditional UPDATE; PostgreSQL uses a transaction advisory lock before UPDATE, avoiding concurrent winners.
80
82
  A blocked promotion logs a warning. A GitHub rule requires a configured trusted GitHub provider/profile mapper.
83
+ The promotion runs after Better Auth creates the user, so the first sign-in response (and a cached session payload) can still show the previous role, while authorization re-reads the role on every request and `/admin/api/me` and later sessions show the admin role.
81
84
  Helpers run no boot queries or DDL.
82
85
 
83
86
  ## Worker entry
@@ -184,10 +187,24 @@ The default is one instance with `admin` and `user` roles. An app that keeps two
184
187
  staff and customers) can run one Better Auth instance per population in the same Worker; every Mantle primitive
185
188
  takes the instance it works on:
186
189
 
187
- - Give each instance its own `basePath`, `advanced.cookiePrefix`, `secret` and table names (`user.modelName`,
188
- `session.modelName`, ... and plugin `schema` options). Keep each instance's options in its own module and pass
189
- one `--auth` per module to `mantle generate`; the tables are merged into one migration, and two instances that
190
- define the same table differently are refused. The native adapter follows each instance's mapping.
190
+ - Give each instance its own `basePath`, `advanced.cookiePrefix`, `secret` and core table names (`user.modelName`,
191
+ `session.modelName`, `account.modelName`, `verification.modelName`). Keep each instance's options in its own
192
+ module and pass one `--auth` per module to `mantle generate`; the tables are merged into one migration, and two
193
+ instances that define the same table differently are refused. The native adapter follows each instance's mapping.
194
+ - Better Auth plugin factories share their schema object, so differing plugin field/model mappings across instances
195
+ in one Worker override each other. Use one of two supported ways:
196
+ 1. Separate the instances by core table names only and leave every plugin mapping at its default.
197
+ 2. When plugin tables must differ (`jwt`'s `jwks`, `@better-auth/oauth-provider`'s `oauth*` tables), map them
198
+ explicitly in every instance, the default one included, and give each instance its own copy of the plugin's
199
+ schema right after constructing it:
200
+ `const keys = jwt({ schema: { jwks: { modelName: "staff_jwks" } } }); const plugin = { ...keys, schema: structuredClone(keys.schema) };`
201
+
202
+ `mantle generate` refuses anything else when two or more `--auth` instances use the same plugin (`jwt`, `admin`,
203
+ `oauth-provider`, `two-factor`, `phone-number`, `anonymous`, `siwe`, `device-authorization`) and a mapping exists
204
+ while an instance keeps the shared schema object, or two instances resolve a mapping to the same name (generate
205
+ cannot tell an identical mapping from an inherited one, so mapped names must differ between instances). Build
206
+ every instance's plugins in the same order in the Worker as in `--auth`; an instance built earlier than a mapped
207
+ one can still inherit its mapping, which generate cannot see.
191
208
  - One `authLoader(load, cookiePrefix)` per instance; call `resolveCaller(request, loader)` with the instance the
192
209
  route belongs to, and mount `authRoutes(loader, ...)` per instance (it guards that instance's `/admin/*`). The
193
210
  loader takes `cookiePrefix` separately on purpose: it checks the cookie before loading Better Auth, so anonymous
@@ -1,9 +1,11 @@
1
1
  # Mantle on ChatGPT Sites
2
2
 
3
+ > **Verification status:** this guide is experimental and has not yet been exercised end to end on a real ChatGPT Site.
4
+
3
5
  Treat the Site as an existing TypeScript host. Import selected generated Views/Procedures, pass the host's native handle and verified Caller, and compose only the surfaces it supports. Use [partial integration](../../skills/integrate/SKILL.md); Mantle supplies no Sites-specific identity or deployment adapter.
4
6
 
5
7
  Choose postgres or sqlite according to the host's actual SQL executor. Custom handles are app-owned; a structural match alone does not guarantee driver semantics. [Database boundaries](../concepts/runtime-and-adapters.md) defines the supported drivers.
6
8
 
7
- The runtime never creates, alters or checks tables. Apply initial `schema.sql` and selected auth/site DDL only to an empty database. For later changes the agent diffs regenerated SQL, writes and rehearses a host-native migration on a copy, and applies it before publishing new code. Cloud executes migrations at deploy time. Destructive/type changes require explicit data migration; Mantle drops nothing.
9
+ The runtime never creates, alters or checks tables. Apply initial `schema.sql` and selected auth/site DDL only to an empty database. For later changes the agent diffs regenerated SQL, writes and rehearses a host-native migration on a copy, and applies it before publishing new code. Destructive/type changes require explicit data migration; Mantle drops nothing.
8
10
 
9
- Use the Site's native publication workflow. Configure trusted origin and verified identity there; never construct a system/admin Caller from browser input. Serving Admin also requires its separate assets and shell security contract. Scheduling and storage provisioning belong to the host.
11
+ Use the Site's native publication workflow. Configure trusted origin and verified identity there; never construct a system/admin Caller from browser input. Serving Admin also requires its app-built assets and shell security contract. Scheduling and storage provisioning belong to the host.
@@ -32,7 +32,7 @@ The runtime never creates, alters or checks tables. Boot and requests run no DDL
32
32
 
33
33
  For the first install, apply generated `schema.sql`, including selected auth/site DDL, to an empty database. Apply selected seed SQL explicitly.
34
34
 
35
- For every later schema change, the application's coding agent diffs the regenerated `schema.sql`, writes a migration with the host's native tool (`wrangler d1 migrations` or the app's PostgreSQL migration tool), rehearses it on a copy, and applies it before deploying the new code. Cloud executes migrations at deploy time.
35
+ For every later schema change, the application's coding agent diffs the regenerated `schema.sql`, writes a migration with the host's native tool (`wrangler d1 migrations` or the app's PostgreSQL migration tool), rehearses it on a copy, and applies it before deploying the new code.
36
36
 
37
37
  Destructive changes and type changes need explicit data migration. Mantle drops nothing. Initial `schema.sql` is not an upgrade script.
38
38
 
@@ -40,6 +40,29 @@ For D1, create a migration with `pnpm exec wrangler d1 migrations create <databa
40
40
 
41
41
  For PostgreSQL, use the app's migration tool and deployment credentials. Plan lock waits and long index builds there; request credentials need no DDL privileges. Configure session settings on the role/database as described in [native execution](../guides/native-execution.md#postgresql-settings). Hyperdrive query caching must be disabled for authority and mutation visibility.
42
42
 
43
+ ## Latency placement and D1 read replication
44
+
45
+ Measure these options against the application's real traffic; neither is a
46
+ default latency guarantee. [Smart Placement](https://developers.cloudflare.com/workers/configuration/placement/)
47
+ can move fetch execution closer to observed back-end services:
48
+
49
+ ```jsonc
50
+ "placement": { "mode": "smart" }
51
+ ```
52
+
53
+ For globally distributed, read-heavy D1 traffic, enable
54
+ [read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/)
55
+ on the database and pass one request-scoped `env.DB.withSession(...)` handle to
56
+ the selected generated Views, Procedures and Store. Those D1 entry points accept
57
+ `D1DatabaseSession`. Without the Sessions API, queries continue to use the primary.
58
+
59
+ Use `first-unconstrained` only when a replica snapshot is acceptable,
60
+ `first-primary` when the flow must start from the latest primary state, or carry
61
+ a bookmark between logical sessions for sequential consistency. Reuse the same
62
+ session within that logical flow; all writes still go to the primary. Keep fresh
63
+ authentication/authorization checks and mutation/read-after-write paths on their
64
+ required current-authority path rather than weakening them for replica latency.
65
+
43
66
  ## Operate
44
67
 
45
68
  Enable Workers Logs and traces in production. The top-level `observability.enabled` alone does not enable traces:
@@ -20,9 +20,8 @@ Each media purpose in `siteDefaults.media.purposes` lists mime **slots** and a b
20
20
  `required` entries use `<input accept>` grammar (full mime, comma list, or a subtype shorthand such as `webp`);
21
21
  `maxBytes` must cover every mime the slots allow, keyed by the full mime type.
22
22
 
23
- OSS applications own their R2 binding, public bucket URL and S3 credentials.
24
- Mantle Cloud supplies that wiring by default; the OSS package has no Cloud API
25
- or credential management. Import `r2MediaStorage` from `@aotter/mantle/media/r2`:
23
+ Applications own their R2 binding, public bucket URL and S3 credentials; the SDK
24
+ package does not manage them. Import `r2MediaStorage` from `@aotter/mantle/media/r2`:
26
25
 
27
26
  ```ts
28
27
  const storage = r2MediaStorage({
@@ -81,6 +81,9 @@ export default {
81
81
  if (shell) return shell;
82
82
  // Your public frontend/web.ts routes can use an anonymous Caller without
83
83
  // loading auth; their generated SQL still controls publication visibility.
84
+ // Mounted here, after every Mantle surface, a root catch-all page (/:slug)
85
+ // cannot shadow /admin, /mcp or REST. Mount them earlier only behind a
86
+ // guard that skips those paths, as the app shell does.
84
87
  return new Response('Not found', { status: 404, headers: { 'cache-control': 'no-store' } });
85
88
  } catch (error) { return surfaceFailure(error); }
86
89
  });
@@ -88,8 +91,11 @@ export default {
88
91
  };
89
92
  ```
90
93
 
91
- Remove imports and array entries for omitted surfaces. `mcp.ts` exists only when
92
- the manifest has an MCP surface, and requires the documented optional MCP peer.
94
+ Keep the MCP and REST entries together for the normal symmetric path. For a deliberate
95
+ asymmetric exception, remove the omitted surface's imports and array entry. `mcp.ts`
96
+ exists only when the manifest has an MCP surface and requires the documented optional
97
+ MCP peer. An application that deliberately does not mount MCP need not import generated
98
+ `mcp.ts`; outside the entry import graph, it contributes nothing to the bundle.
93
99
  Pass the same handlers to each selected surface that exposes ref Procedures.
94
100
  Bind site/media/identity capabilities through the selected Admin/MCP
95
101
  options; do not expose native auth administrative routes without `authRoutes` or
@@ -106,12 +112,19 @@ its collection parameter selects only a generated table and visibility policy.
106
112
 
107
113
  ## Workers assets and secrets
108
114
 
115
+ `mantle generate --admin --wrangler wrangler.jsonc` sets the selected assets
116
+ directory. A new assets block defaults to `binding: "ASSETS"`, `run_worker_first: true`
117
+ and `html_handling: "none"`. Existing blocks keep their binding and routing; when
118
+ `run_worker_first` is already true, a missing `html_handling` defaults to `none`.
119
+ A different binding needs a custom Admin HTML loader. Deploy the built assets and
120
+ Worker together.
121
+
109
122
  `mantle generate cloudflare-d1 generated manifests/*.yaml --auth src/auth.ts --admin`
110
123
  updates the split-build Wrangler keys (`main` and `base_dir` under `dist/worker`),
111
124
  ensures `nodejs_compat` and defaults to `global_fetch_strictly_public` ([public fetch routing](deploy-and-operate.md#public-fetch-routing))
112
125
  and, with `--admin`, `assets.directory`: `mantle bundle` builds Admin into
113
- `dist/client/admin`, served at `/admin`. Configure the rest explicitly; JSONC
114
- comments and unrelated settings survive regeneration:
126
+ `dist/client/admin`, served at `/admin`. Configure existing assets blocks explicitly;
127
+ JSONC comments and unrelated settings survive regeneration:
115
128
 
116
129
  ```jsonc
117
130
  {
@@ -11,7 +11,7 @@ The runtime never creates, alters or checks tables. Boot and requests run no DDL
11
11
 
12
12
  For the first install, apply generated `schema.sql`, including selected auth/site DDL, to an empty database. Apply selected seed SQL explicitly.
13
13
 
14
- For every later schema change, the application's coding agent diffs the regenerated `schema.sql`, writes a migration with the host's native tool (`wrangler d1 migrations` or the app's PostgreSQL migration tool), rehearses it on a copy, and applies it before deploying the new code. Cloud executes migrations at deploy time.
14
+ For every later schema change, the application's coding agent diffs the regenerated `schema.sql`, writes a migration with the host's native tool (`wrangler d1 migrations` or the app's PostgreSQL migration tool), rehearses it on a copy, and applies it before deploying the new code.
15
15
 
16
16
  Destructive changes and type changes need explicit data migration. Mantle drops nothing. Initial `schema.sql` is not an upgrade script.
17
17
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  Use the [Worker quickstart](../start/quickstart-worker.md) for a complete native manifest and entry, [typed queries](../guides/typed-queries.md) for embedding, and [service composition](../cloudflare/service-entry.md) for optional surfaces.
4
4
 
5
- The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.0-alpha.8/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
5
+ The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.0/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
@@ -13,21 +13,20 @@ carries.
13
13
  The Mantle plugin carries one OSS authoring skill, `mantle`, and local
14
14
  acceptance references. It does not configure a hosted-service connection.
15
15
  Install from the Git tag matching the exact SDK version you selected (currently
16
- `v0.2.0-alpha.8`). Alpha releases integrate on `develop`; an unqualified
17
- `aotter/mantle` source follows `main` and can install older, incompatible
18
- instructions. A skill install does not upgrade the application's SDK.
16
+ `v0.2.0`). Pin the tag to keep instructions aligned with the application's
17
+ SDK. A skill install does not upgrade the application's SDK.
19
18
 
20
19
  For Codex, pin the marketplace checkout:
21
20
 
22
21
  ```sh
23
- codex plugin marketplace add aotter/mantle --ref v0.2.0-alpha.8
22
+ codex plugin marketplace add aotter/mantle --ref v0.2.0
24
23
  codex plugin add mantle@mantle
25
24
  ```
26
25
 
27
26
  For Claude Code, first clone the release into a directory you will keep:
28
27
 
29
28
  ```sh
30
- git clone --depth 1 --branch v0.2.0-alpha.8 https://github.com/aotter/mantle.git mantle-plugin
29
+ git clone --depth 1 --branch v0.2.0 https://github.com/aotter/mantle.git mantle-plugin
31
30
  ```
32
31
 
33
32
  Then, in Claude Code, use that checkout's absolute path:
@@ -41,7 +40,7 @@ Cursor and GitHub Copilot use their plugin manifests from the same tagged
41
40
  checkout. Without a plugin host, install only the consumer skill by name:
42
41
 
43
42
  ```sh
44
- npx skills add https://github.com/aotter/mantle/tree/v0.2.0-alpha.8 --skill mantle
43
+ npx skills add https://github.com/aotter/mantle/tree/v0.2.0 --skill mantle
45
44
  ```
46
45
 
47
46
  When upgrading the SDK, update the marketplace ref or local tagged checkout and
@@ -54,10 +53,16 @@ project and copies no handbook.
54
53
 
55
54
  ## 2. Install the SDK, then read its own docs
56
55
 
57
- Use the version the project already pins. For a new project, choose a 0.2.x
58
- version with `npm view @aotter/mantle dist-tags` and install it with an exact
59
- version; every `@aotter/mantle*` package stays at that same version. Then
60
- read, from the application:
56
+ Use the version the project already pins. For a new project, install stable
57
+ 0.2.0 exactly:
58
+
59
+ ```sh
60
+ pnpm add --save-exact @aotter/mantle@0.2.0
61
+ ```
62
+
63
+ Add optional `@aotter/mantle-ui@0.2.0` and `@aotter/mantle-admin@0.2.0` only
64
+ when the selected surfaces need them. Every selected `@aotter/mantle*` package
65
+ stays at the same exact version. Read, from the application:
61
66
 
62
67
  ```text
63
68
  node_modules/@aotter/mantle/README.md