Skip to main content
Glama
t0saki
by t0saki
README.md
# sitedrop

**English** · [简体中文](README.zh-CN.md)

Self-hosted static site publishing for AI coding agents. One Cloudflare Worker,
one HTTP call, a live URL:

```
POST /api/sites  →  https://sitedrop.you.workers.dev/amber-canyon/
```

Your agent (Claude Code, Cursor, Codex, …) publishes the page it just built,
gets a URL back, and shows it to you. No build step, no git push, no wait.

Everything runs in **your** Cloudflare account: the Worker, the D1 database and
the R2 bucket. There is no sitedrop service and no account to sign up for.

- **One MCP tool.** `publish`, nothing else to learn.
- **File bytes never pass through the model.** The tool returns a signed curl
  command; the agent runs it and the bytes go straight from disk to R2.
- **Instant and atomic.** Publishing is a single pointer flip — visitors see the
  old version or the new one, never a half-uploaded site.
- **Free plan first.** Designed inside 10 ms CPU and 50 subrequests per request.
- **~2,500 lines, one runtime dependency** (`fflate`, for zip).

---

## Deploy

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/t0saki/sitedrop)

Or from a clone:

```sh
npm install
npx wrangler secret put ADMIN_TOKEN      # openssl rand -hex 32
npx wrangler secret put SESSION_SECRET   # openssl rand -hex 32
npx wrangler deploy
```

The D1 database and R2 bucket are declared without ids, so `wrangler deploy`
provisions them on first run. Tables are created on the first request — there
is no migration step.

Then open `https://<your-worker>/admin`, sign in with `ADMIN_TOKEN`, and create
a token for each agent.

> **Note on R2:** Cloudflare's free R2 tier generally requires a payment card on
> file before the bucket can be created, even though nothing is charged at these
> volumes. This is not documented by Cloudflare but is consistently reported. D1
> and Workers have no such requirement.

### Custom domain

Point a hostname you own at the Worker from the Cloudflare dashboard —
**Workers & Pages → sitedrop → Settings → Domains & Routes → Add → Custom
domain** — which also creates the DNS record and issues the certificate. This
is deliberately not in `wrangler.jsonc`: a hostname belongs to one deployment,
and hardcoding it would break every fork and the Deploy button. Custom domains
live on the Worker rather than in the config, so later `wrangler deploy` runs
leave them alone.

With a custom domain, also pin the canonical origin:

```sh
npx wrangler secret put BASE_URL          # https://sitedrop.example.com
```

A secret rather than a `vars` entry for the same reason — and because
`wrangler deploy` replaces the whole `vars` block on every deploy, while
secrets persist. See [Security model](#security-model) for what it buys.

### Continuous deployment

`.github/workflows/deploy.yml` deploys `main` once the tests pass. It stays
inert until the repository has the secrets below, so forks are unaffected: they
run the tests and skip the deploy step.

To enable it on your own copy, create a Cloudflare API token
([My Profile → API Tokens → Create Token → Custom token][cf-token]) with:

| Scope   | Permission         | Level |
| ------- | ------------------ | ----- |
| Account | Workers Scripts    | Edit  |
| Account | D1                 | Edit  |
| Account | Workers R2 Storage | Edit  |
| Account | Account Settings   | Read  |

Then add it under **Settings → Secrets and variables → Actions**:

- `CLOUDFLARE_API_TOKEN` — required.
- `CLOUDFLARE_ACCOUNT_ID` — only if the token can see more than one account.

No zone permissions are needed: the token only updates the Worker, and the
custom domain above is already attached to it.

[cf-token]: https://dash.cloudflare.com/profile/api-tokens

## Connect an agent

The admin UI prints a ready-made snippet for each client when you create a
token. For Claude Code:

```sh
claude mcp add --transport http sitedrop https://<your-worker>/mcp \
  --header "Authorization: Bearer sd_..."
```

For clients that cannot send custom headers, the token can go in the path
instead: `https://<your-worker>/mcp/sd_...`. It is more leak-prone (URLs end up
in logs and config files), so prefer the header.

### What the agent sees

`publish` takes metadata only — `slug`, `title`, `password`, `spa` — and returns
a short-lived upload URL plus the exact commands to run:

```
Site will be live at: https://sitedrop.you.workers.dev/amber-canyon/
Upload within 15 minutes by running ONE of these in the shell:

# a single HTML file:
curl -fsS -H 'Content-Type: text/html' --data-binary @index.html 'https://.../api/upload/sdt_…'

# a whole directory (needs the zip binary):
(cd DIR && zip -qr - .) | curl -fsS -H 'Content-Type: application/zip' --data-binary @- 'https://.../api/upload/sdt_…'

# large sites, more than 40 files, or no zip binary:
curl -fsSL https://.../cli -o /tmp/sitedrop && sh /tmp/sitedrop publish DIR --ticket 'sdt_…'
```

The ticket is an HMAC-signed, single-slug, 15-minute credential. Nothing is
stored server-side for it, and the long-lived agent token never appears in the
transcript.

For a small page the agent already holds in memory, `publish(html: "…")` skips
the round trip and returns the live URL immediately.

## CLI

The Worker serves its own CLI with the deployment URL baked in — POSIX `sh` and
`curl`, nothing else:

```sh
curl -fsSL https://<your-worker>/cli -o sitedrop
export SITEDROP_TOKEN=sd_...

sh sitedrop publish ./dist            # directory: uploaded file by file
sh sitedrop publish page.html         # single page
sh sitedrop list
sh sitedrop password my-site hunter2  # or --remove
sh sitedrop rollback my-site
sh sitedrop delete my-site
```

It prints the URL on stdout and everything else on stderr, so
`URL=$(sh sitedrop publish ./dist)` just works. It is never interactive: a
missing argument is an error, not a prompt. Publishing a directory writes
`.sitedrop.json` next to it so the next publish updates the same URL.

## REST

Full schema at `/openapi.json`. The essentials:

```sh
# single page
curl -H "Authorization: Bearer $SITEDROP_TOKEN" -H 'Content-Type: text/html' \
     --data-binary @index.html "$BASE/api/sites?slug=my-site"

# a zip
(cd dist && zip -qr - .) | curl -H "Authorization: Bearer $SITEDROP_TOKEN" \
     -H 'Content-Type: application/zip' --data-binary @- "$BASE/api/sites?slug=my-site"

# JSON envelope
curl -H "Authorization: Bearer $SITEDROP_TOKEN" -H 'Content-Type: application/json' \
     -d '{"slug":"my-site","files":[{"path":"index.html","content":"<h1>hi</h1>"}]}' \
     "$BASE/api/sites"
```

Options travel as query parameters (`slug`, `title`, `spa`, `indexable`,
`sandbox`), as JSON body fields, or — for passwords — in the
`X-Sitedrop-Password` header. An empty password removes it; omitting it leaves
it unchanged.

Sites larger than one request go through the batch endpoints
(`POST /api/sites/:slug/versions` → `PUT …/files/<path>` → `POST …/finalize`),
which is what the CLI uses for directories. There is no size limit on that path.

## How it works

```
Worker ── D1 ── sites / versions / tokens        (metadata, pointer flip)
   └──── R2 ── sites/<site>/<version>/<path>     (one object per file)
```

- **Publishing** writes every R2 object first, then runs one `db.batch()` that
  demotes the old version, promotes the new one and repoints the site. Readers
  therefore never observe a partial publish. Concurrent publishes to the same
  slug both succeed; one of them wins the pointer.
- **Versions** are immutable prefixes in R2. The last three are kept, so
  rollback is another pointer flip and never re-uploads anything. Older versions
  are deleted in `waitUntil` using the manifest stored on the version row.
- **Serving** costs two indexed row reads (slug lookup + version join) and one
  R2 `get` with the request's conditional headers, so `If-None-Match` becomes a
  304 without transferring the body.
- **No KV, no Durable Objects.** KV's negative caching would make a
  publish-then-fetch 404 for up to a minute; D1 and R2 are strongly consistent.

### Caching, and what it actually saves

Cloudflare's cache sits **in front of** the Worker (`cache: { enabled: true }`),
so a hit is answered at the edge without invoking it at all — no CPU, no D1 row
read, no R2 GET. Public site responses are written to make use of that:

| Response | `Cache-Control` |
|---|---|
| HTML | `public, max-age=0, must-revalidate, s-maxage=3600, stale-while-revalidate=3600` |
| assets | `public, max-age=300, s-maxage=3600, stale-while-revalidate=3600` |
| password-protected | `private, …` — never enters a shared cache |
| 404s, redirects, the gate, all of `/api` | `no-store` |

Browsers keep revalidating HTML on every navigation, so a publish is visible on
the next reload, while every *other* visitor is served from the edge. What makes
the long shared TTL safe is that publish, rollback, option changes and delete all
purge the site's cache tag. Tune it with `EDGE_CACHE_SECONDS`; `0` sends every
request to the Worker.

**It does not reduce your request count.** Cloudflare is explicit: *"all requests
to your Worker are billed at the standard Workers request rate — the same
per-rate as any other request to your Worker — whether the response comes from
cache or from your Worker."* Caching cuts CPU time, D1 rows read, R2 Class B
operations and latency. It does not lower the free plan's 100,000 requests/day —
if you are hitting that ceiling, the $5/month paid plan (10M requests included)
is the lever, not the cache.

The free plan allows 5 purges/minute (bucket of 25), which is ample for one purge
per publish but not for a runaway republish loop. A rejected purge is logged
rather than raised — a publish must not fail over the cache — so the worst case
is a site serving its previous version for up to `EDGE_CACHE_SECONDS`. Deploying
the Worker also clears everything, because the Worker version is part of the
cache key unless you turn on `cross_version_cache`.

## Security model

sitedrop is single-tenant: every valid agent token may publish, update or delete
any site. Tokens are for handing to your own agents, not for sharing.

- Tokens are stored as SHA-256 hashes; the plaintext is shown once at creation.
  `last_used_at` is recorded (throttled to one write per hour).
- The admin UI never sets a cookie — it holds the admin token in
  `sessionStorage` and sends it as a header — so nothing on this origin can ride
  an ambient admin session.
- **Site passwords gate previews, they are not accounts.** They are hashed with
  HMAC-SHA256 under a server-side key rather than a real KDF, because the free
  plan's 10 ms CPU budget cannot run PBKDF2/scrypt at a useful cost factor. A
  database leak alone does not allow offline cracking (the key never leaves the
  Worker), but do not reuse a password that matters.
- The unlock cookie is bound to the current password hash: changing the password
  signs out everyone immediately.
- A protected site's subresources are refused when the `Referer` says the
  request came from a *different* path on this deployment, which stops another
  page on the same origin from riding the unlock cookie. Requests with no
  `Referer` are allowed — that is a deliberate, documented gap.
- **All sites share one origin.** `localStorage`, `sessionStorage` and cookie
  jars are per-origin, so a page published at `/a/` can read what a page at
  `/b/` stored. Do not host untrusted code next to something sensitive; turn on
  the per-site sandbox CSP (admin UI → Options) for pages you did not write.
- Uploaded paths are rejected for traversal (including percent-encoded),
  backslashes, control characters and padded segments. Executables
  (`.exe`, `.sh`, `.dll`, …) are skipped with a warning.
- Zip archives are checked against their central directory *before* anything is
  inflated, so a zip bomb is rejected without running the decompressor.
- Every site response carries `X-Robots-Tag: noindex` unless the site opts in,
  and `/robots.txt` disallows everything.

## Limits

Defaults, all configurable in `wrangler.jsonc`:

| Variable | Default | Meaning |
|---|---|---|
| `EDGE_CACHE_SECONDS` | 3600 | edge TTL for public sites; `0` disables edge caching |
| `LIMIT_MAX_FILES` | 40 | files in one request (zip or JSON) |
| `LIMIT_MAX_ZIP_BYTES` | 2 MB | archive size |
| `LIMIT_MAX_FILE_BYTES` | 5 MB | one file |
| `LIMIT_MAX_SITE_BYTES` | 25 MB | whole site |
| `KEEP_VERSIONS` | 3 | versions kept for rollback |

These keep a single request inside the free plan's 10 ms CPU and 50 subrequests.
Bigger sites are not blocked — they go through the batch API, which the CLI uses
automatically. On a paid plan you can raise all of them.

Cache purges are budgeted (5/minute on the free plan), so sitedrop issues
exactly one per publish.

## Development

```sh
npm install
cp .dev.vars.example .dev.vars   # fill in ADMIN_TOKEN and SESSION_SECRET
npm run dev

npm run typecheck
npm test                          # vitest, runs in workerd via miniflare
./scripts/conformance.sh          # MCP conformance suite
ADMIN_TOKEN=… ./scripts/smoke.sh https://<your-worker>   # against a real deploy
```

### MCP implementation notes

The server is a hand-written stateless Streamable HTTP endpoint (~200 lines, no
SDK). Deliberate deviations:

- never responds with SSE — every reply is one JSON body;
- ignores `Mcp-Session-Id` and `Last-Event-ID`, because there is no session;
- `GET`/`DELETE /mcp` return 405 (there is no stream to open);
- `401` carries no `WWW-Authenticate`, so clients do not start an OAuth
  discovery flow this server does not implement;
- JSON-RPC batching is refused with `-32600`, per the 2025-06-18 spec;
- business failures (bad slug, too many files, …) come back as `isError: true`
  tool results that say whether retrying helps — not as protocol errors.

Set `BASE_URL` to the deployment's canonical origin to enable strict
DNS-rebinding protection; without it the origin check falls back to the
request's own `Host` header.

## License

MIT