PLAN /api/drops/metadata HTTP/1.1
Status: approved, ready to implement
Scope: worker-side only; CLI follow-up separate
Pattern: Pattern 2 — server-generated IDs + metadata lookup
No-ship: upsert endpoint, ?if_exists=replace

Queryable drop metadata

First-class metadata field on every drop. Filterable on GET /api/drops. No upsert endpoint.

01Why

The CLI's flagship CI use case — "stable PR-preview URL across N pushes for {repo, pr}" — currently needs a 3-step workaround that uses the drop title as a synthetic external ID. Brittle, racy, ugly. This change makes external identifiers first-class.

3 → 2
API calls per push
0
new endpoints
1
new column

02Locked decisions

01 · Drop-level
Metadata lives on drops alongside title. Versions stay HTML-only history.
02 · No upsert
CLI does GET ?metadata.…PUT /:slug or POST. CI concurrency handles races.
03 · JSON TEXT column
SQLite/D1 JSON1 functions filter via json_extract. No side table in v1.
04 · PATCH = replace
Absent → untouched. {} → cleared. {k:"v"} → overwrite whole object.

03Pattern lineage

Pattern 2 — server-generated IDs + metadata/name lookup. The dominant model for "external system wants to address by its own identifier." Battle-tested.

GitHub Releases Notion API Linear API Terraform Cloud Stripe (metadata + Search)

Pattern 1 (client IDs, e.g. S3, Algolia, Firestore) breaks htmlbin's random-slug UX. Pattern 3 (single-call upsert-by-query, e.g. Salesforce, Mongo) has no meaningful HTTP-API precedent. We deliberately stay in Pattern 2.

04API surface

VerbPathChange
POST /api/drops Body accepts optional metadata: Record<string,string>. Stored on new drop. Default {}.
PUT /api/drops/:slug Body accepts optional metadata. Replaces drop-level metadata as side effect of new version. Absent → untouched.
PATCH /api/drops/:slug Body accepts optional metadata. Replace-whole semantics. Same forbid-html rule as today.
GET /api/drops Repeated metadata.<key>=<value> query params. AND semantics. Scoped under existing user_id clause.

05CLI flow — lookup → mutate

Steady-state PR push (drop exists)
GET /api/drops?metadata.repo=u/r&metadata.pr=42 PUT /api/drops/aB3xK7gPq v2 minted
First push (cold start)
GET → empty POST /api/drops (with metadata) slug minted

Two parallel CI pushes for the same {repo, pr} can race. Solved by GitHub Actions concurrency: group:. Documented in the workflow example.

06Schema

-- schema.sql + new migrations/ file
ALTER TABLE drops ADD COLUMN metadata TEXT NOT NULL DEFAULT '{}';

No new index in v1. D1 scale is small; filter scans WHERE user_id = ? first. Generated-column index later only if perf bites.

07Validation

Limits

  • ≤ 10 keys per drop
  • ≤ 64 chars per key
  • ≤ 256 chars per value
  • Key regex: /^[a-z0-9_]([a-z0-9_.-]*[a-z0-9_])?$/i
  • Values must be strings (no nested objects)

Conventional keys

  • repo — e.g. "utsengar/htmlbin1"
  • pr — e.g. "42"
  • sha — short or full git SHA
  • agent — e.g. "claude-code"
  • ci_run — workflow run ID

Convention only — server enforces neither names nor presence. All violations: 400 invalid_arg.

08Discoverability — single contract

Per the CLAUDE.md "discoverability surfaces" rule, every surface updates in the same PR:

09Test plan

  1. POST with metadata → round-trips on response and on GET by slug.
  2. PATCH metadata replaces. PATCH without metadata field → untouched.
  3. PUT with metadata updates as side effect of new version.
  4. GET filter: matches all keys (AND); misses when partial.
  5. Size violation → 400 invalid_arg.
  6. Bad key (reserved chars, length) → 400 invalid_arg.

10Out of scope (CLI repo, separate PR)

11Verification

# local + preview e2e
$ npm run test:e2e
$ BASE_URL=https://<preview-id>.htmlbin.workers.dev npm run test:e2e

# manual CLI-perspective smoke
$ curl -sS -H "Authorization: Bearer $HB" \
    "$BASE/api/drops?metadata.repo=u/r&metadata.pr=42"
# → { data: [], pagination: {…} }

$ curl -sS -H "Authorization: Bearer $HB" -X POST \
    "$BASE/api/drops" \
    -d '{"title":"PR #42","html":"…","metadata":{"repo":"u/r","pr":"42"}}'
# → { slug: "aB3xK7gPq", metadata: {repo, pr}, … }

$ curl -sS -H "Authorization: Bearer $HB" \
    "$BASE/api/drops?metadata.repo=u/r&metadata.pr=42"
# → { data: [{ slug: "aB3xK7gPq", … }] }

$ curl -sS -H "Authorization: Bearer $HB" -X PUT \
    "$BASE/api/drops/aB3xK7gPq" \
    -d '{"html":"…updated…"}'
# → { slug: "aB3xK7gPq", latest_version: 2, metadata: {repo, pr}, … }