@aotter/mantle 0.1.2-alpha.6 → 0.1.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.
Files changed (110) hide show
  1. package/README.md +15 -10
  2. package/dist/cli/generate.d.ts +9 -0
  3. package/dist/cli/generate.d.ts.map +1 -1
  4. package/dist/cli/generate.js +40 -1
  5. package/dist/cli/generate.js.map +1 -1
  6. package/dist/cli/main.d.ts +1 -1
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/main.js +37 -9
  9. package/dist/cli/main.js.map +1 -1
  10. package/docs/adapter-guide.md +6 -1
  11. package/docs/adr/adr-lite-845-frontend-client.md +38 -0
  12. package/docs/agent-prompts.md +91 -0
  13. package/docs/api-mcp-authorization.md +1 -1
  14. package/docs/auth-hosting-model.md +1 -1
  15. package/docs/examples/README.md +22 -0
  16. package/docs/examples/builtin-commerce.md +269 -0
  17. package/docs/examples/builtin-intake.md +143 -0
  18. package/docs/examples/builtin-legal-documents.md +189 -0
  19. package/docs/examples/builtin-procurement.md +241 -0
  20. package/docs/examples/builtin-publication.md +241 -0
  21. package/docs/examples/builtin-reservation.md +149 -0
  22. package/docs/examples/cf-primitives-commerce-inventory.md +809 -0
  23. package/docs/examples/cf-primitives-guarded-api.md +429 -0
  24. package/docs/examples/cf-primitives-intake-hooks.md +319 -0
  25. package/docs/examples/host-chatgpt-sites/.openai/hosting.json +1 -0
  26. package/docs/examples/host-chatgpt-sites/README.md +53 -0
  27. package/docs/examples/host-chatgpt-sites/drizzle/0000_sites_users.sql +8 -0
  28. package/docs/examples/host-chatgpt-sites/drizzle/0001_mantle.sql +294 -0
  29. package/docs/examples/host-chatgpt-sites/drizzle/0002_article_cover.sql +5 -0
  30. package/docs/examples/host-chatgpt-sites/drizzle/meta/_journal.json +1 -0
  31. package/docs/examples/host-chatgpt-sites/manifests/site.yaml +27 -0
  32. package/docs/examples/host-chatgpt-sites/package.json +1 -0
  33. package/docs/examples/host-chatgpt-sites/public/site.css +1 -0
  34. package/docs/examples/host-chatgpt-sites/scripts/build.mjs +12 -0
  35. package/docs/examples/host-chatgpt-sites/scripts/check.mjs +99 -0
  36. package/docs/examples/host-chatgpt-sites/scripts/migration.mjs +9 -0
  37. package/docs/examples/host-chatgpt-sites/src/chatgpt-auth.ts +63 -0
  38. package/docs/examples/host-chatgpt-sites/src/index.ts +46 -0
  39. package/docs/examples/host-chatgpt-sites/src/mcp.ts +49 -0
  40. package/docs/examples/host-chatgpt-sites/src/media.ts +109 -0
  41. package/docs/examples/host-chatgpt-sites/src/r2-lab.ts +38 -0
  42. package/docs/examples/host-chatgpt-sites/src/storage-fingerprint.json +1 -0
  43. package/docs/examples/host-chatgpt-sites/src/web.ts +35 -0
  44. package/docs/examples/host-chatgpt-sites/tsconfig.json +1 -0
  45. package/docs/examples/host-chatgpt-sites/wrangler.jsonc +10 -0
  46. package/docs/examples/host-local-admin-otp/.dev.vars.example +3 -0
  47. package/docs/examples/host-local-admin-otp/README.md +70 -0
  48. package/docs/examples/host-local-admin-otp/ensure-dev-vars.mjs +5 -0
  49. package/docs/examples/host-local-admin-otp/package.json +29 -0
  50. package/docs/examples/host-local-admin-otp/public/.gitkeep +1 -0
  51. package/docs/examples/host-local-admin-otp/smoke.mjs +141 -0
  52. package/docs/examples/host-local-admin-otp/src/index.ts +54 -0
  53. package/docs/examples/host-local-admin-otp/wrangler.jsonc +23 -0
  54. package/docs/examples/{minimal-worker → host-minimal-worker}/README.md +9 -5
  55. package/docs/examples/host-minimal-worker/manifests/site.yaml +25 -0
  56. package/docs/examples/{minimal-worker → host-minimal-worker}/package.json +3 -3
  57. package/docs/examples/host-minimal-worker/tsconfig.json +17 -0
  58. package/docs/handbook/cloudflare/authentication.md +17 -2
  59. package/docs/handbook/cloudflare/bindings.md +9 -7
  60. package/docs/handbook/cloudflare/chatgpt-sites.md +29 -0
  61. package/docs/handbook/cloudflare/conventional-worker.md +3 -3
  62. package/docs/handbook/cloudflare/deferred-hooks-queues.md +0 -1
  63. package/docs/handbook/cloudflare/deploy-and-operate.md +14 -20
  64. package/docs/handbook/cloudflare/media-r2.md +2 -2
  65. package/docs/handbook/cloudflare/public-web.md +1 -1
  66. package/docs/handbook/cloudflare/site-chrome.md +75 -0
  67. package/docs/handbook/concepts/authorization.md +2 -2
  68. package/docs/handbook/concepts/four-atoms.md +2 -2
  69. package/docs/handbook/concepts/lifecycle-and-locales.md +1 -1
  70. package/docs/handbook/concepts/mcp-and-agents.md +1 -1
  71. package/docs/handbook/concepts/procedures-and-triggers.md +1 -1
  72. package/docs/handbook/concepts/runtime-and-adapters.md +2 -2
  73. package/docs/handbook/concepts/views.md +2 -2
  74. package/docs/handbook/examples/commerce-transaction.md +4 -806
  75. package/docs/handbook/examples/commerce.md +11 -0
  76. package/docs/handbook/examples/guarded-api.md +3 -420
  77. package/docs/handbook/examples/hub.md +10 -0
  78. package/docs/handbook/examples/intake-form.md +6 -313
  79. package/docs/handbook/examples/intake-hooks.md +11 -0
  80. package/docs/handbook/examples/legal-documents.md +3 -211
  81. package/docs/handbook/examples/procurement-approvals.md +3 -233
  82. package/docs/handbook/examples/publication.md +3 -233
  83. package/docs/handbook/examples/reservation.md +3 -213
  84. package/docs/handbook/navigation.json +17 -2
  85. package/docs/handbook/reference/authorization.md +1 -1
  86. package/docs/handbook/reference/procedure.md +2 -2
  87. package/docs/handbook/reference/schema.md +3 -3
  88. package/docs/handbook/reference/site-config.md +5 -16
  89. package/docs/handbook/reference/surface.md +3 -7
  90. package/docs/handbook/sites/equipment-checkout.md +231 -0
  91. package/docs/handbook/sites/host-reference.md +113 -0
  92. package/docs/handbook/sites/index.md +111 -0
  93. package/docs/handbook/start/project-and-cli.md +22 -14
  94. package/docs/handbook/start/quickstart-admin.md +239 -0
  95. package/docs/handbook/start/quickstart-worker.md +22 -23
  96. package/docs/migration-0.1.2.md +26 -0
  97. package/docs/release-process.md +92 -7
  98. package/docs/sealed-pipeline-ownership.md +2 -2
  99. package/docs/spec-only-host-adoption.md +3 -4
  100. package/docs/transaction-patterns.md +2 -2
  101. package/package.json +15 -15
  102. package/skills/README.md +18 -2
  103. package/skills/develop/SKILL.md +32 -23
  104. package/skills/install/SKILL.md +50 -11
  105. package/skills/provision/SKILL.md +21 -5
  106. /package/docs/examples/{minimal-worker → host-local-admin-otp}/manifests/site.yaml +0 -0
  107. /package/docs/examples/{minimal-worker → host-local-admin-otp}/tsconfig.json +0 -0
  108. /package/docs/examples/{minimal-worker → host-minimal-worker}/smoke.mjs +0 -0
  109. /package/docs/examples/{minimal-worker → host-minimal-worker}/src/index.ts +0 -0
  110. /package/docs/examples/{minimal-worker → host-minimal-worker}/wrangler.jsonc +0 -0
@@ -0,0 +1,319 @@
1
+ ---
2
+ description: A public intake form with a Turnstile bot check before the write and an email notification after it.
3
+ ---
4
+ # Intake form with bot check and notification
5
+
6
+ **Handler class:** cf-primitives · **Builder:** no · [Examples hub](./README.md). The Builder-safe Manifest (builtin create only) is [Intake form](./builtin-intake.md).
7
+
8
+ This example collects a public request, verifies a Cloudflare Turnstile token before the row is written, and notifies staff after it is written. Two lifecycle Triggers and two small handlers sit on a declarative intake Schema. Read it if you need any form that anonymous visitors submit.
9
+
10
+ ## Problem
11
+
12
+ Visitors submit a name, an email address and a message. Staff read recent submissions in Admin, over the staff View REST route, or through Staff MCP. The public write must reject automated submissions before anything is stored, and a new row should trigger an email to the team without making the visitor wait for it or fail when email is not configured. Submissions are live records, not authored content, so the Schema is `operational`.
13
+
14
+ ## Manifest
15
+
16
+ ```yaml
17
+ apiVersion: cms.mantle.aotter.net/v1
18
+ kind: Schema
19
+ metadata:
20
+ name: requests
21
+ spec:
22
+ title: Requests
23
+ description: Requests submitted through the public intake flow.
24
+ lifecycle: operational
25
+ schema:
26
+ type: object
27
+ additionalProperties: false
28
+ required: [name, email, message]
29
+ properties:
30
+ name: { type: string, minLength: 1, maxLength: 120 }
31
+ email: { type: string, format: email }
32
+ message: { type: string, minLength: 1, maxLength: 2000 }
33
+ createdAt: { type: number, x-mcp-hint: timestamp-ms, x-mantle-bind: now }
34
+ ---
35
+ apiVersion: cms.mantle.aotter.net/v1
36
+ kind: View
37
+ metadata:
38
+ name: recent-requests
39
+ spec:
40
+ title: Recent requests
41
+ surface: staff
42
+ from: requests
43
+ fields: [id, name, email, message, createdAt]
44
+ orderBy:
45
+ - { field: createdAt, direction: desc }
46
+ limit: 50
47
+ ---
48
+ apiVersion: cms.mantle.aotter.net/v1
49
+ kind: Procedure
50
+ metadata:
51
+ name: submit-request
52
+ spec:
53
+ title: Submit request
54
+ description: Create a new public request.
55
+ input:
56
+ type: object
57
+ additionalProperties: false
58
+ required: [name, email, message]
59
+ properties:
60
+ name: { type: string, minLength: 1, maxLength: 120 }
61
+ email: { type: string, format: email }
62
+ message: { type: string, minLength: 1, maxLength: 2000 }
63
+ turnstileToken: { type: string }
64
+ output: { type: object }
65
+ handler: { kind: builtin, op: create, schema: requests }
66
+ ---
67
+ apiVersion: cms.mantle.aotter.net/v1
68
+ kind: Trigger
69
+ metadata:
70
+ name: submit-request-http
71
+ spec:
72
+ source: { kind: http, method: POST, path: /api/requests }
73
+ target: { procedure: submit-request }
74
+ ---
75
+ apiVersion: cms.mantle.aotter.net/v1
76
+ kind: Trigger
77
+ metadata:
78
+ name: submit-request-mcp
79
+ spec:
80
+ source: { kind: mcp, surface: public }
81
+ target: { procedure: submit-request }
82
+ ---
83
+ apiVersion: cms.mantle.aotter.net/v1
84
+ kind: Procedure
85
+ metadata:
86
+ name: verify-turnstile
87
+ spec:
88
+ input:
89
+ type: object
90
+ properties:
91
+ turnstileToken: { type: string }
92
+ output: { type: object }
93
+ handler: { kind: ref, ref: verify-turnstile }
94
+ ---
95
+ apiVersion: cms.mantle.aotter.net/v1
96
+ kind: Trigger
97
+ metadata:
98
+ name: 010-requests-verify-turnstile
99
+ spec:
100
+ source:
101
+ kind: lifecycle
102
+ schema: requests
103
+ on: [before_create]
104
+ errorPolicy: abort
105
+ target: { procedure: verify-turnstile }
106
+ ---
107
+ apiVersion: cms.mantle.aotter.net/v1
108
+ kind: Procedure
109
+ metadata:
110
+ name: notify-request
111
+ spec:
112
+ input:
113
+ type: object
114
+ properties:
115
+ name: { type: string }
116
+ email: { type: string }
117
+ message: { type: string }
118
+ output: { type: object }
119
+ handler: { kind: ref, ref: notify-request }
120
+ ---
121
+ apiVersion: cms.mantle.aotter.net/v1
122
+ kind: Trigger
123
+ metadata:
124
+ name: 020-requests-notify
125
+ spec:
126
+ source:
127
+ kind: lifecycle
128
+ schema: requests
129
+ on: [after_create]
130
+ errorPolicy: continue
131
+ target: { procedure: notify-request }
132
+ ```
133
+
134
+ Three details carry the pattern:
135
+
136
+ - `submit-request.input` declares `turnstileToken` even though the `requests` Schema does not. The builtin `create` projects `input ∩ Schema.properties`, so the token is never stored. Because the input sets `additionalProperties: false`, the token must be declared or the request fails with `INPUT_VALIDATION_FAILED`.
137
+ - `before_create` hooks receive the original, pre-projection input, so `verify-turnstile` can read the token. Its own `input` schema must not set `additionalProperties: false`; it receives `name`, `email` and `message` too.
138
+ - `after_create` hooks receive the persisted `entry.data`. The token is gone by then, which is why verification cannot be an `after_*` hook.
139
+
140
+ Lifecycle Triggers on the same `(schema, hook)` run alphabetically by `metadata.name`; the `010-`/`020-` prefixes make the order explicit. See [Writes: Procedures, Triggers and hooks](../handbook/concepts/procedures-and-triggers.md).
141
+
142
+ ## Worker and handlers
143
+
144
+ ```ts
145
+ // src/handlers.ts
146
+ import { InvokeFailure, type HandlerContext } from "@aotter/mantle/runtime";
147
+ import { runtimeDiagnostic } from "@aotter/mantle/spec";
148
+ import type { Env } from "./index.js";
149
+
150
+ interface SiteverifyResult {
151
+ readonly success?: boolean;
152
+ readonly "error-codes"?: readonly string[];
153
+ }
154
+
155
+ export async function verifyTurnstile(
156
+ input: { readonly turnstileToken?: string },
157
+ ctx: HandlerContext<Env>,
158
+ ): Promise<{ ok: true }> {
159
+ const secret = ctx.env.TURNSTILE_SECRET_KEY?.trim();
160
+ if (!secret) return { ok: true }; // fail-open until the secret exists; see below
161
+
162
+ const token = input.turnstileToken?.trim();
163
+ if (!token) reject("Turnstile verification is required.");
164
+
165
+ const body = new FormData();
166
+ body.set("secret", secret);
167
+ body.set("response", token);
168
+ const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
169
+ method: "POST",
170
+ body,
171
+ });
172
+ const result = response.ok
173
+ ? ((await response.json().catch(() => null)) as SiteverifyResult | null)
174
+ : null;
175
+ if (!result?.success) reject("Turnstile verification failed.", result?.["error-codes"]);
176
+ return { ok: true };
177
+ }
178
+
179
+ function reject(message: string, value?: unknown): never {
180
+ throw new InvokeFailure(
181
+ runtimeDiagnostic({
182
+ code: "LIFECYCLE_HOOK_REJECTED",
183
+ severity: "error",
184
+ path: "/turnstileToken",
185
+ value,
186
+ expected: "a valid Cloudflare Turnstile token",
187
+ message,
188
+ }),
189
+ );
190
+ }
191
+
192
+ export async function notifyRequest(
193
+ input: { readonly name?: string; readonly email?: string; readonly message?: string },
194
+ ctx: HandlerContext<Env>,
195
+ ): Promise<{ ok: true }> {
196
+ const { EMAIL, INTAKE_NOTIFY_TO, INTAKE_NOTIFY_FROM } = ctx.env;
197
+ if (!EMAIL || !INTAKE_NOTIFY_TO || !INTAKE_NOTIFY_FROM) {
198
+ console.info("[requests] notification not configured", { entry: ctx.event?.entry?.id });
199
+ return { ok: true }; // fail-soft: the row is already committed
200
+ }
201
+ await EMAIL.send({
202
+ to: INTAKE_NOTIFY_TO,
203
+ from: INTAKE_NOTIFY_FROM,
204
+ subject: `New request from ${input.name ?? "website"}`,
205
+ text: [`Name: ${input.name ?? ""}`, `Email: ${input.email ?? ""}`, "", input.message ?? ""].join("\n"),
206
+ ...(input.email ? { replyTo: input.email } : {}),
207
+ });
208
+ return { ok: true };
209
+ }
210
+ ```
211
+
212
+ ```ts
213
+ // src/index.ts
214
+ import { createMantleWorker, type MantleCloudflareEnv } from "@aotter/mantle/cloudflare";
215
+ import { plan, type MantleHandlers } from "../.mantle/generated/mantle.js";
216
+ import { notifyRequest, verifyTurnstile } from "./handlers.js";
217
+
218
+ interface EmailBinding {
219
+ send(message: { to: string; from: string; subject: string; text?: string; replyTo?: string }): Promise<unknown>;
220
+ }
221
+
222
+ export interface Env extends MantleCloudflareEnv {
223
+ readonly TURNSTILE_SECRET_KEY?: string;
224
+ readonly EMAIL?: EmailBinding;
225
+ readonly INTAKE_NOTIFY_TO?: string;
226
+ readonly INTAKE_NOTIFY_FROM?: string;
227
+ }
228
+
229
+ const handlers = {
230
+ "verify-turnstile": verifyTurnstile,
231
+ "notify-request": notifyRequest,
232
+ } satisfies MantleHandlers<Env>;
233
+
234
+ export default createMantleWorker<Env>({ plan, extend: () => ({ handlers }) });
235
+ ```
236
+
237
+ The keys of `handlers` are the opaque `handler.ref` strings from the Manifest. A missing key fails at boot with `HANDLER_NOT_REGISTERED`.
238
+
239
+ Bindings live in `wrangler.toml`: `[[send_email]] name = "EMAIL"` for Cloudflare Email Service, `INTAKE_NOTIFY_TO` and `INTAKE_NOTIFY_FROM` as vars, and `TURNSTILE_SECRET_KEY` as a secret (`wrangler secret put TURNSTILE_SECRET_KEY`). See [Bindings and primitives](../handbook/cloudflare/bindings.md).
240
+
241
+ Two policies are deliberate and reversible:
242
+
243
+ - **Bot check fails open when the secret is unset.** A first deployment without Turnstile configured still accepts submissions instead of rejecting every visitor with an opaque error. To fail closed, replace `if (!secret) return { ok: true }` with `if (!secret) reject("Turnstile is not configured.")`.
244
+ - **Notification fails soft.** The Trigger's `errorPolicy: continue` means a throwing `after_create` handler is logged and never rolls back the row; the handler additionally returns `ok` when the binding is absent so logs stay quiet. If you later route `after_*` hooks through a Queue, keep the handler idempotent as described in [Deferred hooks with Queues](../handbook/cloudflare/deferred-hooks-queues.md).
245
+
246
+ > **Warning**
247
+ > The runtime does not substitute an error code when a `before_*` hook aborts; the caller receives exactly the diagnostic the hook threw. This handler throws `LIFECYCLE_HOOK_REJECTED` (409). Throwing `INPUT_VALIDATION_FAILED` (400) is equally valid if you prefer to treat a missing token as a malformed request.
248
+
249
+ ## Try it
250
+
251
+ Submit a request:
252
+
253
+ ```sh
254
+ curl -sS -X POST http://localhost:8787/api/requests \
255
+ -H 'content-type: application/json' \
256
+ -d '{"name":"Ada","email":"ada@example.test","message":"Please call me back.","turnstileToken":"<token>"}'
257
+ ```
258
+
259
+ ```json
260
+ {
261
+ "ok": true,
262
+ "data": {
263
+ "id": "req_01j...",
264
+ "collection": "requests",
265
+ "status": "published",
266
+ "version": 1,
267
+ "data": { "name": "Ada", "email": "ada@example.test", "message": "Please call me back.", "createdAt": 1788879363492 },
268
+ "authorId": null,
269
+ "createdAt": 1788879363492,
270
+ "updatedAt": 1788879363492
271
+ }
272
+ }
273
+ ```
274
+
275
+ The builtin `create` returns the `EntryRow`; `status` is `published` immediately because the Schema is operational. A rejected token, with the secret configured:
276
+
277
+ ```json
278
+ {
279
+ "ok": false,
280
+ "diagnostic": {
281
+ "code": "LIFECYCLE_HOOK_REJECTED",
282
+ "phase": "runtime",
283
+ "severity": "error",
284
+ "path": "/turnstileToken",
285
+ "expected": "a valid Cloudflare Turnstile token",
286
+ "message": "Turnstile verification failed."
287
+ }
288
+ }
289
+ ```
290
+
291
+ That response is HTTP 409 and no row exists. A missing `name` is HTTP 400 `INPUT_VALIDATION_FAILED` before any hook runs.
292
+
293
+ Staff read the queue at `GET /admin/api/views/recent-requests?page=1&show=50` with a staff session; the envelope is `{ ok, data: { rows, page, show, hasMore } }`.
294
+
295
+ MCP tools:
296
+
297
+ | Surface | Tool | Origin |
298
+ |---|---|---|
299
+ | `/mcp` | `submit_request` | `submit-request-mcp` Trigger |
300
+ | `/mcp/staff` | `query_view_recent_requests` | `recent-requests` View |
301
+ | `/mcp/staff` | `create_record_requests`, `update_record_requests` | operational Schema `requests` |
302
+
303
+ An agent calling `submit_request` on `/mcp` has no browser Turnstile widget. With the secret unset the call succeeds; with the secret set it is rejected unless the agent supplies a valid token. Keep or remove `submit-request-mcp` deliberately.
304
+
305
+ ## What this deliberately leaves out
306
+
307
+ - **Deduplication.** Two identical submissions create two rows. Add a `uniqueIndexes` tuple or a `before_create` lookup if duplicates matter.
308
+ - **Rate limiting beyond Turnstile.** The adapter applies its own request limits to Auth and Admin routes, not a per-form quota.
309
+ - **CRM sync.** Forwarding rows to an external system belongs in another `after_create` handler, ideally deferred through a Queue with the `${ctx.event.id}:${ctx.event.trigger}` idempotency key.
310
+
311
+ Related: [Reservation requests](./builtin-reservation.md) uses the same builtin-create shape without hooks; [Procurement approvals](./builtin-procurement.md) adds member and staff roles.
312
+
313
+ ## Source
314
+
315
+ - [Procedure reference](../handbook/reference/procedure.md) — builtin table, side-channel input fields, lifecycle hooks
316
+ - [`packages/mantle-runtime/src/usecase/lifecycle/RunLifecycleHooksUseCase.ts`](../../packages/mantle-runtime/src/usecase/lifecycle/RunLifecycleHooksUseCase.ts) — abort propagates the hook's diagnostic
317
+ - [`packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts`](../../packages/mantle-runtime/src/usecase/procedure/InvokeProcedureUseCase.ts) — `InvokeFailure`
318
+ - [`packages/mantle-spec/src/kernel/diagnostic.ts`](../../packages/mantle-spec/src/kernel/diagnostic.ts) — `LIFECYCLE_HOOK_REJECTED` → 409
319
+ - [`packages/adapters/cloudflare/src/handlers/turnstile.ts`](../../packages/adapters/cloudflare/src/handlers/turnstile.ts) — Turnstile verification helper
@@ -0,0 +1 @@
1
+ {"d1":"DB","r2":"MEDIA_BUCKET"}
@@ -0,0 +1,53 @@
1
+ # Mantle on ChatGPT Sites: runnable reference
2
+
3
+ The runnable application for [Mantle on ChatGPT Sites](../../handbook/sites/index.md) connects Sites D1 + R2 bindings, Sign in with ChatGPT identity, Mantle Admin and staff roles, same-origin media upload, a published-only article frontend, anonymous read-only `/api/mcp`, and Sites-session staff tools at `/api/mcp/staff`. Remote OAuth MCP remains a separate integration; see [MCP support](../../handbook/sites/host-reference.md#remote-mcp-is-a-separate-gate).
4
+
5
+ After setup, follow [Publish your first article](../../handbook/sites/index.md#publish-your-first-article) to verify the editorial workflow in Admin.
6
+
7
+ ## Before writing code
8
+
9
+ Read the user's business request and author the manifest for **their** records and lifecycle. The included `articles` example deliberately allows a title-only draft; `body` uses `x-mcp-hint: markdown`, while `coverAssetId` uses both `x-mantle-ref: media_assets` (Admin picker) and `x-mcp-hint: media-image` (agent guidance). The `published-articles` View is a list projection, not the detail page contract. If the user's content must always have a body, add it to `required`; if the public API must return body or cover ID, add those to the View's `fields`. Review staff roles, public filters and indexes before deployment. `mantle validate` checks grammar, **not** whether this model matches the business request. Manifest changes after deployment require a new reviewed D1 migration and matching storage fingerprint; never edit an applied migration.
10
+
11
+ ## Install and run
12
+
13
+ Requires Mantle 0.1.2 or newer. Copy this directory outside the SDK checkout, then:
14
+
15
+ ```bash
16
+ npm install # or: bun install
17
+ npx mantle validate --phase deploy # or: bunx mantle ...
18
+ npm run generate && npm run check
19
+ npx wrangler d1 migrations apply DB --local
20
+ npm run dev -- --port 4174 # leave running
21
+ npm test # smoke, in a second terminal
22
+ ```
23
+
24
+ `npm install` resolves `@aotter/mantle*` from the `latest` dist-tag; this
25
+ example does not commit a lockfile, so every fresh install picks up the
26
+ current stable release. `bun install` works too, resolving from
27
+ `package.json` the same way.
28
+
29
+ Keep `.openai/hosting.json`; do not copy an existing Site's `project_id`. To use another port, also set the Worker's `PUBLIC_ORIGIN` and the test's `MANTLE_TEST_ORIGIN` to that same localhost origin. Local test headers simulate Sites' trusted dispatcher; they do **not** prove deployed ChatGPT login.
30
+
31
+ Review the entire [smoke script](./scripts/check.mjs) before adapting it. It covers D1 CRUD/version conflict, owner/member/role revocation, R2 read/write/delete, media create → PUT → commit → public read, both advertised MCP URLs, public MCP `initialize`/`tools/list`/View call, staff MCP authentication/catalog, draft isolation, published article HTML/Markdown/SEO, and negative auth/Origin/size checks. It creates and deletes only its own test records and objects.
32
+
33
+ To run this example against an unreleased Mantle checkout instead of the registry, use `node scripts/check-packed-consumer.mjs --project docs/examples/host-chatgpt-sites --output <new dir> -- pnpm build` from a clean SDK checkout and work in its `consumer/`. That path is for SDK development only.
34
+
35
+ The checked-in `drizzle/` migrations and `src/storage-fingerprint.json` match the example manifest. `scripts/migration.mjs` shows the one-time generation mechanism; do **not** run it against a deployed database or overwrite an applied migration. For a new business manifest, generate/review an initial migration before the first deployment; for a later change, generate an additive migration from the previous schema state.
36
+
37
+ ## Publish with Sites
38
+
39
+ 1. Create a new Site through Sites and request **both D1 and R2**. Confirm its saved hosting manifest has `d1: "DB"` and `r2: "MEDIA_BUCKET"`; the local example intentionally omits `project_id` until Sites provisions one. A missing R2 binding cannot be repaired by adding an R2 type to TypeScript. Sites controls provisioning and publishing, not `wrangler deploy`.
40
+ 2. In Sites settings, set `PUBLIC_ORIGIN` to the exact production origin and `OWNER_EMAIL` to the intended first owner. Do not commit hosted secrets or identity headers. Keep the Site audience narrow until verification. A Site Viewer is not automatically a Mantle staff member.
41
+ 3. Review the D1 migration, save a Sites version, then deploy that version. Every deployment URL is production. Confirm the deployed artifact includes `dist/server/index.js`, `dist/client/`, `dist/.openai/hosting.json` and `dist/.openai/drizzle/`; `npm run build` prepares those artifacts.
42
+ 4. On the deployed Site, check `/health`, `/admin/sign-in`, one `/_mantle/admin/assets/*` file, owner login and staff revocation, Admin media upload and committed public image URL, then publish an article with that asset as its cover. Anonymous draft and uncommitted image URLs must return 404. Check public HTML, Markdown, canonical/JSON-LD, sitemap and `llms.txt`; POST `/api/mcp` should discover only the public View tool, while `/api/mcp/staff` must reject anonymous/member requests and expose staff tools to a current staff session. The root `/mcp` path was intercepted in the lab and is not used by this reference.
43
+
44
+ The media path uses only the R2 binding: `ports.mediaStorage` + `media.purposes` + authenticated same-origin PUT + committed-only public GET. It does not need an R2 S3 endpoint or API keys. The upload route is browser-session authenticated, so it is **not** an MCP agent upload route. Do not expose the Worker outside Sites' identity-stripping ingress: `src/chatgpt-auth.ts` trusts the `oai-*` headers only under that condition.
45
+
46
+ ## Stop conditions
47
+
48
+ - `/admin/media` says storage is disabled: check **all three** of `r2` binding, `ports.mediaStorage`, and `media.purposes`; a successful R2 probe alone is insufficient.
49
+ - Admin HTML loads but JS assets 404: check `dist/client/_mantle/admin/assets/` and `ASSETS` routing.
50
+ - `/api/mcp/staff` uses the Sites-injected browser identity and re-reads the Mantle staff role. It is mounted and advertised in Admin, but it is not an OAuth resource server. Do not register it as a remote ChatGPT connector until OAuth discovery, a standards-compliant bearer challenge and token verification are implemented and tested.
51
+ - OAuth discovery returns sign-in HTML: Sites' page session is not an OAuth bearer challenge. Do not bypass Sites auth or trust browser cookies as remote MCP credentials.
52
+
53
+ See the [Sites host reference](../../handbook/sites/host-reference.md) and [OpenAI Sites documentation](https://learn.chatgpt.com/docs/sites).
@@ -0,0 +1,8 @@
1
+ CREATE TABLE sites_users (
2
+ id TEXT PRIMARY KEY NOT NULL,
3
+ email TEXT NOT NULL UNIQUE,
4
+ name TEXT NOT NULL,
5
+ role TEXT CHECK (role IN ('owner','editor','contributor') OR role IS NULL),
6
+ signed_in INTEGER NOT NULL DEFAULT 0,
7
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
8
+ );
@@ -0,0 +1,294 @@
1
+ CREATE TABLE IF NOT EXISTS site_config (
2
+ key TEXT PRIMARY KEY,
3
+ value TEXT NOT NULL
4
+ );
5
+ --> statement-breakpoint
6
+ CREATE TABLE IF NOT EXISTS user (
7
+ id TEXT PRIMARY KEY NOT NULL,
8
+ name TEXT NOT NULL,
9
+ email TEXT NOT NULL UNIQUE,
10
+ emailVerified INTEGER NOT NULL DEFAULT 0,
11
+ image TEXT,
12
+ createdAt TEXT NOT NULL,
13
+ updatedAt TEXT NOT NULL,
14
+ role TEXT,
15
+ banned INTEGER DEFAULT 0,
16
+ banReason TEXT,
17
+ banExpires TEXT,
18
+ githubLogin TEXT
19
+ );
20
+ --> statement-breakpoint
21
+ -- Partial index keeps ensureBootstrapOwner's role-IN scan off
22
+ -- the pile of role=NULL rows.
23
+ CREATE INDEX IF NOT EXISTS user_role_idx ON user (role) WHERE role IS NOT NULL;
24
+ --> statement-breakpoint
25
+ CREATE TABLE IF NOT EXISTS session (
26
+ id TEXT PRIMARY KEY NOT NULL,
27
+ expiresAt TEXT NOT NULL,
28
+ token TEXT NOT NULL UNIQUE,
29
+ createdAt TEXT NOT NULL,
30
+ updatedAt TEXT NOT NULL,
31
+ ipAddress TEXT,
32
+ userAgent TEXT,
33
+ userId TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
34
+ impersonatedBy TEXT
35
+ );
36
+ --> statement-breakpoint
37
+ CREATE INDEX IF NOT EXISTS session_userId_idx ON session (userId);
38
+ --> statement-breakpoint
39
+ CREATE TABLE IF NOT EXISTS account (
40
+ id TEXT PRIMARY KEY NOT NULL,
41
+ issuer TEXT NOT NULL,
42
+ accountId TEXT NOT NULL,
43
+ providerId TEXT NOT NULL,
44
+ userId TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
45
+ accessToken TEXT,
46
+ refreshToken TEXT,
47
+ idToken TEXT,
48
+ accessTokenExpiresAt TEXT,
49
+ refreshTokenExpiresAt TEXT,
50
+ scope TEXT,
51
+ password TEXT,
52
+ createdAt TEXT NOT NULL,
53
+ updatedAt TEXT NOT NULL,
54
+ UNIQUE (issuer, accountId)
55
+ );
56
+ --> statement-breakpoint
57
+ CREATE INDEX IF NOT EXISTS account_userId_idx ON account (userId);
58
+ --> statement-breakpoint
59
+ CREATE TABLE IF NOT EXISTS verification (
60
+ id TEXT PRIMARY KEY NOT NULL,
61
+ identifier TEXT NOT NULL,
62
+ value TEXT NOT NULL,
63
+ expiresAt TEXT NOT NULL,
64
+ createdAt TEXT NOT NULL,
65
+ updatedAt TEXT NOT NULL
66
+ );
67
+ --> statement-breakpoint
68
+ CREATE INDEX IF NOT EXISTS verification_identifier_idx ON verification (identifier);
69
+ --> statement-breakpoint
70
+ CREATE TABLE IF NOT EXISTS jwks (
71
+ id TEXT PRIMARY KEY NOT NULL,
72
+ publicKey TEXT NOT NULL,
73
+ privateKey TEXT NOT NULL,
74
+ createdAt TEXT NOT NULL,
75
+ expiresAt TEXT,
76
+ alg TEXT,
77
+ crv TEXT
78
+ );
79
+ --> statement-breakpoint
80
+ CREATE TABLE IF NOT EXISTS oauthClient (
81
+ id TEXT PRIMARY KEY NOT NULL,
82
+ clientId TEXT NOT NULL UNIQUE,
83
+ clientSecret TEXT,
84
+ clientDiscoveryId TEXT,
85
+ disabled INTEGER DEFAULT 0,
86
+ skipConsent INTEGER,
87
+ enableEndSession INTEGER,
88
+ subjectType TEXT,
89
+ scopes TEXT,
90
+ clientCredentialsScopes TEXT DEFAULT '[]',
91
+ userId TEXT REFERENCES user(id) ON DELETE CASCADE,
92
+ createdAt TEXT,
93
+ updatedAt TEXT,
94
+ name TEXT,
95
+ uri TEXT,
96
+ icon TEXT,
97
+ contacts TEXT,
98
+ tos TEXT,
99
+ policy TEXT,
100
+ softwareId TEXT,
101
+ softwareVersion TEXT,
102
+ softwareStatement TEXT,
103
+ redirectUris TEXT NOT NULL,
104
+ postLogoutRedirectUris TEXT,
105
+ backchannelLogoutUri TEXT,
106
+ backchannelLogoutSessionRequired INTEGER,
107
+ tokenEndpointAuthMethod TEXT,
108
+ applicationType TEXT,
109
+ jwks TEXT,
110
+ jwksUri TEXT,
111
+ grantTypes TEXT,
112
+ responseTypes TEXT,
113
+ requirePKCE INTEGER,
114
+ dpopBoundAccessTokens INTEGER DEFAULT 0,
115
+ referenceId TEXT,
116
+ metadata TEXT
117
+ );
118
+ --> statement-breakpoint
119
+ CREATE INDEX IF NOT EXISTS oauthClient_userId_idx ON oauthClient (userId);
120
+ --> statement-breakpoint
121
+ CREATE TABLE IF NOT EXISTS oauthResource (
122
+ id TEXT PRIMARY KEY NOT NULL,
123
+ identifier TEXT NOT NULL UNIQUE,
124
+ name TEXT NOT NULL,
125
+ accessTokenTtl INTEGER,
126
+ refreshTokenTtl INTEGER,
127
+ signingAlgorithm TEXT,
128
+ signingKeyId TEXT,
129
+ allowedScopes TEXT,
130
+ customClaims TEXT,
131
+ dpopBoundAccessTokensRequired INTEGER DEFAULT 0,
132
+ disabled INTEGER DEFAULT 0,
133
+ createdAt TEXT,
134
+ updatedAt TEXT,
135
+ policyVersion INTEGER DEFAULT 1,
136
+ metadata TEXT
137
+ );
138
+ --> statement-breakpoint
139
+ CREATE TABLE IF NOT EXISTS oauthClientResource (
140
+ id TEXT PRIMARY KEY NOT NULL,
141
+ clientId TEXT NOT NULL REFERENCES oauthClient(clientId) ON DELETE CASCADE,
142
+ resourceId TEXT NOT NULL REFERENCES oauthResource(identifier) ON DELETE CASCADE,
143
+ metadata TEXT,
144
+ createdAt TEXT,
145
+ UNIQUE (clientId, resourceId)
146
+ );
147
+ --> statement-breakpoint
148
+ CREATE INDEX IF NOT EXISTS oauthClientResource_clientId_idx
149
+ ON oauthClientResource (clientId);
150
+ --> statement-breakpoint
151
+ CREATE INDEX IF NOT EXISTS oauthClientResource_resourceId_idx
152
+ ON oauthClientResource (resourceId);
153
+ --> statement-breakpoint
154
+ CREATE TABLE IF NOT EXISTS oauthRefreshToken (
155
+ id TEXT PRIMARY KEY NOT NULL,
156
+ token TEXT NOT NULL UNIQUE,
157
+ clientId TEXT NOT NULL REFERENCES oauthClient(clientId) ON DELETE CASCADE,
158
+ sessionId TEXT REFERENCES session(id) ON DELETE SET NULL,
159
+ userId TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
160
+ referenceId TEXT,
161
+ authorizationCodeId TEXT,
162
+ resources TEXT,
163
+ requestedUserInfoClaims TEXT,
164
+ expiresAt TEXT NOT NULL,
165
+ createdAt TEXT NOT NULL,
166
+ revoked TEXT,
167
+ rotatedAt TEXT,
168
+ rotationReplayResponse TEXT,
169
+ rotationReplayExpiresAt TEXT,
170
+ authTime TEXT,
171
+ confirmation TEXT,
172
+ scopes TEXT NOT NULL
173
+ );
174
+ --> statement-breakpoint
175
+ CREATE INDEX IF NOT EXISTS oauthRefreshToken_clientId_idx ON oauthRefreshToken (clientId);
176
+ --> statement-breakpoint
177
+ CREATE INDEX IF NOT EXISTS oauthRefreshToken_sessionId_idx ON oauthRefreshToken (sessionId);
178
+ --> statement-breakpoint
179
+ CREATE INDEX IF NOT EXISTS oauthRefreshToken_userId_idx ON oauthRefreshToken (userId);
180
+ --> statement-breakpoint
181
+ CREATE INDEX IF NOT EXISTS oauthRefreshToken_authorizationCodeId_idx
182
+ ON oauthRefreshToken (authorizationCodeId);
183
+ --> statement-breakpoint
184
+ CREATE TABLE IF NOT EXISTS oauthAccessToken (
185
+ id TEXT PRIMARY KEY NOT NULL,
186
+ token TEXT NOT NULL UNIQUE,
187
+ clientId TEXT NOT NULL REFERENCES oauthClient(clientId) ON DELETE CASCADE,
188
+ sessionId TEXT REFERENCES session(id) ON DELETE SET NULL,
189
+ userId TEXT REFERENCES user(id) ON DELETE CASCADE,
190
+ referenceId TEXT,
191
+ authorizationCodeId TEXT,
192
+ resources TEXT,
193
+ requestedUserInfoClaims TEXT,
194
+ refreshId TEXT REFERENCES oauthRefreshToken(id) ON DELETE CASCADE,
195
+ expiresAt TEXT NOT NULL,
196
+ createdAt TEXT NOT NULL,
197
+ revoked TEXT,
198
+ confirmation TEXT,
199
+ scopes TEXT NOT NULL
200
+ );
201
+ --> statement-breakpoint
202
+ CREATE INDEX IF NOT EXISTS oauthAccessToken_clientId_idx ON oauthAccessToken (clientId);
203
+ --> statement-breakpoint
204
+ CREATE INDEX IF NOT EXISTS oauthAccessToken_sessionId_idx ON oauthAccessToken (sessionId);
205
+ --> statement-breakpoint
206
+ CREATE INDEX IF NOT EXISTS oauthAccessToken_userId_idx ON oauthAccessToken (userId);
207
+ --> statement-breakpoint
208
+ CREATE INDEX IF NOT EXISTS oauthAccessToken_refreshId_idx ON oauthAccessToken (refreshId);
209
+ --> statement-breakpoint
210
+ CREATE INDEX IF NOT EXISTS oauthAccessToken_authorizationCodeId_idx
211
+ ON oauthAccessToken (authorizationCodeId);
212
+ --> statement-breakpoint
213
+ CREATE TABLE IF NOT EXISTS oauthConsent (
214
+ id TEXT PRIMARY KEY NOT NULL,
215
+ clientId TEXT NOT NULL REFERENCES oauthClient(clientId) ON DELETE CASCADE,
216
+ userId TEXT REFERENCES user(id) ON DELETE CASCADE,
217
+ referenceId TEXT,
218
+ resources TEXT,
219
+ requestedUserInfoClaims TEXT,
220
+ scopes TEXT NOT NULL,
221
+ createdAt TEXT NOT NULL,
222
+ updatedAt TEXT NOT NULL
223
+ );
224
+ --> statement-breakpoint
225
+ CREATE INDEX IF NOT EXISTS oauthConsent_clientId_idx ON oauthConsent (clientId);
226
+ --> statement-breakpoint
227
+ CREATE INDEX IF NOT EXISTS oauthConsent_userId_idx ON oauthConsent (userId);
228
+ --> statement-breakpoint
229
+ CREATE TABLE IF NOT EXISTS oauthClientAssertion (
230
+ id TEXT PRIMARY KEY NOT NULL,
231
+ expiresAt TEXT NOT NULL
232
+ );
233
+ --> statement-breakpoint
234
+ CREATE TABLE IF NOT EXISTS media_assets (
235
+ id TEXT PRIMARY KEY,
236
+ created_at INTEGER NOT NULL,
237
+ owner_id TEXT,
238
+ alt TEXT,
239
+ caption TEXT,
240
+ variants TEXT NOT NULL,
241
+ metadata TEXT
242
+ );
243
+ --> statement-breakpoint
244
+ CREATE INDEX IF NOT EXISTS media_assets_by_owner_created
245
+ ON media_assets (owner_id, created_at DESC);
246
+ --> statement-breakpoint
247
+ CREATE TABLE IF NOT EXISTS pending_media_uploads (
248
+ id TEXT PRIMARY KEY,
249
+ record TEXT NOT NULL,
250
+ expires_at INTEGER NOT NULL
251
+ );
252
+ --> statement-breakpoint
253
+ CREATE INDEX IF NOT EXISTS pending_media_uploads_expires_at
254
+ ON pending_media_uploads (expires_at);
255
+ --> statement-breakpoint
256
+ CREATE TABLE IF NOT EXISTS _mantle_boot_state (
257
+ id TEXT PRIMARY KEY NOT NULL,
258
+ fingerprint TEXT NOT NULL
259
+ );
260
+ --> statement-breakpoint
261
+ CREATE TABLE IF NOT EXISTS _mantle_schema_tables (
262
+ name TEXT PRIMARY KEY NOT NULL,
263
+ projection TEXT NOT NULL
264
+ );
265
+ --> statement-breakpoint
266
+ CREATE TABLE IF NOT EXISTS _mantle_storage_state (
267
+ id INTEGER PRIMARY KEY CHECK (id = 1),
268
+ fingerprint TEXT NOT NULL
269
+ );
270
+ --> statement-breakpoint
271
+ CREATE TABLE IF NOT EXISTS "articles" (
272
+ "_mantle_id" TEXT PRIMARY KEY,
273
+ "_mantle_status" TEXT NOT NULL,
274
+ "_mantle_version" INTEGER NOT NULL DEFAULT 1,
275
+ "_mantle_author_id" TEXT,
276
+ "_mantle_created_at" INTEGER NOT NULL,
277
+ "_mantle_updated_at" INTEGER NOT NULL
278
+ );
279
+ --> statement-breakpoint
280
+ ALTER TABLE "articles" ADD COLUMN "body" TEXT;
281
+ --> statement-breakpoint
282
+ ALTER TABLE "articles" ADD COLUMN "summary" TEXT;
283
+ --> statement-breakpoint
284
+ ALTER TABLE "articles" ADD COLUMN "title" TEXT;
285
+ --> statement-breakpoint
286
+ CREATE INDEX IF NOT EXISTS "m_61727469636c6573_updated" ON "articles"("_mantle_updated_at" DESC, "_mantle_id" DESC);
287
+ --> statement-breakpoint
288
+ CREATE INDEX IF NOT EXISTS "m_61727469636c6573_status_updated" ON "articles"("_mantle_status", "_mantle_updated_at" DESC, "_mantle_id" DESC);
289
+ --> statement-breakpoint
290
+ CREATE INDEX IF NOT EXISTS "m_61727469636c6573_created" ON "articles"("_mantle_created_at");
291
+ --> statement-breakpoint
292
+ INSERT INTO _mantle_schema_tables(name,projection) VALUES ('articles','{"columns":[["body","TEXT","string",false],["summary","TEXT","string",false],["title","TEXT","string",false]],"indexes":[]}');
293
+ --> statement-breakpoint
294
+ INSERT INTO _mantle_storage_state(id,fingerprint) VALUES (1,'e3cdedf91dbe27db4b9af7ff7e3512e81ae0b0bd69e5a90bd1c9e0526d86ba6b');