# Ephemera — HTTP API service on Cloudflare (Worker + bindings + CLI)

> Self-executing Markdown. The **Cloudflare binding** of the *http-service* intent — a request→response
> Worker that serves an API on a route/custom-domain and composes resource bindings (D1, KV, R2, AI, Vectorize,
> Durable Objects, Queues). Distinct from `web.cloudflare.md` (static/SPA assets) and `task-runner.cloudflare.md`
> (async queue/cron). The cloud is the source of truth; this file is intent + ledger + audit.

> **Provides / Requires**: **Provides** `http-service(WORKER_NAME @ URL)`. **Requires** zero-or-more upstream
> bindings — `r2-bucket` (`storage.cloudflare.md`), `d1-db` (`database.cloudflare.md`), `kv-namespace`,
> `vectorize-index` (`vector.cloudflare.md`), `workers-ai` (`inference.cloudflare.md`), `durable-object`
> (`realtime.cloudflare.md`), `queue` (`task-runner.cloudflare.md`) — each **discovered** by name at bind time.
>
> **Consumer precondition — CORS (contributed learning):** a browser app on another origin consuming this
> API works only if the Worker's CORS allowlist names that site's **exact origin(s)**. A `*.pages.dev` /
> preview origin is a *different* origin, NOT covered by allowing the site's custom domain — the full app
> flow (auth exchange, POST, SSE) stays CORS-blocked from a preview URL until that origin is (temporarily)
> allowed too. A web plan that Requires this service should assert "worker CORS allows this site's
> origin(s)" as an explicit precondition.

---

## 🤖 Director prompt

You are the Director. Execute this plan:
- Observe-before-act; verify each step before advancing
- Stop at every 🔴 GATE and 💥 for human "go"
- Write realized values + verify results back into Live State
- Teardown is resumable and never deletes a **bound** (upstream) resource — only the Worker this plan creates
- Use only the commands in this plan

> **Candor:** authored, **not yet dogfooded** — confirm exact `wrangler` flags against `--help` live (the CLI
> is the source of truth). Mark drift in Live State as you run.

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## Intent

A single Worker that answers HTTP requests — a JSON API, a webhook receiver, a BFF — on a stable URL, reading
and writing through resource bindings instead of network calls + IAM. One script, one deploy, seconds to
ship, instant rollback. The Worker is the **front door**; the data planes (R2/D1/KV/Vectorize/AI/DO/Queues)
are wired by *name* in `wrangler.jsonc`, not by ARNs or service accounts.

**Shared acceptance contract** (every http-service binding — CF Worker / AWS API GW+Lambda / GCP Cloud Run —
must pass):
1. `GET /healthz` → `200` + `{ "ok": true }`
2. a request that touches a **bound** resource returns live data (proves the binding wiring — e.g. `GET /ping-db`)
3. **(`ROUTING != workers-dev`)** the route / custom-domain serves: `https://<host>/healthz` → `200`
4. a **secret** is readable in-handler **and absent from `wrangler.jsonc`** (negative: config holds no secret value)

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | How is the service exposed? | `workers-dev` / `route` / `custom-domain` | `workers-dev` | `ROUTING` | §4 (exposure) + acceptance 3 |
| 2 | Does it need secrets? | `none` / `wrangler-secret` | `none` | `SECRETS` | §3 (`wrangler secret put`) + acceptance 4 |
| 3 | Edge observability/logs? | `on` / `off` | `on` | `OBSERVABILITY` | §5 (`observability` block) |
| 4 | Bot protection on public POSTs? | `none` / `turnstile` | `none` | `BOT_PROTECT` | §6 (Turnstile siteverify) |
| 5 | Environment | `dev` / `stg` / `uat` / `prod` | `dev` | `ENV` | every resource name (`*-${ENV}`) |

**Plus a bindings manifest** (composition — each optional, discovered then bound; default = none):

| Binding | Knob | Discover with | wrangler.jsonc key |
|---------|------|---------------|--------------------|
| R2 bucket | `BIND_R2` (name or empty) | `wrangler r2 bucket info <name>` | `r2_buckets` |
| D1 database | `BIND_D1` | `wrangler d1 info <name>` | `d1_databases` |
| KV namespace | `BIND_KV` | `wrangler kv namespace list` | `kv_namespaces` |
| Vectorize index | `BIND_VEC` | `wrangler vectorize get <name>` | `vectorize` |
| Workers AI | `BIND_AI` (`on`/empty) | (no resource — account feature) | `ai` |
| Durable Object | `BIND_DO` (class) | (declared in `realtime.cloudflare.md`) | `durable_objects` + `migrations` |
| Queue (producer) | `BIND_QUEUE` | `wrangler queues list` | `queues.producers` |

**Why `workers-dev` is the default:** a free, HTTPS `*.workers.dev` URL needs no zone and serves immediately —
the fastest path to "it answers." `route` binds the Worker to a path pattern on a zone you already own
(`api.example.com/*`); `custom-domain` provisions the hostname + cert as a Workers Custom Domain (the Worker
*is* the origin). Both `route`/`custom-domain` Require the zone (`domain.cloudflare.md`).

```yaml
# → written into Live State once resolved
resolved_inputs:
  routing:       workers-dev   # workers-dev | route | custom-domain
  secrets:       none          # none | wrangler-secret
  observability: on            # on | off
  bot_protect:   none          # none | turnstile
  bindings:      []            # subset of: r2 d1 kv vectorize ai durable-object queue
  env:           dev
  resolved_by:   <human who confirmed>
  resolved_at:   <timestamp>
```

## Tags & provenance (binding asymmetry)

**Cloudflare has no resource-tag API.** Provenance is carried by **naming** (`${SVC}-${ENV}`) + the Worker's
**`[vars]`** (`MANAGED_BY`, `SOURCE`, `PLAN_VERSION`, `ENVIRONMENT` — visible in the dashboard and at runtime)
+ `wrangler deploy --message "Source=service.cloudflare.md PlanVersion=2026-07-15"`. Bound upstream resources
keep **their own** provenance (set by their plans) — this plan tags nothing it didn't create.

## 0. Variables

```bash
export ENV="dev"
export ROUTING="workers-dev" SECRETS="none" OBSERVABILITY="on" BOT_PROTECT="none"
export SVC="api"                               # free-text identity
export WORKER_NAME="${SVC}-${ENV}"               # e.g. api-dev
export CONFIG="$PWD/${SVC}.jsonc"
export ROUTE_PATTERN="" ROUTE_ZONE=""            # ROUTING=route:  api.example.com/*  + example.com
export CUSTOM_DOMAIN=""                          # ROUTING=custom-domain:  api.example.com
# bindings manifest (empty => not bound)
export BIND_R2="" BIND_D1="" BIND_KV="" BIND_VEC="" BIND_AI="" BIND_DO="" BIND_QUEUE=""
# NOTE: `command wrangler` bypasses this machine's wrangler shell-fn so the ambient CLOUDFLARE_API_TOKEN is used.
```

## Requires-discovery (read-only — pre-fill bindings from the cloud)  ✔

```bash
# For each non-empty BIND_*, confirm the upstream resource EXISTS before wiring it. Empty result => the
# upstream plan hasn't been applied; stop and apply it first (the frontier is wrong otherwise).
[ -n "$BIND_R2" ]  && { command wrangler r2 bucket info "$BIND_R2" >/dev/null 2>&1 || { echo "R2 $BIND_R2 missing"; exit 1; }; }
[ -n "$BIND_D1" ]  && { command wrangler d1 info "$BIND_D1"       >/dev/null 2>&1 || { echo "D1 $BIND_D1 missing"; exit 1; }; }
[ -n "$BIND_VEC" ] && { command wrangler vectorize get "$BIND_VEC" >/dev/null 2>&1 || { echo "Vectorize $BIND_VEC missing"; exit 1; }; }
[ -n "$BIND_QUEUE" ] && { command wrangler queues list 2>&1 | grep -q "$BIND_QUEUE" || { echo "Queue $BIND_QUEUE missing"; exit 1; }; }
# → Live State: each discovered name (bound, NOT created here).
```

## Dependency frontier

```
[Requires: zone (route/custom-domain), bound R2/D1/KV/Vectorize/AI/DO/Queue] ── discovered ──┐
worker code (§1) ─┬─> wrangler.jsonc bindings (§2) ─> secrets (§3, 🟡) ─> 🔴 deploy+expose (§4) ─> ✔ acceptance
                  └─ observability (§5) / turnstile (§6) optional
```
Non-negotiable edges: **every bound resource exists before §2 wires it** (discover first); **secrets are set
before deploy** so the first request can read them; **the route/custom-domain needs the zone delegated**.
Teardown reverses: remove route/domain, delete the Worker; **leave bound resources alone**.

## 1. Worker code  🟢

```bash
mkdir -p "$(dirname "$CONFIG")/src"
cat > "$(dirname "$CONFIG")/src/${SVC}.js" <<'JS'
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === '/healthz') return Response.json({ ok: true, svc: env.SOURCE });
    if (url.pathname === '/ping-db' && env.DB) {
      const row = await env.DB.prepare('SELECT 1 AS up').first();
      return Response.json({ db: row?.up === 1 });
    }
    // ...your routes here; reach data planes via env.<BINDING> (env.ASSETS, env.KV, env.AI, env.VECTORIZE)...
    return Response.json({ error: 'not found' }, { status: 404 });
  }
};
JS
test -f "$(dirname "$CONFIG")/src/${SVC}.js" && echo "worker code written"   # ✔ verify
```

## 2. Bindings — `${SVC}.jsonc`  🟡

> The config *is* the wiring. Include only the blocks for bindings in the manifest (omit the rest).

```jsonc
{
  "name": "api-dev",
  "main": "src/api.js",
  "compatibility_date": "2025-04-01",
  "compatibility_flags": ["nodejs_compat"],
  "observability": { "enabled": true },
  "vars": { "MANAGED_BY": "ephemera", "SOURCE": "service.cloudflare.md", "PLAN_VERSION": "2026-07-15", "ENVIRONMENT": "dev" },
  // include per the manifest:
  "d1_databases": [ { "binding": "DB", "database_name": "<BIND_D1>", "database_id": "<discovered>" } ],
  "r2_buckets":   [ { "binding": "ASSETS", "bucket_name": "<BIND_R2>" } ],
  "kv_namespaces":[ { "binding": "KV", "id": "<discovered>" } ],
  "vectorize":    [ { "binding": "VECTORIZE", "index_name": "<BIND_VEC>" } ],
  "ai":           { "binding": "AI" },
  "queues":       { "producers": [ { "queue": "<BIND_QUEUE>", "binding": "TASK_QUEUE" } ] }
}
```
```bash
command wrangler deploy --config "$CONFIG" --dry-run 2>&1 | tail -5     # ✔ config valid (no deploy)
```

## 3. Secrets  🟡  *(`SECRETS=wrangler-secret` — value never enters the repo)*

```bash
# secrets are set out-of-band, encrypted at Cloudflare; they NEVER appear in $CONFIG or git.
# Always PIPE the value in — never argv / history / a temp file. Mint-or-capture straight into the pipe:
openssl rand -hex 32 | command wrangler secret put API_SIGNING_KEY --name "$WORKER_NAME"   # minted secret
printf '%s' "$SOME_TOKEN" | command wrangler secret put GITLAB_ACCESS_TOKEN --name "$WORKER_NAME"
# an API-returned secret: capture with `node -e 'process.stdout.write(...)'` (or python) piped directly in
command wrangler secret list --name "$WORKER_NAME"      # ✔ verify the KEY is present (value is never shown)
```
> → Live State: secret KEY names set (never the values). Acceptance 4 asserts the value is absent from $CONFIG.

## 4. Deploy + expose  🔴 (outward-facing)

> 🔴 Human go — publishes a public endpoint. Print the URL/route, then deploy. Exposure is set in the config's
> `routes`/`workers_dev` (edit before deploy):
> - `workers-dev` → `"workers_dev": true` → `https://api-dev.<subdomain>.workers.dev`
> - `route` → `"routes": [ { "pattern": "api.example.com/*", "zone_name": "example.com" } ]`
> - `custom-domain` → `"routes": [ { "pattern": "api.example.com", "custom_domain": true } ]` (provisions cert+DNS)

```bash
command wrangler deploy --config "$CONFIG" --message "Source=service.cloudflare.md PlanVersion=$PLAN_VERSION"
# → Live State: URL/route; status: live
```

## 5. Observability  🟡  *(`OBSERVABILITY=on` — default; logs + analytics)*

> Set by the `"observability": { "enabled": true }` block in §2; no separate call. Tail live with:
```bash
command wrangler tail "$WORKER_NAME" --format pretty     # ✔ live request logs (Ctrl-C to stop)
```

## 6. Turnstile bot protection  🟡  *(`BOT_PROTECT=turnstile` — gate public POSTs)*

> Create a Turnstile widget (dashboard or API) → put the **secret key** via `wrangler secret put
> TURNSTILE_SECRET`; the handler calls `siteverify` before processing. The site key is a `[vars]`/client value
> (public); the secret key is a Worker secret (§3). See `web.cloudflare.md` for the denial-of-wallet rationale.
>
> ⚠ **Never deploy Cloudflare's published always-pass Turnstile TEST key pair.** The test sitekey/secret are
> for `wrangler dev` only — setting the *deployed* Worker's `TURNSTILE_SECRET` to the public test secret
> **silently disables the bot gate** on a live endpoint (contributed learning; a deploy-time review caught it
> live). General rule: any "test mode always succeeds" credential never reaches a deployed env.

```js
// in fetch, before mutating routes:
const tok = (await request.formData()).get('cf-turnstile-response');
const v = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify',
  { method:'POST', body: new URLSearchParams({ secret: env.TURNSTILE_SECRET, response: tok }) }).then(r=>r.json());
if (!v.success) return new Response('blocked', { status: 403 });
```

## Acceptance verify  ✔  *(the shared contract)*

```bash
URL="https://api-dev.<subdomain>.workers.dev"   # or the route/custom-domain, realized in §4
curl -sS "$URL/healthz" | grep -q '"ok":true' && echo "1: healthz OK"
[ -n "$BIND_D1" ] && curl -sS "$URL/ping-db" | grep -q '"db":true' && echo "2: binding OK"
# 4: NEGATIVE — the config must NOT contain a secret value:
! grep -qiE 'token|secret|sk_live|pk_live' "$CONFIG" && echo "4: no secret in config OK"
```
> → write Live State: status: live; fill the verify rows (incl. the negative).

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   authored — http-service Worker (routing/secrets/observability/turnstile knobs + bindings manifest)
last_verified: —
resolved_inputs: { routing: workers-dev, secrets: none, observability: on, bot_protect: none, bindings: [], env: dev }
```

| key          | value (filled on apply) |
|--------------|-------------------------|
| WORKER_NAME  | `${SVC}-${ENV}` |
| URL / ROUTE  | `—` |
| bound        | `—` (discovered names: R2/D1/KV/Vectorize/AI/DO/Queue) |
| secrets set  | `—` (KEY names only — never values) |

| ✔ check                       | expected                                   | observed | result |
|-------------------------------|--------------------------------------------|----------|--------|
| `GET /healthz`                | `200` + `{"ok":true}`                       | —        | — |
| bound resource reachable      | live data through the binding               | —        | — |
| route/custom-domain serves    | `200` (when `ROUTING != workers-dev`)       | —        | — |
| no secret in config           | `$CONFIG` contains no secret value (negative)| —        | — |

## Update (idempotent reconcile)

- New code/config → re-run §4 `wrangler deploy` (atomic; `wrangler rollback` reverts).
- Add a binding → discover it (Requires-discovery), add the block to §2, redeploy.
- Rotate a secret → `wrangler secret put <KEY>` again (overwrites); no redeploy needed.

## Teardown (observe-first, resumable)  💥

> 💥 Human go. Remove exposure, then delete the Worker. **Bound R2/D1/KV/Vectorize/Queue/DO are upstream —
> their own plans own teardown; do NOT delete them here.** Secrets die with the Worker.

```bash
# routes/custom-domains attached via config are removed when the Worker is deleted; an explicit route:
[ "$ROUTING" = route ] && command wrangler triggers delete --routes "$ROUTE_PATTERN" 2>/dev/null || true
command wrangler delete --config "$CONFIG"           # removes the Worker + its secrets + workers.dev route
```
```bash
command wrangler deployments list --name "$WORKER_NAME" 2>&1 | grep -qi "not found\|no deployments" && echo "service gone"  # ✔
```
> → Live State: status: gone; clear realized ids. Bound upstream resources untouched.

---

## Portability ledger — same intent, three bindings

| | AWS (`service.aws.md`, Lambda) | Cloudflare (`service.cloudflare.md`, Worker) | GCP (`service.gcp.md`, Cloud Run) |
|---|---|---|---|
| Components | Lambda + IAM role (Function URL default; HTTP API + Cognito JWT for gated APIs) | **1 Worker** — `fetch` handler, no gateway | Cloud Run service + IAM SA |
| Data-plane wiring | IAM + ARNs threaded per resource | **bindings by name** in `wrangler.jsonc` | env vars + IAM SA per API |
| Secrets | Secrets Manager / SSM + IAM read | `wrangler secret put` (encrypted, in-Worker) | Secret Manager + IAM |
| Custom domain | API GW custom domain + ACM + Route53 | `custom_domain: true` (cert+DNS auto) | domain mapping + managed cert |
| Cold start | Lambda cold starts | **none** (V8 isolates, ~0ms) | container cold starts |
| Auth API→fn | Lambda resource policy (source-ARN) | none — same isolate; binding is the boundary | IAM invoker |
| Tags | real resource tags | **none** — naming + `[vars]` + `--message` | labels |

## Deliberately not included

- **Static asset serving** — that's `web.cloudflare.md` (Workers Static Assets / `SITE_TYPE`). A service can
  *also* serve assets via the `assets` binding, but a pure API stays lean.
- **Async/background processing** — `task-runner.cloudflare.md` (`queue`/`workflow`/cron). This plan is the
  synchronous front door; it can *produce* to a Queue (manifest `BIND_QUEUE`) but doesn't host the consumer.
- **Multi-service service-bindings mesh** — Worker-to-Worker `services` bindings (RPC) are a composition
  concern; see the multi-Worker recipe in `docs/recipes/`.
- **Rate limiting beyond Turnstile** — WAF rate-limit rules / the Rate Limiting binding; noted as a knob, not wired.
