First-class metadata field on every drop. Filterable on GET /api/drops. No upsert endpoint.
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.
drops alongside title. Versions stay HTML-only history.GET ?metadata.… → PUT /:slug or POST. CI concurrency handles races.json_extract. No side table in v1.{} → cleared. {k:"v"} → overwrite whole object.Pattern 2 — server-generated IDs + metadata/name lookup. The dominant model for "external system wants to address by its own identifier." Battle-tested.
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.
| Verb | Path | Change |
|---|---|---|
| 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. |
Two parallel CI pushes for the same {repo, pr} can race. Solved by GitHub Actions concurrency: group:. Documented in the workflow example.
-- 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.
/^[a-z0-9_]([a-z0-9_.-]*[a-z0-9_])?$/irepo — e.g. "utsengar/htmlbin1"pr — e.g. "42"sha — short or full git SHAagent — e.g. "claude-code"ci_run — workflow run IDConvention only — server enforces neither names nor presence. All violations: 400 invalid_arg.
Per the CLAUDE.md "discoverability surfaces" rule, every surface updates in the same PR:
src/onboard.ts — JSON descriptor + markdown walkthrough. Document the lookup-then-mutate recipe.src/discoverability.ts — /openapi.json Drop schema + query params; agent-card capability fields.src/skill.ts + skills/htmlbin/SKILL.md — explain stable PR-URL pattern for agents.metadata field → untouched.400 invalid_arg.400 invalid_arg.htmlbin publish --metadata k=v flaghtmlbin list --filter k=v flagexamples/cloud-publish-workflow.yml to drop the find-by-title patternconcurrency: group: to the example workflow# 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}, … }