@aotter/mantle 0.1.0-alpha.9 → 0.1.2-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/docs/adapter-guide.md +20 -24
- package/docs/adr/0008-structured-diagnostic-shape.md +7 -2
- 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 +37 -1
- 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/README.md +18 -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 +149 -0
- package/docs/adr/adr-lite-823-home-statistics.md +63 -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 +233 -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 +98 -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 +183 -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 +247 -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 +34 -1
- package/docs/performance-harness.md +125 -8
- package/docs/release-process.md +85 -231
- package/docs/schema-indexes.md +3 -180
- package/docs/sealed-pipeline-ownership.md +5 -4
- 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
|
@@ -1,666 +1,5 @@
|
|
|
1
1
|
# API and MCP authorization
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
guard Procedure, and only then reaches the target.
|
|
7
|
-
|
|
8
|
-
Mantle does **not** issue or store API keys or personal tokens, define a scope
|
|
9
|
-
catalog, read payment-provider state, or decide who is entitled to a product.
|
|
10
|
-
Those are site-owned concerns. The Cloudflare adapter supplies a narrow
|
|
11
|
-
resolver seam and the runtime supplies the common enforcement machinery.
|
|
12
|
-
|
|
13
|
-
## Ownership boundary
|
|
14
|
-
|
|
15
|
-
| Mantle Core SDK | Generated site / Mantle Site |
|
|
16
|
-
| --- | --- |
|
|
17
|
-
| Curated OAuth resource and audience options | API-key and personal-token generation, hashing, storage, rotation, and revocation |
|
|
18
|
-
| JWT verification and linked-provider token facade | Scope names and grant rules |
|
|
19
|
-
| `ConsumerCredentialResolver` normalization seam | Account, transaction, subscription, and entitlement tables |
|
|
20
|
-
| `HandlerContext.auth`, closed predicates, and guard orchestration | Guard handlers and payment-state freshness rules |
|
|
21
|
-
| Consistent REST/MCP diagnostics and reflection | CORS policy and business response fields |
|
|
22
|
-
|
|
23
|
-
Authentication and entitlement are deliberately separate. A resolver answers
|
|
24
|
-
“is this credential valid, and who/what does it represent?” A guard answers
|
|
25
|
-
“is that currently verified caller allowed to perform this business action?”
|
|
26
|
-
|
|
27
|
-
## Core contracts
|
|
28
|
-
|
|
29
|
-
After the adapter verifies a caller, runtime handlers see only normalized,
|
|
30
|
-
non-secret metadata:
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
interface HandlerContext {
|
|
34
|
-
readonly user: { readonly id: string } | null;
|
|
35
|
-
readonly staff: { readonly id: string; readonly role: StaffRole } | null;
|
|
36
|
-
readonly auth?: {
|
|
37
|
-
readonly credential: "session" | "oauth" | "api-key" | "personal-token";
|
|
38
|
-
readonly credentialId: string | null;
|
|
39
|
-
readonly clientId: string | null;
|
|
40
|
-
readonly scopes: readonly string[];
|
|
41
|
-
};
|
|
42
|
-
// env, waitUntil, and event omitted here
|
|
43
|
-
}
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Raw credentials and refresh tokens never enter this context. The manifest
|
|
47
|
-
vocabulary stays closed:
|
|
48
|
-
|
|
49
|
-
```yaml
|
|
50
|
-
requires:
|
|
51
|
-
auth:
|
|
52
|
-
all:
|
|
53
|
-
- ctx.auth
|
|
54
|
-
- ctx.user
|
|
55
|
-
- { "ctx.auth.scope": "orders:read" }
|
|
56
|
-
guard:
|
|
57
|
-
procedure: require-active-api-access
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
- `ctx.auth` requires any verified credential.
|
|
61
|
-
- `ctx.user` requires a verified user subject. Service API keys may have no
|
|
62
|
-
user.
|
|
63
|
-
- each `ctx.auth.scope` entry requires that opaque, site-defined scope;
|
|
64
|
-
repeat the predicate to require multiple scopes.
|
|
65
|
-
- `ctx.staff` continues to use the closed staff-role list.
|
|
66
|
-
- `guard.procedure` names one ordinary, unguarded `handler.kind: ref`
|
|
67
|
-
Procedure. It is not a fifth Policy atom.
|
|
68
|
-
|
|
69
|
-
The runtime order is fixed:
|
|
70
|
-
|
|
71
|
-
1. verify and normalize the transport credential;
|
|
72
|
-
2. evaluate static predicates before exposing input-schema details;
|
|
73
|
-
3. validate/coerce target input or View params;
|
|
74
|
-
4. invoke the guard with that validated value and the same context;
|
|
75
|
-
5. invoke the target only after the guard succeeds.
|
|
76
|
-
|
|
77
|
-
Missing/invalid credentials return `401`; a verified caller missing a required
|
|
78
|
-
role or scope returns `403`; a site guard may return
|
|
79
|
-
`ENTITLEMENT_REQUIRED`/`402`. Guards run on every call and are not cached.
|
|
80
|
-
|
|
81
|
-
### Identity-bound Views
|
|
82
|
-
|
|
83
|
-
Use the closed `{ "$ctx.user": "id" }` filter sentinel for rows owned by the
|
|
84
|
-
current site-local Better Auth user. The caller never supplies this value, so
|
|
85
|
-
the same View is safe on both REST and public MCP:
|
|
86
|
-
|
|
87
|
-
```yaml
|
|
88
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
89
|
-
kind: Schema
|
|
90
|
-
metadata: { name: orders }
|
|
91
|
-
spec:
|
|
92
|
-
schema:
|
|
93
|
-
type: object
|
|
94
|
-
properties:
|
|
95
|
-
userId: { type: string, x-mantle-bind: ctx.user }
|
|
96
|
-
orderNumber: { type: string }
|
|
97
|
-
orderStatus: { type: string }
|
|
98
|
-
totalMinor: { type: integer }
|
|
99
|
-
placedAt: { type: integer }
|
|
100
|
-
indexes: [[userId, placedAt]]
|
|
101
|
-
---
|
|
102
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
103
|
-
kind: View
|
|
104
|
-
metadata: { name: my-orders }
|
|
105
|
-
spec:
|
|
106
|
-
surface: public
|
|
107
|
-
from: orders
|
|
108
|
-
requires:
|
|
109
|
-
auth:
|
|
110
|
-
all: [ctx.user]
|
|
111
|
-
filter:
|
|
112
|
-
and:
|
|
113
|
-
- { eq: { field: status, value: published } }
|
|
114
|
-
- { eq: { field: userId, value: { "$ctx.user": id } } }
|
|
115
|
-
fields: [orderNumber, orderStatus, totalMinor, placedAt]
|
|
116
|
-
orderBy: [{ field: placedAt, direction: desc }]
|
|
117
|
-
limit: 50
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
Core rejects this sentinel unless the View requires `ctx.user` and the bound
|
|
121
|
-
field is the leftmost field of a declared Schema index. Missing identity fails
|
|
122
|
-
with `401`; it never drops the filter or falls back to all rows. REST exposes
|
|
123
|
-
`GET /api/views/my-orders`; public MCP exposes `query_view_my_orders`. Both
|
|
124
|
-
call `ExecuteViewUseCase` and bind the same `ctx.user.id`.
|
|
125
|
-
|
|
126
|
-
The id belongs to the customer site's Better Auth user row. It is not a
|
|
127
|
-
Mantle Platform user id, Hosted Auth upstream subject, email, or provider id.
|
|
128
|
-
Hosted Auth may establish the site session, but Platform is not part of the
|
|
129
|
-
View query path.
|
|
130
|
-
|
|
131
|
-
## Site OAuth symmetry
|
|
132
|
-
|
|
133
|
-
A site-issued OAuth access token represents the same caller on public MCP and
|
|
134
|
-
manifest HTTP routes. Both surfaces populate `ctx.user` and `ctx.auth` from the
|
|
135
|
-
same token grant; expiry, revocation, scope, client, and resource audience are
|
|
136
|
-
enforced before the Procedure or View runs.
|
|
137
|
-
|
|
138
|
-
## Cloudflare consumer wiring
|
|
139
|
-
|
|
140
|
-
Pass one site-owned resolver to `createMantleRuntimeRef`. Return `not-handled` when the
|
|
141
|
-
request is not one of the site's credential formats, `invalid` when it is a
|
|
142
|
-
recognized but bad/revoked credential, and `verified` only after checking the
|
|
143
|
-
authoritative site record.
|
|
144
|
-
|
|
145
|
-
This example table and query are consumer code, not a Mantle migration:
|
|
146
|
-
|
|
147
|
-
```ts
|
|
148
|
-
import type { ConsumerCredentialResolver } from "@aotter/mantle/cloudflare";
|
|
149
|
-
|
|
150
|
-
type CredentialRow = {
|
|
151
|
-
id: string;
|
|
152
|
-
kind: "api-key" | "personal-token";
|
|
153
|
-
user_id: string | null;
|
|
154
|
-
scopes_json: string;
|
|
155
|
-
revoked_at: string | null;
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
export function siteCredentialResolver(db: D1Database): ConsumerCredentialResolver {
|
|
159
|
-
return async (request) => {
|
|
160
|
-
const apiKey = request.headers.get("x-api-key");
|
|
161
|
-
const authorization = request.headers.get("authorization");
|
|
162
|
-
|
|
163
|
-
let kind: CredentialRow["kind"];
|
|
164
|
-
let raw: string;
|
|
165
|
-
if (apiKey !== null) {
|
|
166
|
-
kind = "api-key";
|
|
167
|
-
raw = apiKey;
|
|
168
|
-
} else if (authorization?.startsWith("Bearer site_pat_")) {
|
|
169
|
-
kind = "personal-token";
|
|
170
|
-
raw = authorization.slice("Bearer ".length);
|
|
171
|
-
} else {
|
|
172
|
-
// Lets configured OAuth bearer or cookie-session auth try next.
|
|
173
|
-
return { kind: "not-handled" };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const digest = await sha256(raw);
|
|
177
|
-
const row = await db
|
|
178
|
-
.prepare(
|
|
179
|
-
"SELECT id, kind, user_id, scopes_json, revoked_at " +
|
|
180
|
-
"FROM site_credentials WHERE token_sha256 = ? AND kind = ? LIMIT 1",
|
|
181
|
-
)
|
|
182
|
-
.bind(digest, kind)
|
|
183
|
-
.first<CredentialRow>();
|
|
184
|
-
|
|
185
|
-
if (!row || row.revoked_at !== null) return { kind: "invalid" };
|
|
186
|
-
const scopes = parseScopes(row.scopes_json);
|
|
187
|
-
if (!scopes) return { kind: "invalid" };
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
kind: "verified",
|
|
191
|
-
credential: {
|
|
192
|
-
credential: row.kind,
|
|
193
|
-
credentialId: row.id, // opaque row id, never the raw key/token
|
|
194
|
-
userId: row.user_id,
|
|
195
|
-
scopes,
|
|
196
|
-
},
|
|
197
|
-
};
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function sha256(value: string): Promise<string> {
|
|
202
|
-
const bytes = await crypto.subtle.digest(
|
|
203
|
-
"SHA-256",
|
|
204
|
-
new TextEncoder().encode(value),
|
|
205
|
-
);
|
|
206
|
-
return [...new Uint8Array(bytes)]
|
|
207
|
-
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
208
|
-
.join("");
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function parseScopes(json: string): string[] | null {
|
|
212
|
-
try {
|
|
213
|
-
const value: unknown = JSON.parse(json);
|
|
214
|
-
return Array.isArray(value) && value.every((scope) => typeof scope === "string")
|
|
215
|
-
? value
|
|
216
|
-
: null;
|
|
217
|
-
} catch {
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
Wire it alongside the existing Auth facade. `jwtBearer` is optional and
|
|
224
|
-
enables JWT bearer verification for manifest REST routes:
|
|
225
|
-
|
|
226
|
-
```ts
|
|
227
|
-
import {
|
|
228
|
-
createMantleWorker,
|
|
229
|
-
} from "@aotter/mantle/cloudflare";
|
|
230
|
-
|
|
231
|
-
export default createMantleWorker({
|
|
232
|
-
plan,
|
|
233
|
-
handlers,
|
|
234
|
-
extend: ({ env }) => ({
|
|
235
|
-
credentialResolver: siteCredentialResolver(env.DB),
|
|
236
|
-
jwtBearer: {
|
|
237
|
-
audience: "https://api.example.com",
|
|
238
|
-
// Optional server-wide floor. Manifest scopes still run per target.
|
|
239
|
-
scopes: ["api"],
|
|
240
|
-
},
|
|
241
|
-
}),
|
|
242
|
-
});
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
The facade mounts both MCP surfaces behind the same Better Auth 1.7 resource.
|
|
246
|
-
Low-level composition must pass that canonical resource to each
|
|
247
|
-
`createMcpApiHandler` explicitly.
|
|
248
|
-
|
|
249
|
-
Resolution precedence is site resolver, configured OAuth bearer, then cookie
|
|
250
|
-
session. A recognized invalid credential never falls back to a valid cookie.
|
|
251
|
-
For each verified user, the adapter re-reads the current staff role rather than
|
|
252
|
-
trusting a token or consent-time snapshot.
|
|
253
|
-
|
|
254
|
-
## 1. Anonymous public API
|
|
255
|
-
|
|
256
|
-
Omit `requires` when the operation is intentionally anonymous:
|
|
257
|
-
|
|
258
|
-
```yaml
|
|
259
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
260
|
-
kind: Procedure
|
|
261
|
-
metadata: { name: public-status }
|
|
262
|
-
spec:
|
|
263
|
-
input: { type: object }
|
|
264
|
-
output:
|
|
265
|
-
type: object
|
|
266
|
-
required: [status]
|
|
267
|
-
properties:
|
|
268
|
-
status: { type: string }
|
|
269
|
-
handler: { kind: ref, ref: publicStatus }
|
|
270
|
-
---
|
|
271
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
272
|
-
kind: Trigger
|
|
273
|
-
metadata: { name: public-status-http }
|
|
274
|
-
spec:
|
|
275
|
-
source: { kind: http, method: POST, path: /api/status }
|
|
276
|
-
target: { procedure: public-status }
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
```ts
|
|
280
|
-
const handlers = {
|
|
281
|
-
publicStatus: async () => ({ status: "ok" }),
|
|
282
|
-
};
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
```bash
|
|
286
|
-
curl -i -X POST https://site.example.com/api/status \
|
|
287
|
-
-H 'content-type: application/json' \
|
|
288
|
-
-d '{}'
|
|
289
|
-
# HTTP/2 200
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
OpenAPI emits no `security` requirement and no auth responses for this
|
|
293
|
-
operation. No MCP tool is created unless a separate MCP Trigger targets the
|
|
294
|
-
Procedure.
|
|
295
|
-
|
|
296
|
-
## 2. Public API requiring an API key
|
|
297
|
-
|
|
298
|
-
The API remains publicly reachable, but its target requires a verified
|
|
299
|
-
credential and the site-defined `catalog:read` scope:
|
|
300
|
-
|
|
301
|
-
```yaml
|
|
302
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
303
|
-
kind: Procedure
|
|
304
|
-
metadata: { name: read-catalog }
|
|
305
|
-
spec:
|
|
306
|
-
requires:
|
|
307
|
-
auth:
|
|
308
|
-
all:
|
|
309
|
-
- ctx.auth
|
|
310
|
-
- { "ctx.auth.scope": "catalog:read" }
|
|
311
|
-
input: { type: object }
|
|
312
|
-
output: { type: object }
|
|
313
|
-
handler: { kind: ref, ref: readCatalog }
|
|
314
|
-
---
|
|
315
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
316
|
-
kind: Trigger
|
|
317
|
-
metadata: { name: read-catalog-http }
|
|
318
|
-
spec:
|
|
319
|
-
source: { kind: http, method: POST, path: /api/catalog/read }
|
|
320
|
-
target: { procedure: read-catalog }
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
```ts
|
|
324
|
-
import type { HandlerContext } from "@aotter/mantle/runtime";
|
|
325
|
-
|
|
326
|
-
const handlers = {
|
|
327
|
-
readCatalog: async (_input: unknown, ctx: HandlerContext) => ({
|
|
328
|
-
credentialId: ctx.auth!.credentialId,
|
|
329
|
-
items: [],
|
|
330
|
-
}),
|
|
331
|
-
};
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
```bash
|
|
335
|
-
curl -i -X POST https://site.example.com/api/catalog/read \
|
|
336
|
-
-H 'content-type: application/json' \
|
|
337
|
-
-H "x-api-key: $SITE_API_KEY" \
|
|
338
|
-
-d '{}'
|
|
339
|
-
# valid key with catalog:read -> 200
|
|
340
|
-
# missing or recognized-invalid key -> 401
|
|
341
|
-
# verified key without catalog:read -> 403
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
`ctx.auth` intentionally means any verified credential; there is no
|
|
345
|
-
credential-kind predicate. Configure and document only the credential sources
|
|
346
|
-
the site intends to accept, or put a kind-specific business rule in a guard.
|
|
347
|
-
With `security.apiKey` configured during OpenAPI emission, the operation
|
|
348
|
-
advertises the real header and carries `x-mantle-required-scopes`.
|
|
349
|
-
|
|
350
|
-
## 3. API key plus a mutable paid/transaction guard
|
|
351
|
-
|
|
352
|
-
Keep key verification in the resolver. Put current paid state in an ordinary,
|
|
353
|
-
site-owned guard Procedure:
|
|
354
|
-
|
|
355
|
-
```yaml
|
|
356
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
357
|
-
kind: Procedure
|
|
358
|
-
metadata: { name: require-active-api-access }
|
|
359
|
-
spec:
|
|
360
|
-
input: { type: object }
|
|
361
|
-
output: { type: object }
|
|
362
|
-
handler: { kind: ref, ref: requireActiveApiAccess }
|
|
363
|
-
---
|
|
364
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
365
|
-
kind: Procedure
|
|
366
|
-
metadata: { name: download-export }
|
|
367
|
-
spec:
|
|
368
|
-
requires:
|
|
369
|
-
auth:
|
|
370
|
-
all:
|
|
371
|
-
- ctx.auth
|
|
372
|
-
- { "ctx.auth.scope": "exports:read" }
|
|
373
|
-
guard: { procedure: require-active-api-access }
|
|
374
|
-
input:
|
|
375
|
-
type: object
|
|
376
|
-
required: [reportId]
|
|
377
|
-
properties:
|
|
378
|
-
reportId: { type: string }
|
|
379
|
-
output: { type: object }
|
|
380
|
-
handler: { kind: ref, ref: downloadExport }
|
|
381
|
-
---
|
|
382
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
383
|
-
kind: Trigger
|
|
384
|
-
metadata: { name: download-export-http }
|
|
385
|
-
spec:
|
|
386
|
-
source: { kind: http, method: POST, path: /api/exports/download }
|
|
387
|
-
target: { procedure: download-export }
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
```ts
|
|
391
|
-
import {
|
|
392
|
-
DiagnosticError,
|
|
393
|
-
runtimeDiagnostic,
|
|
394
|
-
} from "@aotter/mantle/spec";
|
|
395
|
-
import type { HandlerContext } from "@aotter/mantle/runtime";
|
|
396
|
-
|
|
397
|
-
const handlers = {
|
|
398
|
-
requireActiveApiAccess: async (_input: unknown, ctx: HandlerContext) => {
|
|
399
|
-
const credentialId = ctx.auth?.credentialId;
|
|
400
|
-
const paid = credentialId
|
|
401
|
-
? await env.DB.prepare(
|
|
402
|
-
"SELECT 1 FROM site_api_entitlements " +
|
|
403
|
-
"WHERE credential_id = ? AND state = 'paid' LIMIT 1",
|
|
404
|
-
)
|
|
405
|
-
.bind(credentialId)
|
|
406
|
-
.first()
|
|
407
|
-
: null;
|
|
408
|
-
|
|
409
|
-
if (!paid) {
|
|
410
|
-
throw new DiagnosticError(
|
|
411
|
-
runtimeDiagnostic({
|
|
412
|
-
code: "ENTITLEMENT_REQUIRED",
|
|
413
|
-
severity: "error",
|
|
414
|
-
path: "site:api-entitlement",
|
|
415
|
-
message: "Active paid API access is required.",
|
|
416
|
-
}),
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
return {};
|
|
420
|
-
},
|
|
421
|
-
downloadExport: async ({ reportId }: { reportId: string }) => ({ reportId }),
|
|
422
|
-
};
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
```bash
|
|
426
|
-
curl -i -X POST https://site.example.com/api/exports/download \
|
|
427
|
-
-H 'content-type: application/json' \
|
|
428
|
-
-H "x-api-key: $SITE_API_KEY" \
|
|
429
|
-
-d '{"reportId":"report-1"}'
|
|
430
|
-
# valid + entitled -> 200
|
|
431
|
-
# invalid key -> 401
|
|
432
|
-
# verified key missing exports:read -> 403
|
|
433
|
-
# verified key whose current paid row is absent/revoked -> 402
|
|
434
|
-
```
|
|
435
|
-
|
|
436
|
-
The guard receives the already validated target input and runs for every call.
|
|
437
|
-
On `402`, the target handler is not invoked. OpenAPI reflects the guard as
|
|
438
|
-
`x-mantle-guard-procedure` and includes a `402` response; Mantle does not infer
|
|
439
|
-
or publish the site's billing model.
|
|
440
|
-
|
|
441
|
-
## 4. Personal token with user scope, shared by REST and MCP semantics
|
|
442
|
-
|
|
443
|
-
This Procedure requires a user subject, a verified credential, a delegated
|
|
444
|
-
scope, and current membership. Bind the same target to HTTP and public MCP:
|
|
445
|
-
|
|
446
|
-
```yaml
|
|
447
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
448
|
-
kind: Procedure
|
|
449
|
-
metadata: { name: require-active-membership }
|
|
450
|
-
spec:
|
|
451
|
-
input: { type: object }
|
|
452
|
-
output: { type: object }
|
|
453
|
-
handler: { kind: ref, ref: requireActiveMembership }
|
|
454
|
-
---
|
|
455
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
456
|
-
kind: Procedure
|
|
457
|
-
metadata: { name: read-account }
|
|
458
|
-
spec:
|
|
459
|
-
requires:
|
|
460
|
-
auth:
|
|
461
|
-
all:
|
|
462
|
-
- ctx.user
|
|
463
|
-
- ctx.auth
|
|
464
|
-
- { "ctx.auth.scope": "accounts:read" }
|
|
465
|
-
guard: { procedure: require-active-membership }
|
|
466
|
-
input:
|
|
467
|
-
type: object
|
|
468
|
-
required: [accountId]
|
|
469
|
-
properties:
|
|
470
|
-
accountId: { type: string }
|
|
471
|
-
output:
|
|
472
|
-
type: object
|
|
473
|
-
required: [accountId]
|
|
474
|
-
properties:
|
|
475
|
-
accountId: { type: string }
|
|
476
|
-
handler: { kind: ref, ref: readAccount }
|
|
477
|
-
---
|
|
478
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
479
|
-
kind: Trigger
|
|
480
|
-
metadata: { name: read-account-http }
|
|
481
|
-
spec:
|
|
482
|
-
source: { kind: http, method: POST, path: /api/accounts/read }
|
|
483
|
-
target: { procedure: read-account }
|
|
484
|
-
---
|
|
485
|
-
apiVersion: cms.mantle.aotter.net/v1
|
|
486
|
-
kind: Trigger
|
|
487
|
-
metadata: { name: read-account-mcp }
|
|
488
|
-
spec:
|
|
489
|
-
source: { kind: mcp, surface: public }
|
|
490
|
-
target: { procedure: read-account }
|
|
491
|
-
```
|
|
492
|
-
|
|
493
|
-
```ts
|
|
494
|
-
import {
|
|
495
|
-
DiagnosticError,
|
|
496
|
-
runtimeDiagnostic,
|
|
497
|
-
} from "@aotter/mantle/spec";
|
|
498
|
-
import type { HandlerContext } from "@aotter/mantle/runtime";
|
|
499
|
-
|
|
500
|
-
const handlers = {
|
|
501
|
-
requireActiveMembership: async (_input: unknown, ctx: HandlerContext) => {
|
|
502
|
-
const active = await env.DB.prepare(
|
|
503
|
-
"SELECT 1 FROM site_memberships " +
|
|
504
|
-
"WHERE user_id = ? AND state = 'active' LIMIT 1",
|
|
505
|
-
)
|
|
506
|
-
.bind(ctx.user!.id)
|
|
507
|
-
.first();
|
|
508
|
-
if (!active) {
|
|
509
|
-
throw new DiagnosticError(
|
|
510
|
-
runtimeDiagnostic({
|
|
511
|
-
code: "ENTITLEMENT_REQUIRED",
|
|
512
|
-
severity: "error",
|
|
513
|
-
path: `site:membership/${ctx.user!.id}`,
|
|
514
|
-
message: "Active membership is required.",
|
|
515
|
-
}),
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
return {};
|
|
519
|
-
},
|
|
520
|
-
readAccount: async ({ accountId }: { accountId: string }) => ({ accountId }),
|
|
521
|
-
};
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
REST uses the site resolver's personal token:
|
|
525
|
-
|
|
526
|
-
```bash
|
|
527
|
-
curl -i -X POST https://site.example.com/api/accounts/read \
|
|
528
|
-
-H 'content-type: application/json' \
|
|
529
|
-
-H "authorization: Bearer $SITE_PERSONAL_TOKEN" \
|
|
530
|
-
-d '{"accountId":"acct-1"}'
|
|
531
|
-
```
|
|
532
|
-
|
|
533
|
-
Standard remote MCP uses the MCP server's OAuth bearer, not the raw site PAT.
|
|
534
|
-
After OAuth normalization, it reaches the same target and guard:
|
|
535
|
-
|
|
536
|
-
```bash
|
|
537
|
-
curl -sS -X POST https://site.example.com/mcp \
|
|
538
|
-
-H 'content-type: application/json' \
|
|
539
|
-
-H "authorization: Bearer $MCP_OAUTH_ACCESS_TOKEN" \
|
|
540
|
-
-d '{
|
|
541
|
-
"jsonrpc":"2.0",
|
|
542
|
-
"id":1,
|
|
543
|
-
"method":"tools/call",
|
|
544
|
-
"params":{
|
|
545
|
-
"name":"read_account",
|
|
546
|
-
"arguments":{"accountId":"acct-1"}
|
|
547
|
-
}
|
|
548
|
-
}'
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
Expected behavior:
|
|
552
|
-
|
|
553
|
-
| State | REST | MCP |
|
|
554
|
-
| --- | --- | --- |
|
|
555
|
-
| valid user credential, `accounts:read`, active membership | `200` target result | JSON-RPC `result` |
|
|
556
|
-
| missing/invalid credential | `401` | OAuth layer rejects the request |
|
|
557
|
-
| verified caller missing user or `accounts:read` | `403` | JSON-RPC error with `error.data.code = "AUTH_DENIED"` |
|
|
558
|
-
| membership revoked while credential remains valid | `402` | JSON-RPC error with `error.data.code = "ENTITLEMENT_REQUIRED"` |
|
|
559
|
-
| MCP bearer missing the resource-level `mcp` scope | n/a | HTTP `403` plus `WWW-Authenticate: ... insufficient_scope` |
|
|
560
|
-
|
|
561
|
-
`tools/list` includes `read_account` only on the public surface selected by its
|
|
562
|
-
MCP Trigger. The standard Tool schema remains standard: required scopes and
|
|
563
|
-
guard metadata are described in text, while every `tools/call` re-evaluates
|
|
564
|
-
the manifest predicates and guard. Staff Views are listed/callable only on the
|
|
565
|
-
staff MCP surface; discovery is never the enforcement boundary.
|
|
566
|
-
|
|
567
|
-
## OAuth resource primitives
|
|
568
|
-
|
|
569
|
-
When one Mantle site is an OAuth client of another, request a stable RFC 8707
|
|
570
|
-
resource and use standard `offline_access` when refresh is needed:
|
|
571
|
-
|
|
572
|
-
```ts
|
|
573
|
-
const clientAuth = createAuth({
|
|
574
|
-
// database, baseURL, secret, other methods...
|
|
575
|
-
methods: [{
|
|
576
|
-
kind: "oauth",
|
|
577
|
-
providerId: "mantle-platform",
|
|
578
|
-
clientId: env.PLATFORM_CLIENT_ID,
|
|
579
|
-
discoveryUrl: "https://platform.example.com/api/auth/.well-known/openid-configuration",
|
|
580
|
-
scopes: ["openid", "offline_access", "accounts:read"],
|
|
581
|
-
resource: "https://api.example.com",
|
|
582
|
-
}],
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
const { accessToken, accessTokenExpiresAt, scopes } =
|
|
586
|
-
await clientAuth.getProviderAccessToken(request, "mantle-platform");
|
|
587
|
-
```
|
|
588
|
-
|
|
589
|
-
The server-side getter is bound to the current local session request and never
|
|
590
|
-
returns a refresh token or account row. On the provider:
|
|
591
|
-
|
|
592
|
-
```ts
|
|
593
|
-
const providerAuth = createAuth({
|
|
594
|
-
// database, baseURL, secret, methods...
|
|
595
|
-
oauthProvider: {
|
|
596
|
-
loginPage: "/sign-in",
|
|
597
|
-
consentPage: "/consent",
|
|
598
|
-
scopes: ["openid", "offline_access", "accounts:read"],
|
|
599
|
-
resources: ["https://api.example.com"],
|
|
600
|
-
},
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
const verification = await providerAuth.verifyOAuthAccessToken(request, {
|
|
604
|
-
audience: "https://api.example.com",
|
|
605
|
-
scopes: ["accounts:read"],
|
|
606
|
-
});
|
|
607
|
-
```
|
|
608
|
-
|
|
609
|
-
The verifier accepts JWT access tokens only and checks the configured issuer,
|
|
610
|
-
JWKS/signature, audience, time claims, required scopes, and—when passed the
|
|
611
|
-
request—DPoP proof binding with database-backed replay protection. It returns
|
|
612
|
-
only `userId`, `clientId`, `credentialId`, and scopes. Opaque tokens are
|
|
613
|
-
rejected; there is no introspection fallback.
|
|
614
|
-
|
|
615
|
-
## OpenAPI reflection
|
|
616
|
-
|
|
617
|
-
Emit only the schemes the deployed REST mount actually accepts:
|
|
618
|
-
|
|
619
|
-
```ts
|
|
620
|
-
import { EmitOpenapiUseCase } from "@aotter/mantle/spec";
|
|
621
|
-
|
|
622
|
-
const { document } = EmitOpenapiUseCase.run({
|
|
623
|
-
manifests,
|
|
624
|
-
title: "Site API",
|
|
625
|
-
version: "1.0.0",
|
|
626
|
-
security: {
|
|
627
|
-
sessionCookie: false,
|
|
628
|
-
oauthBearer: {
|
|
629
|
-
openIdConnectUrl:
|
|
630
|
-
"https://platform.example.com/api/auth/.well-known/openid-configuration",
|
|
631
|
-
},
|
|
632
|
-
apiKey: { in: "header", name: "X-API-Key" },
|
|
633
|
-
personalToken: { bearerFormat: "PAT" },
|
|
634
|
-
},
|
|
635
|
-
});
|
|
636
|
-
```
|
|
637
|
-
|
|
638
|
-
Anonymous operations have no security requirement. Protected operations use
|
|
639
|
-
configured scheme alternatives, OAuth scopes derive from repeated
|
|
640
|
-
`ctx.auth.scope` predicates, and guard-backed targets advertise `402`. Cookie
|
|
641
|
-
sessions are represented as cookies, never mislabeled as bearer tokens.
|
|
642
|
-
|
|
643
|
-
## Runnable contract check
|
|
644
|
-
|
|
645
|
-
The integration fixture uses mutable, consumer-owned credential and
|
|
646
|
-
entitlement fakes. It proves this sequence for one Procedure over REST and MCP:
|
|
647
|
-
|
|
648
|
-
```text
|
|
649
|
-
grant -> REST succeeds -> MCP succeeds
|
|
650
|
-
revoke entitlement while credential remains valid
|
|
651
|
-
-> next REST call is 402 -> next MCP call is ENTITLEMENT_REQUIRED
|
|
652
|
-
```
|
|
653
|
-
|
|
654
|
-
Run the guide/contract and normalization checks from the Mantle repository:
|
|
655
|
-
|
|
656
|
-
```bash
|
|
657
|
-
pnpm --filter @aotter/mantle-cloudflare exec vitest run \
|
|
658
|
-
test/authorization-integration.test.ts \
|
|
659
|
-
test/resolve-caller.test.ts \
|
|
660
|
-
test/mount-http-trigger-auth.test.ts
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
`authorization-integration.test.ts` also asserts that this shipped guide still
|
|
664
|
-
contains all four scenarios and the exact public API names used by the fixture.
|
|
665
|
-
The package typecheck catches changes to those APIs; the integration test
|
|
666
|
-
catches changes to REST/MCP enforcement and mutable guard behavior.
|
|
3
|
+
See [Authorization](handbook/reference/authorization.md),
|
|
4
|
+
[Guarded API access](handbook/examples/guarded-api.md), and
|
|
5
|
+
[OAuth resource primitives](handbook/cloudflare/authentication.md#oauth-resource-primitives).
|
|
@@ -7,8 +7,8 @@ basic login. The split is:
|
|
|
7
7
|
run its own auth.
|
|
8
8
|
- **Mantle's conventional Cloudflare adapter** runs the generated site's
|
|
9
9
|
selected self-hosted or Mantle Platform hosted client configuration.
|
|
10
|
-
- **
|
|
11
|
-
|
|
10
|
+
- **The application owner/host** declares the explicit auth mode and provider
|
|
11
|
+
configuration; legacy Landing/Starters remain on alpha.17.
|
|
12
12
|
- **Mantle Platform** can sell hosted identity, provider setup, email,
|
|
13
13
|
and billing convenience for site owners who do not want to operate
|
|
14
14
|
those pieces.
|
|
@@ -120,7 +120,7 @@ GitHub OAuth token is still Landing-owned unless a separate token
|
|
|
120
120
|
handoff design is introduced.
|
|
121
121
|
|
|
122
122
|
The conventional hosted-auth client wiring belongs in Core's Cloudflare
|
|
123
|
-
adapter.
|
|
123
|
+
adapter. Applications declare its environment bindings; a provisioning host supplies an
|
|
124
124
|
allocated client. A site can still replace Auth construction through
|
|
125
125
|
`createMantleWorker({ auth })` when it needs a different curated identity
|
|
126
126
|
design. Core continues to own the normalized manifest/runtime credential
|
|
@@ -138,7 +138,7 @@ handler that checks current business state.
|
|
|
138
138
|
Mantle Platform may be the identity or OAuth token authority for a hosted
|
|
139
139
|
flow. That does not make token claims the generated site's live membership or
|
|
140
140
|
entitlement authority. The target site's guard reads its authoritative state
|
|
141
|
-
on every call. See [API and MCP authorization](api
|
|
141
|
+
on every call. See [API and MCP authorization](handbook/examples/guarded-api.md) for
|
|
142
142
|
the exact public API and four consumer examples.
|
|
143
143
|
|
|
144
144
|
## SDK Surface Rule
|