@aotter/mantle 0.2.1 → 0.2.2-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/auth/routes.d.ts +3 -1
  2. package/dist/auth/routes.js +15 -2
  3. package/dist/build/compiler.d.ts +1 -0
  4. package/dist/build/compiler.js +1 -1
  5. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  6. package/dist/bun/assets.d.ts +3 -1
  7. package/dist/bun/assets.js +4 -1
  8. package/dist/domain/describe/chunk-GMUORMOX.js +1 -0
  9. package/dist/domain/describe/index.js +1 -1
  10. package/dist/domain/describe/public.js +1 -1
  11. package/dist/domain/generated-tools.js +24 -9
  12. package/dist/infrastructure/bundle.js +30 -1
  13. package/dist/infrastructure/inputs.js +3 -2
  14. package/dist/infrastructure/wrangler.js +4 -3
  15. package/dist/kernel/authorization.d.ts +1 -0
  16. package/dist/kernel/authorization.js +12 -5
  17. package/dist/kernel/mcp-call.d.ts +3 -3
  18. package/dist/kernel/mcp-call.js +4 -4
  19. package/dist/kernel/mcp-handler.js +4 -3
  20. package/dist/kernel/mcp.d.ts +12 -10
  21. package/dist/kernel/mcp.js +1 -1
  22. package/dist/kernel/media-fetch.js +109 -0
  23. package/dist/kernel/media.d.ts +15 -2
  24. package/dist/kernel/media.js +138 -16
  25. package/dist/kernel/r2-media.d.ts +3 -2
  26. package/dist/kernel/r2-media.js +12 -8
  27. package/dist/kernel/result-meta.d.ts +10 -0
  28. package/dist/kernel/result-meta.js +1 -0
  29. package/dist/kernel/site-types.d.ts +54 -6
  30. package/dist/kernel/site.d.ts +1 -1
  31. package/dist/usecase/generate.js +2 -2
  32. package/dist/usecase/mcp-media.js +17 -5
  33. package/dist/usecase/mcp.js +6 -5
  34. package/dist/usecase/site.js +4 -2
  35. package/docs/examples/README.md +5 -1
  36. package/docs/examples/app-shell/README.md +5 -1
  37. package/docs/examples/app-shell/package.json +9 -9
  38. package/docs/examples/app-shell/src/jwks.ts +22 -0
  39. package/docs/examples/app-shell/src/service.ts +12 -1
  40. package/docs/examples/app-shell/wrangler.jsonc +1 -1
  41. package/docs/examples/club-events/README.md +10 -3
  42. package/docs/examples/club-events/manifests/club-events.yaml +42 -11
  43. package/docs/examples/club-events/smoke.mjs +31 -1
  44. package/docs/examples/club-events/src/handlers.ts +9 -8
  45. package/docs/examples/frontend-mcp-app/README.md +23 -0
  46. package/docs/examples/frontend-mcp-app/app.tsx +50 -0
  47. package/docs/examples/frontend-mcp-app/manifest.yaml +30 -0
  48. package/docs/examples/purchasing/README.md +9 -0
  49. package/docs/examples/purchasing/manifests/purchasing.yaml +18 -16
  50. package/docs/examples/purchasing/smoke.mjs +5 -0
  51. package/docs/examples/purchasing/wrangler.jsonc +1 -1
  52. package/docs/examples/recipes/wrangler.jsonc +1 -1
  53. package/docs/handbook/cloudflare/authentication.md +51 -0
  54. package/docs/handbook/cloudflare/deploy-and-operate.md +5 -0
  55. package/docs/handbook/cloudflare/media-r2.md +5 -2
  56. package/docs/handbook/cloudflare/service-entry.md +28 -7
  57. package/docs/handbook/examples/hub.md +1 -1
  58. package/docs/handbook/guides/mcp-apps.md +94 -18
  59. package/docs/handbook/guides/native-admin.md +29 -0
  60. package/docs/handbook/guides/performance.md +5 -1
  61. package/docs/handbook/guides/revisions.md +1 -1
  62. package/docs/handbook/guides/sql-patterns.md +33 -0
  63. package/docs/handbook/reference/authorization.md +1 -1
  64. package/docs/handbook/reference/describe.md +5 -0
  65. package/docs/handbook/reference/schema.md +19 -0
  66. package/docs/handbook/reference/site-config.md +2 -0
  67. package/docs/handbook/reference/surface.md +66 -10
  68. package/docs/handbook/releases/index.md +24 -1
  69. package/package.json +10 -10
  70. package/dist/domain/describe/chunk-CW7X5Q5T.js +0 -1
@@ -46,6 +46,15 @@ Mantle reads the official admin plugin’s `adminRoles` when auth loads (default
46
46
  - `withAuthTasks(ctx.waitUntil.bind(ctx), () => auth.handler(request))` retains background sends/cleanup for the current request, with concurrent requests isolated. `authHardening` installs `retainAuthTask` as Better Auth's background handler. Failure logs are generic and contain no OTPs/credentials.
47
47
  - `authSetupIncomplete()` returns 503/no-store while secrets/providers are unconfigured; do not authenticate by fallback.
48
48
 
49
+ `authRoutes` also gives public OTP validation failures one JSON response (400,
50
+ `INVALID_OTP`, private/no-store), including error headers. Better Auth still enforces
51
+ expiry and attempt limits. Success cookies, HTTP 429 and other protocol errors are
52
+ preserved. This matches error codes rather than URL spelling and does not equalize
53
+ response time or replace rate limiting. An application mounting `auth.handler`
54
+ directly can call `normalizeEmailOtpResponse(await auth.handler(request))` from
55
+ `@aotter/mantle/auth/routes`; apply host CORS and security headers outside this
56
+ normalizer. It does not alter the application's native Better Auth hooks.
57
+
49
58
  Better Auth config is app-owned, so restrict who may sign up (an allowlist, an invitation) with a
50
59
  `databaseHooks.user.create.before` hook that throws `APIError("FORBIDDEN")` from `better-auth/api` (the app shell calls
51
60
  `src/signup.ts` there; returning `false` instead breaks email-OTP sign-in in Better Auth 1.7.7, which does not expect a refused create).
@@ -62,6 +71,48 @@ or `oauthProvider` for ordinary OAuth. These are optional app dependencies behin
62
71
  Use the live grant/session hooks and route wiring in [OAuth](../guides/oauth.md); merge verification hooks with the app shell's
63
72
  user hooks. `genericOAuth` is a login plugin, distinct from the OAuth provider.
64
73
 
74
+ ### Passkeys
75
+
76
+ `authRoutes` may list `{ kind: "passkey" }` in `methods` when the app adds Better Auth's `@better-auth/passkey` plugin
77
+ in its own `auth.ts`, pinned to the same version as `better-auth`. Admin lists the method to its `SignIn` slot
78
+ ([Customising Admin](../guides/native-admin.md#sign-in)) and renders no passkey sign-in of its own; the sign-in
79
+ experience is the application's.
80
+
81
+ - **`rpID` is the auth host.** Every origin under the `rpID` may ask for assertions with those passkeys, so use a parent
82
+ domain only when the deployment owns the whole registrable domain.
83
+ - **User verification is not enforced.** The plugin verifies sign-in with `requireUserVerification: false`, so a tap
84
+ without biometrics or a PIN signs in. Its sign-in endpoint does not prove the person is present; do not use it to
85
+ approve an action.
86
+ - `mantle generate --auth src/auth.ts` adds the plugin's `passkey` table to `schema.sql`.
87
+
88
+ ### Verifying access tokens on Workers
89
+
90
+ `resolveCaller` verifies a Bearer or DPoP access token with Better Auth's resource client, which fetches the
91
+ issuer's keys from `<baseURL>/jwks` by URL and caches them per isolate for five minutes. When the issuer is the
92
+ same Worker, each request that lands on an isolate without those keys first makes a public-network request back
93
+ into the Worker, which also reads D1 and passes Better Auth's HTTP rate limiter. Better Auth 1.7 has no supported
94
+ in-process key source for this path yet ([better-auth/better-auth#11604](https://github.com/better-auth/better-auth/issues/11604)).
95
+
96
+ Until it does, the app shell's `src/jwks.ts` answers exactly that URL in process. `service.ts` installs it once per
97
+ isolate around `globalThis.fetch`, with the URL its `auth.ts` produces (`${PUBLIC_ORIGIN}/api/auth/jwks`):
98
+
99
+ ```ts
100
+ globalThis.fetch = withInProcessJwks(globalThis.fetch, `${env.PUBLIC_ORIGIN}/api/auth/jwks`,
101
+ async () => (await loader.get()).api.getJwks());
102
+ ```
103
+
104
+ - Only a GET of that exact URL is answered, from `auth.api.getJwks()`: public keys only, under the jwt plugin's own
105
+ rotation rules. Every other request uses the real `fetch`.
106
+ - Better Auth still verifies the token, its DPoP binding and replay, and Mantle still checks the live grant. Better
107
+ Auth still caches the keys by that URL, so an isolate reads them once per five minutes.
108
+ - The call does not go through Better Auth's HTTP router, so it shares no rate-limit bucket and needs no
109
+ `ExecutionContext`.
110
+ - Keep the URL in step with `baseURL`, `basePath` and the jwt plugin's `jwksPath`. A mismatch is not a failure: the
111
+ request just goes over the network as before. With `jwks.remoteUrl`, keys live elsewhere: do not install it.
112
+
113
+ This patches `fetch` inside the application's own Worker; Mantle Core does not. Remove `src/jwks.ts` once Better
114
+ Auth's resource client accepts an in-process key source.
115
+
65
116
  ## Identity helpers
66
117
 
67
118
  `@aotter/mantle/auth/identity` exports `d1Identity(db, auth, caller, request.headers)` and
@@ -60,6 +60,11 @@ can move fetch execution closer to observed back-end services:
60
60
  "placement": { "mode": "smart" }
61
61
  ```
62
62
 
63
+ A placed Worker runs every request that reaches it in that location, static files
64
+ included. Keep static prefixes out of `assets.run_worker_first` (the generated
65
+ default already excludes `/admin/assets/*` and `/assets/*`) so the edge answers them next to the
66
+ visitor; see [Workers assets](service-entry.md#workers-assets-and-secrets).
67
+
63
68
  For globally distributed, read-heavy D1 traffic, enable
64
69
  [read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/)
65
70
  on the database and pass one request-scoped `env.DB.withSession(...)` handle to
@@ -44,7 +44,9 @@ An existing S3 signer (for example an application already using aws4fetch) fits
44
44
  this port. This package adds no AWS SDK. The signer must return a SigV4
45
45
  query-signed PUT URL and preserve `X-Amz-Expires`; never give its credentials to
46
46
  the client. Configure bucket CORS to allow PUT from the application's origins
47
- and the returned Content-Type header. Uploads go to `uploads/<purpose>/<group>/<role>.<ext>` and each commit copies them
47
+ and the returned Content-Type header (and from each MCP host's App sandbox origin
48
+ when you register the [upload card](../guides/mcp-apps.md#the-upload-card); its
49
+ CSP names the storage's `uploadOrigin`, the origin of `endpoint`). Uploads go to `uploads/<purpose>/<group>/<role>.<ext>` and each commit copies them
48
50
  to `committed/<group>/<attempt>/<role>.<ext>`. Serve the `committed/` prefix on the public domain. Keep `uploads/`
49
51
  private/unserved on the public domain and add a bucket lifecycle rule for abandoned upload keys. Mantle 0.1.x wrote
50
52
  `<purpose>/<group>/<role>.<ext>` keys (`<group>/<role>.<ext>` when the purpose was not a plain slug); an asset reads its keys from the stored `storageKey` and `publicUrl` of each
@@ -52,7 +54,8 @@ variant, so 0.1.x assets keep working while their prefixes stay served.
52
54
 
53
55
  Commit checks every object's MIME and size, then rechecks the exact GET
54
56
  response used for the copy. Public keys cannot be reached by an upload URL. Each commit attempt has a separate
55
- public-key UUID, while the asset id remains the upload-group UUID. A concurrent
57
+ public-key UUID, while the asset id remains the upload-group id (`<tag>_<uuid>`; the tag is
58
+ this database's `site_config.media_id_tag`, so another server's id is refused as `MEDIA_NOT_OWNED`). A concurrent
56
59
  loser can neither overwrite nor remove the winner's objects. After SQL selects
57
60
  the durable asset, the losing attempt removes only its own unselected copies.
58
61
  Upload sources remain until the database commit succeeds, so an uncertain DB
@@ -115,9 +115,10 @@ its collection parameter selects only a generated table and visibility policy.
115
115
  ## Workers assets and secrets
116
116
 
117
117
  `mantle generate --admin --wrangler wrangler.jsonc` sets the selected assets
118
- directory. A new assets block defaults to `binding: "ASSETS"`, `run_worker_first: true`
119
- and `html_handling: "none"`. Existing blocks keep their binding and routing; when
120
- `run_worker_first` is already true, a missing `html_handling` defaults to `none`.
118
+ directory. A new assets block defaults to `binding: "ASSETS"`,
119
+ `run_worker_first: ["/*", "!/admin/assets/*", "!/assets/*"]` and `html_handling: "none"`.
120
+ Existing blocks keep their binding and routing; when `run_worker_first` is true or a
121
+ list, a missing `html_handling` defaults to `none`.
121
122
  A different binding needs a custom Admin HTML loader. Deploy the built assets and
122
123
  Worker together.
123
124
 
@@ -133,15 +134,32 @@ JSONC comments and unrelated settings survive regeneration:
133
134
  "assets": {
134
135
  "directory": "dist/client",
135
136
  "binding": "ASSETS",
136
- "run_worker_first": true,
137
+ "run_worker_first": ["/*", "!/admin/assets/*", "!/assets/*"],
137
138
  "html_handling": "none"
138
139
  },
139
140
  "d1_databases": [{ "binding": "DB", "database_name": "your-database", "database_id": "your-id" }]
140
141
  }
141
142
  ```
142
143
 
143
- `run_worker_first` lets the application supply shell/security/cache headers;
144
- `none` keeps `index.html` available by name. `adminShell(request, assets)` passes
144
+ The Worker serves HTML shells and APIs; static files are answered by the edge without
145
+ running it. `mantle bundle` writes `<assets>/_headers` with `nosniff` and a one-year
146
+ `immutable` cache for Admin's content-hashed `/admin/assets/*` (an existing `_headers`
147
+ is not edited: the build prints the rule to add). `mantle bundle` also copies the
148
+ working directory's `public/` into the assets directory (removing what it copied last
149
+ time; `public/admin` is refused). `/assets/*` is excluded by default, so
150
+ `public/assets/*` is answered by the edge; other public files reach the Worker first,
151
+ which can serve them with `env.ASSETS.fetch(request)` as the app shell does. Cloudflare
152
+ sends `Cache-Control: public, max-age=0, must-revalidate` and an ETag by default; set
153
+ `immutable` in `public/_headers` only for content-hashed files. `_headers` does not
154
+ apply to responses the Worker builds. A path the Worker must answer stays out of the
155
+ exclusion.
156
+
157
+ After a deploy the previous version's assets are gone, so an open Admin tab that loads
158
+ a chunk from the old build gets a 404; Admin then offers a reload and warns that unsaved changes may be lost.
159
+ With [gradual deployments](https://developers.cloudflare.com/workers/versions-and-deployments/gradual-deployments/version-affinity/),
160
+ set a version key (`Cloudflare-Workers-Version-Key`) so a page and its chunks come from
161
+ the same version. `none` keeps `index.html`
162
+ available by name. `adminShell(request, assets)` passes
145
163
  paths relative to the Admin directory, so the app fetches them under `/admin/`:
146
164
  `path => env.ASSETS.fetch(new Request(new URL("/admin/" + path, request.url), request))`. Admin assets are not a public database
147
165
  API. PostgreSQL uses application-owned Hyperdrive/postgres.js wiring,
@@ -199,7 +217,10 @@ const reader = async (path: string) => (await assets('/' + path)) ?? new Respons
199
217
  ```
200
218
 
201
219
  The reader returns a native `Response` or `null`, rejects traversal/control
202
- characters and escaping symlinks; the app owns shell, CSP and cache headers.
220
+ characters and escaping symlinks, sets `nosniff` on every file and caches Admin's
221
+ content-hashed `/assets/` chunks for a year (`immutable`); the app owns shell, CSP
222
+ and any other cache headers. Root it at Admin's directory: it caches every `/assets/` path for a year, so serve `public/` files with your own reader and cache headers. Bun has no edge in front of it: put a CDN or reverse
223
+ proxy before the server to keep static requests off it.
203
224
  Use ignored `.env` on Bun.
204
225
 
205
226
  Admin's build tools and UI packages are dependencies of `@aotter/mantle-admin`,
@@ -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.1/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
5
+ The runnable [reference service](https://github.com/aotter/mantle/tree/v0.2.2-alpha.1/docs/examples/reference-service) exercises the native package integration. See the [generated public website](../../examples/generated-web/README.md) for a native Worker example.
@@ -45,6 +45,11 @@ than `["app"]` on an `appOnly` tool) fails when `mcp()` is constructed.
45
45
  from clients that declare no MCP Apps support, as `appOnly` does for Views. It
46
46
  is a listing hint only: `tools/call` still runs every identity, scope, `check`
47
47
  and `authorize` decision, so an App-only write is authorized like any other.
48
+ It is not an access boundary either: any client holding the same credential can
49
+ call an App-only tool directly. An action that needs the person present is
50
+ enforced on the server, for example by a Procedure that accepts only a
51
+ single-use value which a person-present check in the browser produces and
52
+ which no MCP result ever returns.
48
53
 
49
54
  A `run` may return `withResultMeta(data, meta)` from `@aotter/mantle/mcp`. The
50
55
  result's text and `structuredContent` are built from `data` exactly as before;
@@ -68,6 +73,13 @@ const uploadTarget: Tool<unknown> = {
68
73
  };
69
74
  ```
70
75
 
76
+ `run` also receives `{ apps }` after `handlers`: `apps` is true when the
77
+ `tools/call` request declares MCP Apps support and an App resource renders the
78
+ tool, so a tool can keep a secret in
79
+ the result `_meta` for an App and return it in the data for a client that has
80
+ none (`upload_media` does this with its targets). `callTool` and `callToolLazy`
81
+ take it as an optional last argument (default `{ apps: false }`).
82
+
71
83
  `callTool` and `callToolLazy` return the wrapper itself, `{ data, _meta }`.
72
84
  A caller that forwards a result to a model, such as an application's own agent,
73
85
  must forward only `data` and drop `_meta`, or the signed URL leaks through that
@@ -135,24 +147,29 @@ file carries the `csp` your `defineAdmin({ mcp: { csp } })` declared; a loader m
135
147
  `resources/read`; it replaces the resource's own `csp` rather than merging with it. A loader
136
148
  that throws answers the host with a redacted error and logs the message on the server.
137
149
 
138
- Resource URIs are immutable cache identities. By default Admin contributes an async
139
- resource source: after the request passes the origin/identity/Admin gates, Admin
140
- reads its built asset and hashes the HTML, full static catalogue and material
141
- resource metadata into `ui://mantle-admin/admin/<digest>`. A new UI build or catalogue
142
- gets a new URI, used consistently by tools and resource reads. Workers cache only
143
- the raw immutable asset/identity per contributor and ASSETS binding; caller-filtered
144
- catalogues are embedded per read. Bun rereads the file so rebuilding in the same
145
- process changes the identity. Run `mantle bundle` and deploy its assets with the server;
146
- missing assets can fail discovery when Admin resolves the build identity.
147
-
148
- An explicit `uri` keeps application-owned identity. A custom `html` function must
149
- supply a build-specific `uri`: Core cannot infer its build or safely memoize its
150
- request-dependent output. A raw `html` string may use the automatic identity.
150
+ Resource URIs are stable identities. The MCP Apps specification lets a host prefetch
151
+ and cache a UI resource by its URI, so by default Admin links its tools to the fixed
152
+ `ui://mantle-admin/admin`: after a deploy a host keeps the link it saved, and a new read
153
+ returns the new build. After the request passes the origin/identity/Admin gates, Admin
154
+ reads its built asset and also serves it at `ui://mantle-admin/admin/<digest>`, a hash of
155
+ the HTML, full static catalogue and material resource metadata, for conversations that
156
+ hold that URI. Workers cache only the raw asset and its identity per contributor and
157
+ ASSETS binding; caller-filtered catalogues are embedded per read. Bun rereads the file,
158
+ so a rebuild in the same process serves the new build. Run `mantle bundle` and deploy its
159
+ assets with the server; missing assets can fail discovery.
160
+
161
+ Change the URI only when saved conversations must not load the new App, because its
162
+ contract with its tools changed incompatibly: pass an explicit `uri` such as
163
+ `ui://mantle-admin/admin/v2`. A custom `html` function has no build identity to hash: it
164
+ is served at the fixed URI or an explicit `uri`.
151
165
 
152
166
  ### Keep old conversations working after an upgrade
153
167
 
154
- A new URI does not invalidate a host's saved tool definitions or old conversation
155
- cards. Keep previously deployed pages with `adminMcpApps({ catalogue, previous })`.
168
+ With the fixed default, conversations keep rendering across deploys without retention.
169
+ A conversation that holds a content-hash URI from an earlier build (one started before
170
+ this default, or before an explicit `uri` change) needs that page
171
+ retained: a new URI does not invalidate a host's saved tool definitions or old
172
+ conversation cards. Keep previously deployed pages with `adminMcpApps({ catalogue, previous })`.
156
173
  `previous` is an array of `AdminMcpSnapshot` from `@aotter/mantle-admin/mcp`:
157
174
  `{ uri, html, csp?, domain?, title? }`. Each `html` includes the original `mantle-catalog`
158
175
  script (the page returned by `resources/read`, not the raw bundle). The SDK serves
@@ -207,7 +224,9 @@ const previous = JSON.parse(readFileSync(path, 'utf8'));
207
224
  const context = { env: {}, request: new Request('https://archive.invalid/mcp') };
208
225
  const source = adminMcpApps({ ...appDefinition,
209
226
  html: readFileSync('dist/client/admin/mcp-app.html', 'utf8') });
210
- const { resources: [resource] } = typeof source === 'function' ? await source(context) : source;
227
+ const { resources } = typeof source === 'function' ? await source(context) : source;
228
+ // The build's own content-hash URI (an explicit `uri` contributes only that URI).
229
+ const resource = resources.find(r => /\/admin\/[a-f0-9]{64}$/.test(r.uri)) ?? resources[0];
211
230
  const tools = new Set([...Object.keys(appDefinition.catalogue.views),
212
231
  ...Object.keys(appDefinition.catalogue.actions)]);
213
232
  const loaded = typeof resource.html === 'string' ? resource.html
@@ -295,19 +314,76 @@ full screen, a row action, and a link. Check too that the host advertises
295
314
  `hostCapabilities.serverTools`; without it the App is read-only. The automated interoperability test
296
315
  uses the official `AppBridge`; your app runs its generated version ([Run the compatibility test](#run-the-compatibility-test)).
297
316
 
317
+ ## The upload card
318
+
319
+ `upload_media` on `/mcp/admin` can open an upload card, an MCP App Admin ships
320
+ prebuilt. Register it beside the Admin App, with the same `media` function you
321
+ pass `mcp(...)`:
322
+
323
+ ```ts
324
+ import { adminMcpApps, mediaUploadCard } from '@aotter/mantle-admin/mcp';
325
+ import type { MediaStorage } from '@aotter/mantle/site';
326
+ import { mcp } from './generated/mcp.js';
327
+ import { mcpAppCatalogues } from './generated/admin-descriptors.js';
328
+
329
+ declare const options: Parameters<typeof mcp>[0];
330
+ declare function storage(env: unknown): MediaStorage; // e.g. r2MediaStorage(...)
331
+
332
+ const handler = mcp({ ...options, media: storage, apps: { admin: [
333
+ adminMcpApps({ catalogue: mcpAppCatalogues.admin }),
334
+ mediaUploadCard({ media: storage }),
335
+ ] } });
336
+ ```
337
+
338
+ The card is `ui://mantle-admin/upload` and renders `upload_media`. The person
339
+ picks an image (and may type alt text); the card reads the purposes with
340
+ `upload_media`, calls it with the file's metadata, PUTs the file straight to the
341
+ target with progress, calls `upload_media` again with the `uploadId`, and sends
342
+ `ui/message` with the asset id (or `ui/update-model-context` when the host takes
343
+ no messages). The bytes never pass through MCP or the host. The card uploads the
344
+ original file as the single variant a purpose takes
345
+ ([ADR-0017](../../adr/0017-media-multi-variant-agent-side-optimization.md)); a
346
+ purpose with several variant slots needs an agent that prepares them, and the
347
+ card says so. It needs the host's `serverTools`.
348
+
349
+ The targets reach the card in the result `_meta` (`net.aotter.mantle/upload`)
350
+ when the request declares MCP Apps support and the card is registered;
351
+ otherwise they are in the result,
352
+ for an agent that PUTs them itself. The card reads either.
353
+
354
+ On `resources/read` the card declares the storage origin as
355
+ `_meta.ui.csp.connectDomains`. It is the storage's `uploadOrigin`:
356
+ `r2MediaStorage` sets it to the origin of its `endpoint`; a custom
357
+ `MediaStorage` sets it to the origin its `uploadUrl`s PUT to. Without it the
358
+ read fails with a logged error.
359
+
360
+ The bucket must answer the card's CORS preflight. Allow `PUT` with the
361
+ `Content-Type` header from the origin the host serves Apps from, and key the
362
+ rule on `Origin`: the request carries no cookies, and some clients (iOS) send no
363
+ `Referer`. A sandboxed frame without its own origin sends `Origin: null`. For
364
+ example, as R2 CORS JSON:
365
+
366
+ ```json
367
+ [{ "AllowedOrigins": ["https://your-host-sandbox.example"], "AllowedMethods": ["PUT"], "AllowedHeaders": ["content-type"], "MaxAgeSeconds": 3600 }]
368
+ ```
369
+
370
+ Use the sandbox origin each host documents for its Apps (a host that honours
371
+ `_meta.ui.domain` serves the App from that origin; `mediaUploadCard` passes
372
+ `domain` through).
373
+
298
374
  ## Customising Admin for MCP hosts
299
375
 
300
376
  `mantle bundle` builds the Admin MCP App from the same entry as the browser Admin
301
377
  (`src/admin.tsx`, or the built-in one) into one self-contained file,
302
378
  `<assets>/admin/mcp-app.html`, which `adminMcpApps` serves as
303
- `ui://mantle-admin/admin/<digest>` by default. Shared View cells and opted-in pages reach the host, with application plugins,
379
+ `ui://mantle-admin/admin` by default. Shared View cells and opted-in pages reach the host, with application plugins,
304
380
  Tailwind classes and CSS. Browser-only record/input overrides do not mount there.
305
381
 
306
382
  ### Which slots render where
307
383
 
308
384
  | Slot | Browser | MCP host |
309
385
  | --- | --- | --- |
310
- | `Shell`, `Sidebar`, `Header`, `Brand`, `Home`, `CollectionList`, `list.actions:<schema>` | yes | never: the App has no shell, sidebar or router |
386
+ | `Shell`, `Sidebar`, `Header`, `Brand`, `Home`, `CollectionList`, `SignIn`, `list.actions:<schema>` | yes | never: the App has no shell, sidebar or router |
311
387
  | `cell:view:<view>.<column>` | yes | yes: each column of the rows the host's View tool returned |
312
388
  | `cell:<schema>.<field>`, `input:<schema>.<field>`, `record.aside:<schema>`, `record.actions:<schema>`, `RecordView` | yes | never: browser-only record/collection slots; their `surface` is `"browser"` (`mantle bundle` reminds you) |
313
389
  | `pages[]`, `nav`, `home`, nav badges | yes | only a page that declares `mcp` (below) |
@@ -227,6 +227,7 @@ collection's name, `<field>` one of its fields and `<column>` a column of an adm
227
227
  | `Brand` | browser | `title`, `image?`, `href?` |
228
228
  | `Home` | browser | `collections`, `site` (what the page shows; the built-in page loads its own data) |
229
229
  | `CollectionList` | browser | `collection` (filters come from the page's location) |
230
+ | `SignIn` | browser | `methods`, `returnTo`, `oauthQuery`, `emailAutoComplete`, `onSignedIn(next)` ([below](#sign-in)) |
230
231
  | `RecordView` | browser | `collection`, `entryId`, `surface` |
231
232
  | `record.aside:<schema>` | browser | `collection`, `entry`, `surface` |
232
233
  | `record.actions:<schema>` | browser | `collection`, `entry`, `surface` |
@@ -404,6 +405,34 @@ View that has an MCP tool (`tsc` checks it against the generated `admin-client.t
404
405
  Authorization is unchanged by any of this. A component that shows or hides something grants or denies nothing; the server
405
406
  checks every route, View and Procedure.
406
407
 
408
+ ### Sign-in
409
+
410
+ `/admin/sign-in` lists the methods `authRoutes` returns from `GET <auth>/methods` (`email-otp`, `magic-link`, `passkey`,
411
+ `social`, `oauth`) through the `SignIn` slot. Admin renders the email code, magic link, social and OAuth methods; it
412
+ renders nothing for `passkey`, which is listed for an application that builds its own. Pass changed props to
413
+ `renderDefault` to reorder or drop `methods`, set the email field's `emailAutoComplete`, or wrap `onSignedIn`; render
414
+ your own elements around it to add a method of your own.
415
+
416
+ Admin calls `onSignedIn(next)` once the email code has signed in. `next` is where Admin goes by default: Better Auth's
417
+ OAuth authorize continuation when the sign-in came from an OAuth client such as an MCP connector, or the page's return
418
+ path. Run your own step first, then navigate to the same `next`. Redirecting methods (social, OAuth, magic link) leave
419
+ the page and do not call it.
420
+
421
+ ```tsx
422
+ export default defineAdmin(admin, {
423
+ components: {
424
+ SignIn: (props) => props.renderDefault({ ...props,
425
+ onSignedIn: next => { sessionStorage.setItem("after-sign-in", next); window.location.assign("/admin/p/welcome"); } }),
426
+ },
427
+ pages: [{ path: "welcome", title: "Welcome", element: <Welcome /> }],
428
+ });
429
+ ```
430
+
431
+ Keep `next` out of the step page's URL: read it back from where your wrapper stored it, then navigate there unchanged.
432
+ A page that follows a `?next=` from its own address is an open redirect, and `next` may be Better Auth's absolute OAuth
433
+ continuation, so a "same-origin only" check would also break connector sign-in. The signed-in method stays locked once
434
+ `onSignedIn` runs, so navigate away (or replace the page) rather than leaving the card in place.
435
+
407
436
  ### Components that also render in an MCP host
408
437
 
409
438
  Record/collection slots carry `surface: "browser"` and do not mount in an MCP
@@ -110,7 +110,11 @@ fast at a realistic volume, and no hot path scans a table that grows.
110
110
  the largest tables at 100,000 rows or 10 times the expected first-year volume,
111
111
  whichever is larger. Seed with SQL straight into that database, setting the
112
112
  native columns as [reserved entry columns](../reference/schema.md#reserved-entry-columns)
113
- describes: it is test data, not an application write.
113
+ describes: it is test data, not an application write. Seed the database the
114
+ app's local dev server uses, not the one a test harness creates and throws
115
+ away, and generate the rows inside the database in one short statement rather
116
+ than sending a long list of values: local D1 refuses a single statement over
117
+ about 100 KB. [Seed in bulk](../reference/schema.md#seed-in-bulk) shows both.
114
118
  - **Load:** call every View and Procedure through the same callers and surfaces
115
119
  the application uses, as each role. The app's local test harness already
116
120
  starts it on a fresh database, signs each role in and calls routes and MCP
@@ -579,6 +579,6 @@ SEO notes:
579
579
 
580
580
  The revisions are the content history: who may read them, switch and prune is the Procedures' `requires`. A record of
581
581
  who changed what and when, across the whole app, is an observability concern rather than part of this content model;
582
- it is planned outside the SDK, with Mantle Cloud's observability or the app's own logging when self-hosted, and the
582
+ it is planned outside the SDK, with the hosting platform's observability or the app's own logging when self-hosted, and the
583
583
  SDK's part is being discussed as a way to observe writes (aotter/mantle#1796). Until then, a revision's `author_id`
584
584
  and `created_at` say who started it and when, and the switch handler is the place to log a switch.
@@ -257,6 +257,39 @@ check is "at least one", so a statement that writes several rows (`json_each(inp
257
257
  passes; give an array input `minItems: 1` when an empty one is a mistake. A ref handler throws its own
258
258
  `ContractError` instead.
259
259
 
260
+ ## Change another member's row: inline author_id
261
+
262
+ A Procedure `restrict` narrows every statement in the program, including the ones after the first. With
263
+ `restrict: { registrations: "author_id = auth.uid()" }`, "cancel my seat and give it to the first person waiting"
264
+ cannot work: the promotion runs as the caller and sees no other member's row, so it changes nothing. Leave the
265
+ Procedure without `restrict` and pin the caller's own row inline, in every statement that reads it, with
266
+ `author_id = auth.uid()`; the statement that acts on other members' rows then runs unrestricted. Decide everything
267
+ that depends on the waiting list before the statement that changes it, and end with the own-row write that must
268
+ change exactly one row (`WHERE id = input.id`), so another member's id is `CONFLICT` and the whole program rolls back.
269
+
270
+ ```sql
271
+ -- 1. Return the seat only if nobody waits for it.
272
+ UPDATE events SET seats_taken = seats_taken - 1
273
+ WHERE id = (SELECT r.eventId FROM registrations r WHERE r.id = input.id AND r.author_id = auth.uid() AND NOT r.waitlisted)
274
+ AND NOT EXISTS (SELECT 1 FROM registrations w WHERE w.eventId = events.id AND w.waitlisted)
275
+ RETURNING capacity - seats_taken AS seats_left;
276
+ -- 2. Otherwise the earliest waiter (another member's row) takes it.
277
+ UPDATE registrations SET waitlisted = false
278
+ WHERE id = (SELECT w.id FROM registrations w JOIN registrations r ON r.eventId = w.eventId
279
+ WHERE r.id = input.id AND r.author_id = auth.uid() AND NOT r.waitlisted AND w.waitlisted
280
+ ORDER BY w.created_at, w.id LIMIT 1)
281
+ RETURNING id AS promoted;
282
+ -- 3. The caller's own row: exactly one, or CONFLICT and nothing above stays.
283
+ DELETE FROM registrations WHERE id = input.id AND author_id = auth.uid() RETURNING id
284
+ ```
285
+
286
+ A `= auth.uid()` term in the SQL is a filter like any other, not a policy: every statement that must stay on the
287
+ caller's rows needs its own. Rows another member waits with are their own inserts, so `author_id` names them; a
288
+ Procedure never inserts a row on someone else's behalf, since `author_id` is stamped from the caller. D1 runs the
289
+ program serialized. Under PostgreSQL's READ COMMITTED, two cancels for the same event at once can both pick the same
290
+ waiter and leave one seat counted as taken; measure that case before relying on it there.
291
+ [Club events](../../examples/club-events/README.md) has the whole waiting list (`cancel-registration`).
292
+
260
293
  ## A row with its children in one read
261
294
 
262
295
  An admin, or an AI client, reads each product with its orders in one call instead of one call per product. A
@@ -17,7 +17,7 @@ requires:
17
17
  | `ctx.admin` | Caller is in the admin layer |
18
18
  | `{ ctx.auth.scope: "scope" }` | User scopes include that scope |
19
19
 
20
- Selected REST, MCP and Admin boundaries accept an optional `authorize(target, caller)` callback. `Target`, exported by `@aotter/mantle/contracts`, contains `kind` (`view`, `procedure` or `schema`) and the manifest `name`; Admin generic entry routes also supply `action` (`read`, `create`, `update` or `delete`). The callback runs after credentials and `requires` pass, before execution and row policy. A false result or rejected promise returns `AUTH_DENIED` (HTTP 403); a synchronous exception is an internal error. MCP tool lists and Admin navigation metadata hide denied targets. Omitting the callback preserves existing behavior; direct generated typed functions do not invoke it.
20
+ Selected REST, MCP and Admin boundaries accept an optional `authorize(target, caller)` callback. `Target`, exported by `@aotter/mantle/contracts`, contains `kind` (`view`, `procedure` or `schema`) and the manifest `name`; Admin generic entry routes also supply `action` (`read`, `create`, `update` or `delete`). The callback runs after credentials and `requires` pass, before execution and row policy. A false result or rejected promise returns `AUTH_DENIED` (HTTP 403); a rejection with an `AUTH_DENIED` `ContractError` keeps its message (for example the role that may call the target), and a synchronous exception is an internal error. MCP tool lists and Admin navigation metadata hide denied targets. Omitting the callback preserves existing behavior; direct generated typed functions do not invoke it.
21
21
 
22
22
  An impersonated session exposes `caller.impersonatedBy`, the real actor’s subject, only on the original branded Caller; applications decide impersonation policy in `authorize(target, caller)`.
23
23
 
@@ -91,6 +91,11 @@ and Admin routers for every route of the example apps:
91
91
  limit the match to that endpoint's tools. Without a path, a name that the two
92
92
  endpoints map to different targets matches nothing.
93
93
 
94
+ A host rule keyed on a request (for example "these targets need a step-up")
95
+ must key on the target `matchTarget` returns, or apply the same normalisation
96
+ itself. A rule on the raw pathname can be bypassed by an equivalent spelling
97
+ such as `/api/views/%6Frders` or a trailing slash.
98
+
94
99
  Media tools (`admin:tool:<name>`) and the optional Admin developer and
95
100
  statistics routes match even when the application does not mount them; the
96
101
  application then answers `NOT_FOUND`. Routes and tools the application adds
@@ -168,6 +168,25 @@ their existing values.
168
168
  | `published_at` (publishing) | integer microseconds when `status` is `'published'`, otherwise `NULL` | `timestamptz` or `NULL` | `NULL` |
169
169
  | scope column (owner or field scope) | the field the `scope` names (`owner` in `owner = auth.uid()`), text, not null | the same | the insert fails |
170
170
 
171
+ ### Seed in bulk
172
+
173
+ For a performance pass, seed the local database the dev server uses (`wrangler dev` on D1, your own PostgreSQL), not
174
+ the one a test harness starts and discards. Generate the rows in the database instead of sending each one: local D1
175
+ refuses a statement larger than about 100 KB (`SQLITE_TOOBIG`), so a long `INSERT … VALUES` list fails where one
176
+ generating statement does not. On SQLite and D1, a recursive CTE counts the rows:
177
+
178
+ ```sql
179
+ -- seed.sql: 100,000 events, one hour apart (date-time fields are integer microseconds on SQLite and D1)
180
+ INSERT INTO events (id, version, created_at, updated_at, title, startsAt, capacity, seats_taken, closed)
181
+ WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 100000)
182
+ SELECT 'seed-' || i, 1, 1767225600000000 + i, 1767225600000000 + i, 'Event ' || i,
183
+ (1767225600 + i * 3600) * 1000000, 50, 0, 0 FROM n;
184
+ ```
185
+
186
+ Apply it with `wrangler d1 execute DB --local --file seed.sql` (`DB` is the binding in `wrangler.jsonc`). On
187
+ PostgreSQL write `FROM generate_series(1, 100000) AS i` instead of the CTE, use `now() + i * interval '1 hour'` for the timestamps, and run
188
+ it with `psql -f seed.sql`. Seed related tables the same way, choosing parent ids by `i` (`'seed-' || (i % 5000 + 1)`).
189
+
171
190
  Units differ by table: entry timestamps (`created_at`, `updated_at`, `published_at` and declared `date-time` fields)
172
191
  are microseconds on SQLite and D1, while the optional media library's `media_assets.created_at` is epoch
173
192
  **milliseconds** on every dialect ([media](../cloudflare/media-r2.md)). A declared `date` field is integer days since
@@ -4,6 +4,8 @@ Select `--site <module>` exporting `siteDefaults`, or pass the same options to p
4
4
 
5
5
  Generation emits the generated site module, deployment DDL for `site_config`, `media_assets`, `pending_media_uploads`, and `site-seed.sql`. Apply initial schema only to an empty database. Apply seed SQL explicitly: only absent brand/title/description are seeded without overwriting edits. Origin, locales, icons and media purposes come from generated defaults. Runtime construction neither seeds nor migrates.
6
6
 
7
+ A media purpose may declare `visibility: "private"`; omitted, it is `"public"`.
8
+
7
9
  Import the generated `site`, then use `site(db, verifiedCaller).read()` and `.updateSettings({ brand, title, description })`. Media is `site(db, caller).media(storage)`. Native SQLite/PostgreSQL are supported. Only selected modules enter the application bundle.
8
10
 
9
11
  Admin site/media routes are optional capabilities; their additional admin-cookie gate and origin checks remain required. The app owns object storage, signing and upload CORS. See [media wiring](../cloudflare/media-r2.md).
@@ -215,6 +215,16 @@ by declared identity, scope and `check` requirements. Without `authorize`, the
215
215
  complete endpoint catalogue is not a permission-filtered catalogue. Operation
216
216
  validation, guards and credential checks still apply to calls.
217
217
 
218
+ With `authorize`, `discovery: "all"` keeps the caller-independent list: every
219
+ caller of the endpoint gets the same `tools/list` and the same tool names in
220
+ resource HTML, and `authorize` (with `requires`, scopes and `check`) decides at
221
+ call time. Prefer it when a caller's grant can change during a conversation, for
222
+ example a role switched in a directory: a host keeps the tool list it saved, and a
223
+ filtered list would leave it naming tools that are gone or missing ones the caller
224
+ now has. An `authorize` that rejects with `new ContractError("AUTH_DENIED", message)`
225
+ returns that message, so a refusal can name the role that may call the tool; a
226
+ plain `false` or any other rejection answers "Target is not authorized."
227
+
218
228
  `toolsPageSize` pages `tools/list`: each page holds at most that many tools and
219
229
  carries an opaque `nextCursor` while more remain; pass it back as `cursor`. A
220
230
  cursor is valid only for the catalogue version that issued it; an unknown or
@@ -389,16 +399,62 @@ own entry; inherited property names never become titles or descriptions.
389
399
  ### Admin media MCP tools
390
400
 
391
401
  With generated site configuration, `mcp({ db, caller, media: env => storage })`
392
- loads the media module only on a configured admin MCP request and
393
- adds `get_media_upload_policy`, `create_media_upload`, `commit_media_upload`,
394
- `list_media_assets`, `get_media_asset`, `update_media_asset` and
395
- `delete_media_asset` to `/mcp/admin` only. `storage` is the same `MediaStorage`
396
- used by the Admin media library. Omitting `media` omits all seven tools.
397
- Admins can invoke them; cookie writes retain the origin check.
398
- Generated boundary code checks each input and advertises the 0.2 schemas and
399
- read-only/destructive/open-world hints. Image bytes are PUT directly to the
400
- returned short-lived URLs with their required headers, then committed; bytes
401
- never enter MCP. Store permanent asset ids, never upload URLs.
402
+ loads the media module only on a configured admin MCP request and adds four
403
+ tools to `/mcp/admin` only. `storage` is the same `MediaStorage` used by the
404
+ Admin media library. Omitting `media` omits every media tool. Admins can invoke
405
+ them; cookie writes retain the origin check. Generated boundary code checks each
406
+ input and advertises read-only/destructive/open-world hints.
407
+
408
+ | Tool | Does |
409
+ |---|---|
410
+ | `upload_media` | Upload one image, in two calls (below). |
411
+ | `find_media` | Committed assets by `ids` (ids not found come back in `missing`), or newest first with `limit`, `cursor` and `search` (the exact id, or text in alt or caption). Read-only. |
412
+ | `update_media` | Set `alt` or `caption` of `id`; an omitted field is unchanged, an empty string clears it. |
413
+ | `delete_media` | Delete `id`'s objects and record (`destructiveHint`, so hosts confirm). A partial failure is retried with the same id. |
414
+
415
+ `upload_media` takes one mode per call:
416
+
417
+ 1. `{ purpose, filename, variants: [{ mimeType, byteSize, role }], alt?, caption? }`
418
+ returns `{ uploadId, expiresAt, targets: [{ role, mimeType, method, url, headers }] }`.
419
+ PUT each variant's bytes to its `url` with its `headers`. Targets expire in
420
+ 15 minutes and serve this one upload. When the request declares MCP Apps
421
+ support and a resource such as the upload card renders `upload_media`,
422
+ `targets` is left out of the result and sent only in the result
423
+ `_meta["net.aotter.mantle/upload"]`, which hosts give the App and not the
424
+ model; an agent without MCP Apps (a CLI agent such as Claude Code) reads them
425
+ in the result.
426
+ 2. `{ uploadId, purpose?, alt?, caption? }` commits and returns the asset.
427
+ Store its id in fields that reference `media_assets.id`, never an upload URL.
428
+ An unknown or expired `uploadId` is `MEDIA_UPLOAD_EXPIRED` (make a new first
429
+ call); before the commit, a `purpose` other than the upload's is
430
+ `MEDIA_UPLOAD_MISMATCH`; a committed `uploadId` returns the same asset again
431
+ (the asset records no purpose).
432
+
433
+ `{ purpose? }` alone returns the purposes' policy, `{ purposes: [{ name, required, maxBytes }] }`
434
+ (each `required` slot lists its accepted MIME types, comma-separated); the
435
+ generated input schema also names the purposes and their caps, and policy
436
+ failures say what was expected. With the [upload card](../guides/mcp-apps.md#the-upload-card)
437
+ registered, an MCP Apps host shows it for this call.
438
+
439
+ `{ purpose, sourceUrl }` and `{ purpose, file: { download_url, file_id } }` have the
440
+ server fetch one image and run both steps in one call. `file` is the tool's
441
+ `openai/fileParams` argument, so a host that hands the tool a chat attachment
442
+ passes its download URL. Either takes a purpose with one variant slot. The fetch
443
+ accepts only `https` on the default port, without credentials, to a public host:
444
+ IPv6 literals, private, loopback, link-local, shared, reserved and multicast
445
+ IPv4 addresses, single-label names and names such as `localhost`, `*.local` and
446
+ `*.internal` are `MEDIA_SOURCE_REJECTED`, as is each redirect target (at most
447
+ three). The body is capped at the purpose's byte cap
448
+ (`MEDIA_VARIANT_SIZE_EXCEEDED`) and must sniff as PNG, JPEG, GIF, WebP or AVIF
449
+ (`MEDIA_MIME_REJECTED`); an unreachable source is `MEDIA_SOURCE_FAILED` and a
450
+ storage PUT failure `MEDIA_STORAGE_FAILED`. Names are checked, not resolved:
451
+ Workers cannot reach private addresses, and on another runtime restrict the
452
+ server's egress to the public internet. File content is never accepted as base64.
453
+
454
+ The 0.1 names `get_media_upload_policy`, `create_media_upload`,
455
+ `commit_media_upload`, `list_media_assets`, `get_media_asset`,
456
+ `update_media_asset` and `delete_media_asset` still work in 0.2, after the four,
457
+ with descriptions marked deprecated; they are removed in 0.3.
402
458
 
403
459
  Generation always emits a `Handlers` type (empty when no Procedure uses `handler: { ref }`), so every app wires the
404
460
  same way: `const handlers: Handlers = {...}`, then `store(db, caller, { handlers })`, `adminBindings({ handlers })`,