@aotter/mantle 0.0.11-alpha.73 → 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -9
- package/dist/generate.js +19 -2
- package/dist/generate.js.map +1 -1
- package/dist/harness-cli.js +1 -1
- package/dist/harness-cli.js.map +1 -1
- package/docs/adapter-guide.md +3 -6
- package/docs/adr/0001-four-atom-manifest-model.md +37 -304
- package/docs/adr/0002-closed-enums-for-bindings.md +6 -15
- package/docs/adr/0007-ai-as-primary-author.md +3 -1
- package/docs/adr/0008-structured-diagnostic-shape.md +2 -2
- package/docs/adr/0009-consumer-supplied-manifests.md +6 -5
- package/docs/adr/0010-locale-and-translates.md +13 -8
- package/docs/adr/0011-adapter-port-spec.md +25 -43
- package/docs/adr/0012-views-as-public-rest.md +6 -10
- package/docs/adr/0014-auth-better-auth-and-multi-tenant-mcp.md +13 -37
- package/docs/adr/README.md +4 -4
- package/docs/api-mcp-authorization.md +57 -2
- package/docs/cloudflare-low-level-composition.md +1 -1
- package/docs/deferred-lifecycle-queues.md +0 -3
- package/docs/design-atoms.md +75 -259
- package/docs/labels.md +4 -2
- package/docs/performance-harness.md +18 -19
- package/docs/release-process.md +19 -6
- package/docs/schema-indexes.md +1 -1
- package/package.json +11 -11
- package/skills/develop/SKILL.md +17 -8
- package/skills/plugin/SKILL.md +1 -1
- package/skills/provision/SKILL.md +4 -0
- package/skills/theme/SKILL.md +1 -1
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
# ADR-0011: Adapter port spec
|
|
2
2
|
|
|
3
|
-
**Status:** Accepted for v0.1.0.
|
|
3
|
+
**Status:** Accepted for v0.1.0. Amended 2026-08-13 to remove the unimplemented adapter stub.
|
|
4
4
|
|
|
5
|
-
**Date:** 2026-05-04 (revised 2026-05-09
|
|
5
|
+
**Date:** 2026-05-04 (revised 2026-05-09, 2026-05-10, 2026-08-11, and 2026-08-13).
|
|
6
6
|
|
|
7
7
|
## Context
|
|
8
8
|
|
|
9
9
|
`@aotter/mantle-runtime` is adapter-agnostic. It owns dispatcher, entry-writer, view executor, content-ops, render pipeline, boot validation, and MCP JSON-RPC dispatch. It depends only on `@aotter/mantle-spec` and a small set of TypeScript interfaces it defines itself.
|
|
10
10
|
|
|
11
|
-
`@aotter/mantle-cloudflare` is the only adapter shipping in v0.1.0. It binds the runtime's interfaces against Cloudflare Workers' D1
|
|
12
|
-
|
|
13
|
-
`@aotter/mantle-netlify` is a v0.2 stub — README only. It exists in the package layout as an engineering forcing function: with N=1 adapter, "adapter-agnostic" silently rots in PR review (a `D1Database` import slips into runtime, then a second, then five). With a second adapter visible in the workspace (even if its impl is a TODO), reviewers have somewhere to point when blocking the slip.
|
|
11
|
+
`@aotter/mantle-cloudflare` is the only adapter shipping in v0.1.0. It binds the runtime's interfaces against Cloudflare Workers' D1 and ASSETS, and supplies a Better Auth instance (per ADR-0014) for sign-in + MCP bearer validation. OAuth grant KV remains adapter-owned infrastructure and is not a runtime port.
|
|
14
12
|
|
|
15
13
|
This ADR fixes the contract so:
|
|
16
14
|
- Future adapter authors have a stable target.
|
|
@@ -21,14 +19,18 @@ The POC accumulated multiple half-decisions about this seam (POC ADR-0015 docume
|
|
|
21
19
|
|
|
22
20
|
## Decision
|
|
23
21
|
|
|
24
|
-
**
|
|
22
|
+
**Two required adapter ports**, defined as TypeScript interfaces in `@aotter/mantle-runtime/src/domain/port/`. Concrete adapters provide implementations and inject them into `createCmsRuntime`.
|
|
25
23
|
|
|
26
24
|
| Port | Surface |
|
|
27
25
|
|---|---|
|
|
28
|
-
| `DatabaseDriver` | All persistent state — `entries`, `site_config`, `staff`, `users`,
|
|
29
|
-
| `KvCache` | Publish-pipeline cache — pre-rendered HTML, `.md` mirrors, `llms.txt` per locale. Read-mostly, written by the publish pipeline. |
|
|
26
|
+
| `DatabaseDriver` | All persistent state — `entries`, `site_config`, `staff`, `users`, plus migrations. |
|
|
30
27
|
| `AssetServer` | Static-asset serving for the admin SPA. The runtime hands the adapter an asset path + `Request`; the adapter returns a `Response` with the right MIME and caching. |
|
|
31
28
|
|
|
29
|
+
Rendered public artifacts are not a second storage model. D1 stays canonical;
|
|
30
|
+
adapters render on origin misses and may use their native HTTP response cache.
|
|
31
|
+
The Cloudflare adapter uses version-local Workers Cache, which runs before the
|
|
32
|
+
Worker for eligible anonymous responses.
|
|
33
|
+
|
|
32
34
|
Optional feature ports may also live in `domain/port/`, but they are
|
|
33
35
|
not part of the first-run adapter contract until a feature is enabled.
|
|
34
36
|
For v0.1.x media hosting and deferred lifecycle dispatch:
|
|
@@ -119,21 +121,7 @@ export interface DatabaseDriver {
|
|
|
119
121
|
|
|
120
122
|
The runtime never sees `D1Database`, `Pool` (postgres), or any concrete driver. The `prepare` / `batch` shape is intentionally close to D1's surface (which is itself close to the SQLite C API) — that's the smallest common denominator. Adapters wrap their native driver to this shape.
|
|
121
123
|
|
|
122
|
-
The CF adapter's impl is a thin proxy over `env.DB` (D1). A future Postgres
|
|
123
|
-
|
|
124
|
-
### `KvCache`
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
export interface KvCache {
|
|
128
|
-
get(key: string): Promise<string | null>;
|
|
129
|
-
put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void>;
|
|
130
|
-
delete(key: string): Promise<void>;
|
|
131
|
-
/** List keys with a prefix — used by sitemap / llms.txt aggregation. */
|
|
132
|
-
list(prefix: string, cursor?: string | null): Promise<{ keys: string[]; cursor: string | null }>;
|
|
133
|
-
}
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
CF adapter: Workers KV. Future: Redis, FS, S3-compatible store.
|
|
124
|
+
The CF adapter's impl is a thin proxy over `env.DB` (D1). A future Postgres adapter can wrap its driver to the same shape.
|
|
137
125
|
|
|
138
126
|
### `AssetServer`
|
|
139
127
|
|
|
@@ -146,7 +134,7 @@ export interface AssetServer {
|
|
|
146
134
|
}
|
|
147
135
|
```
|
|
148
136
|
|
|
149
|
-
CF adapter: wraps `env.ASSETS.fetch(req)`.
|
|
137
|
+
CF adapter: wraps `env.ASSETS.fetch(req)`. Other adapters can use a filesystem or object storage.
|
|
150
138
|
|
|
151
139
|
The admin SPA itself lives in `@aotter/mantle-admin-ui` as a pre-built `dist/`. The adapter binds `AssetServer` to whatever serves that `dist/`; the runtime knows nothing about static asset serving except "ask the port and pass through the response."
|
|
152
140
|
|
|
@@ -164,7 +152,6 @@ import {
|
|
|
164
152
|
mountServerEndpoints,
|
|
165
153
|
AssetsAssetServer,
|
|
166
154
|
D1DatabaseDriver,
|
|
167
|
-
KvCacheBinding,
|
|
168
155
|
} from "@aotter/mantle-cloudflare";
|
|
169
156
|
|
|
170
157
|
const auth = createAuth({
|
|
@@ -188,7 +175,6 @@ const cms = createCmsRef({
|
|
|
188
175
|
handlers,
|
|
189
176
|
bindings: {
|
|
190
177
|
db: new D1DatabaseDriver(env.DB),
|
|
191
|
-
kv: new KvCacheBinding(env.KV),
|
|
192
178
|
assets: new AssetsAssetServer(env.ASSETS),
|
|
193
179
|
},
|
|
194
180
|
auth,
|
|
@@ -210,7 +196,7 @@ export default createOAuthProvider({
|
|
|
210
196
|
});
|
|
211
197
|
```
|
|
212
198
|
|
|
213
|
-
The runtime gets
|
|
199
|
+
The runtime gets two required adapter ports (`db`, `assets`)
|
|
214
200
|
alongside manifests, handlers, templates, and site defaults. Auth is
|
|
215
201
|
owned by the adapter layer that mounts HTTP/MCP surfaces; the runtime
|
|
216
202
|
receives authenticated context when the adapter dispatches requests.
|
|
@@ -220,28 +206,25 @@ There's no module-global state holding adapter-specific bindings.
|
|
|
220
206
|
|
|
221
207
|
**Hard-enforced boundaries**:
|
|
222
208
|
- `@aotter/mantle-runtime` MUST NOT import `D1Database`, `KVNamespace`, `Fetcher` (CF Workers ASSETS), `@cloudflare/*`, or any other adapter-specific type. CI will lint for this; PR reviewers can grep.
|
|
223
|
-
- A new required port can be added only by amending this ADR and updating
|
|
209
|
+
- A new required port can be added only by amending this ADR and updating every shipping adapter in the same change. Optional feature ports must be documented here and must state when adapters are required to implement them.
|
|
224
210
|
- Removing a port is also possible (if a port is found to overlap or be unnecessary), again by amending this ADR.
|
|
225
211
|
|
|
226
212
|
**Discoverability for adapter authors**:
|
|
227
|
-
- A future
|
|
213
|
+
- A future adapter author reads this ADR + [`docs/adapter-guide.md`](../adapter-guide.md), implements the two required ports, then wires boot and HTTP/MCP surfaces. That's the contract. No hidden state, no implicit assumptions about the HTTP framework.
|
|
228
214
|
|
|
229
215
|
**Test ergonomics**:
|
|
230
|
-
- Each port is small and isolated. Tests can mock individual ports without spinning up D1
|
|
216
|
+
- Each port is small and isolated. Tests can mock individual ports without spinning up D1 or an OAuth provider.
|
|
231
217
|
- The runtime's test suite exercises against in-memory port impls; the adapter's test suite exercises the binding against real CF resources via `wrangler dev` or live deploy.
|
|
232
218
|
|
|
233
|
-
**The Netlify stub's job**:
|
|
234
|
-
- The `@aotter/mantle-netlify` package's README declares a public commitment to an N>=2 adapter world. If a PR adds CF-specific code to runtime, reviewers point at the stub README and reject. The stub doesn't have to ship code to perform its function — its existence is the constraint.
|
|
235
|
-
|
|
236
219
|
## Alternatives considered
|
|
237
220
|
|
|
238
|
-
**(a) Single mega-port** — One `RuntimePorts` interface containing every method (db.prepare,
|
|
221
|
+
**(a) Single mega-port** — One `RuntimePorts` interface containing every method (db.prepare, assets.fetch, media.createUpload, …). **Rejected**: leaks the entire surface onto every adapter. Discrete ports keep change blast radius per port.
|
|
239
222
|
|
|
240
223
|
**(b) Concrete CF types in runtime** — Just `import type { D1Database } from "@cloudflare/workers-types"` directly into `mantle-runtime`. Treat "CF-only" as a v0.1.0 reality, defer the abstraction. **Rejected**: this is what the POC did (via `cms-server` having implicit assumptions about D1 shape) and it's the trap the rebuild exists to escape. Once concrete CF types land in runtime, removing them is a multi-PR uplift later. Cheaper to do it right at v0.1.0.
|
|
241
224
|
|
|
242
|
-
**(c) Function-injection (no interfaces, just functions)** — Runtime accepts a record of functions
|
|
225
|
+
**(c) Function-injection (no interfaces, just functions)** — Runtime accepts a record of functions such as `{ dbPrepare, assetFetch, sessionRead, … }`. **Rejected**: TypeScript interfaces are more discoverable and document grouping.
|
|
243
226
|
|
|
244
|
-
**(d) Plugin pattern (each port is a separate package)** — `@aotter/mantle-port-database`, `@aotter/mantle-port-kv`, etc., and runtime depends on one package per port. **Rejected**: the port set is too small to warrant per-port packages.
|
|
227
|
+
**(d) Plugin pattern (each port is a separate package)** — `@aotter/mantle-port-database`, `@aotter/mantle-port-kv`, etc., and runtime depends on one package per port. **Rejected**: the port set is too small to warrant per-port packages. Ports are TypeScript interfaces in `mantle-runtime`'s `src/domain/port/` directory — that's enough.
|
|
245
228
|
|
|
246
229
|
**(e) gRPC / wire-protocol seam** — Make ports a network protocol so adapters can be in any language. **Rejected**: the runtime is not an external service, it's a TypeScript library that adapters compose into a single Worker / Function. Network seam adds latency, deployment complexity, and operational surface for zero authoring benefit. The ports are in-process; they always will be.
|
|
247
230
|
|
|
@@ -251,29 +234,28 @@ When you're authoring `@aotter/mantle-runtime` code:
|
|
|
251
234
|
|
|
252
235
|
1. If you reach for a CF-specific type, **stop**. Define a method on a port instead.
|
|
253
236
|
2. If a port is missing the method you need, **amend this ADR first** in the same PR, then add the method. Adapters in the same PR.
|
|
254
|
-
3. Tests must use port mocks (in-memory implementations) — never reach into a real D1
|
|
237
|
+
3. Tests must use port mocks (in-memory implementations) — never reach into a real D1 from runtime tests.
|
|
255
238
|
|
|
256
|
-
When you're authoring an adapter
|
|
239
|
+
When you're authoring an adapter:
|
|
257
240
|
|
|
258
241
|
1. Read `mantle-runtime/src/domain/port/`. Implement each required port against your runtime's primitives.
|
|
259
|
-
2. Compose the runtime via `createCmsRuntime({ db,
|
|
242
|
+
2. Compose the runtime via `createCmsRuntime({ db, assets, manifests, handlers, templates, siteDefaults, ... })`.
|
|
260
243
|
3. Call `runtime.bootInit()` once before serving CMS traffic.
|
|
261
|
-
4. Bind to your HTTP framework
|
|
244
|
+
4. Bind to your HTTP framework.
|
|
262
245
|
5. Provide adapter-owned auth and map sessions/scopes/roles into runtime handler context.
|
|
263
246
|
6. Bundle `@aotter/mantle-admin-ui`'s `dist/` via your runtime's static-asset surface and bind `AssetServer` to it.
|
|
264
247
|
|
|
265
248
|
When you're reviewing a PR:
|
|
266
249
|
|
|
267
250
|
1. Grep the diff for `@cloudflare`, `D1Database`, `KVNamespace`, `Fetcher` — flag any occurrence in `mantle-runtime/`.
|
|
268
|
-
2. If a new port method shows up, check it
|
|
269
|
-
3. If a port shape changed,
|
|
251
|
+
2. If a new port method shows up, check it is also reflected in this ADR.
|
|
252
|
+
3. If a port shape changed, every shipping adapter gets updated in the same PR.
|
|
270
253
|
|
|
271
254
|
## Implementation status
|
|
272
255
|
|
|
273
256
|
- [x] Required port interface files live in `packages/mantle-runtime/src/domain/port/*.ts`.
|
|
274
257
|
- [x] Cloudflare required port implementations live in `packages/adapters/cloudflare/src/bindings/*.ts`.
|
|
275
258
|
- [x] Optional feature port `MediaStorage` (public bucket) is declared but not required by first-run adapters. `PrivateMediaStorage` is v0.2.
|
|
276
|
-
- [x] Netlify stub README references this ADR.
|
|
277
259
|
- [ ] CI lint: forbid `@cloudflare/*` / `D1Database` / `KVNamespace` imports in `mantle-runtime/` (post-v0.1.0; manual review until then)
|
|
278
260
|
|
|
279
261
|
## See also
|
|
@@ -88,7 +88,7 @@ spec:
|
|
|
88
88
|
eq: { field: locale, value: { $param: locale } }
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
The `$param` discriminator key was chosen to match the JSON Schema `$ref` convention.
|
|
91
|
+
The `$param` discriminator key was chosen to match the JSON Schema `$ref` convention. The later closed `{ "$ctx.user": "id" }` sentinel follows the same `$<name>` shape; `$now` remains unimplemented.
|
|
92
92
|
|
|
93
93
|
Boot validator gates:
|
|
94
94
|
- `View.spec.params` MUST be `type: object` with `properties` declared (`VIEW_PARAMS_INVALID_SHAPE`).
|
|
@@ -96,8 +96,6 @@ Boot validator gates:
|
|
|
96
96
|
- Every `{ $param: <name> }` ref MUST resolve to a declared param (`VIEW_FILTER_PARAM_REF_UNKNOWN`).
|
|
97
97
|
- Every `{ $param: <name> }` ref MUST appear in `params.required` (`VIEW_FILTER_PARAM_REF_NOT_REQUIRED`).
|
|
98
98
|
|
|
99
|
-
The required-only rule is a v0.1.0 simplification. v0.1.x will promote optional-with-skip semantics (filter clauses referencing missing optional params evaluate to TRUE / no-op) — the runtime compiler already implements drop semantics for forward compatibility, but the parser rejects it today so authors get a clear "not yet" diagnostic.
|
|
100
|
-
|
|
101
99
|
### 6. Response envelope is `{ rows, page, show, hasMore }`
|
|
102
100
|
|
|
103
101
|
```json
|
|
@@ -130,12 +128,11 @@ Query strings arrive as strings; `View.spec.params` declares the JSON Schema typ
|
|
|
130
128
|
|
|
131
129
|
Required params not present → `400 INPUT_VALIDATION_FAILED`. Coercion failure → `400 INPUT_VALIDATION_FAILED`. Unknown query-string keys are silently ignored (lenient v0.1.0; strict mode is a candidate v0.1.x flag).
|
|
132
130
|
|
|
133
|
-
##
|
|
131
|
+
## Current limits
|
|
134
132
|
|
|
135
|
-
-
|
|
136
|
-
-
|
|
137
|
-
-
|
|
138
|
-
- **DRAFT filter operators** (`contains` / `in` / `like` / `not`). v0.1 keeps comparison operators closed to `eq` / `gt` / `gte` / `lt` / `lte`; field-to-field comparisons remain out of scope.
|
|
133
|
+
- Param refs must be required.
|
|
134
|
+
- Filter operators are closed to `eq` / `gt` / `gte` / `lt` / `lte`;
|
|
135
|
+
field-to-field comparisons are unsupported.
|
|
139
136
|
- **Row-level policy rewriting.** `requires` authorizes the whole View; it does
|
|
140
137
|
not inject per-row visibility predicates. Consumer-specific membership,
|
|
141
138
|
payment, or entitlement checks belong in the optional guard Procedure.
|
|
@@ -149,13 +146,12 @@ Required params not present → `400 INPUT_VALIDATION_FAILED`. Coercion failure
|
|
|
149
146
|
- Cheap pagination + dynamic filters without hand-writing handlers.
|
|
150
147
|
|
|
151
148
|
**Authors lose:**
|
|
152
|
-
-
|
|
149
|
+
- Each named query shape remains an explicit View.
|
|
153
150
|
- No internal-only View surface; choose public/staff or keep the query in a TS
|
|
154
151
|
helper.
|
|
155
152
|
|
|
156
153
|
**Runtime gains:**
|
|
157
154
|
- One executor and response shape cover public/staff REST and MCP reads.
|
|
158
|
-
- Forward-compat for join / group-by / aggregation: envelope generalises by Views declaring `output.kind` later.
|
|
159
155
|
|
|
160
156
|
**Reviewers / future contributors should:**
|
|
161
157
|
- Reject any PR adding `Schema.spec.expose.rest` or a similar Schema-level public-read flag.
|
|
@@ -44,7 +44,7 @@ A 2026 Workers-friendly auth library — [Better Auth](https://better-auth.com)
|
|
|
44
44
|
- **MCP plugin (`mcp`)** — purpose-built on top of the OAuth 2.1 provider for MCP DCR; auto-mounts `.well-known/oauth-authorization-server` + `.well-known/oauth-protected-resource`, exposes `auth.api.getMcpSession()` for protected-resource validation
|
|
45
45
|
- Account linking with policies (verified-email match + reauth requirement)
|
|
46
46
|
|
|
47
|
-
Better Auth depends on a Kysely / Drizzle / Prisma adapter for the database, not on any Cloudflare-specific service. The auth machinery
|
|
47
|
+
Better Auth depends on a Kysely / Drizzle / Prisma adapter for the database, not on any Cloudflare-specific service. The auth machinery remains platform-agnostic.
|
|
48
48
|
|
|
49
49
|
## Decision (historical baseline; amended below)
|
|
50
50
|
|
|
@@ -64,7 +64,7 @@ Adopt Better Auth as the SDK's full auth surface. It owns:
|
|
|
64
64
|
|
|
65
65
|
The auth runtime stops being an adapter port. `OAuthVerifier` port + `WorkersOAuthVerifier` adapter are deleted. Validating bearer tokens at `/mcp` and `/staff/mcp` becomes `auth.api.getMcpSession(req.raw)` — a direct Better Auth API call, no port indirection.
|
|
66
66
|
|
|
67
|
-
This makes the runtime more platform-agnostic, not less: Better Auth runs on Workers (D1 via Kysely), Bun (sqlite), Node (postgres) without
|
|
67
|
+
This makes the runtime more platform-agnostic, not less: Better Auth runs on Workers (D1 via Kysely), Bun (sqlite), and Node (postgres) without runtime changes.
|
|
68
68
|
|
|
69
69
|
### 2. `staff` table → `user.role` via Better Auth admin plugin
|
|
70
70
|
|
|
@@ -81,23 +81,18 @@ admin({
|
|
|
81
81
|
|
|
82
82
|
The manifest grammar predicate `requires.auth.all: [{ "ctx.staff": ["editor"] }]` evaluates against `session.user.role` at runtime. Closed enum membership unchanged.
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
### 3. Two explicit MCP surfaces
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
```
|
|
91
|
-
predicate contains ctx.staff: [...] → tool exposed on /staff/mcp only
|
|
92
|
-
predicate only ctx.user / no predicate → tool exposed on /mcp only
|
|
93
|
-
```
|
|
86
|
+
`/mcp` and `/staff/mcp` are mounted side-by-side from boot. `/staff/mcp`
|
|
87
|
+
exposes staff authoring/lifecycle tools and `/mcp` exposes declared public
|
|
88
|
+
Views and Procedures. An MCP Trigger explicitly chooses `surface: public |
|
|
89
|
+
staff`; the target's `requires.auth` still gates every call.
|
|
94
90
|
|
|
95
91
|
Tool partition rules:
|
|
96
92
|
|
|
97
93
|
- Per-collection auto-emitted authoring tools (`create_draft_<schema>`, `update_draft_<schema>`) — predicate baked-in to require `ctx.staff: [contributor+]`; route to `/staff/mcp`
|
|
98
94
|
- `list_entries` / `get_entry` / `request_publish` / `archive_entry` / `unpublish_entry` — staff-only (return drafts, mutate state); `/staff/mcp` only
|
|
99
|
-
- `query_view_<name>`
|
|
100
|
-
- v0.2 community / v0.2.x fan-club user-facing writes (comment, reaction, subscribe, ...) — predicate `ctx.user` or `ctx.user.subscription`; `/mcp`
|
|
95
|
+
- `query_view_<name>` follows `View.spec.surface`.
|
|
101
96
|
|
|
102
97
|
### 4. Scope-aware DCR via Better Auth `oauthProvider`
|
|
103
98
|
|
|
@@ -140,7 +135,7 @@ The token can carry `role` via `customAccessTokenClaims` for caller convenience,
|
|
|
140
135
|
|
|
141
136
|
### 6. Single auth surface, no port indirection
|
|
142
137
|
|
|
143
|
-
Auth is no longer an adapter port. `mantle-runtime` does NOT define an auth port and `createCmsRuntime()` does not accept auth. Adapter packages
|
|
138
|
+
Auth is no longer an adapter port. `mantle-runtime` does NOT define an auth port and `createCmsRuntime()` does not accept auth. Adapter packages construct the Better Auth instance with the right database adapter for their platform and keep it in the adapter-owned HTTP/MCP mount layer.
|
|
144
139
|
|
|
145
140
|
The adapter uses Better Auth to validate sessions, MCP bearer tokens, scopes, and roles, then passes authenticated user/staff context into runtime dispatchers. Better Auth remains platform-agnostic, but it is not a runtime dependency.
|
|
146
141
|
|
|
@@ -188,7 +183,7 @@ This makes the implicit explicit. The SDK's auth surface is committee-curated; u
|
|
|
188
183
|
|
|
189
184
|
### 8. Path to `@aotter/mantle-better-auth` separate package (deferred)
|
|
190
185
|
|
|
191
|
-
|
|
186
|
+
Each adapter owns its Better Auth wiring. Today the seam is:
|
|
192
187
|
|
|
193
188
|
- `Auth` interface lives in the adapter (could move to runtime or a separate package without breaking the contract — adapters consume the type, not the implementation).
|
|
194
189
|
- `createAuth.ts` is the only file with `import { betterAuth }` (~290 LOC, no Cloudflare-binding-specific code outside `config.database: D1Database`).
|
|
@@ -201,10 +196,10 @@ The future split looks like:
|
|
|
201
196
|
@aotter/mantle-runtime ← ports + use cases (today)
|
|
202
197
|
@aotter/mantle-better-auth ← createAuth + EmailSender impls + appleClientSecret (new, when needed)
|
|
203
198
|
@aotter/mantle-cloudflare ← Workers adapter; depends on (or accepts) Auth-shape (today)
|
|
204
|
-
|
|
199
|
+
future adapter ← same contract, implemented when needed
|
|
205
200
|
```
|
|
206
201
|
|
|
207
|
-
The pivot point — when to extract — is when
|
|
202
|
+
The pivot point — when to extract — is when a second adapter needs the same wiring. Until then, in-place co-location is cheaper than a new package boundary.
|
|
208
203
|
|
|
209
204
|
## Consequences
|
|
210
205
|
|
|
@@ -241,7 +236,6 @@ The pivot point — when to extract — is when the second adapter (`mantle-netl
|
|
|
241
236
|
- `databaseHooks.user.create.after` for `ensureBootstrapOwner` semantics
|
|
242
237
|
- Two `/.well-known/oauth-protected-resource/*` metadata endpoints (Better Auth helpers)
|
|
243
238
|
- Public View MCP tools: dispatcher emits `query_view_<name>` on `/mcp`.
|
|
244
|
-
- Future manifest grammar tools: dispatcher will read `Procedure.requires.auth.all` to route user-facing tools to `/mcp` or `/staff/mcp`.
|
|
245
239
|
- Skills + docs updates for the dual MCP URL handoff
|
|
246
240
|
|
|
247
241
|
### Backward compatibility
|
|
@@ -263,20 +257,9 @@ User MCP URL: https://<worker>.workers.dev/mcp (give to visitors / t
|
|
|
263
257
|
The publication starter repo's production smoke recipe uses `/mcp/staff`
|
|
264
258
|
for the MCP operator smoke step.
|
|
265
259
|
|
|
266
|
-
### Future-proof for v0.2
|
|
267
|
-
|
|
268
|
-
The end-user MCP via DCR + role-gated content (community / fan-club) requires no architectural change — just:
|
|
269
|
-
|
|
270
|
-
- Enable Better Auth `socialProviders.google` / `.apple` (config-only)
|
|
271
|
-
- Enable `magicLink` and `emailOTP` plugins (config + `EmailSender` wiring already in place)
|
|
272
|
-
- Promote DRAFT manifest grammar from POC ADR-0005 — `Schema.spec.policies.readable: ctx.user` and `requires.auth.all: [{ ctx.user.subscription: [premium] }]`
|
|
273
|
-
- Add `additionalFields: { subscriptionTier: ... }` on user when Stripe entitlement lands
|
|
274
|
-
|
|
275
|
-
No config flag flips, no surface migration. The dispatcher partition rule (predicate → surface) handles new tool emission automatically.
|
|
276
|
-
|
|
277
260
|
### Platform agnosticism
|
|
278
261
|
|
|
279
|
-
By removing `@cloudflare/workers-oauth-provider` and routing auth through Better Auth, the SDK no longer depends on any CF-specific auth service. A future
|
|
262
|
+
By removing `@cloudflare/workers-oauth-provider` and routing auth through Better Auth, the SDK no longer depends on any CF-specific auth service. A future adapter can construct Better Auth against its database while the runtime + dispatcher + skills + prompts stay unchanged. ADR-0011 (adapter port spec) is amended: the `OAuthVerifier` port disappears; auth becomes adapter-owned, with platform-agnostic Better Auth as the type.
|
|
280
263
|
|
|
281
264
|
## Alternatives considered
|
|
282
265
|
|
|
@@ -346,13 +329,6 @@ Phase 2 (v0.1.x):
|
|
|
346
329
|
- Magic-link + email-OTP plugins enabled (need `ResendEmailSender` wired)
|
|
347
330
|
- Account-linking with reauth UI in publication starter
|
|
348
331
|
|
|
349
|
-
Phase 3 (v0.2+, with community / fan-club):
|
|
350
|
-
|
|
351
|
-
- POC ADR-0005 DRAFT grammar promotion: `Schema.spec.policies.readable`, `requires.auth.all: ctx.user.subscription[*]`
|
|
352
|
-
- Subscription tier on user (`additionalFields`)
|
|
353
|
-
- Stripe webhook → entitlement updater
|
|
354
|
-
- Community / fan-club starter manifests
|
|
355
|
-
|
|
356
332
|
## How to apply
|
|
357
333
|
|
|
358
334
|
When reviewing or implementing a change that touches auth, MCP routing, or roles:
|
package/docs/adr/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Records of *why* mantle ended up shaped this way. The numbering preserves POC AD
|
|
|
12
12
|
| [0008](0008-structured-diagnostic-shape.md) | Diagnostic shape for validate/boot/runtime failures, with a reserved consumer-test phase; measured harnesses keep purpose-shaped reports. | Accepted + amended |
|
|
13
13
|
| [0009](0009-consumer-supplied-manifests.md) | Consumers own manifest YAML; the installed CLI emits the parser-free runtime module and handler types. Core ships no application manifests. | Accepted + amended |
|
|
14
14
|
| [0010](0010-locale-and-translates.md) | Locale 3-layer (manifest / D1 site_config / data field) + translates pattern. Boot decoupled from `site_config` (issue #60 fix). | Accepted (refreshed) |
|
|
15
|
-
| [0011](0011-adapter-port-spec.md) | Adapter port spec. Required runtime ports plus optional feature ports.
|
|
15
|
+
| [0011](0011-adapter-port-spec.md) | Adapter port spec. Required runtime ports plus optional feature ports. | Accepted (new) |
|
|
16
16
|
| [0012](0012-views-as-public-rest.md) | Views auto-expose matching REST and `query_view_*` MCP reads on their declared `public` or `staff` surface. Schemas never get a public REST endpoint. | Accepted + amended |
|
|
17
17
|
| [0013](0013-agent-provisioned-consumer-projects.md) | Historical agent-provisioned consumer projects path. Superseded for first launch by landing provision bundles. | Superseded |
|
|
18
18
|
| [0014](0014-auth-better-auth-and-multi-tenant-mcp.md) | The Cloudflare adapter owns the curated Better Auth identity/session facade and top-level `@cloudflare/workers-oauth-provider` MCP transport. Both normalize verified callers into runtime context; mutable staff role and target authorization are re-evaluated per call. | Accepted + amended |
|
|
@@ -39,12 +39,12 @@ for new v0.1.0 boundaries, and folds / drops the rest:
|
|
|
39
39
|
|
|
40
40
|
- **POC ADR-0003** OpenAPI emission → folded into `mantle-spec` README (the *what* is implementation; the *why* was already captured by ADR-0001's grammar lock).
|
|
41
41
|
- **POC ADR-0004** D1 today, Hyperdrive PG tomorrow → folded into `mantle-cloudflare` README (now a v0.2 roadmap item, not an architectural decision).
|
|
42
|
-
- **POC ADR-0005** v0.1 minimum
|
|
42
|
+
- **POC ADR-0005** v0.1 minimum grammar → folded into ADR-0001's fail-closed grammar policy.
|
|
43
43
|
- **POC ADR-0006** multi-doc YAML → folded into ADR-0001 §"Authoring shape: multi-doc YAML."
|
|
44
|
-
- **POC ADR-0011** lifecycle binary opt-in → distilled to a §"Lifecycle" subsection in `docs/design-atoms.md`. v0.1.0 ships `
|
|
44
|
+
- **POC ADR-0011** lifecycle binary opt-in → distilled to a §"Lifecycle" subsection in `docs/design-atoms.md`. v0.1.0 ships `publishing` and `operational`.
|
|
45
45
|
- **POC ADR-0012** strategic posture vs adjacent CMS designs → strategic / marketing material, lives in `README.md` if anywhere.
|
|
46
46
|
- **POC ADR-0013** role-split surfaces (coder agent vs operator agent) → folded into ADR-0007 (Part B).
|
|
47
|
-
- **POC ADR-0014** builtin handlers and lifecycle Triggers → promoted to v0.1.0 and implemented in the rebuild via `LifecycleHookingEntryRepository` and `InvokeBuiltinUseCase`.
|
|
47
|
+
- **POC ADR-0014** builtin handlers and lifecycle Triggers → promoted to v0.1.0 and implemented in the rebuild via `LifecycleHookingEntryRepository` and `InvokeBuiltinUseCase`. Full shape spec lives in `docs/design-atoms.md`.
|
|
48
48
|
- **POC ADR-0015** cms-astro internal seam discipline → POC-specific to a package that no longer exists; replaced by ADR-0011 (adapter port spec).
|
|
49
49
|
- **POC ADR-0029** drop Astro from cms-cloudflare → POC-specific historical record; the rebuild starts post-Astro.
|
|
50
50
|
|
|
@@ -78,6 +78,63 @@ Missing/invalid credentials return `401`; a verified caller missing a required
|
|
|
78
78
|
role or scope returns `403`; a site guard may return
|
|
79
79
|
`ENTITLEMENT_REQUIRED`/`402`. Guards run on every call and are not cached.
|
|
80
80
|
|
|
81
|
+
### Identity-bound Views
|
|
82
|
+
|
|
83
|
+
Use the closed `{ "$ctx.user": "id" }` filter sentinel for rows owned by the
|
|
84
|
+
current site-local Better Auth user. The caller never supplies this value, so
|
|
85
|
+
the same View is safe on both REST and public MCP:
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
89
|
+
kind: Schema
|
|
90
|
+
metadata: { name: orders }
|
|
91
|
+
spec:
|
|
92
|
+
schema:
|
|
93
|
+
type: object
|
|
94
|
+
properties:
|
|
95
|
+
userId: { type: string, x-mantle-bind: ctx.user }
|
|
96
|
+
orderNumber: { type: string }
|
|
97
|
+
orderStatus: { type: string }
|
|
98
|
+
totalMinor: { type: integer }
|
|
99
|
+
placedAt: { type: integer }
|
|
100
|
+
indexes: [[userId, placedAt]]
|
|
101
|
+
---
|
|
102
|
+
apiVersion: cms.mantle.aotter.net/v1
|
|
103
|
+
kind: View
|
|
104
|
+
metadata: { name: my-orders }
|
|
105
|
+
spec:
|
|
106
|
+
surface: public
|
|
107
|
+
from: orders
|
|
108
|
+
requires:
|
|
109
|
+
auth:
|
|
110
|
+
all: [ctx.user]
|
|
111
|
+
filter:
|
|
112
|
+
and:
|
|
113
|
+
- { eq: { field: status, value: published } }
|
|
114
|
+
- { eq: { field: userId, value: { "$ctx.user": id } } }
|
|
115
|
+
fields: [orderNumber, orderStatus, totalMinor, placedAt]
|
|
116
|
+
orderBy: [{ field: placedAt, direction: desc }]
|
|
117
|
+
limit: 50
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Core rejects this sentinel unless the View requires `ctx.user` and the bound
|
|
121
|
+
field is the leftmost field of a declared Schema index. Missing identity fails
|
|
122
|
+
with `401`; it never drops the filter or falls back to all rows. REST exposes
|
|
123
|
+
`GET /api/views/my-orders`; public MCP exposes `query_view_my_orders`. Both
|
|
124
|
+
call `ExecuteViewUseCase` and bind the same `ctx.user.id`.
|
|
125
|
+
|
|
126
|
+
The id belongs to the customer site's Better Auth user row. It is not a
|
|
127
|
+
Mantle Platform user id, Hosted Auth upstream subject, email, or provider id.
|
|
128
|
+
Hosted Auth may establish the site session, but Platform is not part of the
|
|
129
|
+
View query path.
|
|
130
|
+
|
|
131
|
+
## Site OAuth symmetry
|
|
132
|
+
|
|
133
|
+
A site-issued OAuth access token represents the same caller on public MCP and
|
|
134
|
+
manifest HTTP routes. Both surfaces populate `ctx.user` and `ctx.auth` from the
|
|
135
|
+
same token grant; expiry, revocation, scope, client, and resource audience are
|
|
136
|
+
enforced before the Procedure or View runs.
|
|
137
|
+
|
|
81
138
|
## Cloudflare consumer wiring
|
|
82
139
|
|
|
83
140
|
Pass one site-owned resolver to `createCmsRef`. Return `not-handled` when the
|
|
@@ -173,7 +230,6 @@ import {
|
|
|
173
230
|
createMcpApiHandler,
|
|
174
231
|
createOAuthProvider,
|
|
175
232
|
D1DatabaseDriver,
|
|
176
|
-
KvCacheBinding,
|
|
177
233
|
mountServerEndpoints,
|
|
178
234
|
} from "@aotter/mantle/cloudflare";
|
|
179
235
|
|
|
@@ -182,7 +238,6 @@ const runtimeRef = createCmsRef({
|
|
|
182
238
|
handlers,
|
|
183
239
|
bindings: {
|
|
184
240
|
db: new D1DatabaseDriver(env.DB),
|
|
185
|
-
kv: new KvCacheBinding(env.KV),
|
|
186
241
|
assets: env.ASSETS
|
|
187
242
|
? new AssetsAssetServer(env.ASSETS)
|
|
188
243
|
: { fetch: async () => null },
|
|
@@ -103,7 +103,6 @@ import type { DeferredHookEnvelope } from "@aotter/mantle/runtime";
|
|
|
103
103
|
import {
|
|
104
104
|
AssetsAssetServer,
|
|
105
105
|
D1DatabaseDriver,
|
|
106
|
-
KvCacheBinding,
|
|
107
106
|
WorkersQueueHookDispatcher,
|
|
108
107
|
createCmsRef,
|
|
109
108
|
createQueueHandler,
|
|
@@ -112,7 +111,6 @@ import {
|
|
|
112
111
|
|
|
113
112
|
interface Env {
|
|
114
113
|
DB: D1Database;
|
|
115
|
-
KV: KVNamespace;
|
|
116
114
|
ASSETS: Fetcher;
|
|
117
115
|
MANTLE_INTERNAL_QUEUE: Queue<DeferredHookEnvelope>;
|
|
118
116
|
}
|
|
@@ -124,7 +122,6 @@ function buildWorker(env: Env) {
|
|
|
124
122
|
auth: createSiteAuth(env),
|
|
125
123
|
bindings: {
|
|
126
124
|
db: new D1DatabaseDriver(env.DB),
|
|
127
|
-
kv: new KvCacheBinding(env.KV),
|
|
128
125
|
assets: new AssetsAssetServer(env.ASSETS),
|
|
129
126
|
deferredHookDispatcher: new WorkersQueueHookDispatcher(
|
|
130
127
|
env.MANTLE_INTERNAL_QUEUE,
|