@aotter/mantle 0.0.11-alpha.47 → 0.0.11-alpha.49

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 CHANGED
@@ -88,6 +88,8 @@ The `mantle-runtime` package never imports Cloudflare-specific types — adapter
88
88
  - Embedded docs and agent skills ship inside this npm package for
89
89
  generated-site agents:
90
90
  - `node_modules/@aotter/mantle/docs/design-atoms.md`
91
+ - `node_modules/@aotter/mantle/docs/api-mcp-authorization.md` (anonymous,
92
+ API-key, paid guard, personal-token, OAuth, REST, and MCP examples)
91
93
  - `node_modules/@aotter/mantle/docs/media-uploads.md` (Cloudflare R2 adapter recipe)
92
94
  - `node_modules/@aotter/mantle/docs/adr/`
93
95
  - `node_modules/@aotter/mantle/skills/develop/SKILL.md`
@@ -98,6 +100,7 @@ The `mantle-runtime` package never imports Cloudflare-specific types — adapter
98
100
  - `node_modules/@aotter/mantle/skills/provision/SKILL.md`
99
101
  - [Repo](https://github.com/aotter/mantle)
100
102
  - [4-atom manifest model (ADR-0001)](https://github.com/aotter/mantle/blob/develop/docs/adr/0001-four-atom-manifest-model.md)
103
+ - [API and MCP authorization](https://github.com/aotter/mantle/blob/develop/docs/api-mcp-authorization.md)
101
104
  - [Release process](https://github.com/aotter/mantle/blob/develop/docs/release-process.md)
102
105
  - [Issues](https://github.com/aotter/mantle/issues)
103
106
 
@@ -66,6 +66,15 @@ The runtime is a library, not an HTTP server. A new adapter must mount equivalen
66
66
 
67
67
  Auth is not a runtime port. Per [ADR-0014](adr/0014-auth-better-auth-and-multi-tenant-mcp.md), the adapter owns Better Auth wiring and passes authenticated user/staff context into runtime dispatchers. Procedure handlers receive that data through `HandlerContext` in `packages/mantle-runtime/src/domain/model/HandlerContext.ts`.
68
68
 
69
+ The adapter must also normalize verified credential metadata into
70
+ `HandlerContext.auth` (`credential`, opaque `credentialId`, optional
71
+ `clientId`, scopes). Platform-native session/OAuth verification stays in the
72
+ adapter. A narrow adapter extension seam may let consumer code verify its own
73
+ API-key or personal-token formats, but credential storage/issuance must not
74
+ become a runtime port. The Cloudflare reference is
75
+ `mount/resolveCaller.ts`; consumer usage is documented in
76
+ [API and MCP authorization](api-mcp-authorization.md).
77
+
69
78
  Minimum HTTP behavior for a full adapter:
70
79
 
71
80
  - Route manifest HTTP Triggers to `runtime.invokeProcedure`.
@@ -74,6 +83,8 @@ Minimum HTTP behavior for a full adapter:
74
83
  - Serve admin SPA assets through `AssetServer`, with an SPA catchall for admin client-side routes.
75
84
  - Mount public render routes and markdown mirrors when the starter exposes public pages.
76
85
  - Translate runtime diagnostics and validation failures into stable HTTP JSON responses instead of throwing raw errors.
86
+ - Evaluate target auth and dynamic guards through the runtime use cases; do
87
+ not duplicate guard logic in HTTP handlers.
77
88
 
78
89
  Minimum auth/MCP behavior:
79
90
 
@@ -82,7 +93,12 @@ Minimum auth/MCP behavior:
82
93
  - Validate `/mcp` requests with any authenticated session (D1 role check is surface-driven, not OAuth-scope-driven — claude.ai rejects colon-shaped scopes).
83
94
  - Advertise a single non-colon scope (default `["mcp"]`) in `scopes_supported`. Per-surface enforcement happens server-side in the apiHandler.
84
95
  - Build `McpAuthContext` from the validated session and pass it to `McpJsonRpcDispatcher`.
85
- - Build procedure `HandlerContext` with `user`, `staff`, adapter `env`, and optional `waitUntil`.
96
+ - Build Procedure/View `HandlerContext` with `user`, live `staff`, normalized
97
+ `auth`, adapter `env`, and optional `waitUntil`.
98
+ - Re-read mutable staff role for each protected REST/MCP invocation. Token or
99
+ consent-time role snapshots are not an authorization boundary.
100
+ - Keep `tools/list` filtering as UX only; route every `tools/call` through the
101
+ same auth evaluator and guard runner used by REST.
86
102
 
87
103
  ## Static assets
88
104
 
@@ -98,11 +114,18 @@ Minimum auth/MCP behavior:
98
114
  - [ ] Mount HTTP Trigger and View REST surfaces.
99
115
  - [ ] Mount admin/public render routes and admin SPA assets.
100
116
  - [ ] Provide adapter-owned Better Auth wiring and session helpers.
117
+ - [ ] Normalize session/OAuth and any consumer credential seam into
118
+ `HandlerContext.auth`; never put raw credentials in runtime context.
101
119
  - [ ] Mount `/mcp/staff` and `/mcp` via the platform's OAuth provider lib (Cloudflare adapter uses `@cloudflare/workers-oauth-provider` at top level). Enforce staff D1 role inside the apiHandler.
120
+ - [ ] Prove one guarded target has identical REST/MCP outcomes, including
121
+ mutable revocation on the next call.
102
122
  - [ ] Add optional `MediaStorage` or `DeferredHookDispatcher` only when the adapter supports those features.
103
123
  - [ ] Verify the runtime package still has no platform-specific imports.
104
124
 
105
125
  ## Current non-goals
106
126
 
107
127
  - Do not add `SessionRepository`, `OAuthVerifier`, `UserRepository`, or `StaffRepository` runtime ports. Those were pre-ADR-0014 concepts and are not part of the current adapter contract.
128
+ - Do not add API-key, personal-token, transaction, billing, or entitlement
129
+ repositories to Core. They are consumer state behind the adapter resolver
130
+ and guard Procedure.
108
131
  - Do not add a second canonical migration chain for a new adapter. The runtime owns canonical migrations; adapters execute them through `DatabaseDriver.migrations`.
@@ -294,9 +294,11 @@ forces it.
294
294
  - `spec.orderBy:`
295
295
  - `spec.limit:`
296
296
  - `spec.params:` (required query params referenced by filter values)
297
+ - `spec.requires.{auth, guard}:` (same authorization contract as Procedure)
297
298
 
298
299
  **Procedure (v0.1)**:
299
300
  - `spec.requires.auth.all:` (closed predicate vocabulary)
301
+ - `spec.requires.guard.procedure:` (one consumer-owned dynamic guard)
300
302
  - `spec.input:` (JSON Schema 2020-12)
301
303
  - `spec.output:` (JSON Schema 2020-12)
302
304
  - `spec.handler.{kind: ref, ref: <opaque-key>}` — author-supplied
@@ -311,14 +313,16 @@ forces it.
311
313
  - `spec.source.kind: lifecycle` — entry-writer hook (promoted to
312
314
  v0.1.0; runtime implemented by `LifecycleHookingEntryRepository`)
313
315
  - `spec.source.{schema, on, errorPolicy}` (when `kind: lifecycle`)
316
+ - `spec.source.kind: mcp` plus `surface: public | staff` — MCP tool
317
+ exposure for a declared Procedure
314
318
  - `spec.target.procedure:`
315
319
 
316
320
  #### v0.1 closed enums
317
321
 
318
322
  - `x-mantle-bind: {ctx.user, ctx.staff, now}`
319
- - `ctx.*` predicate identity: `{user, staff}` (no `system` until a
320
- use case forces it)
321
- - `Trigger.source.kind: {http, lifecycle}`
323
+ - `ctx.*` predicate vocabulary: `{user, staff, auth, auth.scope}` (no
324
+ `system` until a use case forces it)
325
+ - `Trigger.source.kind: {http, lifecycle, mcp}`
322
326
  - `Procedure.handler.kind: {ref, builtin}`
323
327
  - `BuiltinOp: {create, update, upsert, delete}`
324
328
  - `LifecycleHook: {before_create, after_create, before_update,
@@ -1,6 +1,7 @@
1
1
  # ADR-0002: Closed enums for identity/time bindings
2
2
 
3
- **Status:** Carried over from POC v0.0.x; refreshed for v0.1.0.
3
+ **Status:** Carried over from POC v0.0.x; refreshed for v0.1.0;
4
+ amended 2026-07-15 for verified credentials and delegated scopes.
4
5
 
5
6
  **Date**: 2026-04-30 (POC); refreshed 2026-05-03
6
7
 
@@ -224,3 +225,38 @@ parser rejects unknown values with the structured diagnostic
224
225
  shape. Verify in code review that new manifest grammar
225
226
  additions do not quietly widen these enums; widening is a
226
227
  grammar-revise, not a code-cleanup.
228
+
229
+ ## Amendment — 2026-07-15: verified credentials and delegated scopes
230
+
231
+ Epic #467 supplied the required grammar-revise evidence: the existing
232
+ `ctx.user` and `ctx.staff` predicates cannot represent service API keys or
233
+ delegated OAuth/personal-token scopes without coupling Core to a credential
234
+ store. The closed predicate vocabulary is therefore extended by exactly two
235
+ entries:
236
+
237
+ ```yaml
238
+ requires:
239
+ auth:
240
+ all:
241
+ - ctx.auth
242
+ - { "ctx.auth.scope": "orders:read" }
243
+ ```
244
+
245
+ - `ctx.auth` requires any credential that an adapter has already verified and
246
+ normalized into `HandlerContext.auth`.
247
+ - `{ "ctx.auth.scope": "<opaque site-owned scope>" }` requires that one exact
248
+ scope. Multiple scopes are expressed by repeating the predicate under the
249
+ existing `all` group.
250
+
251
+ The full v0.1 vocabulary is now `ctx.user`, `ctx.staff`, `ctx.auth`, and
252
+ `ctx.auth.scope`. The latter two do not add a credential-kind expression,
253
+ `any` group, policy array, scope catalog, or entitlement lookup. API keys may
254
+ have no `ctx.user`; OAuth and personal tokens may supply one. Missing identity
255
+ or credential yields `UNAUTHENTICATED` (`401`), while a verified credential
256
+ missing a required role/scope yields `AUTH_DENIED` (`403`).
257
+
258
+ Current membership, payment, ownership, or transaction state is intentionally
259
+ not a static predicate. A target may name one ordinary Procedure through
260
+ `requires.guard.procedure`; that Procedure performs the site-owned dynamic
261
+ check after target input validation and before target execution. This keeps
262
+ the predicate set closed and preserves the four-atom model.
@@ -458,3 +458,56 @@ The boundary is deliberately narrower than "hosted auth everywhere":
458
458
  hosted auth for `customer.com` must use an OAuth/OIDC broker flow:
459
459
  Platform authenticates and returns identity; the customer site creates
460
460
  its own local session and maps identity into local grants.
461
+
462
+ ## Amendment — 2026-07-15: one adapter-owned authorization pipeline
463
+
464
+ Epic #467 extends the auth contract without moving auth into
465
+ `mantle-runtime`. The original adapter-ownership rule remains authoritative:
466
+ Better Auth is the curated Cloudflare default for identity/session/OIDC, and
467
+ `@cloudflare/workers-oauth-provider` remains the compatibility transport for
468
+ remote MCP. Both adapters now normalize verified callers into the same
469
+ runtime context before invoking a target.
470
+
471
+ `HandlerContext` gains an additive optional `auth` member carrying only:
472
+
473
+ ```ts
474
+ {
475
+ credential: "session" | "oauth" | "api-key" | "personal-token";
476
+ credentialId: string | null;
477
+ clientId: string | null;
478
+ scopes: readonly string[];
479
+ }
480
+ ```
481
+
482
+ Raw keys/tokens and refresh tokens are forbidden in this context. `ctx.user`
483
+ remains the authenticated subject when one exists; `ctx.staff` is a mutable
484
+ privilege overlay and is re-read from D1 for each protected REST/MCP call.
485
+
486
+ The Cloudflare adapter exposes one `ConsumerCredentialResolver` seam for a
487
+ site to recognize and verify its own API-key or personal-token formats. It
488
+ distinguishes `not-handled`, `invalid`, and `verified`; a recognized invalid
489
+ credential never falls back to a cookie. Core adds no credential repository,
490
+ table, issuance API, or runtime auth port.
491
+
492
+ The curated Better Auth facade also adds the OAuth resource primitives needed
493
+ by a generated site acting as a client or provider:
494
+
495
+ - generic OAuth client `resource` is retained across authorization, code
496
+ exchange, and refresh;
497
+ - OAuth provider `validAudiences` constrains minted JWT audiences;
498
+ - `getProviderAccessToken(request, providerId)` uses the current local session
499
+ and returns no refresh token/account row;
500
+ - `verifyOAuthAccessToken()` verifies issuer/JWKS, audience, time claims, and
501
+ scopes, rejects opaque tokens, and preserves the `401`/`403` distinction.
502
+
503
+ REST and MCP transport verification remain adapter-specific. After
504
+ normalization, both call the same runtime auth evaluator and optional guard
505
+ Procedure. Standard remote MCP continues to use its OAuth bearer and the
506
+ single compatibility `mcp` resource scope; manifest scopes are re-evaluated on
507
+ every `tools/call`. MCP does not promise to accept a raw REST API key/PAT.
508
+
509
+ Dynamic membership, billing, and entitlement state remains consumer-owned.
510
+ `requires.guard.procedure` orchestrates a site handler on every invocation but
511
+ does not introduce a Policy atom or an entitlement service. See
512
+ [`API and MCP authorization`](../api-mcp-authorization.md) for the public API
513
+ and end-to-end examples.
@@ -0,0 +1,632 @@
1
+ # API and MCP authorization
2
+
3
+ Mantle gives generated sites one authorization pipeline for manifest HTTP
4
+ Triggers, Views, Procedures, and MCP tools. Core verifies or normalizes a
5
+ caller, evaluates the closed manifest predicates, invokes an optional dynamic
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
+ ## Cloudflare consumer wiring
82
+
83
+ Pass one site-owned resolver to `createCmsRef`. Return `not-handled` when the
84
+ request is not one of the site's credential formats, `invalid` when it is a
85
+ recognized but bad/revoked credential, and `verified` only after checking the
86
+ authoritative site record.
87
+
88
+ This example table and query are consumer code, not a Mantle migration:
89
+
90
+ ```ts
91
+ import type { ConsumerCredentialResolver } from "@aotter/mantle/cloudflare";
92
+
93
+ type CredentialRow = {
94
+ id: string;
95
+ kind: "api-key" | "personal-token";
96
+ user_id: string | null;
97
+ scopes_json: string;
98
+ revoked_at: string | null;
99
+ };
100
+
101
+ export function siteCredentialResolver(db: D1Database): ConsumerCredentialResolver {
102
+ return async (request) => {
103
+ const apiKey = request.headers.get("x-api-key");
104
+ const authorization = request.headers.get("authorization");
105
+
106
+ let kind: CredentialRow["kind"];
107
+ let raw: string;
108
+ if (apiKey !== null) {
109
+ kind = "api-key";
110
+ raw = apiKey;
111
+ } else if (authorization?.startsWith("Bearer site_pat_")) {
112
+ kind = "personal-token";
113
+ raw = authorization.slice("Bearer ".length);
114
+ } else {
115
+ // Lets configured OAuth bearer or cookie-session auth try next.
116
+ return { kind: "not-handled" };
117
+ }
118
+
119
+ const digest = await sha256(raw);
120
+ const row = await db
121
+ .prepare(
122
+ "SELECT id, kind, user_id, scopes_json, revoked_at " +
123
+ "FROM site_credentials WHERE token_sha256 = ? AND kind = ? LIMIT 1",
124
+ )
125
+ .bind(digest, kind)
126
+ .first<CredentialRow>();
127
+
128
+ if (!row || row.revoked_at !== null) return { kind: "invalid" };
129
+ const scopes = parseScopes(row.scopes_json);
130
+ if (!scopes) return { kind: "invalid" };
131
+
132
+ return {
133
+ kind: "verified",
134
+ credential: {
135
+ credential: row.kind,
136
+ credentialId: row.id, // opaque row id, never the raw key/token
137
+ userId: row.user_id,
138
+ scopes,
139
+ },
140
+ };
141
+ };
142
+ }
143
+
144
+ async function sha256(value: string): Promise<string> {
145
+ const bytes = await crypto.subtle.digest(
146
+ "SHA-256",
147
+ new TextEncoder().encode(value),
148
+ );
149
+ return [...new Uint8Array(bytes)]
150
+ .map((byte) => byte.toString(16).padStart(2, "0"))
151
+ .join("");
152
+ }
153
+
154
+ function parseScopes(json: string): string[] | null {
155
+ try {
156
+ const value: unknown = JSON.parse(json);
157
+ return Array.isArray(value) && value.every((scope) => typeof scope === "string")
158
+ ? value
159
+ : null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+ ```
165
+
166
+ Wire it alongside the existing Auth facade. `oauthBearer` is optional and
167
+ enables JWT bearer verification for manifest REST routes:
168
+
169
+ ```ts
170
+ import {
171
+ AssetsAssetServer,
172
+ createCmsRef,
173
+ createMcpApiHandler,
174
+ createOAuthProvider,
175
+ D1DatabaseDriver,
176
+ KvCacheBinding,
177
+ mountServerEndpoints,
178
+ } from "@aotter/mantle/cloudflare";
179
+
180
+ const runtimeRef = createCmsRef({
181
+ manifests,
182
+ handlers,
183
+ bindings: {
184
+ db: new D1DatabaseDriver(env.DB),
185
+ kv: new KvCacheBinding(env.KV),
186
+ assets: env.ASSETS
187
+ ? new AssetsAssetServer(env.ASSETS)
188
+ : { fetch: async () => null },
189
+ },
190
+ auth,
191
+ credentialResolver: siteCredentialResolver(env.DB),
192
+ oauthBearer: {
193
+ audience: "https://api.example.com",
194
+ // Optional server-wide floor. Manifest scopes still run per target.
195
+ scopes: ["api"],
196
+ },
197
+ });
198
+
199
+ mountServerEndpoints(app, runtimeRef);
200
+
201
+ const oauthProvider = createOAuthProvider({
202
+ defaultHandler: {
203
+ fetch: (request, workerEnv, ctx) => app.fetch(request, workerEnv, ctx),
204
+ },
205
+ apiHandlers: {
206
+ "/mcp/staff": createMcpApiHandler({ ref: runtimeRef, surface: "staff" }),
207
+ "/mcp": createMcpApiHandler({ ref: runtimeRef, surface: "public" }),
208
+ },
209
+ scopesSupported: ["mcp"],
210
+ });
211
+ ```
212
+
213
+ Export or delegate to `oauthProvider` as the Worker's top-level handler so the
214
+ OAuth provider can verify MCP bearers before dispatching to either MCP surface.
215
+
216
+ Resolution precedence is site resolver, configured OAuth bearer, then cookie
217
+ session. A recognized invalid credential never falls back to a valid cookie.
218
+ For each verified user, the adapter re-reads the current staff role rather than
219
+ trusting a token or consent-time snapshot.
220
+
221
+ ## 1. Anonymous public API
222
+
223
+ Omit `requires` when the operation is intentionally anonymous:
224
+
225
+ ```yaml
226
+ apiVersion: cms.mantle.aotter.net/v1
227
+ kind: Procedure
228
+ metadata: { name: public-status }
229
+ spec:
230
+ input: { type: object }
231
+ output:
232
+ type: object
233
+ required: [status]
234
+ properties:
235
+ status: { type: string }
236
+ handler: { kind: ref, ref: publicStatus }
237
+ ---
238
+ apiVersion: cms.mantle.aotter.net/v1
239
+ kind: Trigger
240
+ metadata: { name: public-status-http }
241
+ spec:
242
+ source: { kind: http, method: POST, path: /api/status }
243
+ target: { procedure: public-status }
244
+ ```
245
+
246
+ ```ts
247
+ const handlers = {
248
+ publicStatus: async () => ({ status: "ok" }),
249
+ };
250
+ ```
251
+
252
+ ```bash
253
+ curl -i -X POST https://site.example.com/api/status \
254
+ -H 'content-type: application/json' \
255
+ -d '{}'
256
+ # HTTP/2 200
257
+ ```
258
+
259
+ OpenAPI emits no `security` requirement and no auth responses for this
260
+ operation. No MCP tool is created unless a separate MCP Trigger targets the
261
+ Procedure.
262
+
263
+ ## 2. Public API requiring an API key
264
+
265
+ The API remains publicly reachable, but its target requires a verified
266
+ credential and the site-defined `catalog:read` scope:
267
+
268
+ ```yaml
269
+ apiVersion: cms.mantle.aotter.net/v1
270
+ kind: Procedure
271
+ metadata: { name: read-catalog }
272
+ spec:
273
+ requires:
274
+ auth:
275
+ all:
276
+ - ctx.auth
277
+ - { "ctx.auth.scope": "catalog:read" }
278
+ input: { type: object }
279
+ output: { type: object }
280
+ handler: { kind: ref, ref: readCatalog }
281
+ ---
282
+ apiVersion: cms.mantle.aotter.net/v1
283
+ kind: Trigger
284
+ metadata: { name: read-catalog-http }
285
+ spec:
286
+ source: { kind: http, method: POST, path: /api/catalog/read }
287
+ target: { procedure: read-catalog }
288
+ ```
289
+
290
+ ```ts
291
+ import type { HandlerContext } from "@aotter/mantle/runtime";
292
+
293
+ const handlers = {
294
+ readCatalog: async (_input: unknown, ctx: HandlerContext) => ({
295
+ credentialId: ctx.auth!.credentialId,
296
+ items: [],
297
+ }),
298
+ };
299
+ ```
300
+
301
+ ```bash
302
+ curl -i -X POST https://site.example.com/api/catalog/read \
303
+ -H 'content-type: application/json' \
304
+ -H "x-api-key: $SITE_API_KEY" \
305
+ -d '{}'
306
+ # valid key with catalog:read -> 200
307
+ # missing or recognized-invalid key -> 401
308
+ # verified key without catalog:read -> 403
309
+ ```
310
+
311
+ `ctx.auth` intentionally means any verified credential; there is no
312
+ credential-kind predicate. Configure and document only the credential sources
313
+ the site intends to accept, or put a kind-specific business rule in a guard.
314
+ With `security.apiKey` configured during OpenAPI emission, the operation
315
+ advertises the real header and carries `x-mantle-required-scopes`.
316
+
317
+ ## 3. API key plus a mutable paid/transaction guard
318
+
319
+ Keep key verification in the resolver. Put current paid state in an ordinary,
320
+ site-owned guard Procedure:
321
+
322
+ ```yaml
323
+ apiVersion: cms.mantle.aotter.net/v1
324
+ kind: Procedure
325
+ metadata: { name: require-active-api-access }
326
+ spec:
327
+ input: { type: object }
328
+ output: { type: object }
329
+ handler: { kind: ref, ref: requireActiveApiAccess }
330
+ ---
331
+ apiVersion: cms.mantle.aotter.net/v1
332
+ kind: Procedure
333
+ metadata: { name: download-export }
334
+ spec:
335
+ requires:
336
+ auth:
337
+ all:
338
+ - ctx.auth
339
+ - { "ctx.auth.scope": "exports:read" }
340
+ guard: { procedure: require-active-api-access }
341
+ input:
342
+ type: object
343
+ required: [reportId]
344
+ properties:
345
+ reportId: { type: string }
346
+ output: { type: object }
347
+ handler: { kind: ref, ref: downloadExport }
348
+ ---
349
+ apiVersion: cms.mantle.aotter.net/v1
350
+ kind: Trigger
351
+ metadata: { name: download-export-http }
352
+ spec:
353
+ source: { kind: http, method: POST, path: /api/exports/download }
354
+ target: { procedure: download-export }
355
+ ```
356
+
357
+ ```ts
358
+ import {
359
+ DiagnosticError,
360
+ runtimeDiagnostic,
361
+ } from "@aotter/mantle/spec";
362
+ import type { HandlerContext } from "@aotter/mantle/runtime";
363
+
364
+ const handlers = {
365
+ requireActiveApiAccess: async (_input: unknown, ctx: HandlerContext) => {
366
+ const credentialId = ctx.auth?.credentialId;
367
+ const paid = credentialId
368
+ ? await env.DB.prepare(
369
+ "SELECT 1 FROM site_api_entitlements " +
370
+ "WHERE credential_id = ? AND state = 'paid' LIMIT 1",
371
+ )
372
+ .bind(credentialId)
373
+ .first()
374
+ : null;
375
+
376
+ if (!paid) {
377
+ throw new DiagnosticError(
378
+ runtimeDiagnostic({
379
+ code: "ENTITLEMENT_REQUIRED",
380
+ severity: "error",
381
+ path: "site:api-entitlement",
382
+ message: "Active paid API access is required.",
383
+ }),
384
+ );
385
+ }
386
+ return {};
387
+ },
388
+ downloadExport: async ({ reportId }: { reportId: string }) => ({ reportId }),
389
+ };
390
+ ```
391
+
392
+ ```bash
393
+ curl -i -X POST https://site.example.com/api/exports/download \
394
+ -H 'content-type: application/json' \
395
+ -H "x-api-key: $SITE_API_KEY" \
396
+ -d '{"reportId":"report-1"}'
397
+ # valid + entitled -> 200
398
+ # invalid key -> 401
399
+ # verified key missing exports:read -> 403
400
+ # verified key whose current paid row is absent/revoked -> 402
401
+ ```
402
+
403
+ The guard receives the already validated target input and runs for every call.
404
+ On `402`, the target handler is not invoked. OpenAPI reflects the guard as
405
+ `x-mantle-guard-procedure` and includes a `402` response; Mantle does not infer
406
+ or publish the site's billing model.
407
+
408
+ ## 4. Personal token with user scope, shared by REST and MCP semantics
409
+
410
+ This Procedure requires a user subject, a verified credential, a delegated
411
+ scope, and current membership. Bind the same target to HTTP and public MCP:
412
+
413
+ ```yaml
414
+ apiVersion: cms.mantle.aotter.net/v1
415
+ kind: Procedure
416
+ metadata: { name: require-active-membership }
417
+ spec:
418
+ input: { type: object }
419
+ output: { type: object }
420
+ handler: { kind: ref, ref: requireActiveMembership }
421
+ ---
422
+ apiVersion: cms.mantle.aotter.net/v1
423
+ kind: Procedure
424
+ metadata: { name: read-account }
425
+ spec:
426
+ requires:
427
+ auth:
428
+ all:
429
+ - ctx.user
430
+ - ctx.auth
431
+ - { "ctx.auth.scope": "accounts:read" }
432
+ guard: { procedure: require-active-membership }
433
+ input:
434
+ type: object
435
+ required: [accountId]
436
+ properties:
437
+ accountId: { type: string }
438
+ output:
439
+ type: object
440
+ required: [accountId]
441
+ properties:
442
+ accountId: { type: string }
443
+ handler: { kind: ref, ref: readAccount }
444
+ ---
445
+ apiVersion: cms.mantle.aotter.net/v1
446
+ kind: Trigger
447
+ metadata: { name: read-account-http }
448
+ spec:
449
+ source: { kind: http, method: POST, path: /api/accounts/read }
450
+ target: { procedure: read-account }
451
+ ---
452
+ apiVersion: cms.mantle.aotter.net/v1
453
+ kind: Trigger
454
+ metadata: { name: read-account-mcp }
455
+ spec:
456
+ source: { kind: mcp, surface: public }
457
+ target: { procedure: read-account }
458
+ ```
459
+
460
+ ```ts
461
+ import {
462
+ DiagnosticError,
463
+ runtimeDiagnostic,
464
+ } from "@aotter/mantle/spec";
465
+ import type { HandlerContext } from "@aotter/mantle/runtime";
466
+
467
+ const handlers = {
468
+ requireActiveMembership: async (_input: unknown, ctx: HandlerContext) => {
469
+ const active = await env.DB.prepare(
470
+ "SELECT 1 FROM site_memberships " +
471
+ "WHERE user_id = ? AND state = 'active' LIMIT 1",
472
+ )
473
+ .bind(ctx.user!.id)
474
+ .first();
475
+ if (!active) {
476
+ throw new DiagnosticError(
477
+ runtimeDiagnostic({
478
+ code: "ENTITLEMENT_REQUIRED",
479
+ severity: "error",
480
+ path: `site:membership/${ctx.user!.id}`,
481
+ message: "Active membership is required.",
482
+ }),
483
+ );
484
+ }
485
+ return {};
486
+ },
487
+ readAccount: async ({ accountId }: { accountId: string }) => ({ accountId }),
488
+ };
489
+ ```
490
+
491
+ REST uses the site resolver's personal token:
492
+
493
+ ```bash
494
+ curl -i -X POST https://site.example.com/api/accounts/read \
495
+ -H 'content-type: application/json' \
496
+ -H "authorization: Bearer $SITE_PERSONAL_TOKEN" \
497
+ -d '{"accountId":"acct-1"}'
498
+ ```
499
+
500
+ Standard remote MCP uses the MCP server's OAuth bearer, not the raw site PAT.
501
+ After OAuth normalization, it reaches the same target and guard:
502
+
503
+ ```bash
504
+ curl -sS -X POST https://site.example.com/mcp \
505
+ -H 'content-type: application/json' \
506
+ -H "authorization: Bearer $MCP_OAUTH_ACCESS_TOKEN" \
507
+ -d '{
508
+ "jsonrpc":"2.0",
509
+ "id":1,
510
+ "method":"tools/call",
511
+ "params":{
512
+ "name":"read_account",
513
+ "arguments":{"accountId":"acct-1"}
514
+ }
515
+ }'
516
+ ```
517
+
518
+ Expected behavior:
519
+
520
+ | State | REST | MCP |
521
+ | --- | --- | --- |
522
+ | valid user credential, `accounts:read`, active membership | `200` target result | JSON-RPC `result` |
523
+ | missing/invalid credential | `401` | OAuth layer rejects the request |
524
+ | verified caller missing user or `accounts:read` | `403` | JSON-RPC error with `error.data.code = "AUTH_DENIED"` |
525
+ | membership revoked while credential remains valid | `402` | JSON-RPC error with `error.data.code = "ENTITLEMENT_REQUIRED"` |
526
+ | MCP bearer missing the resource-level `mcp` scope | n/a | HTTP `403` plus `WWW-Authenticate: ... insufficient_scope` |
527
+
528
+ `tools/list` includes `read_account` only on the public surface selected by its
529
+ MCP Trigger. The standard Tool schema remains standard: required scopes and
530
+ guard metadata are described in text, while every `tools/call` re-evaluates
531
+ the manifest predicates and guard. Staff Views are listed/callable only on the
532
+ staff MCP surface; discovery is never the enforcement boundary.
533
+
534
+ ## OAuth resource primitives
535
+
536
+ When one Mantle site is an OAuth client of another, request a stable RFC 8707
537
+ resource and use standard `offline_access` when refresh is needed:
538
+
539
+ ```ts
540
+ const clientAuth = createAuth({
541
+ // database, baseURL, secret, other methods...
542
+ methods: [{
543
+ kind: "oauth",
544
+ providerId: "mantle-platform",
545
+ clientId: env.PLATFORM_CLIENT_ID,
546
+ discoveryUrl: "https://platform.example.com/api/auth/.well-known/openid-configuration",
547
+ scopes: ["openid", "offline_access", "accounts:read"],
548
+ resource: "https://api.example.com",
549
+ }],
550
+ });
551
+
552
+ const { accessToken, accessTokenExpiresAt, scopes } =
553
+ await clientAuth.getProviderAccessToken(request, "mantle-platform");
554
+ ```
555
+
556
+ The server-side getter is bound to the current local session request and never
557
+ returns a refresh token or account row. On the provider:
558
+
559
+ ```ts
560
+ const providerAuth = createAuth({
561
+ // database, baseURL, secret, methods...
562
+ oauthProvider: {
563
+ loginPage: "/sign-in",
564
+ consentPage: "/consent",
565
+ scopes: ["openid", "offline_access", "accounts:read"],
566
+ validAudiences: ["https://api.example.com"],
567
+ },
568
+ });
569
+
570
+ const verification = await providerAuth.verifyOAuthAccessToken(request, {
571
+ audience: "https://api.example.com",
572
+ scopes: ["accounts:read"],
573
+ });
574
+ ```
575
+
576
+ The verifier accepts JWT access tokens only and checks the configured issuer,
577
+ JWKS/signature, audience, time claims, and required scopes. It returns only
578
+ `userId`, `clientId`, `credentialId`, and scopes. Opaque tokens are rejected;
579
+ there is no introspection fallback.
580
+
581
+ ## OpenAPI reflection
582
+
583
+ Emit only the schemes the deployed REST mount actually accepts:
584
+
585
+ ```ts
586
+ import { EmitOpenapiUseCase } from "@aotter/mantle/spec";
587
+
588
+ const { document } = EmitOpenapiUseCase.run({
589
+ manifests,
590
+ title: "Site API",
591
+ version: "1.0.0",
592
+ security: {
593
+ sessionCookie: false,
594
+ oauthBearer: {
595
+ openIdConnectUrl:
596
+ "https://platform.example.com/api/auth/.well-known/openid-configuration",
597
+ },
598
+ apiKey: { in: "header", name: "X-API-Key" },
599
+ personalToken: { bearerFormat: "PAT" },
600
+ },
601
+ });
602
+ ```
603
+
604
+ Anonymous operations have no security requirement. Protected operations use
605
+ configured scheme alternatives, OAuth scopes derive from repeated
606
+ `ctx.auth.scope` predicates, and guard-backed targets advertise `402`. Cookie
607
+ sessions are represented as cookies, never mislabeled as bearer tokens.
608
+
609
+ ## Runnable contract check
610
+
611
+ The integration fixture uses mutable, consumer-owned credential and
612
+ entitlement fakes. It proves this sequence for one Procedure over REST and MCP:
613
+
614
+ ```text
615
+ grant -> REST succeeds -> MCP succeeds
616
+ revoke entitlement while credential remains valid
617
+ -> next REST call is 402 -> next MCP call is ENTITLEMENT_REQUIRED
618
+ ```
619
+
620
+ Run the guide/contract and normalization checks from the Mantle repository:
621
+
622
+ ```bash
623
+ pnpm --filter @aotter/mantle-cloudflare exec vitest run \
624
+ test/authorization-integration.test.ts \
625
+ test/resolve-caller.test.ts \
626
+ test/mount-http-trigger-auth.test.ts
627
+ ```
628
+
629
+ `authorization-integration.test.ts` also asserts that this shipped guide still
630
+ contains all four scenarios and the exact public API names used by the fixture.
631
+ The package typecheck catches changes to those APIs; the integration test
632
+ catches changes to REST/MCP enforcement and mutable guard behavior.
@@ -118,9 +118,24 @@ GitHub OAuth token is still Landing-owned unless a separate token
118
118
  handoff design is introduced.
119
119
 
120
120
  The generated site's hosted-auth client code belongs in the starter or
121
- a starter overlay. Core owns only the auth contract, manifest/runtime
122
- identity vocabulary (`ctx.user`, `ctx.staff`), and curated Better Auth
123
- server primitives.
121
+ a starter overlay. Core owns only the auth contract, normalized
122
+ manifest/runtime credential vocabulary (`ctx.user`, `ctx.staff`, `ctx.auth`),
123
+ guard orchestration, and curated Better Auth server primitives.
124
+
125
+ ## API and MCP Authorization
126
+
127
+ Login hosting and business API authorization are related but separate. Core
128
+ provides a normalized verified-credential context (`ctx.auth`), closed scope
129
+ predicates, one Cloudflare consumer credential resolver seam, and a
130
+ Procedure-backed guard that REST and MCP both execute. A generated site owns
131
+ API keys, personal tokens, grants, transactions, subscriptions, and the guard
132
+ handler that checks current business state.
133
+
134
+ Mantle Platform may be the identity or OAuth token authority for a hosted
135
+ flow. That does not make token claims the generated site's live membership or
136
+ entitlement authority. The target site's guard reads its authoritative state
137
+ on every call. See [API and MCP authorization](api-mcp-authorization.md) for
138
+ the exact public API and four consumer examples.
124
139
 
125
140
  ## SDK Surface Rule
126
141
 
@@ -135,5 +150,14 @@ The current first-party SSO use case justifies these optional fields on
135
150
  - `crossSubDomainCookies`
136
151
  - `cookiePrefix`
137
152
 
138
- All three are additive. Existing generated sites that do not pass them
139
- keep their previous cookie and session behavior.
153
+ The cross-site API use case additionally justifies these curated fields and
154
+ facades:
155
+
156
+ - generic OAuth method `resource`
157
+ - OAuth provider `validAudiences`
158
+ - `Auth.getProviderAccessToken(request, providerId)`
159
+ - `Auth.verifyOAuthAccessToken(tokenOrRequest, { audience, scopes })`
160
+
161
+ All are additive. Existing generated sites that do not pass them keep their
162
+ previous cookie, session, and REST behavior. These are not a raw Better Auth
163
+ options passthrough.
@@ -338,6 +338,12 @@ names — `page` / `show` / `cursor` — must NOT appear in
338
338
  `spec.params.properties` (the parser rejects with
339
339
  `VIEW_PARAMS_RESERVED_NAME`).
340
340
 
341
+ Views may declare the same `requires.auth.all` predicates and optional
342
+ `requires.guard.procedure` as Procedures. Static auth runs before parameter
343
+ validation; the guard receives validated params and authorizes the whole
344
+ query. It does not rewrite SQL or filter individual rows. REST and MCP View
345
+ calls share this path.
346
+
341
347
  Response envelope:
342
348
 
343
349
  ```json
@@ -405,10 +411,33 @@ sdk.registerHandler("send-contact-message", sendContactMessage);
405
411
  **v0.1 `requires.auth`**: `{ all: [<predicate>] }` only. Predicates:
406
412
  - `ctx.user` — caller is any signed-in end-user
407
413
  - `ctx.staff: [<role>, ...]` — caller is staff in one of these roles
414
+ - `ctx.auth` — caller supplied any adapter-verified credential
415
+ - `ctx.auth.scope: <scope>` — verified credential carries the exact opaque,
416
+ consumer-owned scope; repeat to require multiple scopes
408
417
 
409
418
  Anything beyond this (`any:`, `owns:`, `withinMinutes:`, `contains:`,
410
419
  `requires.window`, `requires.quota`, `errors`, `retry`) is DRAFT.
411
420
 
421
+ Both Procedures and Views may add one dynamic guard beside `auth`:
422
+
423
+ ```yaml
424
+ requires:
425
+ auth:
426
+ all:
427
+ - ctx.auth
428
+ - { "ctx.auth.scope": "orders:read" }
429
+ guard:
430
+ procedure: require-active-access
431
+ ```
432
+
433
+ The guard is an ordinary declared `handler.kind: ref` Procedure. It receives
434
+ the validated target input/View params and the same `HandlerContext`; success
435
+ permits the target. Any guard diagnostic, invalid output, missing handler, or
436
+ throw fails closed. Guards cannot be builtin, self-referential, or guarded
437
+ themselves. Current payments, membership, and entitlement state belongs in
438
+ the consumer guard handler, not in a new atom or Core repository. See
439
+ [API and MCP authorization](api-mcp-authorization.md).
440
+
412
441
  **v0.1.0 `handler.kind`**: `ref` (author-supplied function) or
413
442
  `builtin` (SDK-supplied CRUD shortcut). For `builtin`, declare
414
443
  `op: <create | update | upsert | delete>` and `schema: <Schema name>`
@@ -450,14 +479,14 @@ The same Procedure can have multiple Triggers — that's how it becomes
450
479
  handler logic. Each transport is one Trigger; the Procedure body is
451
480
  shared.
452
481
 
453
- **v0.1.0 `Trigger.source.kind`**: `http` (public endpoint) or
454
- `lifecycle` (entry-writer hook). For `lifecycle`, declare `schema`,
482
+ **v0.1 `Trigger.source.kind`**: `http` (public endpoint), `mcp` (named
483
+ tool on `surface: public | staff`), or `lifecycle` (entry-writer hook). For `lifecycle`, declare `schema`,
455
484
  `on: [<hook>, ...]` from `LifecycleHook`, and optional `errorPolicy`
456
485
  (`abort` rejects only on `before_*` hooks; `continue` is the default).
457
486
  Lifecycle hooks are wired through `LifecycleHookingEntryRepository`, so
458
487
  MCP, admin, and builtin write paths share the same hook behavior.
459
488
 
460
- - `mcp` / `cron` / `queue` are **DRAFT (v0.2+)** — speculative, gated by
489
+ - `cron` / `queue` are **DRAFT (v0.2+)** — speculative, gated by
461
490
  concrete consumer demand. Same appendix § "DRAFT (v0.2+)."
462
491
 
463
492
  The state-machine "lifecycle" from the Schema atom
@@ -494,13 +523,20 @@ Mapping:
494
523
  OpenAPI Operation Object at `path` + `method`
495
524
  - The target Procedure's `input` → OpenAPI request body schema
496
525
  - The target Procedure's `output` → OpenAPI 200 response schema
497
- - `requires.auth.all: [{ ctx.staff: [<roles>] }]` OpenAPI `security`
498
- with role names as scopes
526
+ - configured cookie, OAuth bearer, API-key, and personal-token schemes →
527
+ accurate OpenAPI `security` alternatives for auth-gated targets
528
+ - repeated `ctx.auth.scope` predicates → OAuth scopes plus
529
+ `x-mantle-required-scopes`
530
+ - `requires.guard.procedure` → `x-mantle-guard-procedure` plus a `402`
531
+ response
499
532
  - Error code → HTTP status mapping (below) → OpenAPI 4xx/5xx response
500
533
  shapes
501
534
 
502
- MCP tool definition emission is deferred to v0.2+ when
503
- `Trigger.source.kind: mcp` lands.
535
+ MCP Procedure tools emit from `Trigger.source.kind: mcp`; Views emit on their
536
+ declared surface. Catalog filtering is discovery UX only. Every `tools/call`
537
+ re-runs static auth and the dynamic guard. Required scopes/guard behavior stay
538
+ in the standard Tool description rather than a non-standard required-scopes
539
+ field.
504
540
 
505
541
  ## Manifest validation — JSON Schema in, zod at runtime
506
542
 
@@ -537,6 +573,7 @@ different constraints.
537
573
  | `INPUT_VALIDATION_FAILED` | `400` | Procedure input fails zod-converted schema |
538
574
  | `UNAUTHENTICATED` | `401` | no active session (admin API: missing/expired session cookie) |
539
575
  | `AUTH_DENIED` | `403` | `requires.auth` predicate evaluated false (or admin: caller lacks required staff role) |
576
+ | `ENTITLEMENT_REQUIRED` | `402` | consumer guard denies current payment/membership/transaction entitlement |
540
577
  | `NOT_FOUND` | `404` | resource not found — admin: approval id; runtime: View name at `/api/views/<name>` |
541
578
  | `HANDLER_NOT_REGISTERED` | `500` | `handler.ref` key not registered at boot |
542
579
  | `DISPATCHER_NOT_BUILT` | `501` | runtime feature not implemented in this SDK build |
@@ -553,10 +590,11 @@ Additional runtime codes activate as future grammar surfaces (e.g.
553
590
 
554
591
  ## RBAC — what v0.1 ships, what's DRAFT
555
592
 
556
- v0.1 covers the staff-RBAC half: `requires.auth.all` with `ctx.user` /
557
- `ctx.staff: [<roles>]` predicates as the auth gate on Procedures.
558
- That's enough for "staff-only", "logged-in-only", and role-gated
559
- Procedures.
593
+ v0.1 auth gates use `requires.auth.all` with `ctx.user`,
594
+ `ctx.staff: [<roles>]`, `ctx.auth`, and `ctx.auth.scope` predicates on
595
+ Procedures and Views. This covers staff-only, logged-in-only,
596
+ credential-protected, and delegated-scope targets. One Procedure-backed guard
597
+ handles mutable consumer business state without widening the static grammar.
560
598
 
561
599
  **Not yet shipped** (DRAFT):
562
600
  - Row-level read visibility (private posts, friend-only audiences)
@@ -579,8 +617,10 @@ sub-specs in the DRAFT spec. See "Future grammar" appendix.
579
617
  4. **What invokes them?** → `Trigger` per source. Multiple Triggers
580
618
  can target the same Procedure (HTTP + MCP + cron, all on one
581
619
  handler).
582
- 5. **Who's allowed?** → `Procedure.spec.requires.auth` for now.
583
- Row-level and field-level rules arrive with future grammar.
620
+ 5. **Who's allowed?** → `Procedure/View.spec.requires.auth` for static
621
+ identity/scope; optional `requires.guard.procedure` for one live,
622
+ consumer-owned business check. Row-level and field-level rules remain
623
+ future grammar.
584
624
 
585
625
  If you find yourself wanting a 5th kind, **stop**. Sketch the same
586
626
  thing as a composition of the four; almost always it works.
@@ -751,6 +791,13 @@ Grammar lives in v0.1.0. Runtime is the
751
791
  stamping and `input ∩ Schema.properties` projection. Full shape lives
752
792
  further down.
753
793
 
794
+ #### `Trigger.source.kind: mcp` and shared authorization
795
+
796
+ An MCP Trigger binds a declared Procedure to either the public or staff MCP
797
+ surface. Procedures/Views share `ctx.auth`/scope predicates and optional guard
798
+ orchestration across REST and MCP. Staff role is loaded live for each protected
799
+ call; staff Views remain absent and un-callable on public MCP.
800
+
754
801
  ### v0.1.x committed
755
802
 
756
803
  > The `handler.kind: builtin` and `Trigger.source.kind: lifecycle`
@@ -904,7 +951,7 @@ validator rejects with `DRAFT_KEY_USED`.
904
951
  - Filter AST extension: `contains` (array containment), `not`, `in`, `like`.
905
952
 
906
953
  #### Procedure future
907
- - **`requires.auth.{any | all}`** with disjunction; predicate
954
+ - **`requires.auth.any`** with disjunction; the shipped `all` predicate
908
955
  vocabulary extends to `owns: { schema, idFrom }`, `contains: {
909
956
  schema, idFrom, field, valueFrom }`.
910
957
  - **`requires.window.{withinMinutes, column?}`** — temporal
@@ -920,8 +967,6 @@ validator rejects with `DRAFT_KEY_USED`.
920
967
  error otherwise).
921
968
 
922
969
  #### Trigger future
923
- - **`source.kind: mcp`** — MCP tool exposure. Same Procedure becomes
924
- an LLM-callable tool by adding a Trigger.
925
970
  - **`source.kind: cron`** with `expr:` — scheduled invocation.
926
971
  - **`source.kind: queue`** — async fan-out / message-driven invocation.
927
972
  - **`source.kind: lifecycle.foo`** — DRAFT extensions to the v0.1.x
@@ -942,7 +987,8 @@ to a committed roadmap.)
942
987
  declaration when projection Triggers ship.
943
988
 
944
989
  #### Cross-cutting future
945
- - **Closed `ctx.*` predicate identity**: v0.1 `{ user, staff }`
990
+ - **Closed `ctx.*` predicate identity**: v0.1 `{ user, staff, auth,
991
+ auth.scope }`
946
992
  extends to `{ ..., system }` when SDK-internal Trigger executor
947
993
  paths land. Multi-tenant deployments would add `{ tenant }` via
948
994
  grammar-revise round if/when that product shape is pursued. New
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aotter/mantle",
3
- "version": "0.0.11-alpha.47",
3
+ "version": "0.0.11-alpha.49",
4
4
  "description": "Umbrella entry for @aotter/mantle. Adopters install this one package and import from subpaths: /spec, /runtime, /cloudflare, /admin-ui. Sub-packages remain individually installable on npm for tooling / alt-adapter authors. The Netlify adapter ships as a private workspace stub in v0.1 — its subpath will be added when the impl lands in v0.2.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://mantle.tools/",
@@ -47,10 +47,10 @@
47
47
  "README.md"
48
48
  ],
49
49
  "dependencies": {
50
- "@aotter/mantle-admin-ui": "0.0.11-alpha.47",
51
- "@aotter/mantle-spec": "0.0.11-alpha.47",
52
- "@aotter/mantle-runtime": "0.0.11-alpha.47",
53
- "@aotter/mantle-cloudflare": "0.0.11-alpha.47"
50
+ "@aotter/mantle-admin-ui": "0.0.11-alpha.49",
51
+ "@aotter/mantle-cloudflare": "0.0.11-alpha.49",
52
+ "@aotter/mantle-runtime": "0.0.11-alpha.49",
53
+ "@aotter/mantle-spec": "0.0.11-alpha.49"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "@cloudflare/workers-oauth-provider": "^0.8.0",
@@ -26,6 +26,7 @@ Closed enums (`x-mantle-bind` values, `ctx.*` predicates, `Trigger.source.kind`,
26
26
  | "I want CAPTCHA / Slack notify on submit" | + Procedure (handler.kind: ref) + Trigger (lifecycle before_/after_create) |
27
27
  | "I want a /search page filtered by tag" | View with params: { tag } |
28
28
  | "I want a public prompt-generator / calculator / configurator page" | A consumer-side `app.get(...)` route in `src/index.ts` — see § Custom public routes |
29
+ | "I want an API key / personal token / paid API / scoped MCP tool" | Procedure/View `requires.auth` plus optional `guard.procedure`; site-owned resolver/handler — see § API and MCP authorization |
29
30
  | "I want a /docs/<slug>/edit-history page" | Defer — v0.1 ships `simple` lifecycle only; `editorial` is v0.1.x |
30
31
  | "I want comments" | v0.1: anonymous-with-email pattern (Schema + write Procedure). End-user member system is v0.2. |
31
32
 
@@ -146,6 +147,43 @@ pnpm mcp-smoke # 12 cases against /mcp
146
147
 
147
148
  If you added a new MCP-relevant Schema, the per-collection authoring tools (`create_draft_<segment>`, `update_draft_<segment>`) auto-emit; verify with `tools/list`.
148
149
 
150
+ ## API and MCP authorization
151
+
152
+ Read the shipped canonical guide before adding an authenticated public API:
153
+
154
+ <https://raw.githubusercontent.com/aotter/mantle/develop/docs/api-mcp-authorization.md>
155
+
156
+ Use only the closed grammar:
157
+
158
+ ```yaml
159
+ requires:
160
+ auth:
161
+ all:
162
+ - ctx.auth
163
+ - { "ctx.auth.scope": "orders:read" }
164
+ guard:
165
+ procedure: require-active-access
166
+ ```
167
+
168
+ - `ctx.auth` means any adapter-verified credential; it is not API-key-only.
169
+ - Repeat `ctx.auth.scope` to require multiple site-owned scopes.
170
+ - Use `ctx.user` as well when a user subject is required.
171
+ - Put current payment, transaction, membership, ownership, or other business
172
+ state in one site-owned `handler.kind: ref` guard Procedure. It receives the
173
+ validated target input/params and the same `HandlerContext`.
174
+ - Put API-key/PAT recognition, hashing, revocation, and normalization in the
175
+ Cloudflare `credentialResolver`. Do not add Core tables, repositories, or a
176
+ generic entitlement layer.
177
+ - Bind the same Procedure to HTTP and MCP Triggers when both transports should
178
+ expose it. Standard remote MCP uses OAuth; it does not promise to send a raw
179
+ REST API key/PAT. Runtime predicates and the guard are shared after caller
180
+ normalization.
181
+
182
+ Expected diagnostics are `UNAUTHENTICATED`/401 for no valid credential,
183
+ `AUTH_DENIED`/403 for a verified caller missing role/scope, and
184
+ `ENTITLEMENT_REQUIRED`/402 when a site guard denies current access. Re-run the
185
+ guide's focused integration command after changing manifests or auth wiring.
186
+
149
187
  ## Custom public routes (consumer-app freedom)
150
188
 
151
189
  The starter owns its `Hono` app instance. If the user wants a public surface that doesn't fit the 4-atom model — a prompt generator, calculator, configurator, starter directory browser, small interactive widget — add a route directly in `src/index.ts`:
@@ -202,7 +240,9 @@ Production fix: iterate every published entry and call `runtime.requestPublish.e
202
240
  - Don't add a Schema-level public-read flag (`Schema.spec.expose.rest` etc) — public reads always go through Views.
203
241
  - Don't add a non-`$param` filter sentinel (`{ $env: ... }`, `{ $cookie: ... }`, `{ $now }`) — none are in v0.1.
204
242
  - Don't bypass the chokepoint by writing to D1 directly — every mutation MUST go through `runtime.entries` (lifecycle hooks fire there).
205
- - Don't use `Trigger.source.kind: cron / mcp / queue` — DRAFT, parser rejects.
243
+ - Don't use `Trigger.source.kind: cron / queue` — DRAFT, parser rejects. MCP is
244
+ shipped; declare `source: { kind: mcp, surface: public | staff }` and bind it
245
+ to a declared Procedure.
206
246
  - Don't use `Procedure.spec.requires.window` / `.quota` — DRAFT.
207
247
  - Don't write a Procedure with `handler.kind: builtin` and `op: archive` on a `lifecycle: simple` Schema — boot rejects (archive is editorial-only).
208
248
  - Don't paste secrets into a manifest (`requires.auth.all` carries predicates only). Secrets go in `wrangler secret put`.