@aotter/mantle 0.1.0-alpha.9 → 0.1.2-alpha.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 +38 -38
- package/dist/cli/generate.d.ts +10 -1
- package/dist/cli/generate.d.ts.map +1 -1
- package/dist/cli/generate.js +49 -4
- package/dist/cli/generate.js.map +1 -1
- package/dist/cli/main.js +0 -8
- package/dist/cli/main.js.map +1 -1
- package/dist/codegen/emitMantleModule.js +8 -0
- package/dist/codegen/emitMantleModule.js.map +1 -1
- package/docs/adapter-guide.md +23 -26
- package/docs/adr/0008-structured-diagnostic-shape.md +7 -2
- package/docs/adr/0010-locale-and-translates.md +14 -16
- package/docs/adr/0011-adapter-port-spec.md +2 -1
- package/docs/adr/0014-auth-better-auth-and-multi-tenant-mcp.md +100 -0
- package/docs/adr/0018-core-starters-repository-boundary.md +3 -0
- package/docs/adr/0019-sealed-manifest-runtime-pipeline.md +38 -2
- package/docs/adr/0020-builtin-handler-contracts-and-matched-upsert.md +199 -0
- package/docs/adr/0021-retire-starter-scaffolding.md +30 -0
- package/docs/adr/0022-caller-observed-version-occ.md +212 -0
- package/docs/adr/0023-port-failure-contract.md +69 -0
- package/docs/adr/0024-manifest-native-schema-tables.md +260 -0
- package/docs/adr/README.md +19 -3
- package/docs/adr/adr-lite-803-request-diagnostics.md +38 -0
- package/docs/adr/adr-lite-808-route-readiness.md +47 -0
- package/docs/adr/adr-lite-809-bounded-public-content.md +71 -0
- package/docs/adr/adr-lite-812-native-parity.md +150 -0
- package/docs/adr/adr-lite-823-home-statistics.md +64 -0
- package/docs/adr/adr-lite-842-mcp-authoring.md +30 -0
- package/docs/adr/adr-lite-861-admin-webmcp.md +42 -0
- package/docs/adr/adr-lite-909-admin-ui-kit.md +37 -0
- package/docs/api-mcp-authorization.md +3 -664
- package/docs/auth-hosting-model.md +4 -4
- package/docs/cloudflare-low-level-composition.md +3 -104
- package/docs/deferred-lifecycle-queues.md +2 -243
- package/docs/design-atoms.md +16 -796
- package/docs/direct-authoring.md +5 -0
- package/docs/examples/minimal-worker/README.md +37 -0
- package/docs/examples/minimal-worker/manifests/site.yaml +25 -0
- package/docs/examples/minimal-worker/package.json +26 -0
- package/docs/examples/minimal-worker/smoke.mjs +23 -0
- package/docs/examples/minimal-worker/src/index.ts +4 -0
- package/docs/examples/minimal-worker/tsconfig.json +17 -0
- package/docs/examples/minimal-worker/wrangler.jsonc +22 -0
- package/docs/handbook/cloudflare/authentication.md +167 -0
- package/docs/handbook/cloudflare/bindings.md +236 -0
- package/docs/handbook/cloudflare/conventional-worker.md +162 -0
- package/docs/handbook/cloudflare/deferred-hooks-queues.md +171 -0
- package/docs/handbook/cloudflare/deploy-and-operate.md +105 -0
- package/docs/handbook/cloudflare/low-level-composition.md +169 -0
- package/docs/handbook/cloudflare/media-r2.md +216 -0
- package/docs/handbook/cloudflare/public-web.md +144 -0
- package/docs/handbook/concepts/authorization.md +116 -0
- package/docs/handbook/concepts/four-atoms.md +173 -0
- package/docs/handbook/concepts/lifecycle-and-locales.md +142 -0
- package/docs/handbook/concepts/mcp-and-agents.md +109 -0
- package/docs/handbook/concepts/procedures-and-triggers.md +221 -0
- package/docs/handbook/concepts/runtime-and-adapters.md +177 -0
- package/docs/handbook/concepts/views.md +191 -0
- package/docs/handbook/examples/commerce-transaction.md +813 -0
- package/docs/handbook/examples/guarded-api.md +427 -0
- package/docs/handbook/examples/intake-form.md +319 -0
- package/docs/handbook/examples/legal-documents.md +218 -0
- package/docs/handbook/examples/procurement-approvals.md +240 -0
- package/docs/handbook/examples/publication.md +240 -0
- package/docs/handbook/examples/reservation.md +220 -0
- package/docs/handbook/navigation.json +63 -0
- package/docs/handbook/reference/authorization.md +295 -0
- package/docs/handbook/reference/diagnostics.md +183 -0
- package/docs/handbook/reference/manifest.md +132 -0
- package/docs/handbook/reference/procedure.md +263 -0
- package/docs/handbook/reference/schema.md +250 -0
- package/docs/handbook/reference/site-config.md +233 -0
- package/docs/handbook/reference/surface.md +207 -0
- package/docs/handbook/reference/trigger.md +193 -0
- package/docs/handbook/reference/view.md +256 -0
- package/docs/handbook/start/project-and-cli.md +122 -0
- package/docs/handbook/start/quickstart-worker.md +193 -0
- package/docs/labels.md +3 -1
- package/docs/media-uploads.md +3 -184
- package/docs/migration-0.1.2.md +60 -3
- package/docs/performance-harness.md +127 -10
- package/docs/release-process.md +85 -231
- package/docs/schema-indexes.md +3 -180
- package/docs/sealed-pipeline-ownership.md +6 -5
- package/docs/spec-only-host-adoption.md +158 -0
- package/docs/transaction-patterns.md +5 -0
- package/package.json +21 -25
- package/skills/README.md +4 -4
- package/skills/develop/SKILL.md +23 -28
- package/skills/install/SKILL.md +55 -147
- package/skills/provision/SKILL.md +4 -3
- package/skills/theme/SKILL.md +6 -6
- package/skills/update/SKILL.md +32 -68
- package/dist/cli/create.d.ts +0 -2
- package/dist/cli/create.d.ts.map +0 -1
- package/dist/cli/create.js +0 -243
- package/dist/cli/create.js.map +0 -1
- package/dist/cli/update.d.ts +0 -2
- package/dist/cli/update.d.ts.map +0 -1
- package/dist/cli/update.js +0 -413
- package/dist/cli/update.js.map +0 -1
- package/dist/provision/renderProvisionBundle.d.ts +0 -70
- package/dist/provision/renderProvisionBundle.d.ts.map +0 -1
- package/dist/provision/renderProvisionBundle.js +0 -367
- package/dist/provision/renderProvisionBundle.js.map +0 -1
- package/dist/provision.d.ts +0 -2
- package/dist/provision.d.ts.map +0 -1
- package/dist/provision.js +0 -2
- package/dist/provision.js.map +0 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Accept public reservation requests and expose the queue to staff, with an optional guard against past dates.
|
|
3
|
+
---
|
|
4
|
+
# Reservation requests
|
|
5
|
+
|
|
6
|
+
This example accepts reservation requests from the public and lists them for staff. It is the Builder `reservation` preset, fully declarative, plus one optional lifecycle guard. Read it if you take appointments, bookings or table requests and confirm them by hand.
|
|
7
|
+
|
|
8
|
+
## Problem
|
|
9
|
+
|
|
10
|
+
A visitor asks for a reservation by giving a name, an email address, the requested date or slot, an optional party size and a note. Staff see the newest requests first in Admin, over the staff View REST route, or through Staff MCP, and follow up outside the system. Requests are live operational records; nothing is drafted or published. The system does not decide whether a slot is free.
|
|
11
|
+
|
|
12
|
+
## Manifest
|
|
13
|
+
|
|
14
|
+
```yaml
|
|
15
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
16
|
+
kind: Schema
|
|
17
|
+
metadata:
|
|
18
|
+
name: reservations
|
|
19
|
+
spec:
|
|
20
|
+
title: Reservations
|
|
21
|
+
lifecycle: operational
|
|
22
|
+
schema:
|
|
23
|
+
type: object
|
|
24
|
+
additionalProperties: false
|
|
25
|
+
required: [name, email, requestedFor]
|
|
26
|
+
properties:
|
|
27
|
+
name: { type: string, minLength: 1, maxLength: 120 }
|
|
28
|
+
email: { type: string, format: email }
|
|
29
|
+
requestedFor: { type: string, description: Requested date, time, or slot. }
|
|
30
|
+
partySize: { type: integer, minimum: 1 }
|
|
31
|
+
note: { type: string, maxLength: 1000 }
|
|
32
|
+
createdAt: { type: number, x-mcp-hint: timestamp-ms, x-mantle-bind: now }
|
|
33
|
+
---
|
|
34
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
35
|
+
kind: View
|
|
36
|
+
metadata:
|
|
37
|
+
name: reservation-queue
|
|
38
|
+
spec:
|
|
39
|
+
title: Reservation queue
|
|
40
|
+
surface: staff
|
|
41
|
+
from: reservations
|
|
42
|
+
fields: [id, name, email, requestedFor, partySize, note, createdAt]
|
|
43
|
+
orderBy:
|
|
44
|
+
- { field: createdAt, direction: desc }
|
|
45
|
+
limit: 50
|
|
46
|
+
---
|
|
47
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
48
|
+
kind: Procedure
|
|
49
|
+
metadata:
|
|
50
|
+
name: submit-reservation
|
|
51
|
+
spec:
|
|
52
|
+
title: Submit reservation
|
|
53
|
+
input:
|
|
54
|
+
type: object
|
|
55
|
+
additionalProperties: false
|
|
56
|
+
required: [name, email, requestedFor]
|
|
57
|
+
properties:
|
|
58
|
+
name: { type: string, minLength: 1, maxLength: 120 }
|
|
59
|
+
email: { type: string, format: email }
|
|
60
|
+
requestedFor: { type: string }
|
|
61
|
+
partySize: { type: integer, minimum: 1 }
|
|
62
|
+
note: { type: string, maxLength: 1000 }
|
|
63
|
+
output: { type: object }
|
|
64
|
+
handler: { kind: builtin, op: create, schema: reservations }
|
|
65
|
+
---
|
|
66
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
67
|
+
kind: Trigger
|
|
68
|
+
metadata:
|
|
69
|
+
name: submit-reservation-http
|
|
70
|
+
spec:
|
|
71
|
+
source: { kind: http, method: POST, path: /api/reservations }
|
|
72
|
+
target: { procedure: submit-reservation }
|
|
73
|
+
---
|
|
74
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
75
|
+
kind: Trigger
|
|
76
|
+
metadata:
|
|
77
|
+
name: submit-reservation-mcp
|
|
78
|
+
spec:
|
|
79
|
+
source: { kind: mcp, surface: public }
|
|
80
|
+
target: { procedure: submit-reservation }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`createdAt` is stamped by the server (`x-mantle-bind: now`); a caller-supplied value is ignored. `requestedFor` is a free string on purpose: the preset does not impose a calendar model. The staff View orders by `createdAt`, so the newest request is first regardless of the requested slot.
|
|
84
|
+
|
|
85
|
+
## Worker and handlers
|
|
86
|
+
|
|
87
|
+
None are required. The Manifest above runs on the minimal Worker:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { createMantleWorker } from "@aotter/mantle/cloudflare";
|
|
91
|
+
import { plan } from "../.mantle/generated/mantle.js";
|
|
92
|
+
|
|
93
|
+
export default createMantleWorker({ plan });
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Optional: reject requests for the past
|
|
97
|
+
|
|
98
|
+
If `requestedFor` is expected to be an ISO date-time, a `before_create` lifecycle Trigger can reject values that already passed. Add two documents to the Manifest:
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
102
|
+
kind: Procedure
|
|
103
|
+
metadata:
|
|
104
|
+
name: reject-past-reservation
|
|
105
|
+
spec:
|
|
106
|
+
input:
|
|
107
|
+
type: object
|
|
108
|
+
properties:
|
|
109
|
+
requestedFor: { type: string }
|
|
110
|
+
output: { type: object }
|
|
111
|
+
handler: { kind: ref, ref: reject-past-reservation }
|
|
112
|
+
---
|
|
113
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
114
|
+
kind: Trigger
|
|
115
|
+
metadata:
|
|
116
|
+
name: reservations-before-create-guard
|
|
117
|
+
spec:
|
|
118
|
+
source:
|
|
119
|
+
kind: lifecycle
|
|
120
|
+
schema: reservations
|
|
121
|
+
on: [before_create]
|
|
122
|
+
errorPolicy: abort
|
|
123
|
+
target: { procedure: reject-past-reservation }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
And one handler. `before_*` hooks receive the caller's original input; a thrown `InvokeFailure` cancels the write and its diagnostic is returned to the caller unchanged:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
// src/handlers.ts
|
|
130
|
+
import { InvokeFailure } from "@aotter/mantle/runtime";
|
|
131
|
+
import { runtimeDiagnostic } from "@aotter/mantle/spec";
|
|
132
|
+
|
|
133
|
+
export async function rejectPastReservation(
|
|
134
|
+
input: { readonly requestedFor?: string },
|
|
135
|
+
): Promise<{ ok: true }> {
|
|
136
|
+
const at = input.requestedFor ? Date.parse(input.requestedFor) : Number.NaN;
|
|
137
|
+
if (Number.isFinite(at) && at < Date.now()) {
|
|
138
|
+
throw new InvokeFailure(
|
|
139
|
+
runtimeDiagnostic({
|
|
140
|
+
code: "LIFECYCLE_HOOK_REJECTED",
|
|
141
|
+
severity: "error",
|
|
142
|
+
path: "/requestedFor",
|
|
143
|
+
value: input.requestedFor,
|
|
144
|
+
expected: "a date and time that has not passed",
|
|
145
|
+
message: "Reservations cannot be requested for a past time.",
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return { ok: true };
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// src/index.ts
|
|
155
|
+
import { createMantleWorker } from "@aotter/mantle/cloudflare";
|
|
156
|
+
import { plan, type MantleHandlers } from "../.mantle/generated/mantle.js";
|
|
157
|
+
import { rejectPastReservation } from "./handlers.js";
|
|
158
|
+
|
|
159
|
+
const handlers = {
|
|
160
|
+
"reject-past-reservation": rejectPastReservation,
|
|
161
|
+
} satisfies MantleHandlers;
|
|
162
|
+
|
|
163
|
+
export default createMantleWorker({ plan, extend: () => ({ handlers }) });
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Unparseable strings pass through so free-form slots such as `Friday evening` still work. Tighten the input schema with `format: date-time` if only timestamps are acceptable. `LIFECYCLE_HOOK_REJECTED` maps to HTTP 409; the hook may throw any catalogued code, for example `INPUT_VALIDATION_FAILED` for 400. See [Writes: Procedures, Triggers and hooks](../concepts/procedures-and-triggers.md).
|
|
167
|
+
|
|
168
|
+
## Try it
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
curl -sS -X POST http://localhost:8787/api/reservations \
|
|
172
|
+
-H 'content-type: application/json' \
|
|
173
|
+
-d '{"name":"Ada","email":"ada@example.test","requestedFor":"2026-10-03T19:00:00+08:00","partySize":4}'
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"ok": true,
|
|
179
|
+
"data": {
|
|
180
|
+
"id": "res_01j...",
|
|
181
|
+
"collection": "reservations",
|
|
182
|
+
"status": "published",
|
|
183
|
+
"version": 1,
|
|
184
|
+
"data": { "name": "Ada", "email": "ada@example.test", "requestedFor": "2026-10-03T19:00:00+08:00", "partySize": 4, "createdAt": 1788879363492 },
|
|
185
|
+
"authorId": null,
|
|
186
|
+
"createdAt": 1788879363492,
|
|
187
|
+
"updatedAt": 1788879363492
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
A `partySize` of `0` is HTTP 400 `INPUT_VALIDATION_FAILED`. With the optional guard installed, a `requestedFor` in the past is HTTP 409 with `diagnostic.code: "LIFECYCLE_HOOK_REJECTED"` and no row is written.
|
|
193
|
+
|
|
194
|
+
Staff list the queue at `GET /admin/api/views/reservation-queue?page=1&show=50` (staff session required); `GET /admin/api/views/reservation-queue/export` returns the matching rows as CSV.
|
|
195
|
+
|
|
196
|
+
MCP tools:
|
|
197
|
+
|
|
198
|
+
| Surface | Tool | Origin |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| `/mcp` | `submit_reservation` | `submit-reservation-mcp` Trigger |
|
|
201
|
+
| `/mcp/staff` | `query_view_reservation_queue` | `reservation-queue` View |
|
|
202
|
+
| `/mcp/staff` | `create_record_reservations`, `update_record_reservations` | operational Schema `reservations` |
|
|
203
|
+
|
|
204
|
+
Mantle Builder ships this Manifest as its Reservation preset.
|
|
205
|
+
|
|
206
|
+
## What this deliberately leaves out
|
|
207
|
+
|
|
208
|
+
Requests are **not confirmed automatically**. A successful `POST` means the request was recorded, nothing more. The pattern omits:
|
|
209
|
+
|
|
210
|
+
- **Slot inventory.** There is no `slots` Schema and no capacity count.
|
|
211
|
+
- **Double-booking prevention.** Two requests for the same time both succeed. Preventing that needs an authority that serializes reservations, as the [commerce example](./commerce-transaction.md) does for stock with a Durable Object.
|
|
212
|
+
- **Calendar sync, payments, deposits.**
|
|
213
|
+
- **Confirmation messages.** Add an `after_create` handler as in the [intake form](./intake-form.md) when staff want a notification.
|
|
214
|
+
|
|
215
|
+
## Source
|
|
216
|
+
|
|
217
|
+
- [`README.md`](../../../README.md) — reservations excerpt
|
|
218
|
+
- [Procedure reference](../reference/procedure.md) — builtin `create`, lifecycle hooks
|
|
219
|
+
- [`packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts`](../../../packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts) — `InvokeFailure`
|
|
220
|
+
- [`packages/mantle-runtime/src/index.ts`](../../../packages/mantle-runtime/src/index.ts) — `InvokeFailure` export re-exported by `@aotter/mantle/runtime`
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Sidebar for the Mantle handbook. Hosts render these groups in order and may insert their own entries (for example a site overview or a product page) into the first group. Links are handbook-root-relative without the .md extension.",
|
|
3
|
+
"groups": [
|
|
4
|
+
{
|
|
5
|
+
"text": "Start here",
|
|
6
|
+
"items": [
|
|
7
|
+
{ "text": "Quickstart: a minimal Worker", "link": "/start/quickstart-worker" },
|
|
8
|
+
{ "text": "Project layout and the CLI loop", "link": "/start/project-and-cli" }
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"text": "Concepts",
|
|
13
|
+
"items": [
|
|
14
|
+
{ "text": "The four atoms", "link": "/concepts/four-atoms" },
|
|
15
|
+
{ "text": "Lifecycle and locales", "link": "/concepts/lifecycle-and-locales" },
|
|
16
|
+
{ "text": "Reads: Views, REST and MCP", "link": "/concepts/views" },
|
|
17
|
+
{ "text": "Writes: Procedures, Triggers and hooks", "link": "/concepts/procedures-and-triggers" },
|
|
18
|
+
{ "text": "Authorization", "link": "/concepts/authorization" },
|
|
19
|
+
{ "text": "MCP and agents", "link": "/concepts/mcp-and-agents" },
|
|
20
|
+
{ "text": "Runtime pipeline and adapters", "link": "/concepts/runtime-and-adapters" }
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"text": "Cloudflare",
|
|
25
|
+
"items": [
|
|
26
|
+
{ "text": "The conventional Worker", "link": "/cloudflare/conventional-worker" },
|
|
27
|
+
{ "text": "Bindings and primitives", "link": "/cloudflare/bindings" },
|
|
28
|
+
{ "text": "Authentication", "link": "/cloudflare/authentication" },
|
|
29
|
+
{ "text": "Public web, SEO and cache", "link": "/cloudflare/public-web" },
|
|
30
|
+
{ "text": "Deferred hooks with Queues", "link": "/cloudflare/deferred-hooks-queues" },
|
|
31
|
+
{ "text": "Media uploads with R2", "link": "/cloudflare/media-r2" },
|
|
32
|
+
{ "text": "Deploy and operate", "link": "/cloudflare/deploy-and-operate" },
|
|
33
|
+
{ "text": "Low-level composition", "link": "/cloudflare/low-level-composition" }
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"text": "Examples",
|
|
38
|
+
"items": [
|
|
39
|
+
{ "text": "Intake form", "link": "/examples/intake-form" },
|
|
40
|
+
{ "text": "Publication", "link": "/examples/publication" },
|
|
41
|
+
{ "text": "Legal documents and consent", "link": "/examples/legal-documents" },
|
|
42
|
+
{ "text": "Reservation requests", "link": "/examples/reservation" },
|
|
43
|
+
{ "text": "Procurement approvals", "link": "/examples/procurement-approvals" },
|
|
44
|
+
{ "text": "Commerce and settlement", "link": "/examples/commerce-transaction" },
|
|
45
|
+
{ "text": "Guarded API access", "link": "/examples/guarded-api" }
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"text": "Reference",
|
|
50
|
+
"items": [
|
|
51
|
+
{ "text": "Manifest envelope", "link": "/reference/manifest" },
|
|
52
|
+
{ "text": "Schema", "link": "/reference/schema" },
|
|
53
|
+
{ "text": "View", "link": "/reference/view" },
|
|
54
|
+
{ "text": "Procedure", "link": "/reference/procedure" },
|
|
55
|
+
{ "text": "Trigger", "link": "/reference/trigger" },
|
|
56
|
+
{ "text": "Authorization requirements", "link": "/reference/authorization" },
|
|
57
|
+
{ "text": "Site defaults and site_config", "link": "/reference/site-config" },
|
|
58
|
+
{ "text": "Diagnostic codes", "link": "/reference/diagnostics" },
|
|
59
|
+
{ "text": "HTTP, MCP, CLI and packages", "link": "/reference/surface" }
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: The requires grammar — closed auth predicates, guard Procedures, staff roles, HandlerContext, evaluation order and the 401/402/403 mapping.
|
|
3
|
+
---
|
|
4
|
+
# Authorization
|
|
5
|
+
|
|
6
|
+
`requires` is the whole authorization grammar. It appears on [Procedure](./procedure.md) and [View](./view.md) and nowhere else; Schemas and Triggers carry no `requires`. This page is the field-level contract and the runtime behaviour it produces. The narrative version is [Authorization](../concepts/authorization.md), and a worked deployment is [Guarded API](../examples/guarded-api.md). Diagnostic codes named here are catalogued in [Diagnostics](./diagnostics.md).
|
|
7
|
+
|
|
8
|
+
## `requires`
|
|
9
|
+
|
|
10
|
+
```yaml
|
|
11
|
+
spec:
|
|
12
|
+
requires:
|
|
13
|
+
auth:
|
|
14
|
+
all:
|
|
15
|
+
- ctx.auth
|
|
16
|
+
- ctx.user
|
|
17
|
+
- { "ctx.auth.scope": "orders:read" }
|
|
18
|
+
guard:
|
|
19
|
+
procedure: require-active-subscription
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Key | Type | Required | Rules |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| `requires` | mapping | no | Omitted means the target is anonymous. Only `auth` and `guard` are accepted. |
|
|
25
|
+
| `requires.auth` | mapping | no | Only `all` is accepted. `any`, `none` and negation do not exist in v0.1. |
|
|
26
|
+
| `requires.auth.all` | array | yes when `auth` is present | Non-empty array of predicates. Every entry must pass. |
|
|
27
|
+
| `requires.guard` | mapping | no | Exactly one key, `procedure`, holding a non-empty Procedure name. |
|
|
28
|
+
|
|
29
|
+
`auth` and `guard` are independent: a target may declare a guard with no static predicates, or predicates with no guard. Shape violations are `INVALID_MANIFEST_ENVELOPE` with a JSON Pointer to the offending key.
|
|
30
|
+
|
|
31
|
+
## Predicates
|
|
32
|
+
|
|
33
|
+
The vocabulary is closed. A bare string form is either `ctx.user` or `ctx.auth`; an object form carries exactly one key.
|
|
34
|
+
|
|
35
|
+
| Predicate | Written as | Passes when |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| Signed-in user | `ctx.user` | `ctx.user` is not `null`. A service API key with no user subject fails this. |
|
|
38
|
+
| Verified credential | `ctx.auth` | `ctx.auth` is present, that is, the adapter verified some credential. There is no credential-kind predicate. |
|
|
39
|
+
| Scope | `{ "ctx.auth.scope": "orders:read" }` | `ctx.auth.scopes` contains that exact opaque string. Repeat the predicate under `all` to require several scopes. |
|
|
40
|
+
| Staff role | `{ "ctx.staff": [owner, editor] }` | `ctx.staff` is present and `ctx.staff.role` is one of the listed roles. This is exact membership, not role-or-above. |
|
|
41
|
+
|
|
42
|
+
| Validation rule | Diagnostic |
|
|
43
|
+
|---|---|
|
|
44
|
+
| Predicate is one of the four forms above. | `INVALID_MANIFEST_ENVELOPE` at `/spec/requires/auth/all/<i>` |
|
|
45
|
+
| Object form has exactly one key, `ctx.staff` or `ctx.auth.scope`. | `INVALID_MANIFEST_ENVELOPE` |
|
|
46
|
+
| `ctx.auth.scope` value is a non-empty string. | `INVALID_MANIFEST_ENVELOPE` |
|
|
47
|
+
| `ctx.staff` value is a non-empty array of strings. | `INVALID_MANIFEST_ENVELOPE` |
|
|
48
|
+
| Every `ctx.staff` role is `owner`, `editor` or `contributor`. | `AUTH_PREDICATE_NOT_IN_ENUM` |
|
|
49
|
+
|
|
50
|
+
Scope strings are opaque to Core. Mantle neither issues credentials nor defines a scope catalog; the deployment owns both.
|
|
51
|
+
|
|
52
|
+
## Denial
|
|
53
|
+
|
|
54
|
+
Predicates are evaluated in declaration order and the first failure denies the call.
|
|
55
|
+
|
|
56
|
+
| Caller state | Code | HTTP |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| No `ctx.auth`, no `ctx.user` and no `ctx.staff`. | `UNAUTHENTICATED` | `401` |
|
|
59
|
+
| Authenticated in any of those three ways but a predicate is unsatisfied. | `AUTH_DENIED` | `403` |
|
|
60
|
+
|
|
61
|
+
Both diagnostics carry the failing predicate's position: the `path` is `<target path>#/requires/auth/all/<i>`, where `<target path>` is `manifest:Procedure/<name>` or `manifest:View/<name>` unless the caller supplied its own prefix. `expected` describes the predicate in prose; the offending value is not echoed. An auth-gated View invoked with no caller context at all is denied `UNAUTHENTICATED` at `<target path>#/requires/auth` before any predicate runs.
|
|
62
|
+
|
|
63
|
+
## Guards
|
|
64
|
+
|
|
65
|
+
`requires.guard.procedure` names one ordinary Procedure. It is not a fifth atom, and it is not a policy language: it is a handler that is allowed to say no.
|
|
66
|
+
|
|
67
|
+
- The guard receives the target's **already-validated** input (Procedure) or params (View) and the **same** `HandlerContext`.
|
|
68
|
+
- It runs through the identical pipeline: its own `requires.auth.all`, its own `input` schema, its handler, its `output` schema.
|
|
69
|
+
- It runs on every call and is never cached.
|
|
70
|
+
- It fails closed. Any non-`ok` guard result is returned to the caller unchanged and the target handler is never invoked.
|
|
71
|
+
|
|
72
|
+
| Rule | Diagnostic | Where checked |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| The named Procedure is declared. | `GUARD_PROCEDURE_UNKNOWN` | validate and runtime |
|
|
75
|
+
| A Procedure does not guard itself. | `GUARD_SELF_REFERENCE` | validate and runtime |
|
|
76
|
+
| The guard uses `handler.kind: ref`, not `builtin`. | `GUARD_PROCEDURE_BUILTIN` | validate and runtime |
|
|
77
|
+
| The guard does not itself declare `requires.guard`. Guard chains are one level deep, never more. | `GUARD_CHAIN_NOT_ALLOWED` | validate and runtime |
|
|
78
|
+
|
|
79
|
+
All four report at `<target path>#/requires/guard/procedure`; diagnostics raised inside the guard itself are prefixed `<target path>#/requires/guard/<guard name>`. The runtime repeats every check the validator already made, so a boot-bypassing embedding still fails closed.
|
|
80
|
+
|
|
81
|
+
A guard denies by throwing a `DiagnosticError` carrying a runtime diagnostic. `ENTITLEMENT_REQUIRED` (`402`) is the conventional code for "verified caller, current business state says no" — an expired subscription, a revoked entitlement, an unpaid invoice. Any other runtime code works the same way and maps through the [status table](./diagnostics.md#runtime).
|
|
82
|
+
|
|
83
|
+
## Staff roles
|
|
84
|
+
|
|
85
|
+
| Role | Rank | Capabilities |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `owner` | 3 | Full control. Staff list, role changes, invitations, site settings, developer console. |
|
|
88
|
+
| `editor` | 2 | Publish, unpublish, delete and manage all entries; media uploads and assets; member list. |
|
|
89
|
+
| `contributor` | 1 | Create and edit drafts. Cannot create or edit operational records, and cannot edit an entry that is no longer `draft`. |
|
|
90
|
+
|
|
91
|
+
`users` is the base identity layer; `staff` is a privilege overlay with one row per privileged user, so `ctx.staff.id` always equals `ctx.user.id`. A signed-in user with no staff row is an ordinary site member with no Admin access and no `/mcp/staff` access.
|
|
92
|
+
|
|
93
|
+
Rank ordering exists for "this role or above" gates — Admin's own route table uses it, so an `owner` passes an `editor`-gated Admin route. Generic staff MCP tools (including Admin WebMCP) use the same rank table: contributors may call `create_draft_*` / `update_draft_*`; publish, unpublish, archive, delete, media, and operational `create_record_*` / `update_record_*` require editor or owner. Declared Procedures still use their own `requires.auth.all`. The manifest predicate does not rank-order: `{ "ctx.staff": [editor] }` admits editors only. List every role you mean.
|
|
94
|
+
|
|
95
|
+
The adapter re-reads the caller's current role from the database on every protected request. A role is never taken from a token, a consent snapshot or a cached catalog, so a demotion takes effect on the next call.
|
|
96
|
+
|
|
97
|
+
## `HandlerContext`
|
|
98
|
+
|
|
99
|
+
Handlers, guards and predicate evaluation all see the same normalized value.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
interface HandlerContext<Env = unknown> {
|
|
103
|
+
readonly user: { readonly id: string } | null;
|
|
104
|
+
readonly staff: { readonly id: string; readonly role: StaffRole } | null;
|
|
105
|
+
readonly auth?: {
|
|
106
|
+
readonly credential: "session" | "oauth" | "api-key" | "personal-token";
|
|
107
|
+
readonly credentialId: string | null;
|
|
108
|
+
readonly clientId: string | null;
|
|
109
|
+
readonly scopes: readonly string[];
|
|
110
|
+
};
|
|
111
|
+
readonly env: Env;
|
|
112
|
+
readonly waitUntil?: (p: Promise<unknown>) => void;
|
|
113
|
+
readonly event?: HandlerLifecycleEvent;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| Field | Notes |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `user` | `{ id }` of the site-local user row, or `null`. The id is the deployment's own user record, never a platform or upstream-provider subject. |
|
|
120
|
+
| `staff` | Privilege overlay, or `null`. |
|
|
121
|
+
| `auth` | Present only after a credential verified. `credentialId` is an opaque record id or token identifier; `clientId` names the OAuth client when there is one; `scopes` is the granted set. |
|
|
122
|
+
| `env` | Adapter bindings. |
|
|
123
|
+
| `waitUntil` | Platform fire-and-forget bridge, when the adapter has one. |
|
|
124
|
+
| `event` | Populated only when the Procedure runs as a lifecycle hook target. See [Trigger](./trigger.md). |
|
|
125
|
+
|
|
126
|
+
Raw credentials never enter this object: no cookie value, no API key, no bearer token, no refresh token. Resolution precedence on the Cloudflare adapter is the deployment's own credential resolver, then configured OAuth bearer verification, then the cookie session; a recognized-but-invalid credential is rejected outright and never falls back to a valid cookie. See [Authentication](../cloudflare/authentication.md).
|
|
127
|
+
|
|
128
|
+
## Order of evaluation
|
|
129
|
+
|
|
130
|
+
The order is fixed and identical on REST, MCP and in-process invocation.
|
|
131
|
+
|
|
132
|
+
| Step | Procedure | View |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| 1 | Adapter verifies and normalizes the credential. | Same. |
|
|
135
|
+
| 2 | `requires.auth.all` against the context. | `requires.auth.all` against the context. |
|
|
136
|
+
| 3 | `input` validated and coerced. | `params` validated and coerced. |
|
|
137
|
+
| 4 | Guard invoked with the validated value and the same context. | Guard invoked with the validated params and the same context. |
|
|
138
|
+
| 5 | Handler dispatched. | Query executed. |
|
|
139
|
+
| 6 | `output` validated. | Rows paginated and returned. |
|
|
140
|
+
|
|
141
|
+
Static predicates run before input validation on purpose: an unauthorized caller learns that it is unauthorized, not what the input schema looks like.
|
|
142
|
+
|
|
143
|
+
## Views
|
|
144
|
+
|
|
145
|
+
Two consequences follow from that order.
|
|
146
|
+
|
|
147
|
+
- **Static auth precedes param validation.** A protected View never reports a parameter error to a caller that failed its predicates.
|
|
148
|
+
- **The guard authorizes the query, not the rows.** It sees the validated params and returns pass or fail for the whole call. It cannot rewrite the filter, drop columns or remove rows.
|
|
149
|
+
|
|
150
|
+
Row-level scoping is a separate mechanism, described in [Views](../concepts/views.md): the closed `{ "$ctx.user": "id" }` filter sentinel. The caller never supplies that value, so the same View is safe on REST and on public MCP. Core rejects the sentinel unless the View declares `ctx.user` in `requires.auth.all` and the compared field is the leftmost field of a declared Schema index; a missing identity is `401`, never a dropped filter or a full-table read. The field-level rules are in [View](./view.md#value-forms).
|
|
151
|
+
|
|
152
|
+
## Example
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
156
|
+
kind: Procedure
|
|
157
|
+
metadata:
|
|
158
|
+
name: require-active-subscription
|
|
159
|
+
spec:
|
|
160
|
+
title: Require an active subscription
|
|
161
|
+
input: { type: object }
|
|
162
|
+
output: { type: object }
|
|
163
|
+
handler: { kind: ref, ref: requireActiveSubscription }
|
|
164
|
+
---
|
|
165
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
166
|
+
kind: Procedure
|
|
167
|
+
metadata:
|
|
168
|
+
name: download-report
|
|
169
|
+
spec:
|
|
170
|
+
title: Download a report
|
|
171
|
+
requires:
|
|
172
|
+
auth:
|
|
173
|
+
all:
|
|
174
|
+
- ctx.user
|
|
175
|
+
- ctx.auth
|
|
176
|
+
- { "ctx.auth.scope": "reports:read" }
|
|
177
|
+
guard: { procedure: require-active-subscription }
|
|
178
|
+
input:
|
|
179
|
+
type: object
|
|
180
|
+
required: [reportId]
|
|
181
|
+
properties:
|
|
182
|
+
reportId: { type: string }
|
|
183
|
+
output:
|
|
184
|
+
type: object
|
|
185
|
+
required: [url]
|
|
186
|
+
properties:
|
|
187
|
+
url: { type: string, format: uri }
|
|
188
|
+
handler: { kind: ref, ref: downloadReport }
|
|
189
|
+
---
|
|
190
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
191
|
+
kind: Trigger
|
|
192
|
+
metadata:
|
|
193
|
+
name: download-report-http
|
|
194
|
+
spec:
|
|
195
|
+
source: { kind: http, method: POST, path: /api/reports/download }
|
|
196
|
+
target: { procedure: download-report }
|
|
197
|
+
---
|
|
198
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
199
|
+
kind: View
|
|
200
|
+
metadata:
|
|
201
|
+
name: my-reports
|
|
202
|
+
spec:
|
|
203
|
+
title: My reports
|
|
204
|
+
surface: public
|
|
205
|
+
from: reports
|
|
206
|
+
requires:
|
|
207
|
+
auth:
|
|
208
|
+
all: [ctx.user]
|
|
209
|
+
guard: { procedure: require-active-subscription }
|
|
210
|
+
filter:
|
|
211
|
+
and:
|
|
212
|
+
- { eq: { field: status, value: published } }
|
|
213
|
+
- { eq: { field: ownerId, value: { "$ctx.user": id } } }
|
|
214
|
+
fields: [title, publishedAt]
|
|
215
|
+
orderBy: [{ field: publishedAt, direction: desc }]
|
|
216
|
+
limit: 50
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The guard handler denies with a structured diagnostic rather than a thrown string, so the boundary emits `402` instead of the `INTERNAL_ERROR` envelope:
|
|
220
|
+
|
|
221
|
+
Because `my-reports` is guarded and binds `$ctx.user`, it must not declare `View.spec.cache`. Shared View caching is reserved for caller-independent anonymous reads; `VIEW_CACHE_INVALID` rejects guarded or identity-bound combinations.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { DiagnosticError, runtimeDiagnostic } from "@aotter/mantle/spec";
|
|
225
|
+
import type { HandlerContext } from "@aotter/mantle/runtime";
|
|
226
|
+
|
|
227
|
+
export const requireActiveSubscription = async (
|
|
228
|
+
_input: unknown,
|
|
229
|
+
ctx: HandlerContext<{ DB: D1Database }>,
|
|
230
|
+
) => {
|
|
231
|
+
const active = await ctx.env.DB
|
|
232
|
+
.prepare("SELECT 1 FROM site_memberships WHERE user_id = ? AND state = 'active' LIMIT 1")
|
|
233
|
+
.bind(ctx.user!.id)
|
|
234
|
+
.first();
|
|
235
|
+
if (active) return {};
|
|
236
|
+
throw new DiagnosticError(
|
|
237
|
+
runtimeDiagnostic({
|
|
238
|
+
code: "ENTITLEMENT_REQUIRED",
|
|
239
|
+
severity: "error",
|
|
240
|
+
path: `site:membership/${ctx.user!.id}`,
|
|
241
|
+
message: "An active subscription is required.",
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
};
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`site_memberships` is deployment-owned. Core creates no credential, entitlement or billing tables and reads no payment-provider state.
|
|
248
|
+
|
|
249
|
+
## OpenAPI projection
|
|
250
|
+
|
|
251
|
+
`mantle emit-openapi` reflects authorization into the emitted OpenAPI 3.1 document. Only schemes the deployment actually accepts are configured, and only those become alternatives.
|
|
252
|
+
|
|
253
|
+
| Manifest | OpenAPI |
|
|
254
|
+
|---|---|
|
|
255
|
+
| Any `requires.auth.all` predicate | `security` alternatives drawn from the configured `sessionCookie`, `oauthBearer`, `apiKey` and `personalToken` schemes, plus `x-mantle-auth-predicates`, a `401` response and a `403` response. |
|
|
256
|
+
| Repeated `{ "ctx.auth.scope": … }` | OAuth scopes on the `oauthBearer` alternative, plus `x-mantle-required-scopes`. |
|
|
257
|
+
| `requires.guard.procedure` | `x-mantle-guard-procedure` and a `402` response. |
|
|
258
|
+
| No `requires` | No `security` requirement and no auth responses. |
|
|
259
|
+
|
|
260
|
+
Cookie sessions are emitted as cookies, never relabelled as bearer tokens. A protected target with no configured security scheme is an emission error, not a silently unprotected operation. MCP is out of scope for the emitter; required scopes and guard behaviour reach agents through the standard Tool description, and every `tools/call` re-runs the predicates and the guard. Discovery is never the enforcement boundary — see [MCP and agents](../concepts/mcp-and-agents.md).
|
|
261
|
+
|
|
262
|
+
Configure the accepted schemes through the public API:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { EmitOpenapiUseCase } from "@aotter/mantle/spec";
|
|
266
|
+
|
|
267
|
+
const { document } = EmitOpenapiUseCase.run({
|
|
268
|
+
linked, // successful linkManifestSet result
|
|
269
|
+
title: "Site API",
|
|
270
|
+
version: "1.0.0",
|
|
271
|
+
security: {
|
|
272
|
+
sessionCookie: false,
|
|
273
|
+
oauthBearer: {
|
|
274
|
+
openIdConnectUrl:
|
|
275
|
+
"https://platform.example.com/api/auth/.well-known/openid-configuration",
|
|
276
|
+
},
|
|
277
|
+
apiKey: { in: "header", name: "X-API-Key" },
|
|
278
|
+
personalToken: { bearerFormat: "PAT" },
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Source
|
|
284
|
+
|
|
285
|
+
- [`packages/mantle-spec/src/domain/model/ManifestGrammar.ts`](../../../packages/mantle-spec/src/domain/model/ManifestGrammar.ts)
|
|
286
|
+
- [`packages/mantle-spec/src/domain/service/ManifestParser.ts`](../../../packages/mantle-spec/src/domain/service/ManifestParser.ts)
|
|
287
|
+
- [`packages/mantle-spec/src/domain/service/ManifestGraphValidator.ts`](../../../packages/mantle-spec/src/domain/service/ManifestGraphValidator.ts)
|
|
288
|
+
- [`packages/mantle-spec/src/domain/service/StaffRoleHierarchy.ts`](../../../packages/mantle-spec/src/domain/service/StaffRoleHierarchy.ts)
|
|
289
|
+
- [`packages/mantle-spec/src/usecase/EmitOpenapiUseCase.ts`](../../../packages/mantle-spec/src/usecase/EmitOpenapiUseCase.ts)
|
|
290
|
+
- [`packages/mantle-runtime/src/domain/model/HandlerContext.ts`](../../../packages/mantle-runtime/src/domain/model/HandlerContext.ts)
|
|
291
|
+
- [`packages/mantle-runtime/src/domain/service/AuthPredicateEvaluator.ts`](../../../packages/mantle-runtime/src/domain/service/AuthPredicateEvaluator.ts)
|
|
292
|
+
- [`packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts`](../../../packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts)
|
|
293
|
+
- [`packages/mantle-runtime/src/usecase/view/ExecuteViewUseCase.ts`](../../../packages/mantle-runtime/src/usecase/view/ExecuteViewUseCase.ts)
|
|
294
|
+
- [`packages/mantle-admin/src/mountMantleAdmin.ts`](../../../packages/mantle-admin/src/mountMantleAdmin.ts)
|
|
295
|
+
- [`packages/adapters/cloudflare/src/mount/mountMcp.ts`](../../../packages/adapters/cloudflare/src/mount/mountMcp.ts)
|