@aotter/mantle 0.2.0-alpha.7 → 0.2.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -5
- package/dist/auth/adapter.js +9 -6
- package/dist/auth/caller.js +2 -1
- package/dist/auth/identity.d.ts +2 -2
- package/dist/auth/oauth.d.ts +33 -1
- package/dist/auth/oauth.js +121 -28
- package/dist/auth/routes.js +6 -4
- package/dist/build/compiler.d.ts +44 -122
- package/dist/build/compiler.js +202 -277
- package/dist/build/spec/domain/model/ManifestGrammar.d.ts +6 -5
- package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
- package/dist/build/spec/domain/service/PlanFingerprint.d.ts +2 -0
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/domain/describe/{chunk-ZIQVLA7X.js → chunk-ZVXBGP7Y.js} +69 -8
- package/dist/domain/describe/index.js +1 -1
- package/dist/domain/describe/target-hash.js +1 -1
- package/dist/domain/describe/targets.d.ts +1 -1
- package/dist/domain/generated-tools.js +2 -0
- package/dist/domain/paging.js +41 -15
- package/dist/infrastructure/bundle.js +118 -15
- package/dist/infrastructure/cli.js +11 -15
- package/dist/infrastructure/generate.d.ts +1 -3
- package/dist/infrastructure/generate.js +11 -3
- package/dist/infrastructure/generateAuth.js +57 -0
- package/dist/infrastructure/inputs.js +8 -3
- package/dist/infrastructure/wrangler.js +50 -12
- package/dist/kernel/binds.d.ts +0 -3
- package/dist/kernel/contracts.d.ts +1 -1
- package/dist/kernel/contracts.js +4 -4
- package/dist/kernel/dialect/common.d.ts +0 -1
- package/dist/kernel/drain.d.ts +5 -0
- package/dist/kernel/drain.js +35 -0
- package/dist/kernel/driver/postgres.js +6 -2
- package/dist/kernel/host-caller.d.ts +1 -0
- package/dist/kernel/host-caller.js +1 -0
- package/dist/kernel/mcp-apps.d.ts +29 -9
- package/dist/kernel/mcp-apps.js +3 -3
- package/dist/kernel/mcp-handler.js +101 -18
- package/dist/kernel/mcp.d.ts +12 -3
- package/dist/kernel/mcp.js +31 -6
- package/dist/kernel/rest-router.js +3 -2
- package/dist/kernel/scalars.js +3 -2
- package/dist/kernel/site.d.ts +1 -3
- package/dist/kernel/site.js +2 -2
- package/dist/kernel/web-index.js +40 -0
- package/dist/kernel/web.d.ts +3 -1
- package/dist/kernel/web.js +6 -37
- package/dist/usecase/adminBindings.js +2 -3
- package/dist/usecase/adminClient.js +87 -0
- package/dist/usecase/adminDescriptors.js +19 -65
- package/dist/usecase/generate.js +5 -4
- package/dist/usecase/mcp-media.js +2 -2
- package/dist/usecase/mcp.js +16 -12
- package/dist/usecase/paging.js +2 -3
- package/dist/usecase/publishing.js +3 -3
- package/dist/usecase/site.js +0 -1
- package/dist/usecase/tool-version.js +9 -0
- package/dist/usecase/translations.js +1 -1
- package/dist/usecase/validation.js +128 -19
- package/dist/usecase/web.js +3 -2
- package/docs/examples/README.md +1 -1
- package/docs/examples/app-shell/README.md +45 -3
- package/docs/examples/app-shell/package.json +14 -6
- package/docs/examples/app-shell/smoke-kit.mjs +54 -0
- package/docs/examples/app-shell/src/auth.ts +6 -3
- package/docs/examples/app-shell/src/service.ts +3 -3
- package/docs/examples/app-shell/tsconfig.json +4 -1
- package/docs/examples/app-shell/worker-configuration.d.ts +2 -2
- package/docs/examples/app-shell/wrangler.jsonc +5 -4
- package/docs/examples/club-events/manifests/club-events.yaml +7 -0
- package/docs/examples/club-events/src/handlers.ts +1 -1
- package/docs/examples/generated-web/README.md +6 -2
- package/docs/examples/generated-web/wrangler.jsonc +1 -0
- package/docs/examples/purchasing/README.md +1 -1
- package/docs/examples/purchasing/manifests/purchasing.yaml +17 -1
- package/docs/examples/purchasing/smoke.mjs +10 -4
- package/docs/examples/purchasing/src/admin.tsx +13 -0
- package/docs/examples/purchasing/src/handlers.ts +1 -1
- package/docs/examples/purchasing/wrangler.jsonc +5 -4
- package/docs/examples/recipes/wrangler.jsonc +5 -4
- package/docs/handbook/cloudflare/authentication.md +43 -6
- package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
- package/docs/handbook/cloudflare/deploy-and-operate.md +45 -1
- package/docs/handbook/cloudflare/media-r2.md +2 -3
- package/docs/handbook/cloudflare/service-entry.md +68 -31
- package/docs/handbook/concepts/authorization.md +1 -1
- package/docs/handbook/concepts/mcp-and-agents.md +19 -0
- package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/admin-ui.md +107 -1
- package/docs/handbook/guides/agent-setup.md +27 -9
- package/docs/handbook/guides/agent-workflow-acceptance.md +222 -0
- package/docs/handbook/guides/mcp-apps.md +522 -6
- package/docs/handbook/guides/native-admin.md +300 -51
- package/docs/handbook/guides/oauth.md +119 -5
- package/docs/handbook/guides/public-pages.md +2 -2
- package/docs/handbook/guides/size-and-startup.md +1 -1
- package/docs/handbook/guides/typed-queries.md +35 -0
- package/docs/handbook/navigation.json +0 -4
- package/docs/handbook/reference/admin-descriptors.md +0 -35
- package/docs/handbook/reference/procedure.md +6 -2
- package/docs/handbook/reference/schema.md +13 -3
- package/docs/handbook/reference/surface.md +129 -15
- package/docs/handbook/reference/trigger.md +43 -1
- package/docs/handbook/reference/view.md +1 -1
- package/docs/handbook/releases/index.md +74 -2
- package/docs/handbook/start/overview.md +1 -1
- package/docs/handbook/start/project-and-cli.md +16 -8
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +46 -18
- package/docs/skills/integrate/SKILL.md +4 -9
- package/docs/skills/media-gc/SKILL.md +7 -5
- package/docs/skills/provision/SKILL.md +18 -12
- package/docs/skills/update/SKILL.md +19 -18
- package/docs/upgrade-0.1-to-0.2.md +6 -6
- package/package.json +2 -1
- package/skills/README.md +11 -16
- package/skills/mantle/SKILL.md +39 -17
- package/skills/mantle/references/local-testing.md +9 -0
- package/skills/mantle/references/web.md +1 -1
- package/skills/mantle/references/website.md +26 -22
- package/docs/examples/app-shell/src/admin.ts +0 -10
- package/docs/examples/purchasing/src/admin.ts +0 -12
- package/docs/handbook/guides/admin-extensions.md +0 -20
- package/docs/skills/develop/mcp-app.md +0 -39
- package/skills/.keep +0 -0
|
@@ -9,7 +9,7 @@ There are three packages, pinned to matching versions:
|
|
|
9
9
|
| Package | Owns |
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| `@aotter/mantle` | One native Core SDK: compiler, generator, query kernel and optional auth/surface primitives. |
|
|
12
|
-
| `@aotter/mantle-admin` | Optional Admin server, SPA
|
|
12
|
+
| `@aotter/mantle-admin` | Optional Admin server, the library an application builds its Admin SPA and MCP App from, design preview and `adminMcpApps`. |
|
|
13
13
|
| `@aotter/mantle-ui` | Shared UI kit and generic interaction controller. |
|
|
14
14
|
|
|
15
15
|
The SDK does not import Admin or its assets. Selecting Admin emits application
|
|
@@ -26,7 +26,7 @@ pnpm exec tsc --noEmit
|
|
|
26
26
|
|
|
27
27
|
Targets are `bun-sqlite`, `bun-postgres`, `cloudflare-d1` and
|
|
28
28
|
`cloudflare-postgres`. Programmatic generation accepts `admin: true` with the
|
|
29
|
-
same target/source options. Optional `--auth
|
|
29
|
+
same target/source options. Optional `--auth` and `--site`
|
|
30
30
|
modules follow their [generation contracts](../cloudflare/service-entry.md).
|
|
31
31
|
Generate, typecheck, implement every printed `Handlers` key, then typecheck
|
|
32
32
|
again. Commit generated TypeScript, SQL and Wrangler edits. Never edit the
|
|
@@ -66,18 +66,19 @@ Do not manufacture an admin/system Caller from request input. Views and operatio
|
|
|
66
66
|
also retain their own generated `requires` checks. Share one request-local
|
|
67
67
|
resolver with auth, consent, MCP and REST; preserve its response cookie headers.
|
|
68
68
|
|
|
69
|
-
The API handles `/admin/api/*`. The app separately serves
|
|
70
|
-
|
|
69
|
+
The API handles `/admin/api/*`. The app separately serves the Admin that
|
|
70
|
+
`mantle bundle` builds into `<assets>/admin` (default `dist/client/admin`, on Workers
|
|
71
|
+
and on Bun, where it builds only Admin) at `/admin`, including
|
|
71
72
|
client routes such as `/admin/sign-in`; missing assets stay 404. Do not serve
|
|
72
73
|
that directory bare: `adminShell(request, assets)` from
|
|
73
74
|
`@aotter/mantle-admin/server` returns the shell for every `/admin` page with
|
|
74
75
|
`frame-ancestors 'none'`, `X-Frame-Options: DENY`, `no-store` and `nosniff`, and
|
|
75
|
-
refuses traversal, encoded separators and
|
|
76
|
-
by path; a 404 means missing; it returns `undefined` for other requests).
|
|
77
|
-
installed Admin README for asset bindings, CSP/import-map hashes and preview
|
|
78
|
-
framing. `@aotter/mantle-ui` continues to supply the shared kit. See
|
|
76
|
+
refuses traversal, encoded separators and control characters (`assets` fetches one built file
|
|
77
|
+
by its path within that directory; a 404 means missing; it returns `undefined` for other requests). `@aotter/mantle-ui` continues to supply the shared kit. See
|
|
79
78
|
[service composition](../cloudflare/service-entry.md) for the complete Fetch/Bun wiring.
|
|
80
79
|
|
|
80
|
+
Browser pages: a collection at `/admin/c/<schema>`, an admin View at `/admin/views/<view>` (its input as query parameters), a custom page at `/admin/p/<path>`.
|
|
81
|
+
|
|
81
82
|
## HTTP routes
|
|
82
83
|
|
|
83
84
|
All paths below start with `/admin/api` and require an admin session; mutations
|
|
@@ -93,6 +94,7 @@ routes. Send JSON with `Content-Type: application/json`.
|
|
|
93
94
|
| `POST /entries/{id}/unpublish?collection=<schema>` | Body `{ expectedVersion }`; returns the entry to draft. |
|
|
94
95
|
| `POST /operations/{name}` | Body is the Procedure's input; runs an Admin operation (a Procedure with `requires: ctx.admin` or a `target`). |
|
|
95
96
|
| `GET /views/{name}` | Generated admin View query parameters plus `limit`/`cursor`; returns `{ rows, nextCursor? }`. |
|
|
97
|
+
| `GET /view-batch?names=a,b` | One to 20 comma-separated admin View names, deduplicated; reads the first row of each as the caller, with the single route's checks (`requires`, scope, TTL, `authorize`), and returns `{ views: { <name>: { rows } \| { unavailable: true } \| { error: true } } }`. A refused View and a missing one are the same `{ unavailable: true }`; a View that cannot answer with no input is `{ error: true }`. Neither fails the batch. Empty or more than 20 names is a 400, before the database is acquired. |
|
|
96
98
|
| `GET /me` | Returns `{ userId, role, admin, login, image }` for the signed-in caller. |
|
|
97
99
|
|
|
98
100
|
Entry create/read/patch/transitions return
|
|
@@ -102,6 +104,164 @@ Transitions accept `{}` to use the current version; tests should send the
|
|
|
102
104
|
observed `expectedVersion` to catch stale writes. Generic writes reject native
|
|
103
105
|
columns inside `data`. See [publishing](../concepts/lifecycle-and-locales.md).
|
|
104
106
|
|
|
107
|
+
## Typed data hooks
|
|
108
|
+
|
|
109
|
+
`--admin` emits `generated/admin-client.ts`: the type of each admin View's input and row, of each collection's entry
|
|
110
|
+
`data` (declared field names, no scope field; every field optional for a `publishing` collection, as a draft is only partly
|
|
111
|
+
validated), and of each admin operation's input and output, and the hooks bound to those types. A View's row has the column
|
|
112
|
+
names the browser route gives it (declared case, where the SQL output name is lowercased). An entry such as `src/admin.tsx`
|
|
113
|
+
passes `admin`, which lists those renames, to `defineAdmin(admin)` so that an MCP App's rows match, and its components read
|
|
114
|
+
data through the hooks:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
import { useOperation, useView } from "../generated/admin-client.js";
|
|
118
|
+
|
|
119
|
+
function Queue() {
|
|
120
|
+
const { rows, status, hasMore, loadMore, loadingMore } = useView("pending-requests", {}); // names, input and rows are checked by tsc
|
|
121
|
+
const approve = useOperation("approve");
|
|
122
|
+
if (status === "unavailable") return null;
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
{rows.map(row => <button key={String(row.id)} onClick={() => approve.run?.({ id: String(row.id) })}>{row.title}</button>)}
|
|
126
|
+
{hasMore ? <button disabled={loadingMore} onClick={loadMore}>Load more</button> : null}
|
|
127
|
+
</>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The hooks read only the routes above, with the Admin session cookie and Admin's own query cache, so every route still checks
|
|
133
|
+
the caller, `requires`, scope and TTL; a hook has no caller, role or system argument. A 403 and a 404 both read as
|
|
134
|
+
`status: "unavailable"` (an operation's `run` rejects with `AdminUnavailableError`), so the front end cannot tell a
|
|
135
|
+
refused View or collection from a missing one. Showing or hiding a component grants or denies nothing.
|
|
136
|
+
|
|
137
|
+
| Hook | Route | Surface |
|
|
138
|
+
| --- | --- | --- |
|
|
139
|
+
| `useView(name, input, { limit? })` -> `{ rows, status, hasMore, loadMore, loadingMore }` | `GET /views/{name}` | browser and MCP App |
|
|
140
|
+
| `useOperation(name)` -> `{ run, pending }`; success refreshes Views, entries, entry editors and totals | `POST /operations/{name}` | browser and MCP App |
|
|
141
|
+
| `useEntries(collection, { filter?, search?, limit? })` -> `{ items, status, hasMore, loadMore, loadingMore }` | `GET /entries` (one `filter_field`/`filter_value`) | browser only |
|
|
142
|
+
| `useEntry(collection, id)` | `GET /entries/{id}` | browser only |
|
|
143
|
+
| `useCollectionTotal(collection)` | `GET /collections/{name}/statistics`, its `total` | browser only |
|
|
144
|
+
| `useMe()` | `GET /me` | browser only |
|
|
145
|
+
| `useBadges(refs)` -> one `{ count, status }` per `{ view, field }`, in order; [freshness below](#sidebar-badges) | `GET /view-batch` | browser only |
|
|
146
|
+
|
|
147
|
+
`useView` and `useEntries` accumulate: `rows` (`items`) holds every page loaded so far, in order, and `loadMore()` fetches the next
|
|
148
|
+
page and appends it while `hasMore` is true. There are no page numbers and no "previous page"; changing the input (or the
|
|
149
|
+
options) starts again from the first page. A failed `loadMore()` leaves `status` at `error` with the rows already loaded kept, and
|
|
150
|
+
calling `loadMore()` again retries. Admin's own lists work the same way, under a "Load more" button, and so does the MCP App, which
|
|
151
|
+
shows the button only when the host has `serverTools` (it calls the View's tool again with the same input and the `cursor`).
|
|
152
|
+
|
|
153
|
+
In an MCP host, `useView` and `useOperation` call the View's or operation's admin MCP tool (its name lowercased, kebab to snake) through the host (`tools/call`),
|
|
154
|
+
under the caller's MCP authorization; the browser-only hooks read `unavailable` there, so declare the data an App needs as an
|
|
155
|
+
admin View. Without the host's `serverTools`, `run` is `undefined`. `useHost()` is `null` in the browser and the host's API
|
|
156
|
+
(context, capabilities, `openLink`, `sendMessage`, `updateModelContext`, `requestDisplayMode`) in a host; `useAdminSurface()`
|
|
157
|
+
says which. `@aotter/mantle-admin/hooks` also exports the same hooks untyped, for code without generated types.
|
|
158
|
+
|
|
159
|
+
`@aotter/mantle-admin/app` and `/hooks`, and so `generated/admin-client.ts`, which imports them, run in the browser only:
|
|
160
|
+
`mantle bundle` refuses them in the Worker's startup graph.
|
|
161
|
+
|
|
162
|
+
## Customising Admin
|
|
163
|
+
|
|
164
|
+
An application replaces or wraps regions of Admin from `src/admin.tsx`. The entry passes the generated metadata and, beside it,
|
|
165
|
+
what to change; the built-in entry needs none of this.
|
|
166
|
+
|
|
167
|
+
If browser-only integrations need APIs that an MCP host forbids, keep their implementations outside the host graph: add a sibling `src/admin.mcp.tsx`, also default-exporting `defineAdmin(...)`, and share the host-safe config and components between the two entries. `mantle bundle` selects it automatically for the MCP asset; the SPA and preview still use `admin.tsx`. The direct build API accepts `mcpEntry`. See [isolate browser-only integrations](mcp-apps.md#isolate-browser-only-integrations).
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
import { defineAdmin } from "@aotter/mantle-admin/app";
|
|
171
|
+
import { admin } from "../generated/admin-client.js";
|
|
172
|
+
|
|
173
|
+
export default defineAdmin(admin, {
|
|
174
|
+
pages: [{ path: "budget", title: "Budget", element: <BudgetPage />, nav: { group: "reports" } }],
|
|
175
|
+
components: {
|
|
176
|
+
// wrap: the built-in page below your own section
|
|
177
|
+
Home: (props) => <><MyQueue />{props.renderDefault(props)}</>,
|
|
178
|
+
// change a prop: the sidebar with a footer
|
|
179
|
+
Sidebar: (props) => props.renderDefault({ ...props, footer: <HelpLink /> }),
|
|
180
|
+
// replace: one value in the requests list, and the control of one field in its record form
|
|
181
|
+
"cell:requests.amount": ({ value }) => <Money cents={value} />,
|
|
182
|
+
"input:products.color": ({ value, onChange }) => <ColorPicker value={value} onChange={onChange} />,
|
|
183
|
+
"record.aside:requests": ({ entry }) => <RequestTimeline id={entry.id} />,
|
|
184
|
+
},
|
|
185
|
+
plugins: [auditTrail()],
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Every component receives the props of the built-in one and `renderDefault(props)`, which renders what it replaces: pass the
|
|
190
|
+
props you were given (spread them to change some), or ignore it to replace the region. A slot is named by a key that `tsc`
|
|
191
|
+
derives from the generated metadata, so a misspelt collection, field or View column is a type error. In a key `<schema>` is a
|
|
192
|
+
collection's name, `<field>` one of its fields and `<column>` a column of an admin View (names contain no `.`).
|
|
193
|
+
|
|
194
|
+
| Slot | Surface | Props (besides `renderDefault`) |
|
|
195
|
+
| --- | --- | --- |
|
|
196
|
+
| `Shell` | browser | `children`, `nav` (sidebar groups), `brand` |
|
|
197
|
+
| `Sidebar` | browser | `groups`, `brand`, `footer?` (above the signed-in user) |
|
|
198
|
+
| `Header` | browser | `title?`, `actions?` (before the built-in controls) |
|
|
199
|
+
| `Brand` | browser | `title`, `image?`, `href?` |
|
|
200
|
+
| `Home` | browser | `collections`, `site` (what the page shows; the built-in page loads its own data) |
|
|
201
|
+
| `CollectionList` | browser | `collection` (filters come from the page's location) |
|
|
202
|
+
| `RecordView` | browser | `collection`, `entryId`, `surface` |
|
|
203
|
+
| `record.aside:<schema>` | browser | `collection`, `entry`, `surface` |
|
|
204
|
+
| `record.actions:<schema>` | browser | `collection`, `entry`, `surface` |
|
|
205
|
+
| `list.actions:<schema>` | browser | `collection`, `selection` (empty in the toolbar, the selected ids in the bulk bar) |
|
|
206
|
+
| `cell:<schema>.<field>` | browser | `value`, `row` (the list's columns only, `null` when empty), `property`, `surface` |
|
|
207
|
+
| `cell:view:<view>.<column>` | both | `value`, `row`, `property`, `surface` |
|
|
208
|
+
| `input:<schema>.<field>` | browser | `value`, `onChange`, `readOnly`, `property`, `surface` |
|
|
209
|
+
|
|
210
|
+
`record.aside:` and `record.actions:` render beside Admin's own side cards and actions, which they do not replace. A
|
|
211
|
+
record form's top-level fields go through `input:`; a View's parameters and a Procedure's inputs do not. A read-only field
|
|
212
|
+
(a bound or `readOnly` property) has no `input:` component, and `readOnly` tells yours that the caller cannot edit the
|
|
213
|
+
record. In the browser Admin, a View column that reads a Schema field also goes through that field's `cell:` component, with the column's own
|
|
214
|
+
`cell:view:` component outside it; the MCP App renders only `cell:view:`. The developer workspace (`/admin/dev`) is Admin's own tool and does not go through any slot. A record with folded child collections opens on a workbench at `/admin/c/<schema>/<id>`, which is not `RecordView`; the record form (and so `RecordView`, `record.aside:`, `record.actions:` and `input:`) is its `/edit` page. A `Shell` that does not call `renderDefault` has no status bar: the record form's Save and Publish bar then stays in the page.
|
|
215
|
+
|
|
216
|
+
`plugins` are fragments of `components`, applied in order: each one's `renderDefault` is the plugin before it, the first
|
|
217
|
+
plugin's is the built-in component, and the application's own `components` are outermost. A plugin is
|
|
218
|
+
`{ components: { ... } }`, so one can be written once and shared.
|
|
219
|
+
|
|
220
|
+
`mcp: { csp: { connectDomains, resourceDomains, frameDomains, baseUriDomains } }` names the remote origins Admin's MCP App may
|
|
221
|
+
load. `mantle bundle` builds that App from this same entry (`<assets>/admin/mcp-app.html`) and fails it on an origin that is not
|
|
222
|
+
declared. The build writes the declaration into the file, and `adminMcpApps` sends it to the host as `_meta.ui.csp` on `resources/read`.
|
|
223
|
+
A page may also be an MCP App view: `pages: [{ path, title, element, mcp: { renders: "<view>" } }]`, where `renders` is an admin
|
|
224
|
+
View that has an MCP tool (`tsc` checks it against the generated `admin-client.ts`).
|
|
225
|
+
|
|
226
|
+
Authorization is unchanged by any of this. A component that shows or hides something grants or denies nothing; the server
|
|
227
|
+
checks every route, View and Procedure.
|
|
228
|
+
|
|
229
|
+
### Components that also render in an MCP host
|
|
230
|
+
|
|
231
|
+
Record/collection slots carry `surface: "browser"` and do not mount in an MCP
|
|
232
|
+
Procedure action form. A slot marked both renders in Admin's browser page and in an MCP host (`surface` is `"browser"` or `"mcp-app"`). A component
|
|
233
|
+
for one of these slots must:
|
|
234
|
+
|
|
235
|
+
1. read and write data only through the hooks, not `fetch`, and keep no login state in `document.cookie` or `localStorage`;
|
|
236
|
+
2. link with `<AdminLink href>` or open URLs with `useOpenLink()` (from `@aotter/mantle-admin/app` and `/hooks`),
|
|
237
|
+
never `window.open` or `target="_blank"`, which a host's sandbox blocks. In a host `<AdminLink>` opens an http(s) URL
|
|
238
|
+
through `ui/open-link` and shows any other href, such as an `/admin/...` path, as plain text, as it has no origin there;
|
|
239
|
+
3. assume no width: a host sets it;
|
|
240
|
+
4. render something on both surfaces when it branches on `surface` or `useAdminSurface()`.
|
|
241
|
+
|
|
242
|
+
`useOpenLink()` resolves `false` for anything but an http(s) URL, and when a host cannot or refuses to open it.
|
|
243
|
+
|
|
244
|
+
`mantle bundle` enforces 1 and 2 on the built App (no `/admin/api`, no `document.cookie`, no undeclared remote origin; `window.open`
|
|
245
|
+
and `target="_blank"` warn), and your CSS and Tailwind classes reach the App. The surface table, the theme mapping and the full
|
|
246
|
+
rules are in [Customising Admin for MCP hosts](mcp-apps.md#customising-admin-for-mcp-hosts).
|
|
247
|
+
|
|
248
|
+
### Hot reload while you work on `src/admin.tsx`
|
|
249
|
+
|
|
250
|
+
By default `wrangler dev` rebuilds Admin with `mantle bundle` when its sources change; reload the page. For hot module
|
|
251
|
+
replacement, add a Vite config yourself; nothing in a template enables it:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
// vite.config.ts
|
|
255
|
+
import { defineConfig } from "vite";
|
|
256
|
+
import { adminVitePlugin } from "@aotter/mantle-admin/build";
|
|
257
|
+
export default defineConfig({ plugins: [adminVitePlugin({ entry: "src/admin.tsx" })] });
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Run `vite` beside `wrangler dev` and open `http://localhost:5173/admin/`. The plugin proxies `/admin/api` and `/api/auth` to
|
|
261
|
+
`http://localhost:8787` (`target` changes it; `authBasePath` names the auth path when the service's is not `/api/auth`). The proxy keeps
|
|
262
|
+
the dev server's `Host`, so the browser's `Origin` and the service's own origin agree and session writes pass the same-origin check.
|
|
263
|
+
The service may still need to trust `http://localhost:5173` as an auth origin for sign-in.
|
|
264
|
+
|
|
105
265
|
## Generated reads and writes
|
|
106
266
|
|
|
107
267
|
`adminBindings()` returns static metadata, schemas, views, operations and the
|
|
@@ -128,7 +288,9 @@ Pages keep native NULL ordering, id ties, microsecond timestamp keys and an
|
|
|
128
288
|
opaque cursor bound to caller, input, filters and ordering. Malformed/foreign
|
|
129
289
|
cursors and unknown selectors fail before acquiring the route's DB handle;
|
|
130
290
|
the app's authentication may already have used its own database. Admin manifest
|
|
131
|
-
Views retain their complete proved key tuple, not a two-key approximation.
|
|
291
|
+
Views retain their complete proved key tuple, not a two-key approximation. A View without a key is accepted only as a proved
|
|
292
|
+
singleton, such as an aggregate without `GROUP BY` (`SELECT COUNT(*) AS count FROM requests WHERE status = 'submitted'`): it
|
|
293
|
+
returns its one row on one page and has no cursor.
|
|
132
294
|
|
|
133
295
|
Generated full/partial input checks validate creates, edits, operations and
|
|
134
296
|
filters. Plain titled string `oneOf` choices use the same enum checks; general
|
|
@@ -159,7 +321,7 @@ from `@aotter/mantle/media`; other custom ContractErrors must carry an explicit
|
|
|
159
321
|
status, otherwise the boundary returns a redacted 500. Do not leak native
|
|
160
322
|
error messages, tokens or credentials to clients.
|
|
161
323
|
|
|
162
|
-
## Statistics
|
|
324
|
+
## Statistics
|
|
163
325
|
|
|
164
326
|
`GET /admin/api/collections/{name}/statistics?range=7d` supports primary or
|
|
165
327
|
standalone collections; folded children/translations are not primary routes.
|
|
@@ -172,23 +334,25 @@ most 20 days and 480 buckets, with safe microsecond arithmetic. Temporal enum
|
|
|
172
334
|
subtypes return original metadata keys; equivalent offset spellings count once
|
|
173
335
|
under the first key, and separate microseconds remain distinct.
|
|
174
336
|
|
|
175
|
-
Site/media/identity,
|
|
176
|
-
through the public Admin modules, not added to the SDK kernel.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
the
|
|
180
|
-
`bodyLimit` checks Content-Length before DB access; the app must bound reads of
|
|
181
|
-
unknown-length raw streams. Built-in JSON routes use the shared bounded reader
|
|
337
|
+
Site/media/identity, WebMCP, design preview and MCP Apps are selected
|
|
338
|
+
through the public Admin modules, not added to the SDK kernel. A route with
|
|
339
|
+
`rawBody` receives the original Request without pre-reading, cloning or
|
|
340
|
+
rebuilding it; its custom `bodyLimit` checks Content-Length before DB access,
|
|
341
|
+
and the app must bound reads of unknown-length raw streams. Built-in JSON routes use the shared bounded reader
|
|
182
342
|
(default 1,000,000 bytes) after caller/origin checks and before DB acquisition.
|
|
183
343
|
The API advertises capabilities for actually mounted routes, including statistics.
|
|
184
344
|
|
|
185
345
|
## Acceptance
|
|
186
346
|
|
|
347
|
+
Before reporting Admin pages or customisations as done, follow the [independent
|
|
348
|
+
acceptance guide](agent-workflow-acceptance.md) with a fresh evaluator in the
|
|
349
|
+
real Admin browser session.
|
|
350
|
+
|
|
187
351
|
Use a disposable database for native tests, then exercise actual OTP sign-in,
|
|
188
352
|
the Admin console and member frontend. Check invalid input, another tenant's
|
|
189
353
|
rows, expired rows, publishing transitions, related/translated entries, paging,
|
|
190
354
|
statistics, CSV, operations/ref effects, stale OCC and persisted state after a
|
|
191
|
-
restart. Recheck
|
|
355
|
+
restart. Recheck custom components, previews, all supported UI languages and the
|
|
192
356
|
complete developer workspace. A generated file, typecheck or HTTP 200 alone
|
|
193
357
|
does not verify these flows.
|
|
194
358
|
|
|
@@ -198,29 +362,44 @@ SDK package bytes and query/write/cold timing are separate checks. Extra selecte
|
|
|
198
362
|
Admin indexes add write/storage maintenance; an indexed SELECT plan does not
|
|
199
363
|
prove unchanged write latency. Report remaining gates honestly; this guide does not promise measured performance.
|
|
200
364
|
|
|
201
|
-
## Extension declarations
|
|
202
|
-
|
|
203
|
-
Pass full declarations to `--admin-extensions`. Generation emits
|
|
204
|
-
`extensionMetadata` in `admin-descriptors.ts` and compiled settings checks plus
|
|
205
|
-
`extensionBindings` in `admin-extensions.ts`. Bind the same runtime definitions
|
|
206
|
-
(including handlers) with `extensionBindings(adminExtensions)`, and merge the
|
|
207
|
-
returned `extensions` and `routes` into `admin()` options. Declaration drift is
|
|
208
|
-
refused before any handler runs. The older descriptor `extensionBindings` export
|
|
209
|
-
contains manifest UI references only; it is not the runtime binding function.
|
|
210
|
-
|
|
211
|
-
Native Admin does not serve `source` modules or modify shell import maps. Serve
|
|
212
|
-
an extension's `module` at a same-origin absolute path and install its `integrity`
|
|
213
|
-
in the host shell import map. Selecting `source` fails generation with that
|
|
214
|
-
instruction. This differs from historical 0.2's automatic source-module route.
|
|
215
|
-
|
|
216
365
|
Entry lists and editor reads/writes return translation badges and related
|
|
217
|
-
sections in one authorized HTTP response.
|
|
366
|
+
sections in one authorized HTTP response.
|
|
367
|
+
Reference fields show titles, not only IDs: a list of an operational collection (for its `list.columns`) and an entry
|
|
368
|
+
read return `ref_titles` / `refTitles`, the title of each record that a displayed `x-mantle-ref` field points at, keyed by
|
|
369
|
+
field and stored value. The server resolves them for the caller with one page query per referenced collection and field
|
|
370
|
+
(at most 50 distinct values), exactly as the parent entry's title is resolved, so visibility and `authorize` apply to the
|
|
371
|
+
target. A missing, deleted or unreadable target is absent, so the three look the same: Admin shows the exact ID with copy.
|
|
372
|
+
A resolved title links to the related record. Admin Views and the MCP App have no such step, since their rows come from the
|
|
373
|
+
View itself; a View that wants a title selects it (join the referenced table, within its own `requires`). `/bootstrap?collection=...` can include
|
|
218
374
|
the first requested list page. CSV exports use `/entries/export` and
|
|
219
375
|
`/views/{name}/export`; native browser downloads stream paged server output.
|
|
220
376
|
A failure after headers aborts the stream, so a download can fail after partial
|
|
221
377
|
bytes have arrived. Existing databases need an app-owned migration for the
|
|
222
378
|
Admin-selected `created_at` statistics indexes emitted in initial `schema.sql`.
|
|
223
379
|
|
|
380
|
+
## Migrating from Admin extensions
|
|
381
|
+
|
|
382
|
+
The extension mechanism is gone: Admin is an application-built React library, and the application's own code replaces
|
|
383
|
+
what an extension contributed. Alpha applications that used it change as follows.
|
|
384
|
+
|
|
385
|
+
| Removed | Use instead |
|
|
386
|
+
| --- | --- |
|
|
387
|
+
| `--admin-extensions <module>`, `adminExtensions`, `src/admin.ts`, generated `admin-extensions.ts` | Drop the flag from `generate` and `check` scripts and delete the module. `generate` now rejects the option and removes the generated `admin-extensions.ts` (it is SDK-owned). |
|
|
388
|
+
| `navGroups` in that module | `defineAdmin(admin, { nav: { groups } })` in `src/admin.tsx`, an object keyed by group id instead of an array: `{ id: "approvals", title, order }` becomes `approvals: { title, order }`. `tsc` refuses a group the manifest uses and `groups` omits. See [Sidebar](#sidebar). |
|
|
389
|
+
| `defineAdminExtension`, `@aotter/mantle-admin/extension` | React components in `defineAdmin`: `components` for the regions below, `pages` for whole pages; see [Customising Admin](#customising-admin) and [Sidebar](#sidebar). |
|
|
390
|
+
| Field widget `widget: <extension>/<contribution>` and `options` | `"input:<schema>.<field>"` in `components`. `widget: textarea` stays. |
|
|
391
|
+
| `list.cells` on a Schema or View | `"cell:<schema>.<field>"` or `"cell:view:<view>.<column>"` in `components`. |
|
|
392
|
+
| `uiSchema.panels`, `record.sidebar/v1` and `home/v1` panels | `"record.aside:<schema>"` and the `Home` component. |
|
|
393
|
+
| `record/v1`, `list.selection/v1` and `list.toolbar/v1` actions | `"record.actions:<schema>"` and `"list.actions:<schema>"`; call a Procedure with `useOperation`. |
|
|
394
|
+
| Handlers `api`, `settings` and `actions.run`, and the `/admin/api/x/<extension>/*` routes | Server work is a Procedure (writes), a View (reads) or an application route. Authorization stays on the server: a Procedure or View carries its own `requires`. |
|
|
395
|
+
| `extensionBindings`, `extensionOptions`, `@aotter/mantle-admin/server/extensions`, the `extensions` and `navGroups` options of `admin()` and the `extensions` field of `/admin/api/site` | Delete them. |
|
|
396
|
+
| The import map and `shared/*.js` shims in the built Admin | None: the application's bundle holds the one React there is. Drop any CSP hash for the import map. |
|
|
397
|
+
| Extension pages (`/admin/x/<extension>/<page>`) | `defineAdmin({ pages: [{ path, title, element, nav }] })` at `/admin/p/<path>`; `nav: { group, order? }` replaces the page's `nav`, in a built-in group or one the manifest uses (an extension's own `navGroups` entry that no Schema or View uses cannot hold a page). A page has no `role`: every member sees it, so gate inside the element with `useMe()`; the `requires` of the View or Procedure it calls still decides what works. |
|
|
398
|
+
| Extension settings pages (declared form, `settings` handlers) | A page in `pages` whose element reads and saves through a View and a Procedure that hold the settings, or an application route. |
|
|
399
|
+
|
|
400
|
+
A manifest that still names a removed key fails `mantle generate` with `SCHEMA_UI_INVALID` or `VIEW_UI_INVALID` at the
|
|
401
|
+
key's path. The `UI_EXTENSION_*` diagnostics no longer exist.
|
|
402
|
+
|
|
224
403
|
## Collection list template today
|
|
225
404
|
|
|
226
405
|
The compiler derives display/filter metadata from Schema `uiSchema`, searchable fields from `searchableFields`, and sortable fields from indexes plus required/non-null fields and selected system columns. It emits per-collection quoted fragments, row policies and bind recipes; Admin uses one shared `nativeEntryPage` implementation, not Store `find`, `search` or `count` for these lists.
|
|
@@ -234,29 +413,97 @@ Store keeps `find({ search })`, `first({ search })` and `count(where?)`. See [St
|
|
|
234
413
|
A Schema with a required `x-mantle-ref` folds under its parent collection: it
|
|
235
414
|
opens from the parent entry's workbench and stays out of the sidebar, while
|
|
236
415
|
`/admin/c/<schema>` still lists it; translation children remain on language
|
|
237
|
-
tabs.
|
|
238
|
-
|
|
239
|
-
|
|
416
|
+
tabs. The parent is the reference named by `Schema.spec.uiSchema.nav.parentField` (a required
|
|
417
|
+
`x-mantle-ref` field, by `id` or by a unique field), else the only required `id` reference, else
|
|
418
|
+
the first required unique-field reference. A Schema with two or more required `id` references to
|
|
419
|
+
existing Schemas (translations excluded) and no `parentField`, with or without `standalone`, is refused by `mantle generate` with `SCHEMA_UI_INVALID`; the diagnostic lists the
|
|
420
|
+
candidates and the `nav: { parentField: <field> }` line that keeps the first one that is not a parent cycle. This applies to every build, with or without Admin, so a join table with two required `id` references needs it too. `parentField` is
|
|
421
|
+
refused on a translation child and when its parent chain leads back to the Schema. Choosing the
|
|
422
|
+
parent changes Admin navigation only: storage and authorization are unchanged.
|
|
423
|
+
|
|
424
|
+
Set `nav.standalone: true` to put a child with a required `id` reference at the top level instead;
|
|
425
|
+
a child folded only by a unique-field reference stays in its parent's workbench. With
|
|
426
|
+
`standalone`, `parentField` must name a required `id` reference.
|
|
240
427
|
|
|
241
428
|
### Sidebar
|
|
242
429
|
|
|
243
|
-
By default the sidebar lists publishing Schemas under Content, operational Schemas under Records (`operations`)
|
|
244
|
-
admin Views under Reports
|
|
430
|
+
By default the sidebar lists publishing Schemas under Content, operational Schemas under Records (`operations`) and
|
|
431
|
+
admin Views under Reports, in manifest declaration order (source files in the order given to `mantle generate`, then
|
|
432
|
+
document order), with Admin's own pages under More. A project arranges it for how its
|
|
245
433
|
staff work, in the same manifest change that adds a Schema or View. Placement is presentation only: permissions,
|
|
246
434
|
REST, MCP and storage are unchanged, entries a caller cannot see stay hidden, and an empty group is not shown.
|
|
247
435
|
|
|
248
436
|
- `Schema.spec.uiSchema.nav` and admin `View.spec.uiSchema.nav` take `group` (a group id), `order` (an integer;
|
|
249
|
-
ascending
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
437
|
+
ascending; entries without one follow in that default order, collections before Views) and
|
|
438
|
+
`hidden: true` (out of the sidebar, still at its URL).
|
|
439
|
+
- Built-in groups need no declaration: `content`, `operations`, `reports`, `more`. A project declares every other
|
|
440
|
+
group once, in `src/admin.tsx`: `defineAdmin(admin, { nav: { groups } })`, each with a title (text or
|
|
441
|
+
`{ locale: text }`) and an `order` among the built-in groups' content 10, operations 20 and reports 30 (default 50);
|
|
442
|
+
More always stays last. Declaring a built-in id is a type error (and ignored at runtime). `generated/admin-client.ts` exports `ManifestNavGroup`, every group the manifest uses, and types
|
|
443
|
+
`nav.groups` so that `tsc` refuses an entry that leaves one out (`mantle generate` no longer checks it). Without an
|
|
444
|
+
`src/admin.tsx` (the built-in entry) nothing refuses it, so an undeclared group still shows its items, titled by its id.
|
|
445
|
+
- `nav.items` adds entries beside the ones `uiSchema.nav` produces: `{ group, title, icon?, order?, to }`, where `title` is
|
|
446
|
+
text or `{ locale: text }`, `icon` is any React icon component, and `to` is `{ collection, filter? }` (the list, preset
|
|
447
|
+
to the value of one field) or `{ view, input? }` (an admin View with its input). Names, the filter field and value and the
|
|
448
|
+
View input are typed from the generated manifest, so a misspelling or a wrong preset fails `tsc`. Several items may lead to
|
|
449
|
+
one resource with different presets. An item whose collection or View the caller cannot see is not shown; showing or
|
|
450
|
+
hiding one changes no permission, because the target's own route still decides.
|
|
451
|
+
- <a id="sidebar-badges"></a>`badge: { view, field }` on an item shows a count after its title: `field` of the one row an aggregate admin View
|
|
452
|
+
returns, typically one with `uiSchema.nav.hidden: true` so it stays out of Reports. The View is typed from the generated manifest
|
|
453
|
+
and runs for the current caller through its own `requires`, scope and TTL, with no system fallback. Admin reads every badge of the
|
|
454
|
+
sidebar in one `GET /view-batch` (the first 20 distinct Views; `defineAdmin` logs the rest and they show nothing). Zero shows a muted
|
|
455
|
+
`0`, loading a placeholder and an unreadable count `!`. A refused or missing View shows nothing, so a badge never reveals a queue
|
|
456
|
+
the caller cannot read, and no state blocks the link. Counts are cached for 30 seconds, read again when the window regains
|
|
457
|
+
focus, and refreshed after a mutation through Admin's shared query cache (`useOperation` or any Admin write); there is no polling.
|
|
458
|
+
The cache is keyed by the signed-in user. A replacement `Sidebar` reads the same counts with `useBadges`. Badges are browser only.
|
|
459
|
+
- `pages` adds a page of the application's own inside Admin's shell: `{ path, title, element, icon?, nav? }`. `path` is one
|
|
460
|
+
segment of lowercase letters, digits and hyphens, and the page is at `/admin/p/<path>`. `nav: { group, order? }` places it in
|
|
461
|
+
the sidebar with the other entries of that group (the same group ids as `nav.items`: a built-in group, or one that a manifest
|
|
462
|
+
`uiSchema.nav.group` uses; `tsc` refuses any other); without `nav` it keeps its route and stays out of the sidebar. A page has
|
|
463
|
+
no `role`: every Admin member sees it, so hide a part of it with `useMe()` and rely on the `requires` of what it calls. A page's `element` reads through the [data hooks](#typed-data-hooks) and writes through
|
|
464
|
+
Procedures; showing a page grants nothing, because each View or Procedure it calls authorizes on the server.
|
|
465
|
+
- `home` names where `/admin` leads: `{ collection, filter? }`, `{ view, input? }` (typed like `nav.items[].to`) or `{ page: "<path>" }`
|
|
466
|
+
for one of `pages`. When the caller can see the target, `/admin` redirects to it and Admin's own home (setup and connector
|
|
467
|
+
content, or your `Home` override) moves to `/admin/setup`, listed first under More. When the caller cannot see the target, or without
|
|
468
|
+
`home`, `/admin` stays the built-in home. Choosing a landing page grants and denies nothing; the target's own route still decides.
|
|
469
|
+
The landing is browser only.
|
|
470
|
+
|
|
471
|
+
`pages[].header` composes actions beside the native page title without replacing
|
|
472
|
+
Admin's shell or targeting its internal DOM:
|
|
473
|
+
|
|
474
|
+
```tsx
|
|
475
|
+
pages: [{ path: "queue", title: { en: "Queue", "zh-TW": "待辦" },
|
|
476
|
+
header: { description: "Items awaiting review", actions: <QueueActions /> },
|
|
477
|
+
element: <QueuePage />, mcp: { renders: "pending-requests" } }]
|
|
478
|
+
```
|
|
254
479
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
480
|
+
The explicit object renders the same native heading on both surfaces. `title`
|
|
481
|
+
and `description` resolve using the browser preference or MCP host locale;
|
|
482
|
+
`actions` is React content, so its component may use the existing hooks and
|
|
483
|
+
host-aware links. Keep action controls disabled or provide a fallback when the
|
|
484
|
+
host lacks a capability. Do not add another h1 to `element` with this header.
|
|
485
|
+
Use `header: false` if the element owns its heading. Omitted preserves existing
|
|
486
|
+
behavior: native title in the browser, element alone in MCP. This option does
|
|
487
|
+
not customize the global application `Header` slot.
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
`src/admin.tsx` is JSX and runs in the browser, so `tsconfig.json` needs `"jsx": "react-jsx"` and `"lib": ["ES2023", "DOM"]` beside the Worker's `types` (`@cloudflare/workers-types`, `node`) and must include `src/**/*.tsx`; `node_modules/@aotter/mantle/docs/examples/app-shell/tsconfig.json` is the working file. The app also depends on `react`, `react-dom` and `@types/react` (see the example's `package.json`). The app owns `react` and `react-dom` (Admin and the kit take them as peers), so pin both to the same exact version: `react-dom` refuses to run beside a different `react`. `mantle bundle` stops with a message when they differ; add `lucide-react` (or another icon package) yourself if you use icons.
|
|
491
|
+
|
|
492
|
+
```tsx
|
|
493
|
+
// src/admin.tsx
|
|
494
|
+
import { Inbox } from "lucide-react";
|
|
495
|
+
import { defineAdmin } from "@aotter/mantle-admin/app";
|
|
496
|
+
import { admin } from "../generated/admin-client.js";
|
|
497
|
+
|
|
498
|
+
export default defineAdmin(admin, {
|
|
499
|
+
nav: {
|
|
500
|
+
groups: { approvals: { title: { en: "Approvals", "zh-TW": "簽核" }, order: 5 } },
|
|
501
|
+
items: [
|
|
502
|
+
{ group: "approvals", title: "Waiting for me", icon: Inbox, to: { view: "pending-requests" } },
|
|
503
|
+
{ group: "approvals", title: "Rejected", to: { collection: "requests", filter: { status: "rejected" } } },
|
|
504
|
+
],
|
|
505
|
+
},
|
|
506
|
+
});
|
|
260
507
|
```
|
|
261
508
|
|
|
262
509
|
```yaml
|
|
@@ -264,8 +511,10 @@ export const adminExtensions: AdminExtension[] = [];
|
|
|
264
511
|
uiSchema: { nav: { group: approvals, order: 1 } }
|
|
265
512
|
```
|
|
266
513
|
|
|
267
|
-
`mantle generate` refuses
|
|
268
|
-
|
|
514
|
+
`mantle generate` refuses a non-integer `order` and
|
|
515
|
+
placement on a Schema without a sidebar entry of its own: a translation, or a folded child that is not
|
|
516
|
+
[`standalone`](#collection-navigation-and-operation-placement).
|
|
517
|
+
The purchasing example (`node_modules/@aotter/mantle/docs/examples/purchasing/`) puts its approval queue and requests in an Approvals group.
|
|
269
518
|
|
|
270
519
|
Operational collections have no generic create or draft: officers add records through a Procedure placed with `collectionAction` (the list header shows its button and input form), so every write is a declared, validated program. For a user-field scoped Schema, SQL INSERT is system-only, so that Procedure is a ref handler that inserts through `ctx.store` ([procedure.md](../reference/procedure.md)).
|
|
271
520
|
|
|
@@ -5,7 +5,10 @@ create your `betterAuth()` instance with `jwt({ disableSettingJwtHeader: true })
|
|
|
5
5
|
and `oauthProvider` (or the provider's MCP preset). Append `oauthGrantExtension`
|
|
6
6
|
**first** in its `extensions`. The helper comes from
|
|
7
7
|
`@aotter/mantle/auth/oauth` and binds issued access tokens to the current consent
|
|
8
|
-
ID and Better Auth’s consent reference without changing `referenceId`.
|
|
8
|
+
ID and Better Auth’s consent reference without changing `referenceId`. Add
|
|
9
|
+
`oauthGrantRevocation()` from the same module to the plugins: it ends a user's
|
|
10
|
+
grants on a password reset or admin action (below), and `mantle generate --auth`
|
|
11
|
+
refuses an OAuth provider without it.
|
|
9
12
|
Configure
|
|
10
13
|
`consentPage: "/oauth/consent"`, loginPage,
|
|
11
14
|
resources, allowed/default client registration scopes, and public/native DCR
|
|
@@ -55,6 +58,15 @@ unchanged. A POST registration whose redirect URIs are all HTTP localhost,
|
|
|
55
58
|
127.0.0.1 or [::1] becomes `application_type: "native"` when omitted; explicit
|
|
56
59
|
types and mixed/external callbacks stay under the SDK's validation.
|
|
57
60
|
|
|
61
|
+
With the optional Admin UI selected, use `consentPage:
|
|
62
|
+
"/admin/oauth/consent"`, as in `docs/examples/app-shell/src/auth.ts`, and keep
|
|
63
|
+
`adminShell` mounted in the service entry. That page uses mantle-ui's `AuthCard`,
|
|
64
|
+
cards and sign-in buttons; it loads the verified request from
|
|
65
|
+
`/oauth/consent/data` and posts the original signed payload to `/oauth/consent`.
|
|
66
|
+
The UI displays the client, callback origin, requested scopes and background
|
|
67
|
+
access explanation. Keep Core's script-free `/oauth/consent` HTML as the
|
|
68
|
+
adapter-neutral fallback, not as the reference application's styled page.
|
|
69
|
+
|
|
58
70
|
`/oauth/consent` and `/oauth/consents` serve escaped, script-free English or zh-TW
|
|
59
71
|
HTML. Their `/data` routes serve the 0.2 UI JSON shapes. Consent approval and
|
|
60
72
|
`POST /oauth/consents/revoke` require a live cookie session and same-origin
|
|
@@ -66,14 +78,116 @@ private/no-store. Connected-app redirects must be same-origin paths.
|
|
|
66
78
|
issuer, audience and request-bound DPoP through Better Auth, including a durable
|
|
67
79
|
single-use proof reservation. It then checks the live consent and session and
|
|
68
80
|
loads the current database user.
|
|
69
|
-
All of sub, azp,
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
All of sub, azp, mantle_consent_id and the consent reference must match, and so
|
|
82
|
+
must sid while the token stands on its session (an `offline_access` token may
|
|
83
|
+
outlive it; see below). The consent must cover the resource and every token
|
|
84
|
+
scope. `scopes` adds a resource-specific scope floor (403 when missing).
|
|
72
85
|
Unbound legacy tokens fail closed. Disconnect deletes the grant first, revokes
|
|
73
86
|
its access/refresh tokens. Tokens issued without an active consent fail closed.
|
|
74
87
|
First-party `skip_consent` tokens instead require a live, enabled client with
|
|
75
|
-
`skipConsent` and allowed scopes, plus
|
|
88
|
+
`skipConsent` and allowed scopes, plus the same session or `offline_access`
|
|
89
|
+
rule as consented tokens. Already-issued
|
|
76
90
|
JWTs fail on the next call, and reconsenting cannot revive the old JWT lineage.
|
|
91
|
+
|
|
92
|
+
Grant lifetime follows Better Auth. Better Auth issues a refresh token only when
|
|
93
|
+
the grant includes `offline_access`; a client registered for `authorization_code`
|
|
94
|
+
may use `refresh_token` too, so registering only `authorization_code` does not
|
|
95
|
+
prevent long-lived grants.
|
|
96
|
+
CIMD is the primary client-discovery path; dynamic client registration (DCR)
|
|
97
|
+
remains available for older MCP clients. Both paths require the client to request
|
|
98
|
+
`offline_access` and the user to consent before a renewable grant is issued.
|
|
99
|
+
The DCR registration defaults below apply only to that compatibility path.
|
|
100
|
+
|
|
101
|
+
For renewable MCP connections, the examples set both `scopes` and
|
|
102
|
+
`clientRegistrationDefaultScopes` to `["mcp", "offline_access"]`, so a dynamically
|
|
103
|
+
registered client that omits `scope` is registered for `mcp offline_access`. The
|
|
104
|
+
client must request those registered scopes during authorization and the user must
|
|
105
|
+
consent to background access. If the client explicitly requests only `mcp`, OAuth
|
|
106
|
+
must not add a scope the user did not consent to: the resulting grant is bound to
|
|
107
|
+
the browser session and has no refresh token. The client must request
|
|
108
|
+
`offline_access`, or the user must reconnect and authorize it, to gain renewable
|
|
109
|
+
access. Declaring the `refresh_token` grant type alone is insufficient.
|
|
110
|
+
`offline_access` belongs in authorization-server metadata, not in the protected
|
|
111
|
+
resource's required scope floor; resource metadata continues to advertise `mcp`.
|
|
112
|
+
|
|
113
|
+
For renewable connections, the app-shell's MCP handler also supplies an explicit
|
|
114
|
+
OAuth linking hint:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const handleMcp = mcp({
|
|
118
|
+
db: (env: Env) => env.DB,
|
|
119
|
+
caller: resolveRequestCaller,
|
|
120
|
+
authorizationScopes: ["offline_access"],
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`authorizationScopes` adds scopes to the standard `WWW-Authenticate` challenge
|
|
125
|
+
when the caller is unauthenticated. With the default `requiredScopes`, that
|
|
126
|
+
challenge requests `mcp offline_access`. It does **not** change the API scope
|
|
127
|
+
floor, add scopes to a valid token, issue tokens or modify an already-signed
|
|
128
|
+
authorization request. An existing valid `mcp`-only token can still call a tool
|
|
129
|
+
that only requires `mcp`. The application must independently permit
|
|
130
|
+
`offline_access` in its provider and the user must explicitly consent.
|
|
131
|
+
Verify the actual requested scopes, refresh-token issuance and post-expiry
|
|
132
|
+
rotation in the intended client. Do not put `offline_access` in the resource's
|
|
133
|
+
required scope floor or silently add it after consent.
|
|
134
|
+
|
|
135
|
+
For an existing connection granted only `mcp`, deploy the allowed-scope change,
|
|
136
|
+
refresh the client's discovery/registration metadata, then reconnect or reauthorize
|
|
137
|
+
once with `offline_access` and obtain updated consent. Allowing a scope does not
|
|
138
|
+
add it to a stored consent. Do not edit consent rows, prolong access-token TTLs or
|
|
139
|
+
disable grant checks to avoid that step. Verify a refresh token was issued and
|
|
140
|
+
that the client can rotate it and resume MCP after access expiry; a successful
|
|
141
|
+
initial `tools/list` does not prove renewable authorization. The reference service's
|
|
142
|
+
local smoke exercises issuance, refresh after website sign-out, rotation retry
|
|
143
|
+
and revocation using real OAuth endpoints and a disposable D1.
|
|
144
|
+
|
|
145
|
+
Without `offline_access` the grant is bound to the browser session: its tokens
|
|
146
|
+
stop working when that session expires or the user signs out. A refresh that
|
|
147
|
+
narrows the scope to drop `offline_access` after the session ends returns
|
|
148
|
+
`invalid_grant`, so it never yields an unusable token. An `offline_access` grant
|
|
149
|
+
outlives the browser session. Expiry, deletion and sign-out of the session leave
|
|
150
|
+
it usable until its current refresh token expires or is revoked. Revocation
|
|
151
|
+
includes disconnect, refresh-token revocation and Better Auth's reuse detection.
|
|
152
|
+
Each refresh rotates the token with a fresh `refreshTokenExpiresIn` (30 days by
|
|
153
|
+
default), so an offline grant that keeps refreshing does not expire. Without a live session, its access tokens need an
|
|
154
|
+
unrevoked, unexpired `offline_access` refresh token for the same user and client.
|
|
155
|
+
The consent, client, scope, resource and ban checks still apply. An access token
|
|
156
|
+
issued before sign-out stays valid until it expires (one hour by default).
|
|
157
|
+
`oauthGrantRevocation()` ends every OAuth grant of the user once one of these
|
|
158
|
+
succeeds: revokes their refresh and access tokens, deletes their consents for
|
|
159
|
+
every client (the next authorization asks again, except for a first-party
|
|
160
|
+
`skip_consent` client, which never asks) and their unredeemed
|
|
161
|
+
authorization codes. The events and the sessions each one ends:
|
|
162
|
+
|
|
163
|
+
| Event | Sessions ended |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| Password reset (`/reset-password`, `/email-otp/reset-password`, `/phone-number/reset-password`) | all |
|
|
166
|
+
| Admin password change (`/admin/set-user-password`) | all |
|
|
167
|
+
| Password change with `revokeOtherSessions` | all; Better Auth gives the current device a new session |
|
|
168
|
+
| Admin ban (`/admin/ban-user`, or `/admin/update-user` setting `banned: true`) | all (Better Auth) |
|
|
169
|
+
| Admin session revocation (`/admin/revoke-user-sessions`) | all (Better Auth) |
|
|
170
|
+
| Email OTP or magic-link sign-in (`/sign-in/email-otp`, `/magic-link/verify`) that proves the address of an unverified user | all, and its password and linked accounts (Better Auth) |
|
|
171
|
+
|
|
172
|
+
The last row covers account pre-hijacking: someone who signed up with another
|
|
173
|
+
person's address before it was verified loses their OAuth grants as well as the
|
|
174
|
+
password, linked accounts and sessions that Better Auth strips when the owner
|
|
175
|
+
proves the address. Mantle ends these grants before Better Auth checks the code or
|
|
176
|
+
link, while the user is still unverified, so a failed OTP or magic-link attempt on an
|
|
177
|
+
unverified user also ends that user's OAuth grants.
|
|
178
|
+
|
|
179
|
+
Otherwise a failed attempt, session expiry, a password change without `revokeOtherSessions`,
|
|
180
|
+
and the user's own sign-out, `/revoke-sessions` and `/revoke-other-sessions`
|
|
181
|
+
("sign out everywhere") end no OAuth grant, as in Better Auth; the user ends a
|
|
182
|
+
connected app's grant by disconnecting it on the connected-apps page
|
|
183
|
+
(`/oauth/consents`). Unbanning does not restore grants. An access token issued
|
|
184
|
+
before the event is rejected on its next use, with one exception: an
|
|
185
|
+
`offline_access` access token of a first-party `skip_consent` client passes again,
|
|
186
|
+
until it expires (one hour by default), once the user grants that client offline
|
|
187
|
+
access anew, because it is checked against any live `offline_access` refresh token
|
|
188
|
+
of the same user and client. Better Auth's `/oauth2/introspect` reports a refreshed
|
|
189
|
+
token inactive once its original session has expired, while Mantle's resource
|
|
190
|
+
check accepts it; Mantle does not use introspection.
|
|
77
191
|
Database failures remain server errors, rather than silently authenticating.
|
|
78
192
|
|
|
79
193
|
For app-owned API keys/personal tokens, supply `resolveCredential(request)` in
|
|
@@ -7,9 +7,9 @@ here instead of repeating it.
|
|
|
7
7
|
|
|
8
8
|
- **Generated `web()`**: list and detail pages straight from public Views, with `llms.txt`, sitemap, Markdown
|
|
9
9
|
alternates and canonical tags. Its contract and limits are in [surface contracts](../reference/surface.md#optional-web-output);
|
|
10
|
-
the smallest example is `docs/examples/generated-web`.
|
|
10
|
+
the smallest example is `node_modules/@aotter/mantle/docs/examples/generated-web`.
|
|
11
11
|
- **App-owned pages** in `src/routes.ts`, when the page needs its own layout, JSON-LD or languages. The reference
|
|
12
|
-
implementation is `docs/examples/recipes` (`src/pages.ts`, `src/routes.ts`; `smoke.mjs` checks every item below).
|
|
12
|
+
implementation is `node_modules/@aotter/mantle/docs/examples/recipes` (`src/pages.ts`, `src/routes.ts`; `smoke.mjs` checks every item below).
|
|
13
13
|
|
|
14
14
|
## Every public page
|
|
15
15
|
|