@aotter/mantle 0.2.0-alpha.4 → 0.2.0-alpha.6
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 +2 -2
- package/dist/admin/createAdminSurface.d.ts +3 -0
- package/dist/admin/createAdminSurface.d.ts.map +1 -1
- package/dist/admin/createAdminSurface.js +51 -8
- package/dist/admin/createAdminSurface.js.map +1 -1
- package/dist/admin/extensions.d.ts +290 -0
- package/dist/admin/extensions.d.ts.map +1 -0
- package/dist/admin/extensions.js +532 -0
- package/dist/admin/extensions.js.map +1 -0
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/index.d.ts.map +1 -1
- package/dist/admin/index.js +1 -0
- package/dist/admin/index.js.map +1 -1
- package/dist/auth/buildAuth.d.ts +3 -1
- package/dist/auth/buildAuth.d.ts.map +1 -1
- package/dist/auth/buildAuth.js +3 -2
- package/dist/auth/buildAuth.js.map +1 -1
- package/dist/auth/callerResolver.d.ts +4 -0
- package/dist/auth/callerResolver.d.ts.map +1 -1
- package/dist/auth/callerResolver.js +4 -1
- package/dist/auth/callerResolver.js.map +1 -1
- package/dist/auth/createMantleAuth.d.ts.map +1 -1
- package/dist/auth/createMantleAuth.js +3 -2
- package/dist/auth/createMantleAuth.js.map +1 -1
- package/dist/auth/db.d.ts +1 -0
- package/dist/auth/db.d.ts.map +1 -1
- package/dist/auth/db.js +6 -3
- package/dist/auth/db.js.map +1 -1
- package/dist/bun/index.d.ts +0 -44
- package/dist/bun/index.d.ts.map +1 -1
- package/dist/bun/index.js +0 -84
- package/dist/bun/index.js.map +1 -1
- package/dist/cli/generate.js +3 -3
- package/dist/cli/generate.js.map +1 -1
- package/dist/cli/preset.d.ts.map +1 -1
- package/dist/cli/preset.js +80 -28
- package/dist/cli/preset.js.map +1 -1
- package/dist/cloudflare/index.d.ts +4 -0
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +2 -0
- package/dist/cloudflare/index.js.map +1 -1
- package/dist/core/dialect.d.ts +13 -1
- package/dist/core/dialect.d.ts.map +1 -1
- package/dist/core/driver.d.ts +7 -0
- package/dist/core/driver.d.ts.map +1 -1
- package/dist/core/runtime/createRuntime.js +1 -1
- package/dist/core/runtime/createRuntime.js.map +1 -1
- package/dist/core/sessionUser.d.ts +14 -0
- package/dist/core/sessionUser.d.ts.map +1 -0
- package/dist/core/sessionUser.js +4 -0
- package/dist/core/sessionUser.js.map +1 -0
- package/dist/core/sql/allowlist.d.ts +2 -0
- package/dist/core/sql/allowlist.d.ts.map +1 -1
- package/dist/core/sql/allowlist.js +43 -11
- package/dist/core/sql/allowlist.js.map +1 -1
- package/dist/core/sql/ast.js +1 -1
- package/dist/core/sql/ast.js.map +1 -1
- package/dist/core/sql/compile.js +1 -1
- package/dist/core/sql/compile.js.map +1 -1
- package/dist/core/sql/policy.d.ts +2 -0
- package/dist/core/sql/policy.d.ts.map +1 -1
- package/dist/core/sql/policy.js +25 -6
- package/dist/core/sql/policy.js.map +1 -1
- package/dist/core/sql/run.d.ts +0 -8
- package/dist/core/sql/run.d.ts.map +1 -1
- package/dist/core/sql/run.js +152 -35
- package/dist/core/sql/run.js.map +1 -1
- package/dist/d1/executor.d.ts +2 -0
- package/dist/d1/executor.d.ts.map +1 -1
- package/dist/d1/executor.js +6 -1
- package/dist/d1/executor.js.map +1 -1
- package/dist/mcp/createMcpSurface.d.ts +5 -2
- package/dist/mcp/createMcpSurface.d.ts.map +1 -1
- package/dist/mcp/createMcpSurface.js +14 -3
- package/dist/mcp/createMcpSurface.js.map +1 -1
- package/dist/mcp/media.d.ts +9 -0
- package/dist/mcp/media.d.ts.map +1 -0
- package/dist/mcp/media.js +33 -0
- package/dist/mcp/media.js.map +1 -0
- package/dist/postgres/adapter.d.ts +5 -2
- package/dist/postgres/adapter.d.ts.map +1 -1
- package/dist/postgres/adapter.js +7 -1
- package/dist/postgres/adapter.js.map +1 -1
- package/dist/postgres/compile/index.d.ts +1 -1
- package/dist/postgres/compile/index.js +1 -1
- package/dist/postgres/driver.d.ts +38 -21
- package/dist/postgres/driver.d.ts.map +1 -1
- package/dist/postgres/driver.js +100 -24
- package/dist/postgres/driver.js.map +1 -1
- package/dist/postgres/executor.d.ts +8 -0
- package/dist/postgres/executor.d.ts.map +1 -1
- package/dist/postgres/executor.js +40 -13
- package/dist/postgres/executor.js.map +1 -1
- package/dist/postgres/index.d.ts +2 -1
- package/dist/postgres/index.d.ts.map +1 -1
- package/dist/postgres/index.js +1 -0
- package/dist/postgres/index.js.map +1 -1
- package/dist/postgres/lower.d.ts.map +1 -1
- package/dist/postgres/lower.js +3 -8
- package/dist/postgres/lower.js.map +1 -1
- package/dist/postgres/print.d.ts.map +1 -1
- package/dist/postgres/print.js +2 -15
- package/dist/postgres/print.js.map +1 -1
- package/dist/postgres/session.d.ts +9 -0
- package/dist/postgres/session.d.ts.map +1 -0
- package/dist/postgres/session.js +111 -0
- package/dist/postgres/session.js.map +1 -0
- package/dist/postgres/storage.d.ts +14 -1
- package/dist/postgres/storage.d.ts.map +1 -1
- package/dist/postgres/storage.js +136 -54
- package/dist/postgres/storage.js.map +1 -1
- package/dist/postgres/validator.d.ts +4 -3
- package/dist/postgres/validator.d.ts.map +1 -1
- package/dist/postgres/validator.js +13 -6
- package/dist/postgres/validator.js.map +1 -1
- package/dist/spec/domain/model/ManifestGrammar.d.ts +4 -0
- package/dist/spec/domain/model/ManifestGrammar.d.ts.map +1 -1
- package/dist/spec/domain/model/ManifestGrammar.js +4 -0
- package/dist/spec/domain/model/ManifestGrammar.js.map +1 -1
- package/dist/spec/domain/service/SchemaAdminUiChecker.d.ts +2 -0
- package/dist/spec/domain/service/SchemaAdminUiChecker.d.ts.map +1 -1
- package/dist/spec/domain/service/SchemaAdminUiChecker.js +50 -6
- package/dist/spec/domain/service/SchemaAdminUiChecker.js.map +1 -1
- package/dist/spec/domain/service/ViewAdminUiChecker.d.ts +1 -1
- package/dist/spec/domain/service/ViewAdminUiChecker.d.ts.map +1 -1
- package/dist/spec/domain/service/ViewAdminUiChecker.js +7 -2
- package/dist/spec/domain/service/ViewAdminUiChecker.js.map +1 -1
- package/dist/spec/infrastructure/sql/compilePlan.d.ts.map +1 -1
- package/dist/spec/infrastructure/sql/compilePlan.js +5 -0
- package/dist/spec/infrastructure/sql/compilePlan.js.map +1 -1
- package/dist/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/spec/kernel/diagnostic.d.ts.map +1 -1
- package/dist/spec/kernel/diagnostic.js +5 -0
- package/dist/spec/kernel/diagnostic.js.map +1 -1
- package/dist/testing/cases/corpus.d.ts +2 -0
- package/dist/testing/cases/corpus.d.ts.map +1 -1
- package/dist/testing/cases/corpus.js +7 -5
- package/dist/testing/cases/corpus.js.map +1 -1
- package/dist/testing/cases/policy.d.ts +14 -1
- package/dist/testing/cases/policy.d.ts.map +1 -1
- package/dist/testing/cases/policy.js +3 -2
- package/dist/testing/cases/policy.js.map +1 -1
- package/dist/testing/cases/printer.d.ts.map +1 -1
- package/dist/testing/cases/printer.js +3 -1
- package/dist/testing/cases/printer.js.map +1 -1
- package/dist/testing/cases/report-view.d.ts.map +1 -1
- package/dist/testing/cases/report-view.js +18 -0
- package/dist/testing/cases/report-view.js.map +1 -1
- package/docs/adr/0031-host-cloud-core-contract.md +62 -1
- package/docs/adr/0032-store-centric-core-and-service-contract.md +17 -0
- package/docs/adr/0037-postgresql-is-the-reference-dialect.md +2 -1
- package/docs/adr/0039-postgresql-is-native.md +130 -0
- package/docs/adr/README.md +2 -0
- package/docs/adr/adr-lite-1376-admin-extensions.md +330 -0
- package/docs/examples/commerce-inventory.md +18 -1
- package/docs/examples/reference-service/package.json +2 -2
- package/docs/handbook/cloudflare/authentication.md +3 -3
- package/docs/handbook/cloudflare/deploy-and-operate.md +4 -1
- package/docs/handbook/cloudflare/media-r2.md +59 -8
- package/docs/handbook/concepts/mcp-and-agents.md +12 -4
- package/docs/handbook/concepts/runtime-and-adapters.md +74 -33
- package/docs/handbook/concepts/views.md +1 -1
- package/docs/handbook/guides/admin-extensions.md +191 -0
- package/docs/handbook/guides/admin-ui.md +2 -1
- package/docs/handbook/navigation.json +2 -1
- package/docs/handbook/reference/diagnostics.md +1 -0
- package/docs/handbook/reference/procedure.md +2 -1
- package/docs/handbook/reference/schema.md +6 -3
- package/docs/handbook/reference/surface.md +6 -2
- package/docs/handbook/reference/view.md +7 -5
- package/docs/handbook/releases/index.md +78 -4
- package/docs/handbook/start/project-and-cli.md +20 -12
- package/docs/skills/develop/SKILL.md +8 -2
- package/docs/skills/provision/SKILL.md +4 -1
- package/docs/skills/update/SKILL.md +3 -0
- package/docs/upgrade-0.1-to-0.2.md +1 -1
- package/package.json +6 -5
- package/dist/postgres/textRows.d.ts +0 -10
- package/dist/postgres/textRows.d.ts.map +0 -1
- package/dist/postgres/textRows.js +0 -44
- package/dist/postgres/textRows.js.map +0 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# ADR-lite 1376: Admin extensions
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted (revision 3: the full contract, for hosts and for
|
|
4
|
+
projects; replaces the pages-only draft)
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
A project or the host that mounts Admin sometimes needs its own UI next to the generated
|
|
9
|
+
collections, Views and operations. Examples are an operator's
|
|
10
|
+
access-management screen, a record action that calls a host service, or a
|
|
11
|
+
field widget for a host-specific format. Today the Admin SPA has no way to add
|
|
12
|
+
any of these:
|
|
13
|
+
|
|
14
|
+
- Its routes and navigation are fixed.
|
|
15
|
+
- `createAdminSurface` has no host routes.
|
|
16
|
+
- The UI kit (ADR-lite 909) deliberately does not export the application
|
|
17
|
+
shell.
|
|
18
|
+
|
|
19
|
+
Hosts are left with forking the SPA or patching its private DOM.
|
|
20
|
+
|
|
21
|
+
The first draft of this record added whole pages only. A survey of how other
|
|
22
|
+
products open their admin turns up the same set of extension points in
|
|
23
|
+
almost all of them. The CMS products were Strapi, Directus, Payload, Sanity,
|
|
24
|
+
Contentful, Keystone, Decap, Tina and WordPress. The platforms were Shopify
|
|
25
|
+
Admin UI extensions, VS Code, Grafana, Backstage, Atlassian Forge, Figma and
|
|
26
|
+
the Keycloak admin console. The recurring points are:
|
|
27
|
+
|
|
28
|
+
- pages with a navigation entry
|
|
29
|
+
- record and list actions
|
|
30
|
+
- record side panels
|
|
31
|
+
- field widgets
|
|
32
|
+
- dashboard widgets
|
|
33
|
+
- settings pages
|
|
34
|
+
- server endpoints
|
|
35
|
+
|
|
36
|
+
The survey also turns up five lessons that bear on this design:
|
|
37
|
+
|
|
38
|
+
1. **Only a narrow, typed surface survives major versions.** Every product
|
|
39
|
+
that exposed its internal context or helper packages broke all its plugins
|
|
40
|
+
at the next major: Strapi's helper-plugin, Sanity's Parts, Payload 2→3,
|
|
41
|
+
Decap's React pin. Contentful's location-scoped SDK and Backstage's typed
|
|
42
|
+
data refs aged well.
|
|
43
|
+
2. **Declare in data, run in code.** The extension says *what* attaches
|
|
44
|
+
*where* as data. Code supplies only the behavior. Grafana had to retrofit a
|
|
45
|
+
static mirror of its imperative `addLink` calls before it could index,
|
|
46
|
+
validate or lazy-load extensions. Declarations can also be validated and
|
|
47
|
+
diffed before anything runs. That matters here because agents author and
|
|
48
|
+
deploy Mantle projects.
|
|
49
|
+
3. **Typed contribution points beat free-form injection zones.** Strapi 5 and
|
|
50
|
+
Sanity steer integrators to typed actions with a position and a dialog
|
|
51
|
+
kind. Strapi now marks its free zones internal.
|
|
52
|
+
4. **Navigation visibility is not authorization.** Strapi, Payload and
|
|
53
|
+
Directus all warn about this. Every extension route has to check the
|
|
54
|
+
caller on the server.
|
|
55
|
+
5. **Same-origin code has no security boundary.** That is acceptable for code
|
|
56
|
+
the host installs and trusts. Untrusted third-party code needs an iframe
|
|
57
|
+
(Contentful, Forge Custom UI) or remote rendering (Shopify). The contract
|
|
58
|
+
should allow such an isolation tier later without changing.
|
|
59
|
+
|
|
60
|
+
## Decision
|
|
61
|
+
|
|
62
|
+
### 1. One declaration per extension, from the project or the host
|
|
63
|
+
|
|
64
|
+
`createAdminSurface` accepts `extensions: AdminExtension[]`. Each extension
|
|
65
|
+
pairs a declaration, which is plain data, with server handlers, which are
|
|
66
|
+
code:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
{
|
|
70
|
+
apiVersion: 1,
|
|
71
|
+
id: 'access', // kebab-case, unique, immutable once deployed
|
|
72
|
+
title: { en: 'Access', 'zh-TW': '存取權限' },
|
|
73
|
+
source: ACCESS_MODULE, // the module's code; Admin serves it at {basePath}/extensions/access.js
|
|
74
|
+
// or, served by the host instead: module: '/assets/access/admin.js', integrity?: 'sha384-…'
|
|
75
|
+
contributes: {
|
|
76
|
+
pages: [{ id, title, role, nav: { group: 'more' | 'settings', order? } }],
|
|
77
|
+
settings: [{ id, title, role, schema }], // JSON Schema form, no client code
|
|
78
|
+
actions: [{ id, title, role, target, presentation, destructive?, when? }],
|
|
79
|
+
panels: [{ id, title, role, target, when? }],
|
|
80
|
+
fields: [{ id, target, when?, optionsSchema? }],
|
|
81
|
+
},
|
|
82
|
+
handlers: {
|
|
83
|
+
api?(request, { caller, extension, path }): Promise<Response | null>,
|
|
84
|
+
settings?: { [id]: { load(caller), save(caller, value) } },
|
|
85
|
+
actions?: { [id]: { run(caller, { record?, selection?, schema? }) } },
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 1a. Projects author extensions; hosts may add their own
|
|
91
|
+
|
|
92
|
+
Extensions are not a host-only feature. An agent customizing a project's Admin
|
|
93
|
+
is the main author:
|
|
94
|
+
|
|
95
|
+
- A project exports `adminExtensions: AdminExtension[]`. For a new project the
|
|
96
|
+
generated preset writes `src/admin-extensions.ts` and passes it to
|
|
97
|
+
`createAdminSurface`; the preset is written once, so an existing
|
|
98
|
+
`src/service.ts` wires it by hand. A host that builds Admin itself (for example a hosted
|
|
99
|
+
platform) reads the same export and appends its own extensions.
|
|
100
|
+
- Extension ids are one namespace per Admin. A host extension whose id
|
|
101
|
+
collides with a project extension fails construction, so a project can
|
|
102
|
+
never shadow a host page, and the reverse.
|
|
103
|
+
- A module needs no build step: Admin's import map provides React, the kit and
|
|
104
|
+
the extension helper (§4), so plain ESM works. `source` lets Admin serve the
|
|
105
|
+
module itself, so a project needs no static-file setup; Admin computes its
|
|
106
|
+
integrity. The same project shows the same extensions locally and when
|
|
107
|
+
deployed; a host only adds.
|
|
108
|
+
|
|
109
|
+
**Trust.** A project extension's module runs in Admin's origin with the
|
|
110
|
+
viewer's session. That gives it no more power than the project already has:
|
|
111
|
+
whoever can deploy the project already controls its server code, its
|
|
112
|
+
handlers and its storage. Two rules keep that equivalence true:
|
|
113
|
+
|
|
114
|
+
- Host extension routes reachable from a project's Admin origin must act only
|
|
115
|
+
on that project. Anything wider (an organization console, other projects)
|
|
116
|
+
lives on a different origin, so a project module never holds its session.
|
|
117
|
+
- A host that restricts what some staff may do inside a project (fine-grained
|
|
118
|
+
policies) must treat the right to deploy as full trust in that project, and
|
|
119
|
+
govern deployment separately.
|
|
120
|
+
|
|
121
|
+
Contributions and their targets (§1b covers how manifests use them):
|
|
122
|
+
|
|
123
|
+
| Contribution | Targets (`/v1`) | Client code |
|
|
124
|
+
| --- | --- | --- |
|
|
125
|
+
| page | its own route `/admin/x/{extension}/{page}`; in navigation when it declares `nav` | a renderer |
|
|
126
|
+
| settings | its own route `/admin/x/{extension}/{id}`, always in navigation | none. Admin renders `schema` (the form subset) with its form; `load` and `save` run on the server |
|
|
127
|
+
| action | `record/v1` (record header), `list.selection/v1` (bulk), `list.toolbar/v1` | `presentation: 'run' \| 'confirm'` needs none (the server `run` is called). `'dialog'` mounts client code in an Admin dialog |
|
|
128
|
+
| panel | `record.sidebar/v1`, `home/v1` | a renderer |
|
|
129
|
+
| field | `field.input/v1`, `field.cell/v1` | a renderer |
|
|
130
|
+
|
|
131
|
+
`when` is a closed predicate over names only: `{ schema?: string[], field?:
|
|
132
|
+
string[], format?: string[] }`. It contains no expressions and no record
|
|
133
|
+
values, so the server can evaluate it.
|
|
134
|
+
|
|
135
|
+
`role` is the minimum staff role, the same vocabulary as `requires.auth`. It is
|
|
136
|
+
required except on `fields`, where it defaults to `contributor`.
|
|
137
|
+
|
|
138
|
+
### 1b. Manifests pick contributions through `uiSchema`
|
|
139
|
+
|
|
140
|
+
There are two ways to attach a contribution. Both stay:
|
|
141
|
+
|
|
142
|
+
- **From the manifest.** `uiSchema` already chooses Admin presentation
|
|
143
|
+
(`fields.<name>.widget: textarea`, `list.columns`, `collectionAction`).
|
|
144
|
+
It may also name a contribution as `<extension>/<contribution>`:
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
# Schema products
|
|
148
|
+
uiSchema:
|
|
149
|
+
fields:
|
|
150
|
+
color: { widget: brand-kit/color, options: { palette: brand } }
|
|
151
|
+
notes: { widget: textarea }
|
|
152
|
+
list:
|
|
153
|
+
cells: { color: brand-kit/swatch } # field.cell/v1
|
|
154
|
+
panels: [brand-kit/usage] # record.sidebar/v1
|
|
155
|
+
# Procedure reassign-order (uiSchema.fields.<name>.widget works the same)
|
|
156
|
+
# View sales-by-region
|
|
157
|
+
uiSchema:
|
|
158
|
+
list: { cells: { region: maps/region-badge } }
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- The binding sits next to the field it changes. An agent writes both in
|
|
162
|
+
one change, and a manifest review shows it.
|
|
163
|
+
- `options` is passed to the renderer as `ctx.options`. A contribution may
|
|
164
|
+
declare `optionsSchema` (the settings form subset). `options` is checked
|
|
165
|
+
against it, and a contribution without one refuses `options`.
|
|
166
|
+
- The target kind must match the key: `fields.*.widget` names a
|
|
167
|
+
`field.input/v1` contribution, `list.cells.*` a `field.cell/v1`
|
|
168
|
+
contribution, and `panels` a `record.sidebar/v1` contribution.
|
|
169
|
+
- **From the extension.** `when: { schema, field, format }` attaches a
|
|
170
|
+
contribution everywhere it matches without touching manifests, for example
|
|
171
|
+
every field with `x-mcp-hint: money-minor`. This suits host extensions and
|
|
172
|
+
cross-cutting widgets.
|
|
173
|
+
|
|
174
|
+
When both apply to one place, the manifest wins.
|
|
175
|
+
|
|
176
|
+
**Validation.**
|
|
177
|
+
- The manifest grammar checks the shape: `<extension>/<contribution>` with
|
|
178
|
+
kebab-case parts. `textarea` stays the one built-in widget.
|
|
179
|
+
- Whether the named contribution exists, has the right target and accepts the
|
|
180
|
+
`options` is checked where the plan and the extensions meet:
|
|
181
|
+
- `createAdminSurface` fails construction with `UI_EXTENSION_UNKNOWN`,
|
|
182
|
+
`UI_EXTENSION_TARGET` or `UI_EXTENSION_OPTIONS`.
|
|
183
|
+
- `checkPlanUiExtensions(plan, extensions)` is exported, so a deploy step or
|
|
184
|
+
a test can run the same check before the service starts.
|
|
185
|
+
|
|
186
|
+
**Who may be named.** A manifest should name only the project's own
|
|
187
|
+
extensions, so the project renders the same locally and when deployed; a host
|
|
188
|
+
attaches its contributions with `when` instead. Core cannot enforce this: the
|
|
189
|
+
extensions reach `createAdminSurface` as one list with no provenance, so it
|
|
190
|
+
checks existence, target and options, not ownership. A host that must keep
|
|
191
|
+
manifests from naming its contributions checks the plan's names
|
|
192
|
+
(`planUiExtensionRefs`) against its own ids before it starts Admin.
|
|
193
|
+
|
|
194
|
+
**A named widget is a decision.** When `uiSchema.fields.<name>.widget` names
|
|
195
|
+
any widget, `textarea` included, a matching `when` does not replace it; a
|
|
196
|
+
contribution the viewer's role does not reach falls back to Admin's own
|
|
197
|
+
control, not to `when`.
|
|
198
|
+
|
|
199
|
+
**Effect.** `uiSchema` stays presentation only. It never changes validation,
|
|
200
|
+
MCP schemas or authorization. The field's JSON Schema still validates what a
|
|
201
|
+
widget sends.
|
|
202
|
+
|
|
203
|
+
### 2. The server decides what a caller sees, and checks again on use
|
|
204
|
+
|
|
205
|
+
- `GET /admin/api/site` (and bootstrap's `site`) returns the contributions
|
|
206
|
+
the caller's role reaches, with their declarations only. Handlers and
|
|
207
|
+
`integrity` are not sent.
|
|
208
|
+
- Every extension route checks a signed-in staff session and the
|
|
209
|
+
contribution's role before it calls the host:
|
|
210
|
+
- `{basePath}/api/x/{extension}/settings/{id}`: `GET` → `load`, `PATCH { value }` → `save`
|
|
211
|
+
- `{basePath}/api/x/{extension}/actions/{id}`: `POST` → `run`, with `{ record }`, `{ selection }` or `{ schema }` for the action's target; `when.schema` is enforced here too
|
|
212
|
+
- `{basePath}/api/x/{extension}/api/{path}` → `api`, for the extension's lowest contribution role
|
|
213
|
+
- `GET {basePath}/extensions/{id}.js` → a `source` extension's module, for the same role
|
|
214
|
+
- `save` input is validated against `schema` before the host sees it (400 with
|
|
215
|
+
`error.fields` otherwise).
|
|
216
|
+
- `null` answers 404. Answers are `no-store` unless the host sets
|
|
217
|
+
`cache-control`. Cross-site mutation protection is the `withCaller`
|
|
218
|
+
wrapper's, as for every Admin route.
|
|
219
|
+
- The checks above are the minimum. Finer authorization is the host's, inside
|
|
220
|
+
its handlers.
|
|
221
|
+
|
|
222
|
+
### 3. Client modules supply renderers keyed by contribution id
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import { defineAdminExtension } from '@aotter/mantle-ui/extension'
|
|
226
|
+
|
|
227
|
+
export default defineAdminExtension({
|
|
228
|
+
pages: { overview: (element, ctx) => cleanup },
|
|
229
|
+
actions: { 'reassign': (element, ctx) => cleanup }, // dialog presentation only
|
|
230
|
+
panels: { 'history': (element, ctx) => cleanup },
|
|
231
|
+
fields: { 'color': (element, ctx) => ({ update(next) {}, unmount() {} }) },
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
A renderer returns nothing, a cleanup, or `{ update, unmount }`. With `update`,
|
|
236
|
+
Admin passes later contexts (a new value, a new language) in place instead of
|
|
237
|
+
mounting again; a field value the renderer reported itself never remounts it.
|
|
238
|
+
|
|
239
|
+
- Admin imports `module` once, when a contribution that needs it is first
|
|
240
|
+
shown, and calls the renderer for that contribution.
|
|
241
|
+
- A renderer for an undeclared contribution is ignored. A declared
|
|
242
|
+
contribution without a renderer shows an error in its place.
|
|
243
|
+
- The context is serializable and typed per target. It holds `{ extension,
|
|
244
|
+
contribution, apiBase, language, theme, caller: { role } }` plus:
|
|
245
|
+
- `record: { schema, id, version }` for record targets
|
|
246
|
+
- `selection: { schema, ids }` for list targets
|
|
247
|
+
- `field: { schema, name, value, readOnly, property }` for field targets,
|
|
248
|
+
and `options` from `uiSchema`; `field.input/v1` also gets `onChange(value)`
|
|
249
|
+
- A small `host` object offers `navigate(path)`, `notify(message)` and
|
|
250
|
+
`close()` for dialogs.
|
|
251
|
+
- Admin offers no other SPA API. This is a supported surface, not a sandbox:
|
|
252
|
+
the module is trusted same-origin code (§5).
|
|
253
|
+
- Each mount gets a fresh element. A renderer that resolves after its mount was
|
|
254
|
+
replaced writes into a detached node, never over the current one.
|
|
255
|
+
|
|
256
|
+
### 4. Shared dependencies come from Admin
|
|
257
|
+
|
|
258
|
+
- Admin's HTML declares an import map for `react`, `react/jsx-runtime`,
|
|
259
|
+
`react-dom`, `react-dom/client`, `@aotter/mantle-ui/kit` and
|
|
260
|
+
`@aotter/mantle-ui/extension`, served beside the shell. The React entries
|
|
261
|
+
re-export Admin's own instance; the kit and the helper are real modules
|
|
262
|
+
that import React through the same map, and the kit's stylesheet loads with
|
|
263
|
+
the first extension.
|
|
264
|
+
- Extension modules use these specifiers directly (or mark them external when
|
|
265
|
+
bundling). They share Admin's single React and keep its look, theme and
|
|
266
|
+
accessibility.
|
|
267
|
+
- The kit and the extension helper are the whole public UI surface. The shell
|
|
268
|
+
stays private (ADR-lite 909).
|
|
269
|
+
|
|
270
|
+
### 5. Trust tier
|
|
271
|
+
|
|
272
|
+
- Extensions are trusted host code. They run in Admin's origin with Admin's
|
|
273
|
+
privileges, and Core does not sandbox them.
|
|
274
|
+
- `module` must be a same-origin path, so Admin keeps its existing CSP and
|
|
275
|
+
framing policy.
|
|
276
|
+
- `integrity` (given for `module`, computed for `source`) goes into the
|
|
277
|
+
shell's import map, so the browser enforces it on import.
|
|
278
|
+
- An isolated tier for untrusted extensions is reserved and is not part of
|
|
279
|
+
this decision. Because contexts are serializable and the UI surface is the
|
|
280
|
+
kit, an iframe or remote-rendering tier can reuse this declaration format
|
|
281
|
+
unchanged.
|
|
282
|
+
|
|
283
|
+
### 6. Stability and tooling
|
|
284
|
+
|
|
285
|
+
- `apiVersion` and the `/v1` target names version the contract. Within a
|
|
286
|
+
version, changes are additive only. A breaking change adds `/v2`, and both
|
|
287
|
+
run side by side through a deprecation window.
|
|
288
|
+
- Construction validates every declaration and fails on:
|
|
289
|
+
- a bad or duplicate id
|
|
290
|
+
- an unknown target or `apiVersion`
|
|
291
|
+
- a non-same-origin `module`, or both `module` and `source`
|
|
292
|
+
- a contribution that needs code when there is neither
|
|
293
|
+
- a settings `schema` outside the form subset
|
|
294
|
+
- Core exports the declaration's JSON Schema, so tooling and agents can check
|
|
295
|
+
a declaration without running it.
|
|
296
|
+
- Core also exports `diffAdminExtensions(previous, next)`. It reports added
|
|
297
|
+
and removed contributions, role changes and module changes, for review
|
|
298
|
+
screens.
|
|
299
|
+
|
|
300
|
+
## Non-goals
|
|
301
|
+
|
|
302
|
+
- Replacing the shell, navigation component or layout.
|
|
303
|
+
- Free-form injection into arbitrary DOM.
|
|
304
|
+
- A marketplace or untrusted third-party extensions (reserved tier, above).
|
|
305
|
+
- Server-side sandboxing of handlers. They are host code in the host's
|
|
306
|
+
process.
|
|
307
|
+
- Declaring extensions in the YAML manifest. They live in the project's
|
|
308
|
+
service module (TypeScript), so the four-atom grammar does not change.
|
|
309
|
+
|
|
310
|
+
## Consequences
|
|
311
|
+
|
|
312
|
+
- Projects and hosts add pages, settings, actions, panels and field widgets
|
|
313
|
+
without forking the SPA, and the SPA stays one build. Without
|
|
314
|
+
`extensions`, nothing changes.
|
|
315
|
+
- An agent can customize a project's Admin with a small module and a
|
|
316
|
+
declaration that tooling validates and diffs before deploy. A `uiSchema`
|
|
317
|
+
line in the manifest binds it to a field, a list cell or a record panel.
|
|
318
|
+
- The `uiSchema` vocabulary widens: `fields.<name>.widget` accepts
|
|
319
|
+
`<extension>/<contribution>` beside `textarea`, with `options`; Schemas gain
|
|
320
|
+
`list.cells` and `panels`; staff Views gain `list.cells`. This record is the
|
|
321
|
+
grammar decision CONTRIBUTING requires for new keys: the name's shape is
|
|
322
|
+
closed and checked at generate time (`SCHEMA_UI_INVALID`,
|
|
323
|
+
`VIEW_UI_INVALID`), and its existence at construction
|
|
324
|
+
(`UI_EXTENSION_UNKNOWN`, `UI_EXTENSION_TARGET`, `UI_EXTENSION_OPTIONS`).
|
|
325
|
+
- Settings pages need no client code at all. This covers most host
|
|
326
|
+
configuration screens and is the easiest kind for an agent to author and
|
|
327
|
+
review.
|
|
328
|
+
- Admin takes on an import map and the `@aotter/mantle-ui/extension` entry.
|
|
329
|
+
- The first draft's `extensions: { pages, api }` is replaced before any
|
|
330
|
+
release; it was never published.
|
|
@@ -321,7 +321,24 @@ Points worth noticing:
|
|
|
321
321
|
- **`expired-orders` is `internal`:** callable only through
|
|
322
322
|
`ctx.store.view` or `runtime.store`. SQL compares `expiresAt` with `now()`.
|
|
323
323
|
- **`picking-list`** unnests order lines with `json_each`, the one comma join
|
|
324
|
-
the dialect allows.
|
|
324
|
+
the D1 dialect allows. This example's SQL is D1 (SQLite) SQL. On PostgreSQL
|
|
325
|
+
the same View reads:
|
|
326
|
+
|
|
327
|
+
```sql
|
|
328
|
+
SELECT o.orderToken, o.customerName, o.shippingAddress,
|
|
329
|
+
item.value ->> 'productSlug' AS "productSlug",
|
|
330
|
+
item.value ->> 'title' AS "productTitle",
|
|
331
|
+
item.value ->> 'quantity' AS quantity
|
|
332
|
+
FROM orders o, jsonb_array_elements(o.items) WITH ORDINALITY AS item(value, n)
|
|
333
|
+
WHERE o.orderStatus = 'paid'
|
|
334
|
+
ORDER BY o.created_at, item.n LIMIT 200
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`jsonb_array_elements_text(o.items) WITH ORDINALITY AS j(value, n)` is the
|
|
338
|
+
form for text elements. A sorted View over a row source needs the alias and
|
|
339
|
+
`WITH ORDINALITY`; Mantle pages by `n`. The
|
|
340
|
+
[rewrite table](../handbook/concepts/runtime-and-adapters.md#the-postgresql-dialect)
|
|
341
|
+
lists the other SQLite spellings PostgreSQL refuses.
|
|
325
342
|
|
|
326
343
|
## Handlers
|
|
327
344
|
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"check": "mantle generate && mantle generate --check && tsc --noEmit && node smoke.mjs"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@aotter/mantle": "0.2.0-alpha.
|
|
12
|
-
"@aotter/mantle-ui": "0.2.0-alpha.
|
|
11
|
+
"@aotter/mantle": "0.2.0-alpha.6",
|
|
12
|
+
"@aotter/mantle-ui": "0.2.0-alpha.6",
|
|
13
13
|
"@better-auth/cimd": "1.7.7",
|
|
14
14
|
"@better-auth/mcp": "1.7.7",
|
|
15
15
|
"@better-auth/oauth-provider": "1.7.7",
|
|
@@ -35,9 +35,9 @@ preset does); without Admin it is `/oauth/consent`, the plain page
|
|
|
35
35
|
then `createCallerResolver(auth, { jwtBearer: { audience: `${origin}/mcp`, scopes: ["mcp"] } })`,
|
|
36
36
|
`createAuthRoutes(auth, { resolver })` and an `AdminIdentity` over the auth's
|
|
37
37
|
own methods. With dialect `postgres` the preset passes `database:
|
|
38
|
-
pgPool(connect), driver: pgDatabaseDriver(connect)`
|
|
39
|
-
|
|
40
|
-
`Database` and `bunSqliteDriver(db)
|
|
38
|
+
pgPool(connect), driver: pgDatabaseDriver(connect)` on both hosts (Hyperdrive on
|
|
39
|
+
`cloudflare`, a `pg.Pool` on `bun`); host `bun` over SQLite passes the bun:sqlite
|
|
40
|
+
`Database` and `bunSqliteDriver(db)`, with `ipAddressHeaders:
|
|
41
41
|
["x-mantle-client-ip"]`, which its entry sets from the socket.
|
|
42
42
|
|
|
43
43
|
### Local sign-in
|
|
@@ -16,7 +16,10 @@ pnpm exec wrangler deploy
|
|
|
16
16
|
|
|
17
17
|
With dialect `postgres`, create the Hyperdrive config instead of the D1
|
|
18
18
|
database (`pnpm exec wrangler hyperdrive create my-service --caching-disabled
|
|
19
|
-
--connection-string=…`) and copy its id into `hyperdrive[0].id`.
|
|
19
|
+
--connection-string=…`) and copy its id into `hyperdrive[0].id`. Give the
|
|
20
|
+
role a statement limit and UTC, which boot checks (`ALTER ROLE app SET
|
|
21
|
+
statement_timeout = '10s'; ALTER ROLE app SET TimeZone = 'UTC'`); the boot
|
|
22
|
+
error names any other setting to change.
|
|
20
23
|
|
|
21
24
|
Set `PUBLIC_ORIGIN` in `wrangler.jsonc` `vars` to the deployed origin, and
|
|
22
25
|
replace the console email sender first
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Media uploads in Mantle 0.2.0 on Cloudflare R2 — site media purposes, r2MediaStorage, wiring Admin
|
|
2
|
+
description: Media uploads in Mantle 0.2.0 on Cloudflare R2 — site media purposes, r2MediaStorage, wiring Admin and staff MCP media tools, the presigned upload flow, and what to store in a Schema.
|
|
3
3
|
---
|
|
4
4
|
# Media uploads with R2
|
|
5
5
|
|
|
@@ -37,7 +37,7 @@ export const mantle = createMantle(service, {
|
|
|
37
37
|
} }),
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
// in mount():
|
|
40
|
+
// in mount(): one media storage, shared by Admin and staff MCP
|
|
41
41
|
const media = r2MediaStorage({
|
|
42
42
|
bucket: env.MEDIA,
|
|
43
43
|
signer: new AwsClient({ accessKeyId: env.R2_ACCESS_KEY_ID, secretAccessKey: env.R2_SECRET_ACCESS_KEY, region: "auto", service: "s3" }),
|
|
@@ -45,11 +45,16 @@ const media = r2MediaStorage({
|
|
|
45
45
|
publicBase: env.MEDIA_PUBLIC_BASE,
|
|
46
46
|
});
|
|
47
47
|
const admin = guard(createAdminSurface(runtime, { basePath: "/admin", media, /* …the generated options… */ }));
|
|
48
|
+
const staffMcp = guard(createMcpSurface(runtime, {
|
|
49
|
+
basePath: "/mcp/staff", surface: "staff", media, resourceMetadata,
|
|
50
|
+
}), { resourceMetadata });
|
|
48
51
|
```
|
|
49
52
|
|
|
50
53
|
`aws4fetch` is the application's dependency; Mantle only calls its `sign`.
|
|
51
|
-
Without both `media` and site defaults, every media route answers 501
|
|
52
|
-
`MEDIA_NOT_CONFIGURED
|
|
54
|
+
Without both `media` and site defaults, every Admin media route answers 501
|
|
55
|
+
`MEDIA_NOT_CONFIGURED`, and MCP advertises no media tools. Public MCP never
|
|
56
|
+
advertises media tools. The generated preset does not invent bucket credentials;
|
|
57
|
+
add the same explicit adapter to its application-owned service composition.
|
|
53
58
|
|
|
54
59
|
## 3. The upload flow
|
|
55
60
|
|
|
@@ -75,16 +80,62 @@ retried.
|
|
|
75
80
|
Optimize images where they are made (in the browser or the agent), and upload
|
|
76
81
|
the variants; Mantle does not transform images.
|
|
77
82
|
|
|
83
|
+
## Staff MCP upload flow
|
|
84
|
+
|
|
85
|
+
Connect the tenant's `/mcp/staff` using its own identity, with the `mcp` scope
|
|
86
|
+
and `editor` or `owner` role. Cloud project membership is not tenant staff.
|
|
87
|
+
The verified role is checked again on every call, as in Admin.
|
|
88
|
+
|
|
89
|
+
1. Call `get_media_upload_policy` to read the declared purpose MIME slots and
|
|
90
|
+
byte caps. Obtain the chat attachment bytes in the agent environment and
|
|
91
|
+
optimize/encode the required variants there; do not ask the user to run a
|
|
92
|
+
terminal command.
|
|
93
|
+
2. Call `create_media_upload` with the same body as Admin's create endpoint.
|
|
94
|
+
3. PUT every variant's actual bytes to its capability URL using all returned
|
|
95
|
+
`requiredHeaders`, before `expiresAt`. Bytes never travel as base64 MCP JSON.
|
|
96
|
+
4. Call `commit_media_upload` with `{ uploadGroupId, alt?, caption? }`.
|
|
97
|
+
Verify success before using the returned permanent asset `id`.
|
|
98
|
+
5. Pass that ID to the application's content Procedure. Images are content
|
|
99
|
+
operations, not a new Cloud source build or deploy.
|
|
100
|
+
|
|
101
|
+
`list_media_assets`, `get_media_asset`, `update_media_asset` (`id`, `alt?`,
|
|
102
|
+
`caption?`) and `delete_media_asset` (`id`) share the same library and editor
|
|
103
|
+
permission as Admin. A create refusal publishes nothing. Expired groups need
|
|
104
|
+
new capabilities; partial delete failures can retry the same ID. Keep upload
|
|
105
|
+
capabilities out of logs and persisted content.
|
|
106
|
+
|
|
78
107
|
## Storing media in a Schema
|
|
79
108
|
|
|
80
|
-
Store the asset
|
|
109
|
+
Store the committed asset ID, not its temporary capability URL:
|
|
81
110
|
|
|
82
111
|
```yaml
|
|
83
|
-
|
|
112
|
+
coverAssetId:
|
|
113
|
+
type: string
|
|
114
|
+
x-mantle-ref: media_assets
|
|
115
|
+
x-mcp-hint: media-image
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The string reference defaults to the native library's `id` and enables Admin's
|
|
119
|
+
existing MediaPicker. The hint alone does not enable the picker. Do not author
|
|
120
|
+
a replacement `media_assets` Schema: the site's library owns that table.
|
|
121
|
+
|
|
122
|
+
An asset ID is not an image URL. Resolve it with the existing site library in
|
|
123
|
+
an application-owned frontend handler, then use a variant's `publicUrl`:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// The same media adapter used by Admin and staff MCP.
|
|
127
|
+
const asset = await runtime.site!.media(media).get(post.coverAssetId);
|
|
128
|
+
const primary = asset.variants.find((variant) => variant.role === "primary")!;
|
|
129
|
+
// Render primary.publicUrl as the image src; asset.alt as its alt text.
|
|
84
130
|
```
|
|
85
131
|
|
|
86
|
-
|
|
87
|
-
|
|
132
|
+
A public frontend route must enforce the content's publication and visibility
|
|
133
|
+
before resolving its media. Return only public URLs/alt/variant metadata it
|
|
134
|
+
needs; never expose upload capabilities or a staff MCP credential. Native site
|
|
135
|
+
product tables are not manifest Store tables: do not query `media_assets` in
|
|
136
|
+
View/Procedure SQL or widen the SQL grammar to resolve them. A custom handler
|
|
137
|
+
can use the existing library capability while its content reads remain
|
|
138
|
+
caller-scoped.
|
|
88
139
|
|
|
89
140
|
## Stale uploads
|
|
90
141
|
|
|
@@ -17,13 +17,19 @@ Both run behind `withCaller(resolver, …)`, so a tool sees the same
|
|
|
17
17
|
|
|
18
18
|
## Which tools
|
|
19
19
|
|
|
20
|
-
A surface lists
|
|
20
|
+
A surface lists these plan tools:
|
|
21
21
|
|
|
22
22
|
- each Procedure bound by a Trigger `{ kind: mcp, surface: <this surface> }`,
|
|
23
23
|
named after the Procedure in snake case (`place-order` is `place_order`);
|
|
24
24
|
- each View whose `surface` is this one, as a read-only tool with the View's
|
|
25
25
|
`input` plus `limit` and `cursor`.
|
|
26
26
|
|
|
27
|
+
A configured staff surface also offers optional media product tools (see
|
|
28
|
+
[media upload](../cloudflare/media-r2.md)); these delegate to the same site
|
|
29
|
+
library as Admin. Without site defaults or media storage they are absent;
|
|
30
|
+
public MCP never exposes them. A name collision with a plan tool fails at
|
|
31
|
+
assembly rather than replacing either tool.
|
|
32
|
+
|
|
27
33
|
A Schema is never a tool: there are no generated create, update or publish
|
|
28
34
|
record tools. To let an agent change rows, declare a Procedure and an `mcp`
|
|
29
35
|
Trigger for it.
|
|
@@ -74,13 +80,15 @@ const staffMcp = withCaller(resolver,
|
|
|
74
80
|
{ resourceMetadata });
|
|
75
81
|
```
|
|
76
82
|
|
|
77
|
-
`resourceMetadata` is the `/mcp` metadata URL.
|
|
83
|
+
`resourceMetadata` is the `/mcp` metadata URL. Pass the same `media` adapter as Admin to enable media tools. They require
|
|
84
|
+
`editor` or `owner`; a contributor can discover them but cannot call them.
|
|
85
|
+
The staff surface admits only
|
|
78
86
|
callers with a staff role, and the role is read on every request.
|
|
79
87
|
|
|
80
88
|
## WebMCP in Admin
|
|
81
89
|
|
|
82
|
-
`GET /admin/api/webmcp` returns `{ tools, routes }`: the staff tools (
|
|
83
|
-
|
|
90
|
+
`GET /admin/api/webmcp` returns `{ tools, routes }`: the manifest staff tools (in its default locale; optional native media tools
|
|
91
|
+
use the existing Admin media REST routes) and, for each tool, the Admin page
|
|
84
92
|
it belongs to (a Procedure with a `target` maps to its collection, a View to its
|
|
85
93
|
report). A browser agent in the console registers these tools and calls one
|
|
86
94
|
with `POST /admin/api/webmcp/<tool>` and the input as the JSON body. Admin runs
|