@aotter/mantle 0.1.2-alpha.6 → 0.1.2
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 +15 -10
- package/dist/cli/generate.d.ts +9 -0
- package/dist/cli/generate.d.ts.map +1 -1
- package/dist/cli/generate.js +40 -1
- package/dist/cli/generate.js.map +1 -1
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +37 -9
- package/dist/cli/main.js.map +1 -1
- package/docs/adapter-guide.md +6 -1
- package/docs/adr/adr-lite-845-frontend-client.md +38 -0
- package/docs/agent-prompts.md +91 -0
- package/docs/api-mcp-authorization.md +1 -1
- package/docs/auth-hosting-model.md +1 -1
- package/docs/examples/README.md +22 -0
- package/docs/examples/builtin-commerce.md +269 -0
- package/docs/examples/builtin-intake.md +143 -0
- package/docs/examples/builtin-legal-documents.md +189 -0
- package/docs/examples/builtin-procurement.md +241 -0
- package/docs/examples/builtin-publication.md +241 -0
- package/docs/examples/builtin-reservation.md +149 -0
- package/docs/examples/cf-primitives-commerce-inventory.md +809 -0
- package/docs/examples/cf-primitives-guarded-api.md +429 -0
- package/docs/examples/cf-primitives-intake-hooks.md +319 -0
- package/docs/examples/host-chatgpt-sites/.openai/hosting.json +1 -0
- package/docs/examples/host-chatgpt-sites/README.md +53 -0
- package/docs/examples/host-chatgpt-sites/drizzle/0000_sites_users.sql +8 -0
- package/docs/examples/host-chatgpt-sites/drizzle/0001_mantle.sql +294 -0
- package/docs/examples/host-chatgpt-sites/drizzle/0002_article_cover.sql +5 -0
- package/docs/examples/host-chatgpt-sites/drizzle/meta/_journal.json +1 -0
- package/docs/examples/host-chatgpt-sites/manifests/site.yaml +27 -0
- package/docs/examples/host-chatgpt-sites/package.json +1 -0
- package/docs/examples/host-chatgpt-sites/public/site.css +1 -0
- package/docs/examples/host-chatgpt-sites/scripts/build.mjs +12 -0
- package/docs/examples/host-chatgpt-sites/scripts/check.mjs +99 -0
- package/docs/examples/host-chatgpt-sites/scripts/migration.mjs +9 -0
- package/docs/examples/host-chatgpt-sites/src/chatgpt-auth.ts +63 -0
- package/docs/examples/host-chatgpt-sites/src/index.ts +46 -0
- package/docs/examples/host-chatgpt-sites/src/mcp.ts +49 -0
- package/docs/examples/host-chatgpt-sites/src/media.ts +109 -0
- package/docs/examples/host-chatgpt-sites/src/r2-lab.ts +38 -0
- package/docs/examples/host-chatgpt-sites/src/storage-fingerprint.json +1 -0
- package/docs/examples/host-chatgpt-sites/src/web.ts +35 -0
- package/docs/examples/host-chatgpt-sites/tsconfig.json +1 -0
- package/docs/examples/host-chatgpt-sites/wrangler.jsonc +10 -0
- package/docs/examples/host-local-admin-otp/.dev.vars.example +3 -0
- package/docs/examples/host-local-admin-otp/README.md +70 -0
- package/docs/examples/host-local-admin-otp/ensure-dev-vars.mjs +5 -0
- package/docs/examples/host-local-admin-otp/package.json +29 -0
- package/docs/examples/host-local-admin-otp/public/.gitkeep +1 -0
- package/docs/examples/host-local-admin-otp/smoke.mjs +141 -0
- package/docs/examples/host-local-admin-otp/src/index.ts +54 -0
- package/docs/examples/host-local-admin-otp/wrangler.jsonc +23 -0
- package/docs/examples/{minimal-worker → host-minimal-worker}/README.md +9 -5
- package/docs/examples/host-minimal-worker/manifests/site.yaml +25 -0
- package/docs/examples/{minimal-worker → host-minimal-worker}/package.json +3 -3
- package/docs/examples/host-minimal-worker/tsconfig.json +17 -0
- package/docs/handbook/cloudflare/authentication.md +17 -2
- package/docs/handbook/cloudflare/bindings.md +9 -7
- package/docs/handbook/cloudflare/chatgpt-sites.md +29 -0
- package/docs/handbook/cloudflare/conventional-worker.md +3 -3
- package/docs/handbook/cloudflare/deferred-hooks-queues.md +0 -1
- package/docs/handbook/cloudflare/deploy-and-operate.md +14 -20
- package/docs/handbook/cloudflare/media-r2.md +2 -2
- package/docs/handbook/cloudflare/public-web.md +1 -1
- package/docs/handbook/cloudflare/site-chrome.md +75 -0
- package/docs/handbook/concepts/authorization.md +2 -2
- package/docs/handbook/concepts/four-atoms.md +2 -2
- package/docs/handbook/concepts/lifecycle-and-locales.md +1 -1
- package/docs/handbook/concepts/mcp-and-agents.md +1 -1
- package/docs/handbook/concepts/procedures-and-triggers.md +1 -1
- package/docs/handbook/concepts/runtime-and-adapters.md +2 -2
- package/docs/handbook/concepts/views.md +2 -2
- package/docs/handbook/examples/commerce-transaction.md +4 -806
- package/docs/handbook/examples/commerce.md +11 -0
- package/docs/handbook/examples/guarded-api.md +3 -420
- package/docs/handbook/examples/hub.md +10 -0
- package/docs/handbook/examples/intake-form.md +6 -313
- package/docs/handbook/examples/intake-hooks.md +11 -0
- package/docs/handbook/examples/legal-documents.md +3 -211
- package/docs/handbook/examples/procurement-approvals.md +3 -233
- package/docs/handbook/examples/publication.md +3 -233
- package/docs/handbook/examples/reservation.md +3 -213
- package/docs/handbook/navigation.json +17 -2
- package/docs/handbook/reference/authorization.md +1 -1
- package/docs/handbook/reference/procedure.md +2 -2
- package/docs/handbook/reference/schema.md +3 -3
- package/docs/handbook/reference/site-config.md +5 -16
- package/docs/handbook/reference/surface.md +3 -7
- package/docs/handbook/sites/equipment-checkout.md +231 -0
- package/docs/handbook/sites/host-reference.md +113 -0
- package/docs/handbook/sites/index.md +111 -0
- package/docs/handbook/start/project-and-cli.md +22 -14
- package/docs/handbook/start/quickstart-admin.md +239 -0
- package/docs/handbook/start/quickstart-worker.md +22 -23
- package/docs/migration-0.1.2.md +26 -0
- package/docs/release-process.md +92 -7
- package/docs/sealed-pipeline-ownership.md +2 -2
- package/docs/spec-only-host-adoption.md +3 -4
- package/docs/transaction-patterns.md +2 -2
- package/package.json +15 -15
- package/skills/README.md +18 -2
- package/skills/develop/SKILL.md +32 -23
- package/skills/install/SKILL.md +50 -11
- package/skills/provision/SKILL.md +21 -5
- /package/docs/examples/{minimal-worker → host-local-admin-otp}/manifests/site.yaml +0 -0
- /package/docs/examples/{minimal-worker → host-local-admin-otp}/tsconfig.json +0 -0
- /package/docs/examples/{minimal-worker → host-minimal-worker}/smoke.mjs +0 -0
- /package/docs/examples/{minimal-worker → host-minimal-worker}/src/index.ts +0 -0
- /package/docs/examples/{minimal-worker → host-minimal-worker}/wrangler.jsonc +0 -0
|
@@ -1,240 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Redirects to the builtin procurement example in the Examples hub.
|
|
3
3
|
---
|
|
4
4
|
# Procurement approvals with member and staff roles
|
|
5
5
|
|
|
6
|
-
This example
|
|
7
|
-
|
|
8
|
-
## Problem
|
|
9
|
-
|
|
10
|
-
A signed-in member submits a purchase requisition: a request number, an item, a quantity, a need-by date and a justification. The member sees only their own requisitions and their current status. Staff with the `owner` or `editor` role see the queue of `submitted` requisitions ordered by need-by date and mark each `approved` or `rejected` with a note. Two reviewers must not silently overwrite each other's decision. Requisitions are live operational records.
|
|
11
|
-
|
|
12
|
-
## Manifest
|
|
13
|
-
|
|
14
|
-
```yaml
|
|
15
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
16
|
-
kind: Schema
|
|
17
|
-
metadata:
|
|
18
|
-
name: purchase-requisitions
|
|
19
|
-
spec:
|
|
20
|
-
title: Purchase requisitions
|
|
21
|
-
description: Member-submitted purchase needs waiting for staff review.
|
|
22
|
-
lifecycle: operational
|
|
23
|
-
uniqueIndexes:
|
|
24
|
-
- [requestNumber]
|
|
25
|
-
indexes:
|
|
26
|
-
- [requestedBy, requestedAt]
|
|
27
|
-
- [requestStatus, needBy]
|
|
28
|
-
schema:
|
|
29
|
-
type: object
|
|
30
|
-
additionalProperties: false
|
|
31
|
-
required: [requestNumber, requestedBy, item, quantity, needBy, justification, requestStatus, requestedAt]
|
|
32
|
-
properties:
|
|
33
|
-
requestNumber: { type: string, pattern: "^REQ-[A-Z0-9-]+$" }
|
|
34
|
-
requestedBy: { type: string, x-mantle-bind: ctx.user }
|
|
35
|
-
item: { type: string, minLength: 1, maxLength: 160 }
|
|
36
|
-
quantity: { type: integer, minimum: 1 }
|
|
37
|
-
needBy: { type: number, x-mcp-hint: timestamp-ms }
|
|
38
|
-
justification: { type: string, minLength: 1, maxLength: 1000 }
|
|
39
|
-
requestStatus: { type: string, enum: [submitted, approved, rejected] }
|
|
40
|
-
reviewerNote: { type: string, maxLength: 1000 }
|
|
41
|
-
requestedAt: { type: number, x-mcp-hint: timestamp-ms, x-mantle-bind: now }
|
|
42
|
-
---
|
|
43
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
44
|
-
kind: View
|
|
45
|
-
metadata:
|
|
46
|
-
name: my-requisitions
|
|
47
|
-
spec:
|
|
48
|
-
title: My requisitions
|
|
49
|
-
surface: public
|
|
50
|
-
from: purchase-requisitions
|
|
51
|
-
requires:
|
|
52
|
-
auth:
|
|
53
|
-
all: [ctx.user]
|
|
54
|
-
fields: [id, requestNumber, item, quantity, needBy, justification, requestStatus, reviewerNote, requestedAt]
|
|
55
|
-
filter:
|
|
56
|
-
eq: { field: requestedBy, value: { "$ctx.user": id } }
|
|
57
|
-
orderBy:
|
|
58
|
-
- { field: requestedAt, direction: desc }
|
|
59
|
-
limit: 100
|
|
60
|
-
---
|
|
61
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
62
|
-
kind: View
|
|
63
|
-
metadata:
|
|
64
|
-
name: pending-approvals
|
|
65
|
-
spec:
|
|
66
|
-
title: Pending approvals
|
|
67
|
-
surface: staff
|
|
68
|
-
from: purchase-requisitions
|
|
69
|
-
requires:
|
|
70
|
-
auth:
|
|
71
|
-
all:
|
|
72
|
-
- { "ctx.staff": [owner, editor] }
|
|
73
|
-
fields: [id, version, requestNumber, requestedBy, item, quantity, needBy, justification, requestedAt]
|
|
74
|
-
filter:
|
|
75
|
-
eq: { field: requestStatus, value: submitted }
|
|
76
|
-
orderBy:
|
|
77
|
-
- { field: needBy, direction: asc }
|
|
78
|
-
limit: 100
|
|
79
|
-
---
|
|
80
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
81
|
-
kind: Procedure
|
|
82
|
-
metadata:
|
|
83
|
-
name: submit-requisition
|
|
84
|
-
spec:
|
|
85
|
-
title: Submit requisition
|
|
86
|
-
requires:
|
|
87
|
-
auth:
|
|
88
|
-
all: [ctx.user]
|
|
89
|
-
input:
|
|
90
|
-
type: object
|
|
91
|
-
additionalProperties: false
|
|
92
|
-
required: [requestNumber, item, quantity, needBy, justification, requestStatus]
|
|
93
|
-
properties:
|
|
94
|
-
requestNumber: { type: string, pattern: "^REQ-[A-Z0-9-]+$" }
|
|
95
|
-
item: { type: string, minLength: 1, maxLength: 160 }
|
|
96
|
-
quantity: { type: integer, minimum: 1 }
|
|
97
|
-
needBy: { type: number, x-mcp-hint: timestamp-ms }
|
|
98
|
-
justification: { type: string, minLength: 1, maxLength: 1000 }
|
|
99
|
-
requestStatus: { type: string, enum: [submitted] }
|
|
100
|
-
output: { type: object }
|
|
101
|
-
handler: { kind: builtin, op: create, schema: purchase-requisitions }
|
|
102
|
-
---
|
|
103
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
104
|
-
kind: Procedure
|
|
105
|
-
metadata:
|
|
106
|
-
name: review-requisition
|
|
107
|
-
spec:
|
|
108
|
-
title: Review requisition
|
|
109
|
-
requires:
|
|
110
|
-
auth:
|
|
111
|
-
all:
|
|
112
|
-
- { "ctx.staff": [owner, editor] }
|
|
113
|
-
input:
|
|
114
|
-
type: object
|
|
115
|
-
additionalProperties: false
|
|
116
|
-
required: [id, expectedVersion, requestStatus]
|
|
117
|
-
properties:
|
|
118
|
-
id: { type: string, x-mantle-ref: purchase-requisitions }
|
|
119
|
-
expectedVersion: { type: number, minimum: 1 }
|
|
120
|
-
requestStatus: { type: string, enum: [approved, rejected] }
|
|
121
|
-
reviewerNote: { type: string, maxLength: 1000 }
|
|
122
|
-
output: { type: object }
|
|
123
|
-
handler: { kind: builtin, op: update, schema: purchase-requisitions }
|
|
124
|
-
---
|
|
125
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
126
|
-
kind: Trigger
|
|
127
|
-
metadata:
|
|
128
|
-
name: submit-requisition-http
|
|
129
|
-
spec:
|
|
130
|
-
source: { kind: http, method: POST, path: /api/requisitions }
|
|
131
|
-
target: { procedure: submit-requisition }
|
|
132
|
-
---
|
|
133
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
134
|
-
kind: Trigger
|
|
135
|
-
metadata:
|
|
136
|
-
name: submit-requisition-mcp
|
|
137
|
-
spec:
|
|
138
|
-
source: { kind: mcp, surface: public }
|
|
139
|
-
target: { procedure: submit-requisition }
|
|
140
|
-
---
|
|
141
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
142
|
-
kind: Trigger
|
|
143
|
-
metadata:
|
|
144
|
-
name: review-requisition-mcp
|
|
145
|
-
spec:
|
|
146
|
-
source: { kind: mcp, surface: staff }
|
|
147
|
-
target: { procedure: review-requisition }
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
### Ownership without trusting the caller
|
|
151
|
-
|
|
152
|
-
`requestedBy` carries `x-mantle-bind: ctx.user`. On create the server stamps the signed-in user's id and ignores any caller value; on update the stamp is preserved. `requestedAt` is stamped the same way with `now`. Because both are in `required`, and because `submit-requisition` requires `ctx.user`, an anonymous caller is rejected with `UNAUTHENTICATED` before the row is even projected.
|
|
153
|
-
|
|
154
|
-
`my-requisitions` filters with the closed sentinel `{ "$ctx.user": id }`. The caller never supplies this value, so the same View is safe over REST and public MCP. Three rules make the sentinel legal, and the parser enforces each:
|
|
155
|
-
|
|
156
|
-
| Rule | Diagnostic when violated |
|
|
157
|
-
|---|---|
|
|
158
|
-
| The View's `requires.auth.all` includes `ctx.user` | `VIEW_FILTER_CTX_USER_REF_REQUIRES_AUTH` |
|
|
159
|
-
| The sentinel appears only under `eq`, as a single-key object whose value is the literal `id` | `VIEW_FILTER_CTX_USER_REF_INVALID` |
|
|
160
|
-
| The filtered field is the leftmost field of a declared index (`[requestedBy, requestedAt]` here) | `VIEW_FILTER_CTX_USER_REF_REQUIRES_INDEX` |
|
|
161
|
-
|
|
162
|
-
A missing identity fails with 401; the runtime never drops the filter and never falls back to all rows.
|
|
163
|
-
|
|
164
|
-
`pending-approvals` uses the staff predicate `{ "ctx.staff": [owner, editor] }`. It includes `version` in `fields` so a reviewer can pass it back as `expectedVersion`.
|
|
165
|
-
|
|
166
|
-
### Optimistic concurrency
|
|
167
|
-
|
|
168
|
-
`review-requisition` is a builtin `update`. Its input must declare `id` (string) and `expectedVersion` (number) in `required`; the parser rejects the Manifest otherwise. `expectedVersion` is the version the reviewer **read**, not that value plus one. At runtime the row is loaded, the patch is merged over existing data (omitted fields and server stamps survive), and the write is applied only if the stored version equals `expectedVersion`. A stale version fails with `CONFLICT` (HTTP 409) and nothing changes; the reviewer re-reads and decides again. Admin binds and hides `expectedVersion` on row-bound forms. `requestStatus` is narrowed to `approved | rejected`, so this Procedure cannot move a row back to `submitted`.
|
|
169
|
-
|
|
170
|
-
### Admin row action
|
|
171
|
-
|
|
172
|
-
`id` carries `x-mantle-ref: purchase-requisitions`, so Admin lists `review-requisition` in the ⋯ menu of each `purchase-requisitions` row and prefills the referenced field. Admin derives the prefilled value from a same-named Schema property first, then from the Schema's lone single-field unique index, and finally from the entry id.
|
|
173
|
-
|
|
174
|
-
> **Warning**
|
|
175
|
-
> This Schema declares exactly one single-field unique index, `[requestNumber]`, and no property named `id`, so Admin prefills `id` with the row's `requestNumber`. A builtin `update` expects the entry id there and would answer `NOT_FOUND`. Until that binding is adjusted, drive reviews from the `pending-approvals` View (which exposes `id` and `version`) through Staff MCP or `POST /admin/api/operations/review-requisition`. Declaring a second `uniqueIndexes` tuple or no unique index changes the derivation to the entry id, at the cost of that uniqueness guarantee.
|
|
176
|
-
|
|
177
|
-
See [Authorization](../concepts/authorization.md) and the [Procedure reference](../reference/procedure.md).
|
|
178
|
-
|
|
179
|
-
## Worker and handlers
|
|
180
|
-
|
|
181
|
-
None. Both Procedures are builtin and the Worker is `export default createMantleWorker({ plan })`. Sign-in is provided by the conventional Worker's Auth; see [Authentication](../cloudflare/authentication.md).
|
|
182
|
-
|
|
183
|
-
## Try it
|
|
184
|
-
|
|
185
|
-
Anonymous read of the member View:
|
|
186
|
-
|
|
187
|
-
```sh
|
|
188
|
-
curl -i http://localhost:8787/api/views/my-requisitions
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
```txt
|
|
192
|
-
HTTP/1.1 401 Unauthorized
|
|
193
|
-
{"ok":false,"diagnostic":{"code":"UNAUTHENTICATED","phase":"runtime","severity":"error","path":"...#/requires/auth/all/0", ...}}
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
Signed in (session cookie), the same request returns only rows whose `requestedBy` equals the caller's id:
|
|
197
|
-
|
|
198
|
-
```sh
|
|
199
|
-
curl -sS http://localhost:8787/api/views/my-requisitions \
|
|
200
|
-
-H "cookie: $SESSION_COOKIE"
|
|
201
|
-
# {"ok":true,"data":{"rows":[{"id":"pr_01j...","requestNumber":"REQ-2026-0001","item":"Standing desk","quantity":1,"needBy":1791475200000,"justification":"...","requestStatus":"submitted","requestedAt":1788879363492}],"page":1,"show":100,"hasMore":false}}
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
Submit as a member (mutations authenticated by a session cookie also pass a same-origin check):
|
|
205
|
-
|
|
206
|
-
```sh
|
|
207
|
-
curl -sS -X POST http://localhost:8787/api/requisitions \
|
|
208
|
-
-H 'content-type: application/json' -H "cookie: $SESSION_COOKIE" \
|
|
209
|
-
-d '{"requestNumber":"REQ-2026-0002","item":"Monitor arm","quantity":2,"needBy":1791475200000,"justification":"Second screen for review work.","requestStatus":"submitted"}'
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
The response is `{ ok: true, data: <EntryRow> }`; `data.requestedBy` is the caller's id even if the body tried to set it. A duplicate `requestNumber` is HTTP 409 `CONFLICT`.
|
|
213
|
-
|
|
214
|
-
Staff review through Staff MCP on `/mcp/staff`. First `query_view_pending_approvals` returns `id` and `version`; then:
|
|
215
|
-
|
|
216
|
-
```json
|
|
217
|
-
{
|
|
218
|
-
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
|
219
|
-
"params": {
|
|
220
|
-
"name": "review_requisition",
|
|
221
|
-
"arguments": { "id": "pr_01j...", "expectedVersion": 1, "requestStatus": "approved", "reviewerNote": "Within budget." }
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
A second reviewer replaying `expectedVersion: 1` after that succeeds receives a JSON-RPC error whose `error.data.code` is `CONFLICT`. A contributor-role session is denied with `AUTH_DENIED`. `review_requisition` appears only in `tools/list` on `/mcp/staff`; the public surface lists `submit_requisition` and `query_view_my_requisitions`.
|
|
227
|
-
|
|
228
|
-
## What this deliberately leaves out
|
|
229
|
-
|
|
230
|
-
- **Multi-step approval chains.** One decision by one staff member. Sequential approvers would add a step field, more enum states and a `before_update` hook that validates the transition.
|
|
231
|
-
- **Budgets.** No cost field and no per-department limit; add a `ref` guard Procedure when a live check is needed.
|
|
232
|
-
- **Notifications.** Neither the submitter nor the reviewer is emailed. The [intake form](./intake-form.md) shows the `after_create` pattern.
|
|
233
|
-
|
|
234
|
-
Related: [Guarded API access](./guarded-api.md) covers credentials and scopes for non-browser callers.
|
|
6
|
+
This example lives in the Examples hub: [`docs/examples/builtin-procurement.md`](../../examples/builtin-procurement.md).
|
|
235
7
|
|
|
236
8
|
## Source
|
|
237
9
|
|
|
238
|
-
- [
|
|
239
|
-
- [`packages/mantle-admin/src/mountMantleAdmin.ts`](../../../packages/mantle-admin/src/mountMantleAdmin.ts) — `discoverRowBindings` and `rowField` derivation
|
|
240
|
-
- [`packages/mantle-runtime/src/domain/service/AuthPredicateEvaluator.ts`](../../../packages/mantle-runtime/src/domain/service/AuthPredicateEvaluator.ts) — `UNAUTHENTICATED` versus `AUTH_DENIED`
|
|
10
|
+
- [`docs/examples/builtin-procurement.md`](../../examples/builtin-procurement.md)
|
|
@@ -1,240 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Redirects to the builtin publication example in the Examples hub.
|
|
3
3
|
---
|
|
4
4
|
# Publication: localized posts with a public list
|
|
5
5
|
|
|
6
|
-
This example
|
|
7
|
-
|
|
8
|
-
## Problem
|
|
9
|
-
|
|
10
|
-
Editors draft posts in one or more locales, review them in Admin, and publish each language version independently. Every language version of a post shares one stable identity (its slug) so the site can link translations together and Admin can show translation completeness. Visitors read a per-locale list of published posts, newest first, over REST and public MCP. Readers may suggest topics; those suggestions are live records staff read in Admin, not content that is published.
|
|
11
|
-
|
|
12
|
-
## Manifest
|
|
13
|
-
|
|
14
|
-
```yaml
|
|
15
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
16
|
-
kind: Schema
|
|
17
|
-
metadata:
|
|
18
|
-
name: posts
|
|
19
|
-
spec:
|
|
20
|
-
title: Posts
|
|
21
|
-
description: Stable post identities shared by every language version.
|
|
22
|
-
localized: false
|
|
23
|
-
lifecycle: publishing
|
|
24
|
-
uniqueIndexes:
|
|
25
|
-
- [slug]
|
|
26
|
-
schema:
|
|
27
|
-
type: object
|
|
28
|
-
required: [slug]
|
|
29
|
-
properties:
|
|
30
|
-
slug: { type: string, pattern: "^[a-z0-9-]+$" }
|
|
31
|
-
---
|
|
32
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
33
|
-
kind: Schema
|
|
34
|
-
metadata:
|
|
35
|
-
name: post-translations
|
|
36
|
-
spec:
|
|
37
|
-
title: Post translations
|
|
38
|
-
description: Localized titles and bodies for each post.
|
|
39
|
-
localized: true
|
|
40
|
-
translates:
|
|
41
|
-
parent: posts
|
|
42
|
-
on: slug
|
|
43
|
-
lifecycle: publishing
|
|
44
|
-
uniqueIndexes:
|
|
45
|
-
- [slug, locale]
|
|
46
|
-
indexes:
|
|
47
|
-
- [locale, publishedAt]
|
|
48
|
-
schema:
|
|
49
|
-
type: object
|
|
50
|
-
required: [slug, locale, title, publishedAt]
|
|
51
|
-
properties:
|
|
52
|
-
slug: { type: string, pattern: "^[a-z0-9-]+$" }
|
|
53
|
-
locale: { type: string }
|
|
54
|
-
title: { type: string }
|
|
55
|
-
excerpt: { type: string }
|
|
56
|
-
body: { type: string, x-mcp-hint: markdown }
|
|
57
|
-
publishedAt: { type: number, x-mcp-hint: timestamp-ms }
|
|
58
|
-
---
|
|
59
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
60
|
-
kind: View
|
|
61
|
-
metadata:
|
|
62
|
-
name: published-posts
|
|
63
|
-
spec:
|
|
64
|
-
title: Published posts
|
|
65
|
-
surface: public
|
|
66
|
-
from: post-translations
|
|
67
|
-
params:
|
|
68
|
-
type: object
|
|
69
|
-
additionalProperties: false
|
|
70
|
-
required: [locale]
|
|
71
|
-
properties:
|
|
72
|
-
locale: { type: string }
|
|
73
|
-
fields: [id, slug, locale, title, excerpt, body, publishedAt, updatedAt]
|
|
74
|
-
filter:
|
|
75
|
-
and:
|
|
76
|
-
- eq: { field: status, value: published }
|
|
77
|
-
- eq: { field: locale, value: { $param: locale } }
|
|
78
|
-
- gte: { field: publishedAt, value: 0 }
|
|
79
|
-
orderBy:
|
|
80
|
-
- { field: publishedAt, direction: desc }
|
|
81
|
-
limit: 50
|
|
82
|
-
---
|
|
83
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
84
|
-
kind: Schema
|
|
85
|
-
metadata:
|
|
86
|
-
name: post-suggestions
|
|
87
|
-
spec:
|
|
88
|
-
title: Post suggestions
|
|
89
|
-
description: Reader suggestions for future posts.
|
|
90
|
-
lifecycle: operational
|
|
91
|
-
schema:
|
|
92
|
-
type: object
|
|
93
|
-
required: [title, email]
|
|
94
|
-
properties:
|
|
95
|
-
title: { type: string }
|
|
96
|
-
email: { type: string, format: email }
|
|
97
|
-
note: { type: string }
|
|
98
|
-
createdAt: { type: integer, x-mcp-hint: timestamp-ms, x-mantle-bind: now }
|
|
99
|
-
---
|
|
100
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
101
|
-
kind: Procedure
|
|
102
|
-
metadata:
|
|
103
|
-
name: submit-post-suggestion
|
|
104
|
-
spec:
|
|
105
|
-
input:
|
|
106
|
-
type: object
|
|
107
|
-
additionalProperties: false
|
|
108
|
-
required: [title, email]
|
|
109
|
-
properties:
|
|
110
|
-
title: { type: string }
|
|
111
|
-
email: { type: string, format: email }
|
|
112
|
-
note: { type: string }
|
|
113
|
-
output: { type: object }
|
|
114
|
-
handler: { kind: builtin, op: create, schema: post-suggestions }
|
|
115
|
-
---
|
|
116
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
117
|
-
kind: Trigger
|
|
118
|
-
metadata:
|
|
119
|
-
name: submit-post-suggestion-http
|
|
120
|
-
spec:
|
|
121
|
-
source: { kind: http, method: POST, path: /api/post-suggestions }
|
|
122
|
-
target: { procedure: submit-post-suggestion }
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
How the pieces fit:
|
|
126
|
-
|
|
127
|
-
- `posts` is the non-localized parent. It holds only what every language shares; here that is the slug.
|
|
128
|
-
- `post-translations` is the localized child. `translates: { parent: posts, on: slug }` requires `localized: true`, at least one content field besides `slug` and `locale`, and a parent that exists and is not itself localized. Admin renders the child as locale tabs inside the parent's editor.
|
|
129
|
-
- `published-posts` takes a required `locale` parameter. `{ $param: locale }` must name a property in `params.properties` that is also in `params.required`. The `gte publishedAt 0` clause keeps the filter aligned with the declared `[locale, publishedAt]` index; confirm the plan with `mantle-harness indexes`.
|
|
130
|
-
- Site locales must be configured. A localized Schema with zero `siteDefaults.locales` fails at boot with `SCHEMA_LOCALIZED_REQUIRES_SITE_LOCALES`, and a write whose `locale` is not in the site list fails with `INPUT_VALIDATION_FAILED`.
|
|
131
|
-
|
|
132
|
-
See [Lifecycle and locales](../concepts/lifecycle-and-locales.md) and the [Schema reference](../reference/schema.md).
|
|
133
|
-
|
|
134
|
-
## Worker and handlers
|
|
135
|
-
|
|
136
|
-
There are none. Every Procedure here is `builtin`, and Views need no Trigger. The Worker is the minimal one:
|
|
137
|
-
|
|
138
|
-
```ts
|
|
139
|
-
import { createMantleWorker } from "@aotter/mantle/cloudflare";
|
|
140
|
-
import { plan } from "../.mantle/generated/mantle.js";
|
|
141
|
-
|
|
142
|
-
export default createMantleWorker({ plan });
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
### Rendering
|
|
146
|
-
|
|
147
|
-
Public HTML pages are optional and separate from the data contract. If you want `/en/posts/<slug>` rendered by the Worker, register a template for the translations collection and pass it to `createMantleWorker({ templates, publicPathResolver })`. A trimmed entry template:
|
|
148
|
-
|
|
149
|
-
```ts
|
|
150
|
-
import { TemplateRegistry } from "@aotter/mantle/web";
|
|
151
|
-
import MarkdownIt from "markdown-it";
|
|
152
|
-
|
|
153
|
-
const markdown = new MarkdownIt({ html: false, linkify: true });
|
|
154
|
-
|
|
155
|
-
export function createTemplates(): TemplateRegistry {
|
|
156
|
-
const templates = new TemplateRegistry();
|
|
157
|
-
templates.registerEntryTemplate("post-translations", ({ entry, site, seo }) => {
|
|
158
|
-
const title = typeof entry.data.title === "string" ? entry.data.title : site.title;
|
|
159
|
-
const body = typeof entry.data.body === "string" ? entry.data.body : "";
|
|
160
|
-
return `<html lang="${entry.locale ?? "en"}"><head><title>${escapeHtml(title)}</title></head>
|
|
161
|
-
<body><main><h1>${escapeHtml(title)}</h1><div>${markdown.render(body)}</div></main></body></html>`;
|
|
162
|
-
});
|
|
163
|
-
return templates;
|
|
164
|
-
}
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
The template receives `{ entry, site, seo, mediaAssets }` and returns a complete HTML string. Escape every field you interpolate; `body` is rendered from Markdown because the Schema marks it `x-mcp-hint: markdown`. Paths, Markdown mirrors, `llms.txt`, sitemap and cache headers are covered in [Public web, SEO and cache](../cloudflare/public-web.md).
|
|
168
|
-
|
|
169
|
-
### The single-Schema alternative
|
|
170
|
-
|
|
171
|
-
The simpler alternative is one localized `posts` Schema with `uniqueIndexes: [[slug, locale]]` and no parent. Each locale row is an independent record; nothing is shared except the slug convention. Choose that shape when translations do not need shared fields or Admin translation grouping. Choose the parent/child shape above when several locale rows are versions of one entity and editors need to see which languages are missing. Both shapes serve the same `published-posts` View contract.
|
|
172
|
-
|
|
173
|
-
## Try it
|
|
174
|
-
|
|
175
|
-
Public list for one locale:
|
|
176
|
-
|
|
177
|
-
```sh
|
|
178
|
-
curl -sS 'http://localhost:8787/api/views/published-posts?locale=en&show=10'
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
```json
|
|
182
|
-
{
|
|
183
|
-
"ok": true,
|
|
184
|
-
"data": {
|
|
185
|
-
"rows": [
|
|
186
|
-
{
|
|
187
|
-
"id": "tr_01j...",
|
|
188
|
-
"slug": "hello-world",
|
|
189
|
-
"locale": "en",
|
|
190
|
-
"title": "Hello, world",
|
|
191
|
-
"excerpt": "First post.",
|
|
192
|
-
"body": "# Hello\n\nFirst post.",
|
|
193
|
-
"publishedAt": 1788879363492,
|
|
194
|
-
"updatedAt": 1788879400000
|
|
195
|
-
}
|
|
196
|
-
],
|
|
197
|
-
"page": 1,
|
|
198
|
-
"show": 10,
|
|
199
|
-
"hasMore": false
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
Omitting `locale` returns HTTP 400 `INPUT_VALIDATION_FAILED`. `show` is capped at the View's `limit` (50); `hasMore` is `rows.length === show`. A View may declare `cache.sharedMaxAge` to cache eligible anonymous REST responses under the Worker's deployment-scoped tag; publishing content changes purge that tag.
|
|
205
|
-
|
|
206
|
-
Submit a suggestion:
|
|
207
|
-
|
|
208
|
-
```sh
|
|
209
|
-
curl -sS -X POST http://localhost:8787/api/post-suggestions \
|
|
210
|
-
-H 'content-type: application/json' \
|
|
211
|
-
-d '{"title":"Write about indexes","email":"reader@example.test"}'
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
The response is `{ ok: true, data: <EntryRow> }` with `collection: "post-suggestions"` and `status: "published"`.
|
|
215
|
-
|
|
216
|
-
MCP tools:
|
|
217
|
-
|
|
218
|
-
| Surface | Tool | Origin |
|
|
219
|
-
|---|---|---|
|
|
220
|
-
| `/mcp` | `query_view_published_posts` (arguments `locale`, `page`, `show`) | public View |
|
|
221
|
-
| `/mcp/staff` | `create_draft_posts`, `update_draft_posts` | publishing Schema `posts` |
|
|
222
|
-
| `/mcp/staff` | `create_draft_post_translations`, `update_draft_post_translations` | publishing Schema `post-translations` |
|
|
223
|
-
| `/mcp/staff` | `create_record_post_suggestions`, `update_record_post_suggestions` | operational Schema `post-suggestions` |
|
|
224
|
-
| `/mcp/staff` | `request_publish`, `unpublish_entry`, `archive_entry`, `delete_entry` | generic lifecycle tools |
|
|
225
|
-
|
|
226
|
-
There is no `submit_post_suggestion` tool because no MCP Trigger targets that Procedure. See [MCP and agents](../concepts/mcp-and-agents.md).
|
|
227
|
-
|
|
228
|
-
## What this deliberately leaves out
|
|
229
|
-
|
|
230
|
-
- **Comments.** Reader comments would be another operational Schema with its own moderation Procedures.
|
|
231
|
-
- **Search.** `searchableFields` powers Admin and Staff MCP substring search only; public full-text search is application code.
|
|
232
|
-
- **Scheduling.** `publishedAt` is a display timestamp set by editors. Nothing publishes a draft at that time; publishing remains an explicit `request_publish` or Admin action.
|
|
233
|
-
|
|
234
|
-
Related: [Intake form](./intake-form.md) hardens the suggestion box with a bot check.
|
|
6
|
+
This example lives in the Examples hub: [`docs/examples/builtin-publication.md`](../../examples/builtin-publication.md).
|
|
235
7
|
|
|
236
8
|
## Source
|
|
237
9
|
|
|
238
|
-
- [
|
|
239
|
-
- [`packages/mantle-web/src/model/TemplateRegistry.ts`](../../../packages/mantle-web/src/model/TemplateRegistry.ts) — `registerEntryTemplate` and `EntryContext`
|
|
240
|
-
- [`overlays/publication/manifests/site.yaml`](https://github.com/aotter/mantle-starters/blob/a66ec0ea3aaefc09a0229b7d8ca35af630f2b55d/overlays/publication/manifests/site.yaml) — retired parent/child publication pattern
|
|
10
|
+
- [`docs/examples/builtin-publication.md`](../../examples/builtin-publication.md)
|