diff --git a/.changeset/cloudflare-playground.md b/.changeset/cloudflare-playground.md new file mode 100644 index 00000000..7b947804 --- /dev/null +++ b/.changeset/cloudflare-playground.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 323ec26d..cd794ba3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/docs/adr/0038-public-playground.md b/docs/adr/0038-public-playground.md index 4359f4dc..9523ad10 100644 --- a/docs/adr/0038-public-playground.md +++ b/docs/adr/0038-public-playground.md @@ -1,6 +1,7 @@ # 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) @@ -8,7 +9,7 @@ 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). +1. **One SQLite-backed Durable Object per session**, addressed by `idFromName()`. 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. @@ -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 diff --git a/docs/rfcs/0036-public-playground.md b/docs/rfcs/0036-public-playground.md index 3b8388d9..937ad47a 100644 --- a/docs/rfcs/0036-public-playground.md +++ b/docs/rfcs/0036-public-playground.md @@ -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. @@ -147,7 +147,7 @@ Layers, so that no single mistake opens a hole: - **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. + - 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. @@ -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 `.` 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//` (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:`, 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 | |---|---|---|---| diff --git a/packages/astro-renderer/src/runtime-workerd.ts b/packages/astro-renderer/src/runtime-workerd.ts index 9a649dd9..3aabb5e3 100644 --- a/packages/astro-renderer/src/runtime-workerd.ts +++ b/packages/astro-renderer/src/runtime-workerd.ts @@ -56,9 +56,46 @@ export function withRequestSite(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(); + +/** Runs `fn` (and everything it awaits) as a request for `siteId` over `store`. */ +export function withRequestStore(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). */ @@ -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; @@ -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. diff --git a/packages/astro-renderer/src/runtime.ts b/packages/astro-renderer/src/runtime.ts index d1147e69..24b22e18 100644 --- a/packages/astro-renderer/src/runtime.ts +++ b/packages/astro-renderer/src/runtime.ts @@ -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"; @@ -57,6 +58,14 @@ export function withRequestSite(_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(_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. diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 87a4e31c..ca81e012 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -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)), + 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//…` (the session's site, `private, no-store`, `noindex`), `/media/*`. Tested on workerd in `test/playground-workerd.test.ts`. diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index b3f42a07..f7c0d707 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -19,7 +19,8 @@ "./purge": "./src/purge.ts", "./mail": "./src/mail.ts", "./comments": "./src/comments.ts", - "./scheduler": "./src/scheduler.ts" + "./scheduler": "./src/scheduler.ts", + "./playground": "./src/playground.ts" }, "files": [ "dist", @@ -43,6 +44,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "catalog:", + "@cloudhal/astro-renderer": "workspace:*", "@types/node": "catalog:", "typescript": "catalog:", "vitest": "catalog:", @@ -77,6 +79,10 @@ "./scheduler": { "types": "./dist/src/scheduler.d.ts", "default": "./dist/src/scheduler.js" + }, + "./playground": { + "types": "./dist/src/playground.d.ts", + "default": "./dist/src/playground.js" } } } diff --git a/packages/cloudflare/src/playground-options.ts b/packages/cloudflare/src/playground-options.ts new file mode 100644 index 00000000..b1e5195a --- /dev/null +++ b/packages/cloudflare/src/playground-options.ts @@ -0,0 +1,84 @@ +/** Options, limits and bindings of the playground Worker (RFC-0036 §1, §7). */ +import type { SiteGraphStore } from "@cloudhal/core-ts"; + +/** A Workers Rate Limiting binding (`ratelimits` in wrangler config). */ +export interface RateLimitBinding { + limit(options: { key: string }): Promise<{ success: boolean }>; +} + +export interface PlaygroundEnv { + /** The `PlaygroundSite` Durable Object class (`new_sqlite_classes`). */ + PLAYGROUND: DurableObjectNamespace; + /** Static files: the landing page, Studio and the site's own assets, demo media. */ + ASSETS: Fetcher; + /** Signs session cookies; at least 32 characters. Without it the Worker serves only the landing page. */ + PLAYGROUND_SECRET?: string; + /** `"false"` is the kill switch: the landing page only; open sessions expire on their own. */ + PLAYGROUND_ENABLED?: string; + /** New sessions per UTC day; past it the kill-switch page shows until midnight UTC. */ + PLAYGROUND_DAILY_SESSIONS?: string; + /** New sessions per IP (key `CF-Connecting-IP`). */ + SESSION_LIMIT?: RateLimitBinding; + /** Writes per session. */ + WRITE_LIMIT?: RateLimitBinding; + /** Turnstile, asked for only after a session-rate-limit hit. */ + TURNSTILE_SECRET?: string; + TURNSTILE_SITE_KEY?: string; +} + +export interface PlaygroundLimits { + /** A session's data lives this long after its last request… */ + idleMinutes: number; + /** …and never longer than this after it was created. */ + maxMinutes: number; + /** `ctx.storage.sql.databaseSize` past which writes get 507. */ + maxDatabaseBytes: number; + /** The session's own blobs (node bodies, bundle shards). */ + maxBlobBytes: number; + /** One blob. */ + maxObjectBytes: number; + /** Nodes a session may hold; creating past it gets 507. */ + maxNodes: number; + /** The API's body limit (one entry or section). */ + maxBodyBytes: number; + /** Any request body, checked in the Worker before forwarding. */ + maxRequestBytes: number; +} + +export const DEFAULT_PLAYGROUND_LIMITS: PlaygroundLimits = { + idleMinutes: 60, + maxMinutes: 120, + maxDatabaseBytes: 20 * 1024 * 1024, + maxBlobBytes: 8 * 1024 * 1024, + maxObjectBytes: 1024 * 1024, + maxNodes: 500, + maxBodyBytes: 200_000, + maxRequestBytes: 1024 * 1024, +}; + +/** What renders a session's site: the Astro handler, run inside `withRequestStore`. */ +export type PlaygroundRender = ( + request: Request, + scope: { store: SiteGraphStore; siteId: string; env: unknown; ctx: ExecutionContext }, +) => Promise; + +export interface PlaygroundOptions { + /** Fills a new session's store (starter site plus the demo pack); returns the site shown. */ + seed(store: SiteGraphStore): Promise<{ siteId: string }>; + /** Renders `/s//…` (the path without the prefix). Without it those paths are 404. */ + render?: PlaygroundRender; + /** Where "Deploy to Cloudflare" leads. */ + deployUrl: string; + /** The landing page among the static files. Default `/landing/`. */ + landingPath?: string; + /** Demo media under this prefix of the static files, read-only beneath each session's blobs. */ + demoMedia?: { prefix: string; keys?: readonly string[] }; + limits?: Partial; +} + +/** Header the Worker sets (and strips from clients) with the verified session ID. */ +export const SESSION_HEADER = "x-cloudhal-playground-session"; +export const RESET_PATH = "/_playground/reset"; +export const SESSION_PATH = "/_playground/session"; +/** Where a session's site renders: `/s//…`. */ +export const SITE_PREFIX = "/s/"; diff --git a/packages/cloudflare/src/playground-render.ts b/packages/cloudflare/src/playground-render.ts new file mode 100644 index 00000000..c5120176 --- /dev/null +++ b/packages/cloudflare/src/playground-render.ts @@ -0,0 +1,42 @@ +/** + * A playground session's rendered site (RFC-0036 §4): private to the session, never cached or + * tagged at the edge, `noindex`, and its links kept under `/s//`. + */ + +/** Paths the Worker serves itself (static files, media, the API), never under the session prefix. */ +const UNPREFIXED = /^\/(?:media|_astro|_cloudhal|assets|api|_playground)(?:\/|$)/; + +/** `/about/` → `/s//about/`; other origins, fragments and Worker paths stay as they are. */ +export function prefixPath(href: string, prefix: string): string { + if (!href.startsWith("/") || href.startsWith("//") || UNPREFIXED.test(href)) return href; + if (href === prefix || href.startsWith(`${prefix}/`)) return href; + return `${prefix}${href}`; +} + +export function privateSiteResponse(response: Response, prefix: string): Response { + const headers = new Headers(response.headers); + headers.set("cache-control", "private, no-store"); + headers.set("cloudflare-cdn-cache-control", "no-store"); + headers.set("x-robots-tag", "noindex"); + headers.delete("cache-tag"); + const location = headers.get("location"); + if (location) headers.set("location", prefixPath(location, prefix)); + const out = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + const html = headers.get("content-type")?.includes("text/html"); + const Rewriter = (globalThis as { HTMLRewriter?: typeof HTMLRewriter }).HTMLRewriter; + if (!html || !Rewriter) return out; + const rewrite = (attr: string) => ({ + element(el: Element) { + const v = el.getAttribute(attr); + if (v) el.setAttribute(attr, prefixPath(v, prefix)); + }, + }); + return new Rewriter() + .on("a[href]", rewrite("href")) + .on("form[action]", rewrite("action")) + .transform(out); +} diff --git a/packages/cloudflare/src/playground-session.ts b/packages/cloudflare/src/playground-session.ts new file mode 100644 index 00000000..f5cf1a2b --- /dev/null +++ b/packages/cloudflare/src/playground-session.ts @@ -0,0 +1,78 @@ +/** + * Playground sessions (RFC-0036 §6): a ULID in a host-only cookie. The ULID's timestamp is the + * creation time, so no server keeps a session table. The cookie carries an HMAC of the ULID: an + * unsigned one would let a client mint sessions at will and skip the per-IP and daily limits on + * session creation (amendment to RFC-0036 §6, ADR-0038). + */ + +export const SESSION_COOKIE = "__Host-cloudhal_try"; +const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/; +const enc = new TextEncoder(); + +/** A new ULID: 48-bit milliseconds, then 80 random bits. */ +export function newSessionId(now: number): string { + let time = ""; + let t = now; + for (let i = 0; i < 10; i++) { + time = CROCKFORD[t % 32] + time; + t = Math.floor(t / 32); + } + const random = crypto.getRandomValues(new Uint8Array(16)); + let rest = ""; + for (const b of random) rest += CROCKFORD[b % 32]; + return time + rest; +} + +/** The creation time a session ID carries, or undefined when it is not a ULID. */ +export function sessionTime(id: string): number | undefined { + if (!ULID_RE.test(id)) return undefined; + let t = 0; + for (const ch of id.slice(0, 10)) t = t * 32 + CROCKFORD.indexOf(ch); + return t; +} + +async function mac(id: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + enc.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(id))); + let bin = ""; + for (const b of sig.slice(0, 16)) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** The cookie value for a session: `.`. */ +export async function signSession(id: string, secret: string): Promise { + return `${id}.${await mac(id, secret)}`; +} + +/** The session ID of a cookie value whose MAC holds, else undefined. */ +export async function verifySession(value: string, secret: string): Promise { + const [id, tag, extra] = value.split("."); + if (!id || !tag || extra !== undefined || sessionTime(id) === undefined) return undefined; + const want = await mac(id, secret); + if (want.length !== tag.length) return undefined; + let diff = 0; + for (let i = 0; i < want.length; i++) diff |= want.charCodeAt(i) ^ tag.charCodeAt(i); + return diff === 0 ? id : undefined; +} + +/** The session cookie's value in a request, if any. */ +export function readSessionCookie(request: Request): string | undefined { + const header = request.headers.get("cookie") ?? ""; + for (const part of header.split(";")) { + const [name, ...value] = part.trim().split("="); + if (name === SESSION_COOKIE) return value.join("="); + } + return undefined; +} + +/** `Set-Cookie` for a session (host-only, `__Host-`: Secure, Path=/, no Domain; ADR-0037). */ +export function sessionCookie(value: string, maxAgeSeconds: number): string { + return `${SESSION_COOKIE}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAgeSeconds}`; +} diff --git a/packages/cloudflare/src/playground-site.ts b/packages/cloudflare/src/playground-site.ts new file mode 100644 index 00000000..34c6e172 --- /dev/null +++ b/packages/cloudflare/src/playground-site.ts @@ -0,0 +1,216 @@ +/** + * `PlaygroundSite` (RFC-0036 §1–§6): one SQLite-backed Durable Object per playground session. It + * holds the session's whole Cloudhal — store, API and renderer — over `DoSqliteDriver`, seeds it + * on first use, deletes everything at the TTL alarm or on Reset, and refuses requests past the + * session's 2-hour cap even if the alarm never ran. The Worker in front has already checked the + * cookie, the guard and the rate limits; this object trusts only its `SESSION_HEADER`. + */ +import { createApi } from "@cloudhal/api"; +import { SiteGraphStore } from "@cloudhal/core-ts"; +import { + type BlobStore, + FetcherBlobStore, + LayeredBlobStore, + SqlBlobStore, +} from "@cloudhal/storage"; +import { DoSqliteDriver } from "@cloudhal/storage/do-sqlite"; +import { + DEFAULT_PLAYGROUND_LIMITS, + type PlaygroundEnv, + type PlaygroundLimits, + type PlaygroundOptions, + RESET_PATH, + SESSION_HEADER, + SITE_PREFIX, +} from "./playground-options.ts"; +import { privateSiteResponse } from "./playground-render.ts"; +import { sessionTime } from "./playground-session.ts"; + +const MINUTE = 60_000; +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", "cache-control": "no-store" }, + }); + +interface Session { + store: SiteGraphStore; + sql: DoSqliteDriver; + siteId: string; + api: ReturnType; +} + +/** The `PlaygroundSite` Durable Object class, as the Worker module exports it. */ +export type PlaygroundSiteClass = new ( + state: DurableObjectState, + env: PlaygroundEnv, +) => { fetch(request: Request): Promise; alarm(): Promise }; + +/** Builds the Durable Object class for `options` (exported by the Worker module as `PlaygroundSite`). */ +export function playgroundSiteClass(options: PlaygroundOptions): PlaygroundSiteClass { + const limits: PlaygroundLimits = { ...DEFAULT_PLAYGROUND_LIMITS, ...options.limits }; + + return class PlaygroundSite { + readonly state: DurableObjectState; + readonly env: PlaygroundEnv; + #session: Session | undefined; + #expiresAt = 0; + + constructor(state: DurableObjectState, env: PlaygroundEnv) { + this.state = state; + this.env = env; + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/_count") return this.#count(); + const id = request.headers.get(SESSION_HEADER) ?? ""; + const created = sessionTime(id); + if (created === undefined) return json(400, { error: "no_session" }); + if (url.pathname === RESET_PATH) { + await this.#wipe(); + return new Response(null, { status: 204 }); + } + const now = Date.now(); + if (now >= created + limits.maxMinutes * MINUTE) { + // No resurrection: past the cap, even when the alarm did not run. + await this.#wipe(); + return json(410, { error: "playground_expired" }); + } + const session = await this.#open(); + this.#expiresAt = Math.min( + now + limits.idleMinutes * MINUTE, + created + limits.maxMinutes * MINUTE, + ); + await this.state.storage.setAlarm(this.#expiresAt); + const full = await fullResponse(request, url, session, limits); + if (full) return full; + if (url.pathname.startsWith("/api/") || url.pathname === "/mcp") + return session.api.fetch(request); + if (url.pathname.startsWith(SITE_PREFIX) || url.pathname.startsWith("/media/")) + return renderSite(request, url, { session, options, state: this.state, env: this.env }); + return json(404, { error: "not_found" }); + } + + async alarm(): Promise { + await this.#wipe(); + } + + /** The daily session counter (an object named `daily:`). */ + async #count(): Promise { + const count = ((await this.state.storage.get("count")) ?? 0) + 1; + await this.state.storage.put("count", count); + if (count === 1) { + const midnight = new Date(); + midnight.setUTCHours(24, 0, 0, 0); + await this.state.storage.setAlarm(midnight.getTime() + MINUTE); + } + return json(200, { count }); + } + + async #wipe(): Promise { + this.#session = undefined; + await this.state.storage.deleteAll(); + await this.state.storage.deleteAlarm(); + } + + /** The session's store, API and site; migrated and seeded on first use. */ + async #open(): Promise { + if (this.#session) return this.#session; + await this.state.blockConcurrencyWhile(async () => { + if (this.#session) return; + const sql = new DoSqliteDriver(this.state.storage); + const own = new SqlBlobStore(sql, { + maxBytes: limits.maxBlobBytes, + maxObjectBytes: limits.maxObjectBytes, + }); + const demo = options.demoMedia; + const blobs: BlobStore = demo + ? new LayeredBlobStore( + new FetcherBlobStore( + this.env.ASSETS, + demo.prefix, + demo.keys ? { keys: demo.keys } : {}, + ), + own, + ) + : own; + const store = new SiteGraphStore({ sql, blobs }); + await store.init(); + let siteId = await this.state.storage.get("siteId"); + if (!siteId) { + siteId = (await options.seed(store)).siteId; + await this.state.storage.put("siteId", siteId); + } + const api = createApi({ + store, + defer: (task) => this.state.waitUntil(task.catch(() => {})), + playground: { + info: () => ({ + expiresAt: new Date(this.#expiresAt).toISOString(), + resetUrl: RESET_PATH, + deployUrl: options.deployUrl, + }), + maxBodyBytes: limits.maxBodyBytes, + }, + }); + this.#session = { store, sql, siteId, api }; + }); + return this.#session as unknown as Session; + } + }; +} + +/** RFC-0036 §7: a full database or too many nodes refuse writes with 507. */ +async function fullResponse( + request: Request, + url: URL, + session: Session, + limits: PlaygroundLimits, +): Promise { + if (request.method === "GET" || request.method === "HEAD" || !url.pathname.startsWith("/api/")) + return undefined; + if (session.sql.databaseSize > limits.maxDatabaseBytes) + return json(507, { + error: "playground_full", + message: "This playground is full. Reset to start over.", + }); + if (request.method === "POST") { + const row = await session.sql.get<{ n: number }>("SELECT count(*) AS n FROM nodes"); + if ((row?.n ?? 0) >= limits.maxNodes) + return json(507, { + error: "playground_full", + message: "This playground holds no more items.", + }); + } + return undefined; +} + +/** `/s//…` (and `/media/…`) through the deployment's renderer, kept private (§4). */ +async function renderSite( + request: Request, + url: URL, + scope: { session: Session; options: PlaygroundOptions; state: DurableObjectState; env: unknown }, +): Promise { + const { session, options, state } = scope; + if (!options.render) return json(404, { error: "not_found" }); + const sitePath = url.pathname.startsWith(SITE_PREFIX); + const prefix = sitePath ? url.pathname.split("/").slice(0, 3).join("/") : ""; + const inner = new URL(url); + inner.pathname = url.pathname.slice(prefix.length) || "/"; + const ctx = { + waitUntil: (p: Promise) => state.waitUntil(p), + passThroughOnException: () => {}, + props: {}, + } as unknown as ExecutionContext; + const response = await options.render(new Request(inner, request), { + store: session.store, + siteId: session.siteId, + env: scope.env, + ctx, + }); + return privateSiteResponse( + response, + prefix || `${SITE_PREFIX}${request.headers.get(SESSION_HEADER)}`, + ); +} diff --git a/packages/cloudflare/src/playground.ts b/packages/cloudflare/src/playground.ts new file mode 100644 index 00000000..3ee2f68d --- /dev/null +++ b/packages/cloudflare/src/playground.ts @@ -0,0 +1,187 @@ +/** + * `@cloudhal/cloudflare/playground` (RFC-0036): the single Worker of a public playground. Every + * visitor gets a disposable Cloudhal in their own `PlaygroundSite` Durable Object; this Worker only + * serves static files (landing page, Studio, site assets), issues and checks the session cookie, + * applies `isBlockedInPlayground`, the rate limits, the daily cap and the kill switch, and forwards. + * + * ```ts + * const playground = createPlaygroundWorker({ seed, render, deployUrl }); + * export const PlaygroundSite = playground.PlaygroundSite; + * export default playground.worker; + * ``` + */ +import { isBlockedInPlayground } from "@cloudhal/api"; +import { + DEFAULT_PLAYGROUND_LIMITS, + type PlaygroundEnv, + type PlaygroundLimits, + type PlaygroundOptions, + RESET_PATH, + SESSION_HEADER, + SESSION_PATH, + SITE_PREFIX, +} from "./playground-options.ts"; +import { + newSessionId, + readSessionCookie, + sessionCookie, + sessionTime, + signSession, + verifySession, +} from "./playground-session.ts"; +import { type PlaygroundSiteClass, playgroundSiteClass } from "./playground-site.ts"; + +export * from "./playground-options.ts"; +export { prefixPath } from "./playground-render.ts"; +export { newSessionId, sessionTime, signSession, verifySession } from "./playground-session.ts"; +export type { PlaygroundSiteClass } from "./playground-site.ts"; + +const MINUTE = 60_000; +const json = (status: number, body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", "cache-control": "no-store", ...headers }, + }); + +/** Paths that belong to a session's Durable Object. */ +function isSessionPath(path: string): boolean { + return ( + path.startsWith("/api/") || + path === "/mcp" || + path.startsWith("/mcp/") || + path.startsWith(SITE_PREFIX) || + path.startsWith("/media/") || + path === RESET_PATH + ); +} + +export interface PlaygroundWorker { + worker: { fetch(request: Request, env: PlaygroundEnv): Promise }; + PlaygroundSite: PlaygroundSiteClass; +} + +export function createPlaygroundWorker(options: PlaygroundOptions): PlaygroundWorker { + const limits: PlaygroundLimits = { ...DEFAULT_PLAYGROUND_LIMITS, ...options.limits }; + const landingPath = options.landingPath ?? "/landing/"; + + const landing = (request: Request, env: PlaygroundEnv, status = 200) => + env.ASSETS.fetch( + new Request(new URL(landingPath, request.url), { headers: request.headers }), + ).then((res) => + status === 200 ? res : new Response(res.body, { status, headers: res.headers }), + ); + + /** The verified, unexpired session of a request. */ + async function sessionOf(request: Request, env: PlaygroundEnv): Promise { + const value = readSessionCookie(request); + if (!value || !env.PLAYGROUND_SECRET) return undefined; + const id = await verifySession(value, env.PLAYGROUND_SECRET); + const created = id ? sessionTime(id) : undefined; + if (!id || created === undefined) return undefined; + const now = Date.now(); + if (created > now + MINUTE || now >= created + limits.maxMinutes * MINUTE) return undefined; + return id; + } + + async function createSession(request: Request, env: PlaygroundEnv): Promise { + const secret = env.PLAYGROUND_SECRET; + if (!secret || secret.length < 32) return landing(request, env, 503); + const ip = request.headers.get("cf-connecting-ip") ?? "unknown"; + if (env.SESSION_LIMIT && !(await env.SESSION_LIMIT.limit({ key: ip })).success) { + if (!(await passedTurnstile(request, env, ip))) + return json(429, { + error: "rate_limited", + message: "Too many new sessions. Try again in a minute.", + ...(env.TURNSTILE_SITE_KEY ? { turnstile: env.TURNSTILE_SITE_KEY } : {}), + }); + } + const cap = Number(env.PLAYGROUND_DAILY_SESSIONS ?? 0); + if (cap > 0) { + const day = new Date().toISOString().slice(0, 10); + const counter = env.PLAYGROUND.get(env.PLAYGROUND.idFromName(`daily:${day}`)); + const { count } = (await (await counter.fetch("https://playground/_count")).json()) as { + count: number; + }; + if (count > cap) return landing(request, env, 503); + } + const id = newSessionId(Date.now()); + const cookie = sessionCookie(await signSession(id, secret), limits.maxMinutes * 60); + return new Response(null, { + status: 303, + headers: { location: "/sites", "set-cookie": cookie }, + }); + } + + async function forward(request: Request, env: PlaygroundEnv, id: string, path: string) { + if (path.startsWith(SITE_PREFIX) && path.split("/")[2] !== id) + return json(404, { error: "not_found" }); + const block = isBlockedInPlayground(request.method, path); + if (block) + return json(403, { + error: "playground", + block, + message: "This is not available in the playground. It works in your own install.", + }); + const length = Number(request.headers.get("content-length") ?? 0); + if (length > limits.maxRequestBytes) return json(413, { error: "payload_too_large" }); + const write = request.method !== "GET" && request.method !== "HEAD"; + if (write && env.WRITE_LIMIT && !(await env.WRITE_LIMIT.limit({ key: id })).success) + return json(429, { error: "rate_limited", message: "Slow down a little." }); + const headers = new Headers(request.headers); + headers.set(SESSION_HEADER, id); + const stub = env.PLAYGROUND.get(env.PLAYGROUND.idFromName(id)); + return stub.fetch(new Request(request, { headers })); + } + + const worker = { + async fetch(request: Request, env: PlaygroundEnv): Promise { + const url = new URL(request.url); + const path = url.pathname; + // Never trust a client's own session header. + if (request.headers.has(SESSION_HEADER)) { + const headers = new Headers(request.headers); + headers.delete(SESSION_HEADER); + request = new Request(request, { headers }); + } + const enabled = env.PLAYGROUND_ENABLED !== "false" && Boolean(env.PLAYGROUND_SECRET); + if (path === SESSION_PATH && request.method === "POST") + return enabled ? createSession(request, env) : landing(request, env, 503); + if (isSessionPath(path)) { + if (!enabled) return json(503, { error: "playground_closed" }); + const id = await sessionOf(request, env); + if (!id) + return path.startsWith("/api/") || path === "/mcp" || path === RESET_PATH + ? json(410, { error: "playground_expired", message: "This session has ended." }) + : Response.redirect(new URL("/", url).href, 303); + if (path === RESET_PATH && request.method !== "POST") return json(405, { error: "method" }); + return forward(request, env, id, path); + } + // Static files. "/" is the landing page unless the visitor has a session (then Studio). + if (path === "/" && (!enabled || !(await sessionOf(request, env)))) + return landing(request, env); + return env.ASSETS.fetch(request); + }, + }; + + return { worker, PlaygroundSite: playgroundSiteClass(options) }; +} + +/** After a rate-limit hit, a valid Turnstile token (form field or header) lets one session through. */ +async function passedTurnstile(request: Request, env: PlaygroundEnv, ip: string): Promise { + if (!env.TURNSTILE_SECRET) return false; + let token = request.headers.get("cf-turnstile-response"); + if (!token && request.headers.get("content-type")?.includes("form")) { + const form = await request.clone().formData(); + token = String(form.get("cf-turnstile-response") ?? ""); + } + if (!token) return false; + const body = new FormData(); + body.set("secret", env.TURNSTILE_SECRET); + body.set("response", token); + body.set("remoteip", ip); + const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + method: "POST", + body, + }); + return ((await res.json()) as { success?: boolean }).success === true; +} diff --git a/packages/cloudflare/test/fixtures/playground/assets/demo/media/hello.txt b/packages/cloudflare/test/fixtures/playground/assets/demo/media/hello.txt new file mode 100644 index 00000000..1ad96f19 --- /dev/null +++ b/packages/cloudflare/test/fixtures/playground/assets/demo/media/hello.txt @@ -0,0 +1 @@ +demo bytes \ No newline at end of file diff --git a/packages/cloudflare/test/fixtures/playground/assets/index.html b/packages/cloudflare/test/fixtures/playground/assets/index.html new file mode 100644 index 00000000..7bba333a --- /dev/null +++ b/packages/cloudflare/test/fixtures/playground/assets/index.html @@ -0,0 +1 @@ +Studio
studio
diff --git a/packages/cloudflare/test/fixtures/playground/assets/landing/index.html b/packages/cloudflare/test/fixtures/playground/assets/landing/index.html new file mode 100644 index 00000000..8e110b1e --- /dev/null +++ b/packages/cloudflare/test/fixtures/playground/assets/landing/index.html @@ -0,0 +1 @@ +Try Cloudhal

landing

diff --git a/packages/cloudflare/test/fixtures/playground/worker.ts b/packages/cloudflare/test/fixtures/playground/worker.ts new file mode 100644 index 00000000..d915f9f3 --- /dev/null +++ b/packages/cloudflare/test/fixtures/playground/worker.ts @@ -0,0 +1,35 @@ +/** + * Test-only playground Worker: the real `createPlaygroundWorker`, the starter seed, demo media + * from the static files, and a renderer that reads the session's store through + * `withRequestStore` the way the Astro pages do (getSource, serveMedia). + */ +import "../../../src/workerd-prelude.ts"; +import { getSource, serveMedia, withRequestStore } from "@cloudhal/astro-renderer/runtime"; +import { seedStarterSite } from "@cloudhal/core-ts"; +import { createPlaygroundWorker } from "../../../src/playground.ts"; + +const playground = createPlaygroundWorker({ + deployUrl: "https://cloudhal.dev/docs/deploy", + seed: async (store) => ({ + siteId: (await seedStarterSite(store, { name: "Playground Demo", slug: "demo" })).id, + }), + demoMedia: { prefix: "demo/", keys: ["media/hello.txt"] }, + render: (request, { store, siteId }) => + withRequestStore(store, siteId, async () => { + const media = await serveMedia(request, { siteId }); + if (media) return media; + const response = { headers: new Headers(), status: 200 }; + const source = await getSource({ siteId } as never, { request, response }); + if (!source) return new Response("unpublished", { status: 503, headers: response.headers }); + const site = await source.site(); + response.headers.set("content-type", "text/html; charset=utf-8"); + response.headers.set("cache-tag", "should-be-removed"); + return new Response( + `

${site.name}

AboutOut`, + { headers: response.headers }, + ); + }), +}); + +export const PlaygroundSite = playground.PlaygroundSite; +export default playground.worker; diff --git a/packages/cloudflare/test/fixtures/playground/wrangler.jsonc b/packages/cloudflare/test/fixtures/playground/wrangler.jsonc new file mode 100644 index 00000000..61e1ca28 --- /dev/null +++ b/packages/cloudflare/test/fixtures/playground/wrangler.jsonc @@ -0,0 +1,14 @@ +// Test-only playground Worker (RFC-0036 phase 3), run by test/playground-workerd.test.ts. +{ + "name": "playground-test", + "main": "worker.ts", + "compatibility_date": "2026-07-01", + "compatibility_flags": ["nodejs_compat"], + "assets": { "directory": "./assets", "binding": "ASSETS", "run_worker_first": true }, + "durable_objects": { "bindings": [{ "name": "PLAYGROUND", "class_name": "PlaygroundSite" }] }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["PlaygroundSite"] }], + "ratelimits": [ + { "name": "SESSION_LIMIT", "namespace_id": "1001", "simple": { "limit": 100, "period": 60 } } + ], + "vars": { "PLAYGROUND_DAILY_SESSIONS": "1000" } +} diff --git a/packages/cloudflare/test/playground-workerd.test.ts b/packages/cloudflare/test/playground-workerd.test.ts new file mode 100644 index 00000000..515e305b --- /dev/null +++ b/packages/cloudflare/test/playground-workerd.test.ts @@ -0,0 +1,156 @@ +/** + * RFC-0036 phase 3 on real workerd (wrangler's test harness): open a session, edit, publish, + * render, reset, refuse an expired session, and 20 sessions at once — each its own SQLite-backed + * Durable Object seeded with the starter site. + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createTestHarness } from "wrangler"; +import { newSessionId, SESSION_HEADER, signSession } from "../src/playground.ts"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(here, "fixtures/playground"); +const SECRET = "playground-workerd-secret-0123456789ab"; +const ORIGIN = "https://try.example.test"; + +describe("playground Worker on workerd (RFC-0036)", () => { + let harness: ReturnType; + + beforeAll(async () => { + harness = createTestHarness({ + root, + workers: [ + { configPath: path.join(root, "wrangler.jsonc"), secrets: { PLAYGROUND_SECRET: SECRET } }, + ], + }); + await harness.listen(); + }, 300_000); + + afterAll(async () => { + await harness?.close(); + }); + + async function openSession(): Promise<{ cookie: string; id: string }> { + const res = await harness.fetch(`${ORIGIN}/_playground/session`, { + method: "POST", + redirect: "manual", + }); + expect(res.status).toBe(303); + const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] as string; + const id = cookie.split("=")[1]?.split(".")[0] as string; + return { cookie, id }; + } + + const call = ( + cookie: string, + p: string, + init: { method?: string; body?: string; headers?: Record } = {}, + ) => + harness.fetch(`${ORIGIN}${p}`, { + redirect: "manual", + ...init, + headers: { + cookie, + origin: ORIGIN, + "content-type": "application/json", + ...(init.headers ?? {}), + }, + }); + + it("opens a session, edits, publishes and renders the session's own site", async () => { + const landing = await harness.fetch(`${ORIGIN}/`); + expect(await landing.text()).toContain("landing"); + + const { cookie, id } = await openSession(); + expect(await (await call(cookie, "/")).text()).toContain("studio"); + + const me = (await (await call(cookie, "/api/me")).json()) as { actor: { id: string } }; + expect(me.actor.id).toBe("user_playground"); + const instance = (await (await call(cookie, "/api/instance")).json()) as { + features: { playground?: { expiresAt: string; resetUrl: string } }; + }; + const left = Date.parse(instance.features.playground?.expiresAt ?? "") - Date.now(); + expect(left).toBeGreaterThan(55 * 60_000); + expect(left).toBeLessThanOrEqual(60 * 60_000); + + const sites = (await (await call(cookie, "/api/sites")).json()) as { + id: string; + name: string; + }[]; + expect(sites.map((s) => s.name)).toEqual(["Playground Demo"]); + const siteId = sites[0]?.id as string; + + // Not published yet: the session's site answers 503, privately. + const before = await call(cookie, `/s/${id}/`); + expect(before.status).toBe(503); + expect(before.headers.get("cache-control")).toBe("private, no-store"); + + // Edit the site's name, then publish. + const site = (await (await call(cookie, `/api/sites/${siteId}`)).json()) as Record< + string, + unknown + >; + const put = await call(cookie, `/api/sites/${siteId}`, { + method: "PUT", + body: JSON.stringify({ ...site, name: "My Playground" }), + }); + expect(put.status, await put.clone().text()).toBeLessThan(300); + const publish = await call(cookie, `/api/sites/${siteId}/snapshots`, { + method: "POST", + body: JSON.stringify({ publish: true }), + }); + expect(publish.status, await publish.clone().text()).toBeLessThan(300); + + const page = await call(cookie, `/s/${id}/`); + const html = await page.text(); + expect(page.status, html).toBe(200); + expect(html).toContain("

My Playground

"); + expect(html).toContain(`href="/s/${id}/about/"`); + expect(html).toContain('href="https://example.org/x"'); + expect(page.headers.get("cache-control")).toBe("private, no-store"); + expect(page.headers.get("x-robots-tag")).toBe("noindex"); + expect(page.headers.get("cache-tag")).toBeNull(); + + // Blocked in the Worker, and another session's site is not reachable. + expect((await call(cookie, "/api/sites", { method: "POST", body: "{}" })).status).toBe(403); + expect( + (await call(cookie, `/api/sites/${siteId}/assets/uploads`, { method: "POST", body: "{}" })) + .status, + ).toBe(403); + expect((await call(cookie, `/s/${newSessionId(Date.now())}/`)).status).toBe(404); + }); + + it("resets a session to a freshly seeded site", async () => { + const { cookie } = await openSession(); + const first = (await (await call(cookie, "/api/sites")).json()) as { id: string }[]; + const reset = await call(cookie, "/_playground/reset", { method: "POST" }); + expect(reset.status).toBe(204); + const second = (await (await call(cookie, "/api/sites")).json()) as { id: string }[]; + expect(second).toHaveLength(1); + expect(second[0]?.id).not.toBe(first[0]?.id); + }); + + it("refuses a session past its cap, in the Worker and in the Durable Object", async () => { + const old = newSessionId(Date.now() - 121 * 60_000); + const cookie = `__Host-cloudhal_try=${await signSession(old, SECRET)}`; + expect((await call(cookie, "/api/me")).status).toBe(410); + // The object refuses on its own, even if a request reached it (no resurrection). + const env = (await harness.getWorker().getEnv()) as { PLAYGROUND: DurableObjectNamespace }; + const stub = env.PLAYGROUND.get(env.PLAYGROUND.idFromName(old)); + const res = await stub.fetch("https://playground/api/me", { + headers: { [SESSION_HEADER]: old }, + }); + expect(res.status).toBe(410); + }); + + it("serves 20 sessions at once, each with its own site", async () => { + const sessions = await Promise.all(Array.from({ length: 20 }, () => openSession())); + const answers = await Promise.all(sessions.map(({ cookie }) => call(cookie, "/api/sites"))); + expect(answers.map((r) => r.status)).toEqual(Array(20).fill(200)); + const ids = await Promise.all( + answers.map(async (r) => ((await r.json()) as { id: string }[])[0]?.id), + ); + expect(new Set(ids).size).toBe(20); + }); +}); diff --git a/packages/cloudflare/test/playground.test.ts b/packages/cloudflare/test/playground.test.ts new file mode 100644 index 00000000..81f28577 --- /dev/null +++ b/packages/cloudflare/test/playground.test.ts @@ -0,0 +1,170 @@ +/** RFC-0036 phase 3, the parts that need no workerd: sessions, link prefixes, Worker routing. */ +import { describe, expect, it } from "vitest"; +import { + createPlaygroundWorker, + newSessionId, + type PlaygroundEnv, + prefixPath, + sessionTime, + signSession, + verifySession, +} from "../src/playground.ts"; +import { readSessionCookie, SESSION_COOKIE } from "../src/playground-session.ts"; + +const SECRET = "playground-unit-secret-0123456789abcdef"; + +describe("playground sessions", () => { + it("encodes the creation time in a ULID", () => { + const now = Date.parse("2026-10-20T18:00:00.123Z"); + const id = newSessionId(now); + expect(id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + expect(sessionTime(id)).toBe(now); + expect(newSessionId(now)).not.toBe(id); + expect(sessionTime("not-a-ulid")).toBeUndefined(); + }); + + it("signs cookies and refuses forged or altered ones", async () => { + const id = newSessionId(Date.now()); + const value = await signSession(id, SECRET); + expect(await verifySession(value, SECRET)).toBe(id); + expect(await verifySession(value, `${SECRET}x`)).toBeUndefined(); + const other = newSessionId(Date.now()); + expect(await verifySession(`${other}.${value.split(".")[1]}`, SECRET)).toBeUndefined(); + expect(await verifySession(id, SECRET)).toBeUndefined(); + expect(await verifySession(`${value}.x`, SECRET)).toBeUndefined(); + }); + + it("reads the cookie among others", () => { + const req = new Request("https://try.test/", { + headers: { cookie: `a=1; ${SESSION_COOKIE}=abc.def; b=2` }, + }); + expect(readSessionCookie(req)).toBe("abc.def"); + }); +}); + +describe("prefixPath", () => { + it("keeps the session's site under its prefix and leaves Worker paths alone", () => { + const p = "/s/01J"; + expect(prefixPath("/", p)).toBe("/s/01J/"); + expect(prefixPath("/blog/post/", p)).toBe("/s/01J/blog/post/"); + expect(prefixPath("/s/01J/x", p)).toBe("/s/01J/x"); + for (const href of [ + "/media/abc/x.png", + "/_astro/a.css", + "https://x.test/", + "//x.test/", + "#top", + "mailto:a@b", + ]) + expect(prefixPath(href, p), href).toBe(href); + }); +}); + +describe("the playground Worker without workerd", () => { + const seen: string[] = []; + const env = (extra: Partial = {}): PlaygroundEnv => + ({ + ASSETS: { + fetch: async (r: Request) => { + seen.push(new URL(r.url).pathname); + return new Response(`asset ${new URL(r.url).pathname}`); + }, + }, + PLAYGROUND: { + idFromName: (n: string) => n, + get: () => ({ fetch: async () => new Response("from the session") }), + }, + PLAYGROUND_SECRET: SECRET, + ...extra, + }) as unknown as PlaygroundEnv; + const { worker } = createPlaygroundWorker({ + deployUrl: "https://cloudhal.dev/docs/deploy", + seed: async () => ({ siteId: "site_x" }), + }); + const cookieFor = async (id: string) => `${SESSION_COOKIE}=${await signSession(id, SECRET)}`; + + it("shows the landing page at / without a session and Studio with one", async () => { + expect(await (await worker.fetch(new Request("https://try.test/"), env())).text()).toBe( + "asset /landing/", + ); + const cookie = await cookieFor(newSessionId(Date.now())); + const studio = await worker.fetch( + new Request("https://try.test/", { headers: { cookie } }), + env(), + ); + expect(await studio.text()).toBe("asset /"); + }); + + it("issues a signed, host-only session cookie", async () => { + const res = await worker.fetch( + new Request("https://try.test/_playground/session", { method: "POST" }), + env(), + ); + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("/sites"); + const set = res.headers.get("set-cookie") ?? ""; + expect(set).toMatch( + /^__Host-cloudhal_try=[0-9A-Z]{26}\.[\w-]+; HttpOnly; Secure; SameSite=Lax; Path=\/; Max-Age=7200$/, + ); + expect(set).not.toContain("Domain"); + }); + + it("refuses expired, future-dated and unsigned sessions", async () => { + for (const cookie of [ + await cookieFor(newSessionId(Date.now() - 121 * 60_000)), + await cookieFor(newSessionId(Date.now() + 10 * 60_000)), + `${SESSION_COOKIE}=${newSessionId(Date.now())}`, + ]) { + const res = await worker.fetch( + new Request("https://try.test/api/me", { headers: { cookie } }), + env(), + ); + expect(res.status).toBe(410); + } + }); + + it("guards the API before the Durable Object and keeps sessions apart", async () => { + const id = newSessionId(Date.now()); + const cookie = await cookieFor(id); + const call = (path: string, method = "GET") => + worker.fetch(new Request(`https://try.test${path}`, { method, headers: { cookie } }), env()); + expect((await call("/api/members")).status).toBe(403); + expect((await call("/mcp", "POST")).status).toBe(403); + expect(await (await call("/api/me")).text()).toBe("from the session"); + expect(await (await call(`/s/${id}/`)).text()).toBe("from the session"); + expect((await call(`/s/${newSessionId(Date.now())}/`)).status).toBe(404); + }); + + it("serves only the landing page behind the kill switch", async () => { + const off = env({ PLAYGROUND_ENABLED: "false" }); + const cookie = await cookieFor(newSessionId(Date.now())); + expect( + await ( + await worker.fetch(new Request("https://try.test/", { headers: { cookie } }), off) + ).text(), + ).toBe("asset /landing/"); + expect( + (await worker.fetch(new Request("https://try.test/api/me", { headers: { cookie } }), off)) + .status, + ).toBe(503); + const session = await worker.fetch( + new Request("https://try.test/_playground/session", { method: "POST" }), + off, + ); + expect(session.status).toBe(503); + expect(session.headers.get("set-cookie")).toBeNull(); + }); + + it("asks for Turnstile only after a rate-limit hit", async () => { + const limited = env({ + SESSION_LIMIT: { limit: async () => ({ success: false }) }, + TURNSTILE_SITE_KEY: "site-key", + }); + const res = await worker.fetch( + new Request("https://try.test/_playground/session", { method: "POST" }), + limited, + ); + expect(res.status).toBe(429); + expect(((await res.json()) as { turnstile?: string }).turnstile).toBe("site-key"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97dd4e1f..4c8339a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -940,6 +940,9 @@ importers: '@cloudflare/workers-types': specifier: 'catalog:' version: 5.20261007.1 + '@cloudhal/astro-renderer': + specifier: workspace:* + version: link:../astro-renderer '@types/node': specifier: 'catalog:' version: 26.6.5