Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cloudflare-playground.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@cloudhal/cloudflare": minor
"@cloudhal/astro-renderer": minor
---

New `@cloudhal/cloudflare/playground` (RFC-0036): `createPlaygroundWorker({ seed, render, deployUrl })` returns the playground Worker and its `PlaygroundSite` Durable Object — one SQLite database per visitor holding the store, the API in playground mode and the rendered site, a signed host-only session cookie, a 60-minute TTL with a 2-hour cap, Reset, size and node caps, per-IP and per-session rate limits, Turnstile after a limit hit, a daily session cap and a kill switch. `@cloudhal/astro-renderer/runtime` gains `withRequestStore(store, siteId, fn)`, which renders a request from a store the entry chooses; such pages are private and never edge-cached.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pnpm i18n:export / pnpm i18n:import [--locale xx] # translations as nested ICU
- `packages/schedules` — RFC-0008: time model (only `temporal-polyfill/implementation`, `now` injected, never `Temporal.Now`), RRULE subset, release model (`rl1:`), automation/run state, taint and budgets, `runDue`; `./sql` is `SqlScheduleStore`, `./engine` the Temporal-free part core-ts and the site Worker use. Worker-safe; conformance suites in `src/testing.ts` on sqlite and Miniflare D1.
- `packages/astro-renderer` — `GraphSource` (live/snapshot), `cloudhal()` integration (virtual config + dev route-cache watcher), `.astro` section components (`Surface.astro` for RFC-0020 surfaces; `./surfaces` renders them, `./kit` has the table of contents and reading time). Routes live in starters, not here.
- `packages/cli` — `cloudhal init|dev|snapshot|build|studio|mcp|deploy|push|pull|search reindex`; TS bin run by Node 24 type stripping. `src/cli.ts` parses flags and dispatches through `COMMANDS`, built from `src/cli/commands-{local,remote,content,ops}.ts` (a new command is one handler plus one line in `src/cli/help.ts`). E2E test spawns the bin (Astro disables dev middleware under `VITEST`). Cloudflare tests run both Workers on workerd via wrangler's `createTestHarness`; test files run serially (shared starter caches).
- `packages/cloudflare` — the admin Worker (`createAdminWorker`). Generated entries must import `@cloudhal/cloudflare/workerd-prelude` first (ADR-0012).
- `packages/cloudflare` — the admin Worker (`createAdminWorker`); `./playground` is RFC-0036's playground Worker (`createPlaygroundWorker`, the `PlaygroundSite` Durable Object, signed session cookies). Generated entries must import `@cloudhal/cloudflare/workerd-prelude` first (ADR-0012).
- `packages/agent-runtime` / `packages/mcp-server` — policy, skills, audit; MCP tools and transports (ADR-0011). `@cloudhal/mcp-server/render` is RFC-0021's `render_page` core: a small DevTools client, the in-page checks and limits; the CLI drives local Chrome with it (`render-chrome.ts`), the admin Worker Browser Rendering (`browser-render.ts`).
- `starters/blog` — the theme boundary: `src/pages/[...path].astro`, `src/layouts`, `src/theme.ts`.
- `packages/api` — Hono app (`createApi`), Better Auth (`src/auth.ts`), error mapping; shared suite `src/testing.ts` on sqlite + D1. Build chat is `build-turn.ts`, its model eval `build-eval.ts`; `design-pass.ts` is RFC-0021 21c (Build chat looks at its render, the eval's vision-judged score).
Expand Down
4 changes: 3 additions & 1 deletion docs/adr/0038-public-playground.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# 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)
- Amendment (2026-10-20, phase 3): session cookies carry an HMAC of the ULID (`PLAYGROUND_SECRET`); an unsigned ID let clients mint sessions past the creation limits. See RFC-0036 "Implementation notes".
- 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/<hash>.json`, bundle shards), so a read-only blob store breaks every save.

## Decision
1. **One SQLite-backed Durable Object per session**, addressed by `idFromName(<ULID from a __Host- cookie>)`. No shared database, no server-side session table, no signed cookie (the ID is unguessable and a session holds nothing of value).
1. **One SQLite-backed Durable Object per session**, addressed by `idFromName(<ULID from a __Host- cookie>)`. No shared database and no server-side session table; the cookie carries an HMAC of the ULID so that sessions are only created through the rate-limited endpoint (amendment).
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.
Expand All @@ -25,6 +26,7 @@ Trying Cloudhal needs Node, wrangler and a Cloudflare account. RFC-0036 proposes
| 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 |
| Unsigned session cookie | No secret to manage | Clients could mint session IDs and skip the creation limits and daily cap | Found in phase 3; the cookie is signed |
| Allow list only, no route walk | Smaller | A forgotten route stays reachable or silently breaks | The walk makes every new route a decision |

## Consequences
Expand Down
16 changes: 14 additions & 2 deletions docs/rfcs/0036-public-playground.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 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.
- 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 implemented (#22, #23 and the phase 3 PR); "Implementation notes" below records where they differ from the text.
- Author: Halil (Madraka), drafted with Claude · Date: 2026-10-20
- Related:
- Spike: an internal spike report, the numbers behind §1 and §3.
Expand Down Expand Up @@ -147,7 +147,7 @@ Layers, so that no single mistake opens a hole:
- **Session.**
- `POST /_playground/session` sets `__Host-cloudhal_try=<ULID>` (`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.
- 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. *Amended in phase 3: the cookie is signed, see Implementation notes 1.*
- **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.
Expand Down Expand Up @@ -215,6 +215,18 @@ The monorepo's CI never deploys or holds playground secrets. The `try` repo only
| 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 | |

## Implementation notes (phases 1–3)
Where building the phases showed the text above wrong or silent, the code follows these notes.

1. **Session cookies are signed** (§6, amends "The cookie is unsigned" and the alternatives row). An unsigned ULID lets a client mint sessions at will, which skips the per-IP limit, Turnstile and the daily cap on session creation. The cookie is `<ulid>.<HMAC-SHA-256 of the ulid, 128 bits, base64url>` keyed by the secret `PLAYGROUND_SECRET` (≥ 32 characters); without it the Worker serves only the landing page. A ULID dated in the future (more than a minute) or older than the 2-hour cap is refused in the Worker as well as in the object.
2. **Studio is served at the root** (§1 diagram showed `/studio/*`). Studio has no base path; its client routes (`/sites`, …) and `/assets/*` come from the static files with SPA fallback. `/` is the landing page (`landingPath`, default `/landing/`) for visitors without a session, Studio for those with one. `POST /_playground/session` answers `303 → /sites` with the cookie. `/media/*` goes to the session's object too, and rendered pages keep `a[href]` and `form[action]` under `/s/<session>/` (HTMLRewriter), except Worker paths (`/media`, `/_astro`, `/_cloudhal`, `/assets`, `/api`, `/_playground`).
3. **Studio without sign-in.** `requireSession` accepts the 403 that the playground gives `/api/auth/*` when `/api/instance` reports `features.playground`. The account surface leaves out Agents, Team and Account, and a direct visit shows "Available in your own install".
4. **The guard** (§5) has two more answers: `schedules` (releases, gates, automations, runs and the scheduler need a ticker and may call out) and `unclassified` (a route on neither list, or not declared at all, is refused at run time, not only by the test). Every media-library change is blocked as `uploads`, since a session's media are the read-only demo pack. The body limit is per request (200 KB) in the API and 1 MB in the Worker.
5. **The principal** is `user_playground` (the existing `user_` prefix). Besides the installation-wide scopes it lacks `asset:manage`, because `asset:manage` implies `asset:upload` in the scope lattice, and the Editor's `members:invite`, `commenters:data` and `automations:manage`.
6. **Entry API** (§11): `createPlaygroundWorker({ seed, render, deployUrl, demoMedia, limits, landingPath })` returns `{ worker, PlaygroundSite }`. `seed(store)` returns the site to show. `render(request, { store, siteId, env, ctx })` is the deploying repo's Astro handler; it wraps it in `withRequestStore` (and `serveMedia`) from `@cloudhal/astro-renderer/runtime`, so the monorepo needs no Astro build. The daily cap counts in an object of the same class named `daily:<UTC date>`, deleted after midnight UTC.
7. **Blob stores** (§2): `FetcherBlobStore` cannot list static files, so it takes the demo keys (`keys`) for `list`; `LayeredBlobStore.delete` only removes the upper copy, so a demo object stays readable after a delete.
8. **Measured** (workerd via wrangler's test harness, iMac): opening a session, editing, publishing and rendering pass; 20 sessions opened at once answered in 0.44 s, each with its own seeded site.

## Alternatives considered
| Option | Pros | Cons | Why not |
|---|---|---|---|
Expand Down
50 changes: 49 additions & 1 deletion packages/astro-renderer/src/runtime-workerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,46 @@ export function withRequestSite<T>(siteId: string, fn: () => T): T {
return requestSite.run(siteId, fn);
}

/**
* RFC-0036 §4: a store the entry chose for this request — a playground session's own database in
* its Durable Object — instead of the D1/R2 bindings. Its pages are private: never edge-cached and
* never tagged.
*/
const requestStore = new AsyncLocalStorage<{ store: SiteGraphStore; siteId: string }>();
const storeResolvers = new WeakMap<SiteGraphStore, SiteResolver>();

/** Runs `fn` (and everything it awaits) as a request for `siteId` over `store`. */
export function withRequestStore<T>(store: SiteGraphStore, siteId: string, fn: () => T): T {
return requestStore.run({ store, siteId }, fn);
}

/** The request's site: the entry's choice, else `CLOUDHAL_SITE_ID`, else the build's. */
function siteOf(fallback: string | undefined): string | undefined {
return requestSite.getStore() ?? (env as unknown as SiteWorkerEnv).CLOUDHAL_SITE_ID ?? fallback;
return (
requestStore.getStore()?.siteId ??
requestSite.getStore() ??
(env as unknown as SiteWorkerEnv).CLOUDHAL_SITE_ID ??
fallback
);
}

/** The resolver over the request's own store (`withRequestStore`), one per store. */
function scopedResolver(siteId: string): SiteResolver | undefined {
const scoped = requestStore.getStore();
if (!scoped || scoped.siteId !== siteId) return undefined;
let resolver = storeResolvers.get(scoped.store);
if (!resolver) {
resolver = new SiteResolver({
store: scoped.store,
siteId,
pointerTtlMs: 0,
previewCookie: false,
// The store lives in the caller's Durable Object, which outlives the response.
persistBundle: (task) => void task.catch(() => {}),
});
storeResolvers.set(scoped.store, resolver);
}
return resolver;
}

/** One resolver per site the isolate has served (pointer, snapshot and bundle caches). */
Expand All @@ -78,6 +115,8 @@ function siteStore(e: SiteWorkerEnv): SiteGraphStore {
}

function getResolver(siteId: string): SiteResolver {
const scoped = scopedResolver(siteId);
if (scoped) return scoped;
const e = env as unknown as SiteWorkerEnv;
const known = resolvers.get(siteId);
if (known) return known;
Expand Down Expand Up @@ -215,6 +254,15 @@ export async function getSource(
h.set("x-cloudhal-mode", "unpublished");
return undefined;
}
if (requestStore.getStore()) {
// RFC-0036 §4: a session's page is the session's alone.
h.set("cache-control", "private, no-store");
h.set("cloudflare-cdn-cache-control", "no-store");
h.set("x-robots-tag", "noindex");
h.set("x-cloudhal-mode", "published");
h.set("x-cloudhal-snapshot", resolution.snapshotId);
return resolution.source;
}
// Published: browsers revalidate, the edge keeps it until a publish purges the tag — or, when a
// time gate lies ahead, until that gate (RFC-0008 §7.4): any page may list any entry, so the
// cap is site-wide and a gate needs no purge.
Expand Down
9 changes: 9 additions & 0 deletions packages/astro-renderer/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SqlCommentStore } from "@cloudhal/comments/sql";
import type { Site } from "@cloudhal/core-contracts";
import type { SiteGraphStore } from "@cloudhal/core-ts";
import { type LocalStore, openLocalStore } from "@cloudhal/core-ts/local";
import type { SqlDriver } from "@cloudhal/storage";
import type { CloudhalRendererConfig } from "./integration.ts";
Expand Down Expand Up @@ -57,6 +58,14 @@ export function withRequestSite<T>(_siteId: string, fn: () => T): T {
return fn();
}

/**
* RFC-0036 §4: on workerd a playground runs each request over its session's store; `cloudhal dev`
* renders from the project's local store, so this only runs `fn`.
*/
export function withRequestStore<T>(_store: SiteGraphStore, _siteId: string, fn: () => T): T {
return fn();
}

/**
* RFC-0034 §6: other sites' published versions of a work. `cloudhal dev` renders the project's one
* site from its local store, so there are none.
Expand Down
35 changes: 35 additions & 0 deletions packages/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,38 @@ export default createAdminWorker({ skills });
| `CLOUDHAL_SIGNUP` | `first-user` (default — only the first account, the owner, can sign up), `closed` or `open` |

The site Worker itself is the project's Astro build (`@cloudhal/astro-renderer/cloudflare-entry`). Both are deployed, configured and tested by `@cloudhal/cli` (`cloudhal deploy`, `test/cloudflare*.test.ts` on workerd).

## Playground Worker (RFC-0036)

`@cloudhal/cloudflare/playground` runs a public playground: every visitor gets a disposable Cloudhal in their own SQLite-backed Durable Object (`PlaygroundSite`), seeded on first use and deleted an hour after the last request (two hours at most), or at once on Reset.

```ts
// worker.ts in the deploying repo (e.g. cloudhal-dev/try)
import "@cloudhal/cloudflare/workerd-prelude";
import { handle } from "@astrojs/cloudflare/handler";
import { serveMedia, withRequestStore } from "@cloudhal/astro-renderer/runtime";
import { createPlaygroundWorker } from "@cloudhal/cloudflare/playground";

const playground = createPlaygroundWorker({
deployUrl: "https://cloudhal.dev/docs/deploy",
seed: async (store) => ({ siteId: (await seedDemo(store)).id }),
render: (request, { store, siteId, env, ctx }) =>
withRequestStore(store, siteId, async () =>
(await serveMedia(request, { siteId })) ?? (handle(request, env as never, ctx) as Promise<Response>)),
demoMedia: { prefix: "demo/", keys: DEMO_KEYS },
});
export const PlaygroundSite = playground.PlaygroundSite;
export default playground.worker;
```

| Binding / secret | Purpose |
|---|---|
| `PLAYGROUND` (Durable Object, `new_sqlite_classes: ["PlaygroundSite"]`) | one database per session, and the daily counter |
| `ASSETS` (`run_worker_first: true`) | landing page (`/landing/`), Studio at the root with SPA fallback, site assets, demo media |
| `PLAYGROUND_SECRET` (≥ 32 chars) | signs the `__Host-cloudhal_try` session cookie; without it only the landing page is served |
| `PLAYGROUND_ENABLED` | `"false"` is the kill switch |
| `PLAYGROUND_DAILY_SESSIONS` | new sessions per UTC day |
| `SESSION_LIMIT`, `WRITE_LIMIT` (rate limits) | new sessions per IP, writes per session |
| `TURNSTILE_SECRET`, `TURNSTILE_SITE_KEY` | asked for only after a session-rate-limit hit |

Routes: `POST /_playground/session` (cookie, then `303 /sites`), `POST /_playground/reset`, `/api/*` (guarded by `isBlockedInPlayground`), `/s/<session>/…` (the session's site, `private, no-store`, `noindex`), `/media/*`. Tested on workerd in `test/playground-workerd.test.ts`.
Loading
Loading