diff --git a/docs/adr/0038-public-playground.md b/docs/adr/0038-public-playground.md new file mode 100644 index 00000000..4359f4dc --- /dev/null +++ b/docs/adr/0038-public-playground.md @@ -0,0 +1,33 @@ +# ADR-0038: Public playground: one SQLite Durable Object per visitor, store and renderer inside it, deny-list guard with a route walk +- Status: Accepted +- Date: 2026-10-20 (decided by Halil) +- Related: RFC-0036, ADR-0002, ADR-0008, ADR-0012, ADR-0018, ADR-0024, ADR-0036, ADR-0037 +- Sources: [Durable Objects SQLite storage API](https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/) · [Durable Objects limits](https://developers.cloudflare.com/durable-objects/platform/limits/) · [Durable Objects alarms](https://developers.cloudflare.com/durable-objects/api/alarms/) · [Workers Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) · [Workers limits (script size, startup)](https://developers.cloudflare.com/workers/platform/limits/) · [EmDash playground](https://github.com/emdash-cms/emdash/tree/main/demos/playground) + +## Context +Trying Cloudhal needs Node, wrangler and a Cloudflare account. RFC-0036 proposes `try.cloudhal.com` (ADR-0037): every visitor gets a private, disposable Cloudhal (Studio, API and rendered site) with no account. An internal spike on workerd measured the two placements of the store and the bundle size: the first open is 24–41 ms of server work, a fresh database is about 815 KB, 20 concurrent sessions all succeeded, and one Worker holding the API and the site renderer is at most 1.6 MiB gzip. With the store in the Worker and SQL over DO RPC, a publish took 26 sequential RPCs (7 ms locally against 2 ms with the store in the DO, an estimated 25–130 ms more in production). Snapshots and publishes write blobs (`nodes/.json`, bundle shards), so a read-only blob store breaks every save. + +## Decision +1. **One SQLite-backed Durable Object per session**, addressed by `idFromName()`. No shared database, no server-side session table, no signed cookie (the ID is unguessable and a session holds nothing of value). +2. **Store, API and renderer run inside the session's DO.** The Worker serves static files, checks the cookie, applies the guard and the rate limits, and forwards with `stub.fetch`. One hop per request, however chatty the call. +3. **`@cloudhal/storage/do-sqlite`**: `DoSqliteDriver` over `ctx.storage.sql`, `batch` atomic through `transactionSync` and never exposed as a transaction to callers (hard rule 4 holds), D1 limits enforced as on every adapter (ADR-0002). Blobs: `SqlBlobStore` (blobs in the same SQL database, capped), `LayeredBlobStore` (read-only base under a writable upper), `FetcherBlobStore` (static assets). All pass the existing conformance suites on Miniflare; core-ts's behavioural suite gets a DO backend. +4. **Playground mode is defence in depth**: the pure `isBlockedInPlayground(method, path)` deny list runs in the Worker and again as API middleware; `createApi({ playground })` leaves auth, MCP, mail, AI and analytics unconfigured; the fixed `PLAYGROUND_PRINCIPAL` holds Editor scopes plus `site:settings` and nothing installation-wide, so the default-deny scope check sits under both. A test walks every route `createApi` registers and fails on any route that is neither blocked nor on the allow list. +5. **Lifetime**: an alarm deletes everything 60 minutes after the last request, never later than 2 hours after creation; a DO older than 2 hours refuses requests even if the alarm did not run. Reset deletes at once. +6. **Session pages are private**: rendered under `/s//`, `cache-control: private, no-store`, `x-robots-tag: noindex`, never a `cache-tag`. ADR-0037 rule 2's "no publishing" is read as "no public publishing": a publish is visible only to its own session. +7. **Agents are off in the MVP**; abuse limits, a daily session cap and a `PLAYGROUND_ENABLED` kill switch bound cost (RFC-0036 §7, §9). +8. **Split**: the general pieces (driver, blob stores, playground mode, `@cloudhal/cloudflare/playground`, `withRequestStore`, Studio's session bar) live in the monorepo, versioned and tested in `pnpm check`. The deployable site (wrangler config, landing page, demo pack, domain, secrets) lives in `cloudhal-dev/try` and only composes published packages. + +## Alternatives considered +| Alternative | Pros | Cons | Why not | +|---|---|---|---| +| Store in the Worker, SQL over DO RPC | DO only runs SQL; render CPU off the DO | One network hop per query (26 for a publish) | Spike: 1.5–3.5× slower locally; worse with real RTT | +| One shared D1, a site per visitor (ADR-0036) | No new driver | Shared blast radius, cleanup across 30+ tables, noisy neighbours, D1 size cap | Isolation is the point | +| A D1 database per visitor | Real D1 | Creation through the API takes seconds; account database limits | Too slow and too limited for anonymous traffic | +| SQLite WASM in the browser | No server cost | Hides the platform; large download; no Worker rendering | Would not show Cloudhal on Cloudflare | +| Read-only blob store | Simplest | Snapshots and publishes write blobs | Breaks every save | +| Allow list only, no route walk | Smaller | A forgotten route stays reachable or silently breaks | The walk makes every new route a decision | + +## Consequences +- **Positive.** Cloudhal runs with no D1 at all (also on Workers Free); isolation is per visitor by construction; first open is tens of milliseconds; idle sessions cost nothing; every new API route is classified for the playground before it ships. +- **Negative.** A third SQL backend to keep in the conformance suites; Studio carries a `features.playground` branch; the DO runs rendering on its single thread. +- **Risks.** Worker startup CPU with a 1.6 MiB bundle (measured on a real deploy in phase 3; lazy-import the renderer if tight); production DO latency from far regions (measured before launch); abuse of compute (rate limits, caps, kill switch). diff --git a/docs/adr/README.md b/docs/adr/README.md index 18eaf982..aecd7b5b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,3 +54,4 @@ One file per decision, numbered, immutable once accepted (write a new ADR to sup | [0035](0035-multilingual-publishing.md) | Published content in several languages: one node per locale in translation groups (source rev/hash, method, provenance, review), structure keeps per-locale maps, locale in the URL with generated `hreflang`, linked landing-page layouts, agents draft behind glossary/checks/score while people review and unreviewed machine text is labelled, Site = publication (RFC-0033) | | [0036](0036-multi-site-installations.md) | Multi-site installations: one Studio, D1 and R2 manage many isolated sites (per-site roles, export and delete), created empty or as editions, served by hostname through `site_hosts` with per-Worker allow-lists, one theme per site Worker and several site Workers per installation (RFC-0034) | | [0037](0037-domain-strategy.md) | Domain strategy: `cloudhal.com` is marketing, a future hosted control plane and the playground; `cloudhal.dev` is docs, schemas, registry and the open-source home; customer sites never run on either (they deploy into the customer's own Cloudflare account), so no third domain or PSL entry | +| [0038](0038-public-playground.md) | Public playground: one SQLite Durable Object per visitor holding store, API and renderer; `@cloudhal/storage/do-sqlite` and SQL/layered/fetcher blob stores; a deny-list guard in the Worker and the API with a route-walk test and a fixed Editor principal; 60-minute TTL with a 2-hour cap; private, uncached session pages (RFC-0036) | diff --git a/docs/rfcs/0036-public-playground.md b/docs/rfcs/0036-public-playground.md new file mode 100644 index 00000000..3b8388d9 --- /dev/null +++ b/docs/rfcs/0036-public-playground.md @@ -0,0 +1,256 @@ +# RFC-0036: Public playground: a disposable Cloudhal per visitor on a SQLite Durable Object + +- Status: Accepted (2026-10-20, by Halil; ADR-0038). Open questions 1 and 2 decided as recommended (session in the path, seed per session); 3 and 4 are measured in phase 3 and before launch; 5 and 6 stay out of the MVP (recorded walkthrough instead of the agent, share links in phase 5). Phases 1–3 follow in order. +- Author: Halil (Madraka), drafted with Claude · Date: 2026-10-20 +- Related: + - Spike: an internal spike report, the numbers behind §1 and §3. + - ADR-0002 (storage interfaces shaped by D1) and ADR-0012 (Workers, workerd prelude). + - RFC-0005 (roles and scopes); RFC-0010 (snapshot bundles); RFC-0029 (FTS5 search). + - RFC-0034 (projects, site creation). + - ADR-0037 (domains: `cloudhal.com` for marketing, Cloud and the playground; `cloudhal.dev` for developers). + - Prior art: EmDash's playground ([demos/playground](https://github.com/emdash-cms/emdash/tree/main/demos/playground)) with one Durable Object per session and a playground middleware. + +## Summary +`try.cloudhal.com` gives every visitor their own Cloudhal, with no account: Studio, the API and the rendered site. It runs over a private SQLite-backed Durable Object that is seeded with demo content and deleted after an hour. The monorepo gains three general-purpose pieces: +- a `SqlDriver` for Durable Object SQLite (`@cloudhal/storage/do-sqlite`) with a SQL-kept `BlobStore`; +- a *playground mode* (a pure route guard, a fixed principal and a Studio feature flag); +- a single-Worker entry `@cloudhal/cloudflare/playground`. + +The deployable site (wrangler config, landing page, demo pack, domain) lives in a separate repo, `cloudhal-dev/try`. Agents are off in the MVP. + +## Motivation +- **Trying Cloudhal today needs a toolchain.** `cloudhal init` and `cloudhal deploy` need Node, wrangler and a Cloudflare account, which is too much for someone deciding whether Cloudhal is worth an afternoon. +- **A playground answers "what is it like?" in ten seconds.** + - The visitor edits a post, changes the design, publishes, and sees the site. + - The "Deploy to your Cloudflare" button sits next to it. + - EmDash shows the model works on Cloudflare at near-zero idle cost. +- **The pieces are useful beyond the demo.** + - A DO-SQLite `SqlDriver` lets anyone run Cloudhal with no D1 at all, including on Workers Free. + - A route guard with a fixed principal is how a read-mostly kiosk or a hands-on workshop would run too. +- **Why now.** + - The domain decision (ADR-0037) puts the playground on `cloudhal.com`. + - RFC-0034 made one installation hold many sites, so a playground session is just a one-site installation. + - The spike settled the two open technical questions: RPC latency and bundle size. + +## Proposal + +### 1. Shape +``` +browser ──► Worker cloudhal-playground (try.cloudhal.com) + ├─ /, /assets/*, /studio/* static (ASSETS): landing page, Studio SPA + ├─ POST /_playground/session new session cookie (rate-limited, §7) + ├─ isBlockedInPlayground(path) ─► 404 / 403 (§5) + └─ everything else ──► stub.fetch(req) DO = idFromName(session) + PlaygroundSite (SQLite-backed Durable Object) + ├─ DoSqliteDriver ─ migrations, SiteGraphStore, SqlBlobStore + ├─ createApi({ fixed principal }) /api/* + ├─ Astro handler, request-scoped store /s//* + └─ alarm: deleteAll() at TTL (§6) +``` +- **The store, API and renderer run inside the session's DO.** The Worker only serves static files, checks the cookie, applies the guard and rate limits, and forwards. +- The spike measured the alternative: the store in the Worker, with every SQL call an RPC to the DO. + - That costs one network hop per query. + - A publish makes 26 sequential calls; locally it was 7 ms against 2 ms with the store in the DO. + - In production it would add an estimated 25–130 ms of RPC. +- In the DO, every request is one hop, however chatty the call. +- The DO runs the same script, so the code is already in its isolate. A one-person, one-hour site does not stress the DO's 128 MB or its single thread. + +### 2. `@cloudhal/storage/do-sqlite`: a `SqlDriver` and a SQL-kept `BlobStore` +```ts +// packages/storage/src/sql/do-sqlite.ts (Worker-only; types from @cloudflare/workers-types) +export class DoSqliteDriver implements SqlDriver { + readonly name = "do-sqlite"; + constructor(storage: DurableObjectStorage); + // get/all/run: storage.sql.exec(sql, ...params); run reads changes() and last_insert_rowid() + // batch: storage.transactionSync(() => statements.map(run)); rolls back on any throw + // exec: storage.sql.exec(sql) (multi-statement DDL) +} + +// packages/storage/src/blob/sql.ts (any SqlDriver; created by a migration-like DDL on first use) +export class SqlBlobStore implements BlobStore { + constructor(sql: SqlDriver, options?: { maxBytes?: number; maxObjectBytes?: number }); +} + +// packages/storage/src/blob/layered.ts +/** Reads `upper` first, then `base`; every write goes to `upper`; `list` merges both. */ +export class LayeredBlobStore implements BlobStore { + constructor(base: BlobStore, upper: BlobStore); +} +/** A BlobStore over static files (the Worker's ASSETS fetcher); writes throw StorageError. */ +export class FetcherBlobStore implements BlobStore { + constructor(fetcher: Fetcher, prefix: string); +} +``` +Invariants: +1. `DoSqliteDriver` enforces `SQL_LIMITS` like every adapter (ADR-0002). Nothing works in the playground that would fail on D1. +2. `batch` is atomic: it runs inside `transactionSync`, and a throw rolls back every statement. The driver never exposes `transactionSync` or `BEGIN` to callers, so hard rule 4 holds and callers keep the `batch`-only discipline. +3. `exec` is for DDL (migrations) only, as on D1. +4. `SqlBlobStore` keeps blobs in the same database (`_blobs(key PRIMARY KEY, data BLOB, content_type, size, sha256)`). It verifies `sha256` like the other stores, stays below DO SQLite's 2 MB value limit (`maxObjectBytes`, default 1 MiB), and enforces a total `maxBytes` cap. It is general purpose, also for Node tests. +5. **The playground's BlobStore cannot be read-only.** The spike showed that `SiteGraphStore` writes `nodes/.json` on every snapshot, and publish snapshots write bundle shards (RFC-0010). The playground therefore uses `LayeredBlobStore(FetcherBlobStore(ASSETS, "demo/"), SqlBlobStore(driver, { maxBytes: 8 MiB }))`. Demo media are read-only, and the session's own node and bundle blobs are writable and capped. Media *uploads* are refused by the route guard (§5), not by the store. +6. Conformance: `describeSqlDriverConformance` and `describeBlobStoreConformance` run against `DoSqliteDriver` and `SqlBlobStore` on Miniflare, through a test-only DO. `core-ts`'s behavioural suite (`src/testing.ts`) gets a third backend next to sqlite and D1. Tests stay sequential (one Miniflare at a time). +7. Worker-safe: the module imports nothing Node-only and is never reachable from `@cloudhal/storage`'s root export, so Node code never pulls in `cloudflare:workers` types. + +### 3. Seeding and first open +- On the first request without data, the DO runs `migrate(driver, migrations)` and then `seedPlayground(store)`. + - `seedPlayground` is `seedStarterSite` plus a demo pack: about 10 posts, an about page, a menu, the blog theme's design and three demo images. + - The demo pack is JSON built into the `try` repo's bundle, applied with the store's normal writes, so no hand-written SQL can drift from migrations. +- Spike numbers (workerd, warm isolate): + - Migrations take 8–15 ms and the starter seed 2–13 ms. The first open is 24–41 ms of server work in all. + - A fresh database is about 815 KB. + - 20 sessions opened at once all succeeded (416 ms wall). +- Target: the first open is under 1 s at p95 in production. The plan's target was 3 s. +- The demo pack's language is English (the global default); its `tr` edition is a later option. + +### 4. Rendering the session's site +- The site renders at `try.cloudhal.com/s//…` from the DO, through the same Astro handler as a site Worker. +- `@cloudhal/astro-renderer` gains a request-scoped store override, next to `withRequestSite` (RFC-0034): + ```ts + // runtime-workerd.ts + export function withRequestStore(store: SiteGraphStore, siteId: string, fn: () => T): T; + ``` +- `getSource` uses that store when one is set, with the same resolver logic otherwise. +- Playground responses carry `cache-control: private, no-store`, `x-robots-tag: noindex`, and never a `cache-tag`: the edge cache must never hold one session's page. +- Only the cookie's owner can open `/s//…`. A signed share link (1 hour) is phase 3. +- Bundle size (spike): the API, store and DO bundle is 800 KiB gzip and the blog site Worker 772 KiB gzip. Both share core-ts, storage and validators, so one Worker is at most 1.6 MiB gzip, inside Workers Free's 3 MiB limit. + +### 5. Playground mode: guard, principal, Studio flag +```ts +// packages/api/src/playground.ts (pure, no I/O; unit-tested with a table of paths) +export type PlaygroundBlock = "auth" | "people" | "keys" | "mcp" | "uploads" | "outbound" + | "transfer" | "sites" | "domains" | "agent"; +/** Why `method path` is refused in playground mode, or undefined when it is allowed. */ +export function isBlockedInPlayground(method: string, path: string): PlaygroundBlock | undefined; + +/** The one principal of a playground session: an Editor with site settings; nothing installation-wide. */ +export const PLAYGROUND_PRINCIPAL: Principal; // actor { kind: "human", id: "usr_playground", name: "Playground Owner" } +``` +The guard is a **deny list with an allow list as a test oracle**: +- **Blocked.** + - `/api/auth/*`, invites, members, password reset. + - Agent and API keys; `/mcp`; elevation. + - Asset upload tickets and uploads; media import by URL (SSRF). + - Bundle import and export (`/api/admin/*`); `push` and `pull`. + - Site and project create and delete (RFC-0034); domains. + - Email, webhooks, analytics settings, comment settings, schedule automations that call out; AI and Build chat. +- **Allowed.** Content, pages, sections, taxonomies, menus, design and theme settings, snapshots, diff, publish, rollback, search, previews. +- A test walks every route registered by `createApi` and checks that each one is either blocked or on the allow list. A new route is then refused in the playground until someone classifies it, so it is never silently exposed. + +Layers, so that no single mistake opens a hole: +1. The Worker applies `isBlockedInPlayground` before forwarding: 404 for blocked pages, `403 {error: "playground"}` for API calls. +2. `createApi` gets `playground: true`, which mounts the same guard as middleware. It also leaves auth, MCP, staff, mail, AI and analytics unconfigured, so those routes do not exist. +3. `PLAYGROUND_PRINCIPAL` holds the Editor scopes plus `site:settings`. It does not hold `asset:upload`, `members:manage`, `agents:manage`, `site:create` or `*`. Even a route the guard missed fails the scope check (RFC-0005 I15, default-deny). +4. `InstanceInfo.features.playground = { expiresAt, resetUrl, deployUrl }` is set. Studio hides the blocked screens and controls behind it, puts the "Available in your own install" note in their place, and shows the session bar ("Playground · 58:12 left · Reset · Deploy to Cloudflare"). These are Kumo components with `useT()` strings, per DESIGN.md. + +`features.playground` is a TypeScript field of the API's `InstanceInfo`, not a JSON Schema contract. No `schemaVersion` changes. + +### 6. Sessions, TTL and reset +- **Session.** + - `POST /_playground/session` sets `__Host-cloudhal_try=` (`HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=7200`). + - The DO is `idFromName(ulid)`. There is no server-side session table: the ULID's timestamp is the creation time. + - The cookie is unsigned. An unguessable 80-bit random part is enough, because a session holds nothing of value and leaks nothing about anyone else. +- **TTL.** + - On the first write the DO sets an alarm at `min(lastAccess + 60 min, createdAt + 120 min)`. + - Each request moves it forward, never past the 2-hour cap. + - `alarm()` calls `ctx.storage.deleteAll()` (data and alarm). A request after expiry gets a 410 and the landing page offers a new session. +- **Reset.** `POST /_playground/reset` runs `deleteAll()` at once, and the next request seeds again. +- **No resurrection.** A DO whose `createdAt` (kept in its own storage) is older than 2 hours refuses requests even if the alarm failed to run. + +### 7. Abuse limits and kill switch +| Limit | Value (start) | Mechanism | +|---|---|---| +| New sessions | 5 / min per IP | Workers Rate Limiting binding (`ratelimits`, key = `CF-Connecting-IP`) | +| Writes | 120 / min per session | Rate Limiting binding, key = session | +| DO database | 20 MB | `sql.databaseSize` checked before writes: over the cap → 507 `playground_full` | +| Blobs | 8 MiB per session, 1 MiB per object | `SqlBlobStore` caps | +| Nodes | 500 per session | count checked on create | +| Body | 200 KB per entry or section | API body limit in playground mode | +| Request body | 1 MB | Worker, before forwarding | + +- **Turnstile on suspicion.** After a rate-limit hit, the next session creation needs a Turnstile token. Ordinary visitors never see it. +- **Kill switch.** `PLAYGROUND_ENABLED=false` makes the Worker serve only the landing page with the "Deploy to Cloudflare" button. Existing DOs simply expire. +- **Low value to abusers.** Published pages are private to the session, `noindex` and uncached. There are no uploads, outbound requests or email. That makes phishing, SEO spam, file hosting and SSRF unrewarding. + +### 8. Privacy +- No account and no personal data are asked for. The page says: "Everything you enter is deleted within 2 hours. Don't enter personal data." +- **No logging of content.** Workers logs keep method, path template, status and duration, never bodies or session IDs. +- **Aggregate counts only.** Analytics are sessions opened, first publish, and "Deploy" clicks, through Workers Analytics Engine with no IP and no cookie ID. +- Deletion is the TTL alarm or Reset. Nothing is backed up. + +### 9. Cost (estimate; verify against the current price list before launch) +- **Per session** (spike rows; assumed 150 requests and 5 publishes): + - about 150 DO requests; + - about 1–2 k rows written and 5–10 k rows read; + - under 1 MB stored for at most 2 hours; + - a few seconds of DO wall time for active requests. +- **Monthly.** Against Workers Paid's included DO quotas (1 M requests, 50 M rows written, 400 k GB-s), about 6 k sessions a month fit inside the $5 plan; requests are the first limit. Above that, the cost is roughly $0.001–0.003 per session, about $100–300 per 100 k sessions. +- **Daily ceiling.** A `PLAYGROUND_DAILY_SESSIONS` cap turns on the kill-switch page for the rest of the UTC day. + +### 10. Agents +- **Off in the MVP.** There is no `/mcp`, no Build chat and no AI commands. In their place a card reads "Try the agent in your own install", linking to the docs on `cloudhal.dev`. +- A later RFC may add a budgeted Build chat: + - our key, with a per-session request and token cap, behind Turnstile; + - `plan`/`manual` mode (ADR-0015), no auto-publish, no outbound fetch; + - a daily global budget that switches it off. + + That needs its own cost and abuse analysis, so it is not part of this RFC. + +### 11. Where the code lives +| Monorepo (`cloudhal-dev/cloudhal`, versioned, tested in `pnpm check`) | `cloudhal-dev/try` (deployable, pinned `@cloudhal/*`) | +|---|---| +| `@cloudhal/storage/do-sqlite` (`DoSqliteDriver`), `SqlBlobStore`, `LayeredBlobStore`, `FetcherBlobStore` + Miniflare conformance | `wrangler.jsonc`: DO binding, `new_sqlite_classes` migration, rate limits, `try.cloudhal.com` custom domain, vars | +| `@cloudhal/api` playground mode: `isBlockedInPlayground`, `PLAYGROUND_PRINCIPAL`, `features.playground` | Landing page, demo content pack, Turnstile site key | +| `@cloudhal/astro-renderer` `withRequestStore` | Deploy pipeline, alerts, the kill switch | +| `@cloudhal/cloudflare/playground`: `createPlaygroundWorker({ seed, limits })` and the `PlaygroundSite` DO class (session cookie, TTL, guard, forwarding) | | +| Studio: the `features.playground` UI (session bar, hidden screens) | | + +The monorepo's CI never deploys or holds playground secrets. The `try` repo only composes published packages. + +### 12. Phases +| Phase | Content | Done when | +|---|---|---| +| 0 · Spike | DO-SQLite driver and store under workerd: latency, first open, bundle size | ✅ internal spike report: go, store in the DO | +| 1 · Storage | `do-sqlite` driver and the three blob stores; conformance and core-ts suite on Miniflare | `pnpm check` green with the DO backend | +| 2 · Mode | `isBlockedInPlayground` with the route-walk test; `PLAYGROUND_PRINCIPAL`; `features.playground`; Studio session bar and hidden screens | every route classified; Studio design lint green | +| 3 · Entry | `@cloudhal/cloudflare/playground` (DO, TTL, reset, limits, kill switch); `withRequestStore` rendering | workerd test: open, edit, publish, render, expire, reset; 20 concurrent sessions | +| 4 · Launch | `cloudhal-dev/try` repo, landing page, demo pack, custom domain | live; production first open < 1 s p95; abuse checklist green | +| 5 · Later | signed share links, theme gallery, guided tour, funnel metrics; budgeted agent (own RFC); moving a trial to your own account | | + +## Alternatives considered +| Option | Pros | Cons | Why not | +|---|---|---|---| +| Store in the Worker, SQL over DO RPC (the plan's first idea) | DO only runs SQL; render CPU off the DO | One hop per query: 26 for a publish, 7 for menus | Spike: 1.5–3.5× slower locally on chatty calls, and the gap grows with real network RTT | +| One shared D1, a site per visitor (RFC-0034 multi-site) | No new driver | Cross-visitor blast radius; cleanup is row deletes across 30+ tables; one noisy visitor slows all; D1 size cap | The playground's whole promise is isolation | +| A D1 database per visitor | Real D1 | DB creation goes through the API and takes seconds; account database limits | Too slow and too limited for anonymous traffic | +| Client-only (SQLite WASM in the browser) | No server cost | No real Worker rendering; a large download; hides the platform | It would not show Cloudhal running on Cloudflare | +| Read-only BlobStore (the plan) | Simplest | Snapshots and publishes write blobs (finding 3) | It would break every save | +| Signed (HMAC) session cookie | Tamper-evident | A secret to manage; a forged ID only reaches an empty, fresh DO | Unneeded: the ID is unguessable and sessions hold nothing of value | + +## Compatibility & migration +- No contract change, no `schemaVersion` change, no data migration. Existing deployments are unaffected. +- `createApi` gains an optional `playground` flag, and `InstanceInfo.features` an optional field. Studio treats a missing field as "not a playground". +- New subpaths: `@cloudhal/storage/do-sqlite` and `@cloudhal/cloudflare/playground`. Both need changesets. +- **Rollout.** Phases 1–3 ship in the monorepo behind no flag, because nothing uses them until the `try` repo deploys. +- **Rollback.** Set `PLAYGROUND_ENABLED=false` or remove the custom domain. Sessions expire by themselves within 2 hours. + +## Security & policy impact +- **No new scopes.** `PLAYGROUND_PRINCIPAL` is a fixed subset of today's scopes, with nothing installation-wide. +- **Defence in depth.** + - The guard runs in the Worker and again in the API. + - The default-deny scope check sits under both. + - The route-walk test means a new route is never silently open. +- **Tenant isolation.** + - Each visitor's data is in a separate DO, addressed only by their own unguessable cookie. + - There is no shared database and no edge caching of session pages. +- **No outbound I/O.** Uploads, URL import, email, webhooks, MCP and AI are all unreachable, so there is no SSRF or mail-abuse path. +- **Residual risks.** + - Cookie theft on the visitor's own device; the session is private and short-lived. + - Abuse of compute; rate limits, size caps, the daily cap and the kill switch bound it. + - The playground on `cloudhal.com` shares no cookies with Cloud (`__Host-` cookie, own subdomain; ADR-0037). + +## Open questions +1. **Session in the URL as well?** `/s//` makes the rendered site look like a real site path. A subdomain per session (`.try.cloudhal.com`) would need a wildcard certificate and route, but gives cleaner absolute URLs. Recommendation: path for the MVP. +2. **Seed on first request, or a pre-seeded template?** DO storage cannot be cloned, and seeding is about 40 ms, so seeding per session is fine. Revisit if the demo pack grows past about 1 MB. +3. **Startup CPU.** A 1.6 MiB bundle must start within the Worker startup limit. Measure on a real deploy in phase 3. If it is tight, lazy-import the Astro handler inside the DO. +4. **Production latency.** The spike ran the DO in-process. Phase 3 measures real first-open and per-call latency from three regions before launch. +5. **Show the agent in the MVP?** The agent is the product's main difference (RFC-0020), and leaving it out undersells it. A recorded walkthrough on the landing page could stand in until the budgeted Build chat RFC. +6. **Share links.** Should a signed, one-hour read-only link to the session's published site be in the MVP, for "look what I made"? It raises the abuse value of a session slightly.