@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 +3 -0
- package/docs/adapter-guide.md +24 -1
- package/docs/adr/0001-four-atom-manifest-model.md +7 -3
- package/docs/adr/0002-closed-enums-for-bindings.md +37 -1
- package/docs/adr/0014-auth-better-auth-and-multi-tenant-mcp.md +53 -0
- package/docs/api-mcp-authorization.md +632 -0
- package/docs/auth-hosting-model.md +29 -5
- package/docs/design-atoms.md +63 -17
- package/package.json +5 -5
- package/skills/extend/SKILL.md +41 -1
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
|
|
package/docs/adapter-guide.md
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
122
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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.
|
package/docs/design-atoms.md
CHANGED
|
@@ -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
|
|
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
|
-
- `
|
|
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
|
-
-
|
|
498
|
-
|
|
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
|
|
503
|
-
|
|
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
|
|
557
|
-
`ctx.staff: [<roles>]`
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
583
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
51
|
-
"@aotter/mantle-
|
|
52
|
-
"@aotter/mantle-runtime": "0.0.11-alpha.
|
|
53
|
-
"@aotter/mantle-
|
|
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",
|
package/skills/extend/SKILL.md
CHANGED
|
@@ -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 /
|
|
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`.
|