stoop

     _                                          ___
 ___| |_ ___   ___  _ __                    ___|
/ __| __/ _ \ / _ \| '_ \               ___|
\__ \ || (_) | (_) | |_) |          ___|
|___/\__\___/ \___/| .__/          |
                   |_|             '--- take a seat, ship a site

A Shopify Quick-style instant deployment platform on Cloudflare: drop a ZIP of HTML/CSS/JS, get a live subdomain with a zero-setup realtime database, identity, and auth — no backend code in the site.

The stack

+----------------+-----------------------------------------------+
| edge           | Cloudflare Workers, wildcard routes           |
| storage        | R2 (site files) · D1 (orgs, projects, auth)   |
| realtime db    | one Durable Object per project (SQLite + WS)  |
| dashboard      | TanStack Start (SSR)                          |
| auth           | Better Auth, organization plugin              |
| logic          | Effect 4 - worker programs, schema contracts  |
| infra as code  | Alchemy v2                                    |
| tooling        | Bun workspaces · oxlint · portless dev proxy  |
+----------------+-----------------------------------------------+

Links: Alchemy v2 · TanStack Start · Effect · Better Auth · oxlint · portless

Code layout

Bun-workspaces monorepo (no task runner — bun run --filter covers it):

  • apps/web — the platform: worker + SSR dashboard + IaC.
    • src/server.ts — worker entry: platform routing first, TanStack Start SSR fall-through for dashboard pages. Exports the ProjectDB DO.
    • src/platform.ts — all platform logic as Effect programs with tagged errors (Unauthenticated/Forbidden/NotFound/…) mapped to HTTP at the boundary (src/errors.ts).
    • src/routes/ — TanStack Start file routes (dashboard, login, /device for CLI sign-in approval).
    • src/project-db.ts — per-project Durable Object (SQLite + WS).
    • alchemy.run.ts — the whole stack as an Effect program (Cloudflare.Website.Vite + D1 + R2 + DO + wildcard routes).
  • packages/core (@stoop/core) — effect/Schema API contracts (the worker emits them, the dashboard and CLI decode them — no casts) plus the typed platform client used by the CLI.
  • packages/cli (@stoop/cli, bin stoop) — terminal deploys, built on effect/unstable/cli: stoop login (OAuth device flow), stoop deploy, stoop open. Publishes to npm as a self-contained Node-target bundle (bun run builddist/index.js, zero runtime deps — @stoop/core is inlined); the source still runs directly under bun for dev.

Architecture

                     +----------------------------------------+
 *.yourdomain.com -->| Router Worker (src/router.ts)          |
                     | Host header -> project lookup (D1)     |
                     | Better Auth session gate (cookie on    |
                     | .yourdomain.com -- the "IAP" wall)     |
                     +----+-------------+--------------+------+
                          |             |              |
            static files  |  /api/db/*  |  /api/me,    |  dashboard,
            from R2       |  -> per-    |  /api/auth/* |  project CRUD,
            sites/<id>/*  |  project DO |  (BetterAuth |  ZIP deploys
                          v             v   + D1)      v
                     +--------+   +------------+   +---------+
                     |   R2   |   | ProjectDB  |   |   D1    |
                     |        |   | DO: SQLite |   | orgs /  |
                     |        |   | + WebSocket|   | projects|
                     +--------+   |  broadcast |   +---------+
                                  +------------+
  • One Durable Object per project is that project's entire database: schemaless JSON collections in the DO's SQLite, realtime subscriptions via hibernating WebSockets. Isolation is physical, not a WHERE site_id =.
  • Sites are pure static files in R2. The client SDK (/__platform/sdk.js, source in src/sdk.ts) gives every site platform.db.collection(...) and platform.identity.me() — Quick's developer experience.
  • Auth: Better Auth on D1, org plugin, session cookie set on the parent domain so one login works on every subdomain. Private sites 302 to login before a single byte of HTML is served.

Setup

bun install
# alchemy is a dependency of apps/web and its binary is not hoisted to the
# root node_modules/.bin, so these need --cwd (or run them from apps/web).
bun --cwd apps/web alchemy login                 # Cloudflare OAuth (one time)
bun --cwd apps/web alchemy cloudflare bootstrap  # alchemy's state store (one time)

Local dev expects a global portless install (bun add -g portless) — it's a system-wide proxy on port 443, not a project dependency. Subdomain routing needs the proxy in wildcard mode; if it was already running without it, restart it once:

sudo portless proxy stop -p 443      # next `bun run dev` restarts it with wildcard
# or persist it in the startup service:
sudo portless service install --wildcard

(Without wildcard mode you can register sites one-by-one as a stopgap: portless alias <slug>.stoop <port>.)

Local dev — https://stoop.localhost, no ports

bun run dev runs portless --wildcard, which starts alchemy's local worker behind portless's HTTPS proxy:

dashboard ....... https://stoop.localhost
every site ...... https://<slug>.stoop.localhost

--wildcard routes unregistered subdomains to the app, and the worker reads the Host header — exactly like production. portless injects PORT (alchemy dev listens on it) and PORTLESS_URL (alchemy.run.ts derives BASE_DOMAIN from it). First run generates and trusts a local CA — no browser warnings.

bun run dev

Open https://stoop.localhost, sign up, create an org and a project (e.g. demo), then deploy the demo guestbook with the CLI:

export STOOP_BASE=https://stoop.localhost   # the CLI defaults to https://stoop.run
bun packages/cli/src/index.ts login          # device flow: approve in browser
bun packages/cli/src/index.ts deploy demo -p demo
# → https://demo.stoop.localhost

(stoop login stores a session token in ~/.config/stoop/config.json; the CLI sends it as a bearer token. bun run deploy:demo in apps/web is a shortcut for the same deploy.)

Open the site in two windows — entries sync live.

Social sign-in locally (a real domain instead of .localhost)

Google's OAuth console refuses any redirect URI that doesn't end in a public top-level domain: https://stoop.localhost/... is rejected with "must end with a public top-level domain", and http://localhost:PORT is the only http exception (no subdomains). So social sign-in can't be exercised on the default dev host at all.

Fix it by pointing a domain at loopback and handing it to portless as the TLD. For stoop that's local.stoop.run, already set up as two DNS-only A records on the stoop.run zone:

stoop.local.stoop.run      A   127.0.0.1
*.stoop.local.stoop.run    A   127.0.0.1

They're namespaced under stoop.local rather than sitting at stoop.stoop.run on purpose: stoop is not a reserved site name (src/site-name.ts), so a record there would permanently shadow a production site deployed with that slug. Nothing under stoop.local. can collide with a real <slug>.stoop.run.

Then run dev on it — portless 0.15.5+ is required, that's the release that accepted dotted TLDs:

sudo portless proxy stop -p 443                       # TLD is a proxy-wide setting
PORTLESS_TLD=local.stoop.run,localhost bun run dev    # → https://stoop.local.stoop.run

Keep localhost in the list: the proxy is system-wide, and dropping it takes every other project's .localhost route down with it. The first entry wins as the primary URL, which is what portless reports in PORTLESS_URL and therefore what alchemy.run.ts turns into BASE_DOMAIN. Vite needs no config change — portless exports __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS and Vite appends it to server.allowedHosts.

The Google OAuth client ("stoop web", GCP project stooop) already carries both callbacks — https://stoop.local.stoop.run/... for dev and https://stoop.run/... for production. Credentials go in apps/web/.env (gitignored) as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET; without them the provider is skipped and the login page hides the button.

Use a domain you control for this. Google's authorized domains must be verified in Search Console, so wildcard-to-loopback services like lvh.me or localtest.me — which do resolve to 127.0.0.1 and work while the client is in Testing — are a dead end once the consent screen is published.

Browser E2E tests

The committed Playwright suite keeps browser coverage thin and focused:

bun run e2e:install   # one time, installs Chromium for Playwright
bun run e2e           # self-contained browser tests

By default, the suite runs browser-level tests that do not require the full Cloudflare dev stack. To include smoke tests against a running Stoop instance, start local dev in another terminal and pass its base URL:

bun run dev
E2E_BASE_URL=https://stoop.localhost bun run e2e

Those app smoke tests currently verify that unauthenticated dashboard traffic lands on the login UI and that unsafe redirect hosts stay hidden in login copy.

Production

Requires a domain on your Cloudflare account:

export BASE_DOMAIN=stoop.example.com
export ZONE_ID=<zone id from the Cloudflare dashboard>
export BETTER_AUTH_SECRET=$(openssl rand -hex 32)
bun run deploy

This creates the wildcard *.BASE_DOMAIN DNS records + Worker routes, D1 (with migrations), R2, and the DO namespace. The dashboard lives at https://BASE_DOMAIN, sites at https://<slug>.BASE_DOMAIN.

Without a domain, deploy with just BETTER_AUTH_SECRET and set BASE_DOMAIN to the workers.dev hostname printed by the first deploy — the dashboard and auth work there, but site subdomains need a real zone (workers.dev doesn't serve nested wildcards).

CI/CD

.github/workflows/ci.yml runs typecheck + lint + tests on every push and PR. .github/workflows/deploy.yml deploys through Alchemy (docs): pushes to main deploy the prod stage; PRs get an ephemeral pr-<n> preview stage that is destroyed when the PR closes.

Deploys need repository secrets:

  • CLOUDFLARE_API_TOKEN — create with bun alchemy cloudflare create-token (interactive; asks for your Global API Key), then gh secret set CLOUDFLARE_API_TOKEN.
  • CLOUDFLARE_ACCOUNT_ID
  • BETTER_AUTH_SECRET — must stay stable across deploys or sessions are invalidated.
  • ALCHEMY_STATE_CREDENTIALS — contents of ~/.alchemy/credentials/default/cloudflare-state-store.json from a machine that ran bun alchemy login. Without it, CI would need account Secrets Store access to mint state-store credentials itself.

And optional repository variables for the prod custom domain: BASE_DOMAIN, ZONE_ID (applied only to the prod stage).

Site developer API

Any deployed site can use, with zero setup:

<script src="/__platform/sdk.js"></script>
<script>
  const posts = platform.db.collection("posts");
  await posts.create({ title: "hi" });          // POST /api/db/posts
  await posts.list();                            // GET  /api/db/posts
  await posts.list({ where: { title: "hi" },     // equality filters, one
    sort: "-_created", limit: 50 });             //   sort field, page size
  const { docs, cursor } = await posts.page({ limit: 100 }); // paginate
  await posts.update(id, { title: "hi!" });      // PUT  /api/db/posts/:id
  await posts.delete(id);                        // DELETE
  await posts.setRules({ write: "members" });    // per-collection ACLs
  const off = posts.subscribe({                  // WebSocket → project DO
    onCreate(doc) {}, onUpdate(doc) {}, onDelete(id) {},
  });
  const me = await platform.identity.me();       // Better Auth session

  // Server-side AI — no client keys, keys held by the platform.
  const answer = await platform.ai.chat("Suggest a taco topping"); // → string
  await platform.ai.chat("Write a haiku", { onToken: (t) => …});   // stream tokens
  const img = await platform.ai.image("a fox on a stoop");         // → image Blob
</script>

Plain HTML gets the house style for free with <link rel="stylesheet" href="/__platform/theme.css">. AI calls are metered per site and per visitor per day (a 429 means the daily cap is reached).

The full site-developer guide ships as an agent skill distributed through skills.sh: npx @stoop/cli init installs it into a project (via npx skills add mislavjc/stoop, which lands it in .agents/skills/stoop/ with per-agent symlinks) and writes an AGENTS.md pointer. The checked-in copy the registry serves lives at skills/stoop/SKILL.md (regenerate with bun run --cwd packages/cli render:skill; a drift test keeps it honest).

Notes / known limitations (MVP)

  • Alchemy v2 and Effect 4 are betas — pin versions before upgrading, and re-run bun run typecheck && bun run lint after any bump.
  • package.json overrides @distilled.cloud/* to 0.12.1 — the 0.11.3 pinned by alchemy beta.59 crashes local dev for Vite workers (ctx.exports/RouterInnerEntrypoint, alchemy-effect#697). Drop the override once alchemy ships with ≥0.12.x.
  • WebSockets don't relay through the local dev chain (alchemy proxy + vite dev). The site SDK falls back to ~2.5s polling automatically and keeps retrying WS, so realtime still works in dev demos and is real WS in production.
  • apps/web/migrations/0002_better_auth.sql and 0003_device_auth.sql are generated from the installed better-auth version. After upgrading better-auth, regenerate the full schema to a scratch file (generate:auth-schema writes 0002 in place — don't run it blindly, 0002 is already applied) and diff for new incremental migrations.
  • Deploys replace the whole site (list + delete + put in R2); no atomic swap or rollbacks yet (deployments table is bookkeeping only).
  • Collection queries are equality-only (where), one sort field, cursor pagination; ACLs are per-collection (read/write: public/members), not per-doc — beyond the always-on visitor-owns-their-docs rule.
  • Public sites skip the login wall entirely (set visibility per project).
  • Upgrade path for user server code: Workers for Platforms dispatch namespaces (~$25/mo) — Alchemy has Cloudflare.WorkersForPlatforms when we get there.