sitedrop
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
[](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
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues