{"openapi":"3.1.0","info":{"title":"Dropper API","version":"0.1.0","description":"Drop a static page, get a URL. Upload a single .html page, a single .md Markdown page, or a .zip of a static site; it is served publicly at /d/{user}/{slug}/, and a .md file inside a drop renders as a styled page for browsers while curl and agents keep the stored bytes. The drop-surface routes (/api/drops, /api/labels, /api/stats/summary, /api/trash, /api/export) require a bearer credential — a personal API key, or an OAuth access token issued to an MCP connector or to dropper login (drops are scoped to the credential owner either way); the account-identity routes (PATCH /api/profile, /api/connections) are session-only — every bearer is refused there; /d routes are public. GET /api/me answers any accepted credential with the account and the credential kind behind it. The admin routes (/api/admin/users, /api/admin/users/{handle} — the data behind the Admin › Users page) are admin-only AND session-only: a signed-in session whose email is in DROPPER_ADMIN_EMAILS; any bearer is a hard 401, a non-admin session a 403 before anything else is looked at. A suspended account answers 403 {\"error\":\"account suspended\"} on every route, its credentials otherwise valid. Limits: 50 MB upload, 200 MB extracted, 2000 zip entries, 20 MB per file on the single-file write route (PUT /api/drops/{slug}/files/{path}), 1 MB per .md file rendered as a page and 4 MB for the rendered page itself — over either, the file is served as stored bytes."},"servers":[{"url":"/"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A personal API key minted in the dashboard (API Keys page), or an OAuth access token issued to an MCP connector or to dropper login (scope \"drops\", audience <origin>/mcp). Browser sessions signed in via Authelia work too — the dashboard rides its session cookie instead of a bearer."},"cookieAuth":{"type":"apiKey","in":"cookie","name":"better-auth.session_token","description":"A signed-in dashboard session (Authelia OIDC or dev login; __Secure- prefixed over HTTPS). Account-identity operations accept ONLY this: presenting any bearer there — API key or MCP access token — is a hard 401, so a leaked credential stays confined to the drop surface."}},"schemas":{"DropInfo":{"type":"object","required":["slug","owner","name","createdAt","kind","files","bytes","protected","public","archived"],"properties":{"slug":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,63}$"},"owner":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$","description":"Handle of the owning user"},"name":{"type":"string","description":"Display name — shown in the drops list and used as the title of a rendered Markdown page (a trailing .md is dropped). Defaults to the uploaded filename."},"description":{"type":"string","maxLength":200,"description":"A short description (trimmed; max 200 chars). Public, so the gallery can show it: every reply that carries the drop has it — the gallery listing (public=1), the admin all=1 listing, an admin owner= view and the per-file PUT/DELETE results included. Absent when none is set."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time","description":"Stamped when the served drop changes: new content (PATCH with a file, single-file writes and deletes) or a new slug. Metadata edits (name, description, labels, password, public, archived) keep it. Absent until the first such change"},"kind":{"type":"string","enum":["html","zip","md"],"description":"html = a single page, zip = a site, md = a single Markdown page (stored as index.md, rendered at the drop root). Flips to zip once a single-file write leaves the drop holding more than one file, and never flips back."},"files":{"type":"integer","description":"Number of files in the drop (recomputed from disk after every single-file write or delete)"},"bytes":{"type":"integer","description":"Total size of the drop’s files in bytes (recomputed the same way)"},"protected":{"type":"boolean","description":"Whether an access code is set (hashes are never returned)"},"password":{"type":"string","description":"The access code in the clear — owner-only. Present just on your own drops: create, edit, and the default listing scope. Never on the gallery listing (public=1), the admin all=1 listing, an admin owner= view, or the per-file PUT/DELETE results under …/files/{path}. Codes are sealed at rest with a key derived from the server secret; one set before drops began storing that seal (or sealed under a since-rotated secret) is simply absent here — set the code again to make it readable."},"labels":{"type":"array","items":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}(/[a-z0-9][a-z0-9-]{0,31}){0,2}$"},"description":"The drop’s label paths (e.g. \"acme/site\"), sorted — owner-only, like password. Present on your own drops — create, edit, restore and the default listing scope — as an empty array when the drop has none. Never on the gallery listing (public=1), the admin all=1 listing, an admin owner= view, or the per-file PUT/DELETE results under …/files/{path}. Only the most specific label is listed: a drop labelled acme/site belongs to acme too (the label= filter and the GET /api/labels counts include sublabels) without acme appearing here. A label renamed, moved or deleted since shows its current path — a deleted sublabel’s drops show its parent’s."},"public":{"type":"boolean","description":"Whether the drop is discoverable in the cross-user gallery listing (GET /api/drops?public=1). Controls discoverability only — it never changes /d/ URL access or access-code behavior."},"archived":{"type":"boolean","description":"Whether the drop is retired from serving. An archived drop 404s at /d/ and on its thumbnail, and drops out of the gallery — but it keeps its slug reserved, still appears in your own listing, and its files stay readable through GET /api/drops/{slug}/files. Toggle with the archived field on PATCH."}}},"DropResult":{"allOf":[{"type":"object","required":["slug","owner","name","createdAt","kind","files","bytes","protected","public","archived"],"properties":{"slug":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,63}$"},"owner":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$","description":"Handle of the owning user"},"name":{"type":"string","description":"Display name — shown in the drops list and used as the title of a rendered Markdown page (a trailing .md is dropped). Defaults to the uploaded filename."},"description":{"type":"string","maxLength":200,"description":"A short description (trimmed; max 200 chars). Public, so the gallery can show it: every reply that carries the drop has it — the gallery listing (public=1), the admin all=1 listing, an admin owner= view and the per-file PUT/DELETE results included. Absent when none is set."},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time","description":"Stamped when the served drop changes: new content (PATCH with a file, single-file writes and deletes) or a new slug. Metadata edits (name, description, labels, password, public, archived) keep it. Absent until the first such change"},"kind":{"type":"string","enum":["html","zip","md"],"description":"html = a single page, zip = a site, md = a single Markdown page (stored as index.md, rendered at the drop root). Flips to zip once a single-file write leaves the drop holding more than one file, and never flips back."},"files":{"type":"integer","description":"Number of files in the drop (recomputed from disk after every single-file write or delete)"},"bytes":{"type":"integer","description":"Total size of the drop’s files in bytes (recomputed the same way)"},"protected":{"type":"boolean","description":"Whether an access code is set (hashes are never returned)"},"password":{"type":"string","description":"The access code in the clear — owner-only. Present just on your own drops: create, edit, and the default listing scope. Never on the gallery listing (public=1), the admin all=1 listing, an admin owner= view, or the per-file PUT/DELETE results under …/files/{path}. Codes are sealed at rest with a key derived from the server secret; one set before drops began storing that seal (or sealed under a since-rotated secret) is simply absent here — set the code again to make it readable."},"labels":{"type":"array","items":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}(/[a-z0-9][a-z0-9-]{0,31}){0,2}$"},"description":"The drop’s label paths (e.g. \"acme/site\"), sorted — owner-only, like password. Present on your own drops — create, edit, restore and the default listing scope — as an empty array when the drop has none. Never on the gallery listing (public=1), the admin all=1 listing, an admin owner= view, or the per-file PUT/DELETE results under …/files/{path}. Only the most specific label is listed: a drop labelled acme/site belongs to acme too (the label= filter and the GET /api/labels counts include sublabels) without acme appearing here. A label renamed, moved or deleted since shows its current path — a deleted sublabel’s drops show its parent’s."},"public":{"type":"boolean","description":"Whether the drop is discoverable in the cross-user gallery listing (GET /api/drops?public=1). Controls discoverability only — it never changes /d/ URL access or access-code behavior."},"archived":{"type":"boolean","description":"Whether the drop is retired from serving. An archived drop 404s at /d/ and on its thumbnail, and drops out of the gallery — but it keeps its slug reserved, still appears in your own listing, and its files stay readable through GET /api/drops/{slug}/files. Toggle with the archived field on PATCH."}}},{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"Public URL of the drop"}}}]},"DropListItem":{"allOf":[{"$ref":"#/components/schemas/DropResult"},{"type":"object","properties":{"views30d":{"type":["integer","null"],"description":"Your own listing with views=1 only: views over the last 30 UTC days — the admin roster’s window, 30 UTC-day buckets ending now (the first starts at UTC midnight 29 days back, the last is today so far), so it approximates a rolling 30 days. 0 for a tracked drop with no views in the window; null for a drop without a statistics id (created before view statistics existed and not edited since) and for every row when statistics are unavailable — the listing itself still answers. Absent without views=1 and on public=1 and all=1 rows."}}}]},"DropFile":{"type":"object","required":["path","bytes","contentType"],"properties":{"path":{"type":"string","description":"Path inside the drop, e.g. \"assets/app.css\""},"bytes":{"type":"integer"},"contentType":{"type":"string"}}},"DropFileWrite":{"type":"object","required":["file","drop"],"description":"Result of a per-file write: the file as it now sits in the drop, and the drop’s updated bookkeeping.","properties":{"file":{"$ref":"#/components/schemas/DropFile"},"drop":{"$ref":"#/components/schemas/DropResult"}}},"ConnectedApp":{"type":"object","required":["id","clientId","name","scopes","grantedAt"],"properties":{"id":{"type":"string","description":"Consent id — the handle DELETE /api/connections takes"},"clientId":{"type":"string","description":"OAuth client id. An HTTPS metadata-document URL for clients discovered through CIMD, an opaque id for RFC 7591 registrations"},"name":{"type":"string","description":"The client's registered display name, or the clientId when it has none"},"clientUri":{"type":"string","format":"uri","description":"The client's homepage, when it registered one"},"scopes":{"type":"array","items":{"type":"string"},"description":"Granted scopes, e.g. [\"drops\", \"offline_access\"]"},"grantedAt":{"type":"string","format":"date-time","description":"When consent was given"}}},"TrashEntry":{"type":"object","required":["id","slug","name","deletedAt","expiresAt","kind","files","bytes","protected","public","archived"],"properties":{"id":{"type":"string","description":"Trash id (`<original-slug>.<epoch-ms>`) — the handle POST/DELETE /api/trash/{id} take"},"slug":{"type":"string","description":"The slug the drop lived at when it was deleted; free for reuse in the meantime"},"name":{"type":"string","description":"Display name it was deleted with"},"deletedAt":{"type":"string","format":"date-time","description":"When the drop was moved to the trash"},"expiresAt":{"type":"string","format":"date-time","description":"When the retention sweep may reclaim it — 30 days after deletedAt. Purging is opportunistic, so an entry can outlive it"},"kind":{"type":"string","enum":["html","zip","md"]},"files":{"type":"integer"},"bytes":{"type":"integer"},"protected":{"type":"boolean","description":"Whether it had an access code (preserved through delete and restore; never returned here)"},"public":{"type":"boolean","description":"Gallery flag it will come back with"},"archived":{"type":"boolean","description":"Whether it was archived when deleted — restore brings it back archived"}}},"DropStats":{"type":"object","required":["range","tracked","totals","series","breakdowns"],"description":"Per-drop view statistics. Carries no drop fields. Every numeric field is a JSON number regardless of the database backend.","properties":{"range":{"type":"object","required":["preset","start","end","bucket"],"properties":{"preset":{"type":"string","enum":["24h","7d","30d","90d"]},"start":{"type":"string","format":"date-time","description":"Inclusive start of the first bucket (UTC)"},"end":{"type":"string","format":"date-time","description":"Exclusive end — the time the query ran"},"bucket":{"type":"string","enum":["hour","day"]}}},"tracked":{"type":"boolean","description":"false for a drop created before view statistics existed and not edited since: it records views from its next edit or re-create onward, and every count here is zero. true otherwise."},"totals":{"type":"object","required":["views","visitors"],"properties":{"views":{"type":"integer","description":"Counted HTML page views in the range — including a .md file rendered as a page for a browser, never its raw bytes (bots and locked views excluded)"},"visitors":{"type":"integer","description":"Distinct visitors in the range — a hash of client address + user agent that rotates at UTC midnight, so any range crossing UTC midnight (the 24h preset nearly always does, and every multi-day range) approximates: a returning visitor counts once per UTC day"}}},"series":{"type":"array","description":"Dense: exactly one entry per bucket in the range (24 hours, or 7/30/90 days), zeros where nothing was recorded, the current partial bucket last","items":{"$ref":"#/components/schemas/StatsBucket"}},"breakdowns":{"type":"object","required":["referrers","browsers","devices"],"properties":{"referrers":{"type":"array","description":"Referrer hosts, most views first, at most 10; same-origin and referrer-less views never appear","items":{"$ref":"#/components/schemas/StatsBreakdownItem"}},"browsers":{"type":"array","description":"Browser families (Chrome, Safari, Firefox, Edge, Other), most views first","items":{"$ref":"#/components/schemas/StatsBreakdownItem"}},"devices":{"type":"array","description":"Device classes (mobile, desktop), most views first","items":{"$ref":"#/components/schemas/StatsBreakdownItem"}}}}}},"StatsBucket":{"type":"object","required":["start","views","visitors"],"properties":{"start":{"type":"string","format":"date-time","description":"UTC boundary the bucket starts at"},"views":{"type":"integer"},"visitors":{"type":"integer","description":"Distinct visitors within this bucket"}}},"StatsBreakdownItem":{"type":"object","required":["name","count"],"properties":{"name":{"type":"string"},"count":{"type":"integer","description":"Views in the range"}}},"StatsSummary":{"type":"object","required":["range","views","top"],"description":"View totals across your own drops for one preset range — the dashboard’s stats tiles. Every count is a JSON number.","properties":{"range":{"type":"string","enum":["24h","7d","30d","90d"],"description":"The preset the totals cover"},"views":{"type":"integer","description":"Counted page views in the range, summed over your non-trashed drops (archived included) — counted exactly as DropStats.totals.views; a drop without a statistics id adds 0"},"top":{"type":["object","null"],"required":["slug","name","views"],"description":"The drop with the most views in the range, a tie going to the slug that sorts first; null when no drop has a view","properties":{"slug":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,63}$"},"name":{"type":"string","description":"Its display name"},"views":{"type":"integer"}}}}},"Label":{"type":"object","required":["id","name","path","parent","color","drops"],"description":"One of your labels as GET /api/labels lists it.","properties":{"id":{"type":"string","description":"Stable label id — what PATCH/DELETE /api/labels/{id} take and what parent names. A rename or move never changes it"},"name":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$","description":"The last name of the path; unique among its siblings only"},"path":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}(/[a-z0-9][a-z0-9-]{0,31}){0,2}$","description":"Names from the top level down, joined with \"/\" — the form drops carry, the labels field takes and label= filters by"},"parent":{"type":["string","null"],"description":"Parent label id; null at the top level"},"color":{"type":"string","enum":["blue","green","purple","orange","cyan","red","gold","magenta","lime","grey"],"description":"Resolved: only top-level labels store a colour, and a sublabel shows its top-level label’s"},"drops":{"type":"integer","description":"Distinct non-trashed drops (archived included) carrying this label or any of its sublabels — a drop under two of its sublabels counts once"}}},"AdminUserSummary":{"type":"object","required":["handle","name","email","role","createdAt","lastLoginAt","suspendedAt","pagesHidden","hasDirectory","drops","trash","apiKeys","connectedApps","sessions","views30d"],"description":"One account as the Admin › Users page lists it — usage metadata only. Carries no drop id, no key material, no session token, no client secret and no better-auth user id; the handle is the identifier. Every timestamp is ISO-8601 UTC.","properties":{"handle":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"name":{"type":"string"},"email":{"type":"string"},"role":{"type":"string","enum":["user","admin"],"description":"Computed live from DROPPER_ADMIN_EMAILS for this request — never the persisted display cache the menu follows"},"createdAt":{"type":"string","format":"date-time","description":"When the account was created"},"lastLoginAt":{"type":["string","null"],"format":"date-time","description":"Last cookie sign-in (dev login or Authelia). Accounts that predate the column fall back to their newest session; null = never. API-key and connector use never counts as a sign-in"},"suspendedAt":{"type":["string","null"],"format":"date-time","description":"When the account was suspended; null while active"},"pagesHidden":{"type":"boolean","description":"Whether the <handle>/.pages-hidden marker is on disk: every /d/ URL, thumbnail and gallery entry of theirs answers 404. Reflects the filesystem, not the row — after a database-only restore it can be true on an active account; unsuspending, or suspended: true with hidePages: false, clears it"},"hasDirectory":{"type":"boolean","description":"False for an account that never uploaded — not an anomaly"},"drops":{"type":"object","required":["count","bytes","publicCount","protectedCount","archivedCount","lastChangeAt"],"description":"Live drops, archived included (they still occupy disk); the trash is the separate object","properties":{"count":{"type":"integer"},"bytes":{"type":"integer","description":"Content bytes: the sum of each live drop’s bytes — meta.json and thumbnails excluded, no disk walk"},"publicCount":{"type":"integer"},"protectedCount":{"type":"integer"},"archivedCount":{"type":"integer"},"lastChangeAt":{"type":["string","null"],"format":"date-time","description":"Newest of createdAt / updatedAt across live drops and deletedAt across the trash; null with neither"}}},"trash":{"type":"object","required":["count","bytes"],"description":"Counts only — trash entries never reach the admin","properties":{"count":{"type":"integer"},"bytes":{"type":"integer"}}},"apiKeys":{"type":"object","required":["count","enabledCount","lastUsedAt"],"properties":{"count":{"type":"integer"},"enabledCount":{"type":"integer"},"lastUsedAt":{"type":["string","null"],"format":"date-time","description":"Newest lastRequest across their keys; null when no key was ever used"}}},"connectedApps":{"type":"integer","description":"OAuth consents on file (claude.ai connectors and the like)"},"sessions":{"type":"object","required":["active","latestCreatedAt"],"properties":{"active":{"type":"integer","description":"Sessions whose expiresAt is still in the future — zero right after a suspend"},"latestCreatedAt":{"type":["string","null"],"format":"date-time","description":"createdAt of the newest active session; null with none"}}},"views30d":{"type":["integer","null"],"description":"Views across their live drops over the last 30 UTC days — 30 UTC-day buckets ending now: the first starts at UTC midnight 29 days back, the last is today so far, so the count approximates a rolling 30 days. null when statistics are unavailable (viewsAvailable false)"}}},"AdminOrphanDirectory":{"type":"object","required":["handle","drops","bytes"],"description":"A valid-handle directory under the drops directory that no account claims — left behind by a deleted account or an interrupted rename. Reported, never acted on.","properties":{"handle":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"drops":{"type":"integer"},"bytes":{"type":"integer"}}},"AdminUsersResponse":{"type":"object","required":["users","orphanDirectories","viewsAvailable","generatedAt"],"properties":{"users":{"type":"array","description":"Sorted by handle","items":{"$ref":"#/components/schemas/AdminUserSummary"}},"orphanDirectories":{"type":"array","items":{"$ref":"#/components/schemas/AdminOrphanDirectory"}},"viewsAvailable":{"type":"boolean","description":"False when the grouped 30-day views query failed — every views30d is then null and the rest of the listing is intact"},"generatedAt":{"type":"string","format":"date-time"}}},"AdminDropRow":{"type":"object","required":["slug","name","kind","files","bytes","createdAt","protected","public","archived","views30d"],"description":"One live drop as metadata: the slug as text — no url, no owner, no id, no file list, no access code","properties":{"slug":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,63}$"},"name":{"type":"string"},"kind":{"type":"string","enum":["html","zip","md"]},"files":{"type":"integer"},"bytes":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"protected":{"type":"boolean"},"public":{"type":"boolean"},"archived":{"type":"boolean"},"views30d":{"type":["integer","null"],"description":"Last 30 UTC days, approximate; null for a drop without a stable id and for every drop when statistics are unavailable"}}},"AdminApiKeyRow":{"type":"object","required":["name","start","prefix","enabled","createdAt","lastRequest","expiresAt","requestCount"],"properties":{"name":{"type":["string","null"]},"start":{"type":["string","null"],"description":"The masked key start the Keys page shows — never the key or its hash"},"prefix":{"type":["string","null"]},"enabled":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"},"lastRequest":{"type":["string","null"],"format":"date-time","description":"Last use; null when never used"},"expiresAt":{"type":["string","null"],"format":"date-time","description":"null never expires"},"requestCount":{"type":"integer","description":"The api-key plugin’s rate-limit window counter — stays 0 with rate limiting disabled"}}},"AdminConnectedAppRow":{"type":"object","required":["clientId","name","scopes","grantedAt"],"properties":{"clientId":{"type":"string"},"name":{"type":"string","description":"The client’s registered display name, else the clientId"},"scopes":{"type":"array","items":{"type":"string"}},"grantedAt":{"type":"string","format":"date-time"}}},"AdminSessionRow":{"type":"object","required":["createdAt","updatedAt","expiresAt","ipAddress","userAgent"],"description":"An active session (expiresAt in the future). No token, no id.","properties":{"createdAt":{"type":"string","format":"date-time","description":"The sign-in moment"},"updatedAt":{"type":"string","format":"date-time","description":"Last activity, bumped at most daily"},"expiresAt":{"type":"string","format":"date-time"},"ipAddress":{"type":["string","null"]},"userAgent":{"type":["string","null"]}}},"AdminUserDetail":{"type":"object","required":["user","drops","trash","apiKeys","connectedApps","sessions","viewsAvailable"],"properties":{"user":{"$ref":"#/components/schemas/AdminUserSummary"},"drops":{"type":"array","description":"Newest first","items":{"$ref":"#/components/schemas/AdminDropRow"}},"trash":{"type":"object","required":["count","bytes"],"description":"Counts only — never the entries","properties":{"count":{"type":"integer"},"bytes":{"type":"integer"}}},"apiKeys":{"type":"array","description":"Newest first","items":{"$ref":"#/components/schemas/AdminApiKeyRow"}},"connectedApps":{"type":"array","description":"Newest grant first","items":{"$ref":"#/components/schemas/AdminConnectedAppRow"}},"sessions":{"type":"array","description":"Active only, newest first","items":{"$ref":"#/components/schemas/AdminSessionRow"}},"viewsAvailable":{"type":"boolean"}}},"Identity":{"type":"object","required":["handle","name","email","role","credential"],"description":"Who the presented credential belongs to, and which credential it is.","properties":{"handle":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"name":{"type":"string","description":"Display name"},"email":{"type":"string","description":"The account’s email"},"role":{"type":"string","enum":["user","admin"],"description":"The live role: admin while the email is in DROPPER_ADMIN_EMAILS, re-read on every request"},"credential":{"description":"The credential that answered, told apart by kind","oneOf":[{"type":"object","required":["kind","name","start","createdAt","expiresAt"],"properties":{"kind":{"type":"string","const":"api_key"},"name":{"type":["string","null"],"description":"The key’s name; null for an unnamed key"},"start":{"type":["string","null"],"description":"The key’s first characters, as the API Keys page shows them — never the full key"},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":["string","null"],"format":"date-time","description":"When the key expires (UTC); null when it never does"}}},{"type":"object","required":["kind","scopes"],"properties":{"kind":{"type":"string","const":"oauth"},"scopes":{"type":"array","items":{"type":"string"},"description":"The OAuth token’s granted scopes, e.g. [\"drops\", \"offline_access\"]"}}},{"type":"object","required":["kind"],"properties":{"kind":{"type":"string","const":"session"}}}]}}},"VersionInfo":{"type":"object","required":["version","releasedAt","commit"],"description":"The deployment’s version and release stamp — baked into the image at build time, shown at the bottom of the dashboard sidebar.","properties":{"version":{"type":"string","description":"MAJOR.MINOR.PATCH for a tagged release, 0.0.0-<branch>.<short sha> for a branch build, dev when the image carries no stamp"},"releasedAt":{"type":["string","null"],"format":"date-time","description":"When the image was built (UTC); null when the image carries no stamp"},"commit":{"type":["string","null"],"description":"The full git commit the image was built from; null when the image carries no stamp"}}},"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}}}},"paths":{"/api/drops":{"post":{"operationId":"createDrop","summary":"Create a drop in your namespace","description":"Upload a single .html page, a single .md Markdown page, or a .zip of a static site. A .md is stored as index.md and its URL renders as a styled page for browsers while ?raw=1 (and curl) still gets the source. Zips are extracted server-side and served S3-style: the entry page at the drop root, assets and nested pages beside it. The drop lands in your namespace — its public URL is /d/<your-handle>/<slug>/ and slugs only collide with your own.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","description":"An .html page, a .md Markdown page or a .zip of a static site (50 MB max upload). The kind comes from this filename; a .md over the 1 MB render cap is still accepted and always serves its source."},"slug":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,63}$","description":"Custom slug (lowercase letters, digits, hyphens; max 64 chars). Random 8 chars when omitted."},"name":{"type":"string","maxLength":256,"description":"Display name (trimmed; max 256 chars) — shown in the drops list and used as the title of a rendered Markdown page (a trailing .md is dropped). Blank or omitted falls back to the uploaded filename. Never affects the kind."},"description":{"type":"string","maxLength":200,"description":"Short description (trimmed; max 200 chars) — public: the gallery shows it when the drop is public. Blank or omitted means none."},"labels":{"type":"string","description":"Label paths, comma-separated: `acme/site,personal`. A path is at most 3 names joined with `/`, each name lowercase letters, digits and hyphens (1–32 chars, starting with a letter or digit); every path is trimmed and empty entries are skipped, nothing is otherwise normalised. A label that does not exist yet is created along with its missing ancestors (a new top-level label gets the default colour, blue), and a label sent together with one of its ancestors keeps only the descendant. The field carries at most 20 paths, so a drop holds at most 20 labels. A malformed path, one deeper than 3 levels, or more than 20 paths is a 400 before anything is written. Blank or omitted means no labels."},"password":{"type":"string","maxLength":256,"description":"Access code (trimmed; max 256 chars). Blank means unprotected."},"public":{"type":"string","enum":["true","false"],"description":"Gallery visibility. Parsed strictly: exactly the string \"true\" or \"false\" — any other value (blank included) is a 400. Omitted means private. Discoverability only — it never changes /d/ URL access or access-code behavior."}}}}}},"responses":{"201":{"description":"Drop created — returns the drop info plus its public url","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DropResult"}}}},"400":{"description":"Missing file, wrong file type, a name over 256 chars, a description over 200 chars, a malformed or too-deep label path, more than 20 label paths, invalid slug, invalid public value, or invalid zip","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Slug already taken in your namespace","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Upload over 50 MB, extracted content over 200 MB, or more than 2000 files","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"operationId":"listDrops","summary":"List your drops, newest first","description":"Your drops, newest first — same shape as create, including owner and url. The protected flag signals password protection (hashes are never returned); the public flag signals gallery visibility; the archived flag marks drops retired from serving, which stay in this listing (they are yours) but 404 at their public URL. Every row carries description when one is set; your own rows also carry labels, the drop’s label paths ([] when none), which gallery and all=1 rows never do — and label=<path> narrows your own listing to the drops carrying that label or any of its sublabels. On your own drops a password field carries the access code in the clear: codes are sealed at rest with a key derived from the server secret and returned to the owner only, so the field never appears on the gallery listing (public=1), the admin all=1 listing, or an admin owner= view — and a code set before drops started storing that seal stays unreadable until you set it again. With views=1 your own rows also carry views30d, each drop’s views over the last 30 UTC days (null when untracked or when statistics are unavailable, which never fails the listing); without it, and on the gallery and all=1 rows, the field is absent. This route does not read the owner query param (one given is ignored) — admins list other users with all=1 instead.","security":[{"bearerAuth":[]}],"parameters":[{"name":"all","in":"query","required":false,"schema":{"type":"string","enum":["1"]},"description":"Admin only: exactly all=1 lists every user’s drops; any other value is ignored and lists your own. Non-admins sending all=1 get 403. The owner query param is not read on this route."},{"name":"public","in":"query","required":false,"schema":{"type":"string","enum":["1"]},"description":"Gallery listing: exactly public=1 lists every user’s gallery-public drops (archived drops are excluded) — open to any authenticated caller, no admin role needed. Takes precedence over all=1 when both are given (the reply stays narrowed to public drops and never 403s). Any other value is ignored and normal scoping applies."},{"name":"views","in":"query","required":false,"schema":{"type":"string","enum":["1"]},"description":"Your own listing only: exactly views=1 adds views30d to every row. Any other value is ignored, and so is views=1 alongside public=1 or all=1."},{"name":"label","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}(/[a-z0-9][a-z0-9-]{0,31}){0,2}$"},"description":"Your own listing only: just the drops carrying this label path or any of its sublabels — label=acme lists drops labelled acme/site too. Matched exactly, never normalised. An unknown path lists nothing (an empty drops array); an empty or malformed value, or a path deeper than 3 levels, is a 400. Ignored alongside public=1 or all=1."}],"responses":{"200":{"description":"Your drops (for admins with all=1, everyone’s; with public=1, every user’s gallery-public drops) — the same drop shape as create, each with owner and public url, plus views30d on your own listing with views=1","content":{"application/json":{"schema":{"type":"object","required":["drops"],"properties":{"drops":{"type":"array","items":{"$ref":"#/components/schemas/DropListItem"}}}}}}},"400":{"description":"An empty, malformed or too-deep label filter on your own listing","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"all=1 without the admin role (public=1 never 403s)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/drops/{slug}":{"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"},"description":"Slug of the drop to edit or delete, within the target namespace"},{"name":"owner","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"description":"Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400."}],"patch":{"operationId":"updateDrop","summary":"Edit a drop — rename, describe, label, change protection, toggle gallery visibility, archive, or replace content","description":"Every field is optional and combinable: rename the slug, change the display name, set or clear the description, replace or clear the labels, set or clear the access code, toggle gallery visibility, archive or unarchive, or replace the content entirely. Changing or clearing the password revokes every viewer unlock cookie; replacing content keeps createdAt, the display name, the description, the labels, the password, and the public and archived flags. Only your own drops resolve — another user’s slug is a 404.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"slug":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,63}$","description":"New slug (rename); the public URL changes."},"name":{"type":"string","maxLength":256,"description":"New display name (trimmed; max 256 chars) — shown in the drops list and used as the title of a rendered Markdown page (a trailing .md is dropped) — with or without a replacement file; a display-name rename alone is a valid update. Blank or omitted leaves it unchanged; a replacement file never renames the drop."},"description":{"type":"string","maxLength":200,"description":"New description (trimmed; max 200 chars). Omitted leaves it unchanged; blank clears it — unlike name, where blank leaves the name as it is."},"labels":{"type":"string","description":"Label paths, comma-separated: `acme/site,personal`. A path is at most 3 names joined with `/`, each name lowercase letters, digits and hyphens (1–32 chars, starting with a letter or digit); every path is trimmed and empty entries are skipped, nothing is otherwise normalised. A label that does not exist yet is created along with its missing ancestors (a new top-level label gets the default colour, blue), and a label sent together with one of its ancestors keeps only the descendant. The field carries at most 20 paths, so a drop holds at most 20 labels. A malformed path, one deeper than 3 levels, or more than 20 paths is a 400 before anything is written. The paths replace the drop’s whole set: blank clears every label, omitted leaves them unchanged. With owner= (admin) the paths resolve in that user’s labels, and missing ones are created there; the reply is the public shape, so it carries no labels."},"password":{"type":"string","maxLength":256,"description":"New access code; an empty string clears protection."},"public":{"type":"string","enum":["true","false"],"description":"Gallery visibility. Parsed strictly: exactly the string \"true\" or \"false\" — any other value (blank included) is a 400. Omitted leaves it unchanged."},"archived":{"type":"string","enum":["true","false"],"description":"\"true\" retires the drop from serving — its /d/ URL and thumbnail 404 and it leaves the gallery, while the slug stays reserved and your own listing and file read-back keep working; \"false\" brings it back. Parsed strictly like public — any other value (blank included) is a 400. Omitted leaves it unchanged."},"file":{"type":"string","contentMediaType":"application/octet-stream","description":"Replacement content: an .html page, a .md Markdown page or a .zip of a static site. The new kind comes from this filename; createdAt, the display name, the description, the labels and the access code survive."}}}}}},"responses":{"200":{"description":"Updated drop info plus its public url","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DropResult"}}}},"400":{"description":"No fields given, a name over 256 chars, a description over 200 chars, a malformed or too-deep label path, more than 20 label paths, wrong file type, invalid slug, invalid public or archived value, invalid zip, or invalid owner handle","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"owner param without the admin role","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No drop with that slug in the target namespace","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"New slug already taken in the target namespace","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"Replacement content over the limits","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"deleteDrop","summary":"Delete a drop — recoverable for 30 days by default","description":"Deleting moves the drop to your trash: the public URL stops working at once and the slug is free to reuse immediately, but the content — access code, gallery and archived flags included — stays restorable through /api/trash for 30 days. The reply names the trash id to restore it by. Pass permanent=1 for the old behavior: content and view statistics gone outright, nothing in the trash, no undo (a trashed drop keeps its statistics until it is purged or swept). Either way the thumbnail is dropped. Only your own drops resolve — another user’s slug is a 404; an admin using owner= puts the entry in that owner’s trash, not their own.","security":[{"bearerAuth":[]}],"parameters":[{"name":"permanent","in":"query","required":false,"schema":{"type":"string","enum":["1"]},"description":"Exactly permanent=1 deletes the content outright, with nothing left in the trash; any other value (blank included) is ignored and the delete is the recoverable one."}],"responses":{"200":{"description":"Moved to the trash (the default, recoverable path)","content":{"application/json":{"schema":{"type":"object","required":["status","trashId","slug","expiresAt"],"properties":{"status":{"type":"string","enum":["trashed"]},"trashId":{"type":"string","description":"The handle POST/DELETE /api/trash/{id} take"},"slug":{"type":"string","description":"The slug it was deleted from — free for reuse right away"},"expiresAt":{"type":"string","format":"date-time","description":"When the retention sweep may reclaim it"}}}}}},"204":{"description":"Deleted outright (permanent=1)"},"400":{"description":"Invalid owner handle","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"owner param without the admin role","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No drop with that slug in the target namespace","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/drops/{slug}/files":{"get":{"operationId":"listDropFiles","summary":"List the files inside one of your drops","description":"Authenticated read-back: enumerates every file in one of your drops with its size and content type — works on protected and archived drops without a viewer unlock. The drop-root meta.json is not listed.","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"},"description":"The drop slug"},{"name":"owner","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"description":"Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400."}],"responses":{"200":{"description":"Files in the drop, sorted by path","content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/DropFile"}}}}}}},"400":{"description":"Invalid owner handle","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"owner param without the admin role","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No drop with that slug in the target namespace","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/drops/{slug}/files/{path}":{"get":{"operationId":"readDropFile","summary":"Read one file from one of your drops","description":"Authenticated read-back of raw file content with its content type — the way to fetch current content before replacing it via PUT …/files/{path} (one file) or PATCH (the whole drop). Works on protected drops without a viewer unlock; never publicly cacheable.","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"},"description":"The drop slug"},{"name":"path","in":"path","required":true,"schema":{"type":"string"},"description":"File path inside the drop, e.g. index.html or assets/app.css"},{"name":"owner","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"description":"Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400."}],"responses":{"200":{"description":"The file, with its content type"},"400":{"description":"Invalid owner handle","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"owner param without the admin role","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No drop, no such file, or a reserved/unsafe path","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"operationId":"writeDropFile","summary":"Write one file into one of your drops","description":"Creates or replaces a single file inside an existing drop — the way to add an image, a stylesheet or a page without re-uploading the whole site (PATCH with a file replaces everything). The drop itself is never created here: an unknown, trashed or another user’s slug is a 404. Two modes, chosen by the `from` query param and never by sniffing the body. Without it, the raw request body is the file’s bytes (`curl -T file.png …` does exactly that): it is streamed to disk with the 20 MB per-file cap enforced while reading, and an empty body writes a zero-byte file. With `?from=<url>` the server downloads the URL into the drop instead, so image bytes never pass through the client; the request body must then be empty (any byte is a 400). The URL must be a public https:// address — loopback, private, link-local and IP-literal hosts, localhost, .local and .internal names and single-label hostnames are refused with a 400 before any connection is made; DNS is then resolved once, every answer re-checked, and the connection pinned to the approved address. In fetch mode the body check and the URL validation run before the drop lookup, so a blocked URL is the same 400 whether or not the drop exists; a valid URL against a missing drop is a 404 and the upstream is never contacted. Only a 200 is accepted: redirects are never followed, and a redirect, any other status, a timeout (one 15 s budget from DNS to the last byte), a connection failure, an empty body or a compressed (content-encoded) body is a 502 that names the upstream status but never the URL. The fetched content-type is not validated — the served type comes from the path’s extension, so name the file accordingly. Every write recomputes the drop’s files and bytes from disk, stamps updatedAt and re-renders the thumbnail; kind flips from html to zip once the drop holds more than one file and never flips back; name, description, labels, password, public and archived are untouched, and archived drops accept writes; the drop in the response never carries the access code or the labels (unlike PATCH on the same drop). The bytes are staged and renamed into place, so /d/ never serves a torn file — but a replaced file can be served stale for up to 60 seconds by the viewer’s own browser (/d/ responses are private, max-age=60, so no CDN holds them). After the write the drop must still fit 200 MB and 2000 files (a replaced file’s old size is counted out first). Writes and deletes to one drop are serialised with each other and with renames, edits and deletes of that drop and restores into its slug, so none of them can interleave.","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"},"description":"The drop slug"},{"name":"path","in":"path","required":true,"schema":{"type":"string"},"description":"File path inside the drop, e.g. img/photo.jpg — parent directories are created. Rejected (400): traversal, empty or dot segments, a trailing slash, control characters, a segment over 255 bytes, a path over 1024 bytes, and the drop-root meta.json in any spelling. A literal .. segment is normalised away by the router before the handler and reads as a plain 404; the 400 covers the encoded forms that reach it — nothing is ever written outside the drop."},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"uri"},"description":"Server-side fetch mode: a public https:// URL the server downloads into the drop, no request body alongside it. Same 20 MB per-file cap; redirects are not followed. Percent-encode it like any query value (`+` as `%2B`) — a literal `+` reads as a space. `curl -X PUT -G --data-urlencode \"from=<url>\" <files url>` does the encoding: `-G` puts the value in the query string, `-X PUT` keeps the request a write."},{"name":"owner","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"description":"Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400."}],"requestBody":{"required":false,"description":"The file’s content as-is (raw-body mode — no `from` param), any content type; the served type comes from the path’s extension. 20 MB max. Empty writes a zero-byte file; a body that ends early is a 400.","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"responses":{"200":{"description":"An existing file at the path was replaced","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DropFileWrite"}}}},"201":{"description":"A new file was created at the path","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DropFileWrite"}}}},"400":{"description":"Unsafe or reserved path (traversal, empty or dot segments, trailing slash, control characters, a segment over 255 bytes or a path over 1024 bytes, drop-root meta.json), a request body alongside ?from=, a raw request body that ended early, an invalid or blocked from URL, or an invalid owner handle","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"owner param without the admin role","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No drop with that slug in the target namespace (never created implicitly), or no path given","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The path is an existing directory, an ancestor segment is an existing file, or a symbolic link sits on the path (never followed)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"413":{"description":"File over 20 MB (raw body or fetched — a declared content-length over the cap is refused before the body), or the drop would exceed 200 MB / 2000 files","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"?from= upstream failure: a redirect (never followed), a non-200 status, a timeout, a connection failure, an empty body or a compressed (content-encoded) body (point from at a URL that serves the raw bytes) — the message carries the upstream status, never the URL","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"deleteDropFile","summary":"Delete one file from one of your drops","description":"Removes a single file and prunes the directories it leaves empty, up to but not including the drop root. The drop’s entry page — whichever of index.html, index.md and README.md the drop root resolves to right now, that exact spelling, the page /d/ serves — cannot be deleted (400): replace it with PUT instead. That refusal is decided after the drop lookup and the existence check, so a foreign or missing drop still reads 404, as does a zip drop with no entry at its root; a nested sub/index.html is an ordinary file. Any other spelling is looked up like any other file, and a file is only removed under the exact name it is stored as: on a case-sensitive filesystem `Index.html` is a distinct file that PUT can create and DELETE removes; on a case-insensitive one it reads 404 rather than removing the real page. Like a write, a delete recomputes files and bytes, stamps updatedAt and re-renders the thumbnail; kind never flips back to html or md. Deleting everything but the entry page leaves a servable page.","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"},"description":"The drop slug"},{"name":"path","in":"path","required":true,"schema":{"type":"string"},"description":"File path inside the drop, e.g. img/photo.png — same path rules as the write"},{"name":"owner","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"description":"Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400."}],"responses":{"200":{"description":"Deleted — the removed path and the drop’s updated info","content":{"application/json":{"schema":{"type":"object","required":["deleted","drop"],"properties":{"deleted":{"type":"string","description":"The path that was removed"},"drop":{"$ref":"#/components/schemas/DropResult"}}}}}},"400":{"description":"Unsafe or reserved path, the drop’s entry page, or an invalid owner handle","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"owner param without the admin role","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No drop with that slug in the target namespace, no such file (a directory path included), or no path given","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/drops/{slug}/stats":{"get":{"operationId":"getDropStats","summary":"View statistics for one of your drops","description":"Views, unique visitors, a dense per-bucket series and referrer / browser / device breakdowns for one of your drops over a preset range. Views are recorded server-side when the drop’s HTML is served at /d/ — unlocked views only, bots filtered by user agent, and nothing is ever injected into your page. A Markdown file rendered as a page for a browser is HTML and counts; the same file’s raw bytes never do, however they were asked for. Uniqueness comes from a hash of client address + user agent that rotates at UTC midnight (no cookies, no stored addresses), so any range that crosses UTC midnight — the 24h preset nearly always does, and every multi-day range — approximates unique visitors: a visitor returning on both sides of midnight counts twice. Statistics key on a stable per-drop identity, so they follow the drop through slug and handle renames. Archived drops keep answering, so their history stays viewable; a trashed drop is 404 until restored. Statistics ride along through the trash and come back on restore; they are erased for good when the drop is permanently deleted, purged from the trash, or reclaimed by the 30-day sweep. A drop created before statistics existed and not edited since answers tracked: false with zero counts — it starts recording from its next edit or re-create. Every count is a JSON number.","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"},"description":"The drop slug"},{"name":"range","in":"query","required":false,"schema":{"type":"string","enum":["24h","7d","30d","90d"],"default":"7d"},"description":"Preset range ending now: 24 hourly buckets, or 7 / 30 / 90 UTC-day buckets. Default 7d; anything else is 400."},{"name":"owner","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"description":"Admin only: target another user’s namespace by handle. Any non-admin use of the param — even naming yourself — gets 403; an invalid handle gets 400."}],"responses":{"200":{"description":"The statistics (cache-control: private, no-store)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DropStats"}}}},"400":{"description":"Invalid range, or invalid owner handle","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"owner param without the admin role","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No drop with that slug in the target namespace (trashed drops included)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The statistics database is unavailable — a fixed body, retry later","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/stats/summary":{"get":{"operationId":"getStatsSummary","summary":"View totals across all your drops","description":"The numbers behind the dashboard’s stats tiles: views across every drop you own in a preset range, and the most viewed of them. Views are counted exactly as on GET /api/drops/{slug}/stats — HTML page views recorded at /d/, bots and locked views left out — and summed over your non-trashed drops: archived drops count, since their history stays; a trashed drop is left out until it is restored; a drop without a statistics id adds 0. top is the drop with the most views in the range, a tie going to the slug that sorts first, and null when no drop has a view. Your own drops only: there is no owner= reach-in (one given is ignored). A database failure is a 503 with a fixed body, as on the per-drop route.","security":[{"bearerAuth":[]}],"parameters":[{"name":"range","in":"query","required":false,"schema":{"type":"string","enum":["24h","7d","30d","90d"],"default":"7d"},"description":"Preset range ending now, the same presets as a drop’s statistics: 24 hourly buckets, or 7 / 30 / 90 UTC-day buckets. Default 7d; anything else is 400."}],"responses":{"200":{"description":"The totals (cache-control: private, no-store)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsSummary"}}}},"400":{"description":"Invalid range","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The statistics database is unavailable — a fixed body, retry later","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/labels":{"get":{"operationId":"listLabels","summary":"List your labels, as a tree","description":"Your labels in tree order — depth-first, siblings sorted by name — each with its id, name, full path, parent id, colour and drop count. The colour is resolved: only top-level labels store one, and a sublabel shows its top-level label’s. The count rolls up: the distinct drops carrying the label or any of its sublabels, so a drop labelled both acme/site and acme/docs counts once on acme; trashed drops are left out, archived ones counted. Labels are per user and drops store label ids, so renaming or moving a label never rewrites a drop. You rarely need to create one first: a drop write naming a path that does not exist yet creates it (the labels field on POST and PATCH /api/drops). Your own labels only: there is no owner= reach-in (one given is ignored). An account with no labels gets an empty list.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Your labels in tree order (cache-control: private, no-store)","content":{"application/json":{"schema":{"type":"object","required":["labels"],"properties":{"labels":{"type":"array","items":{"$ref":"#/components/schemas/Label"}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"operationId":"createLabel","summary":"Create a label","description":"Creates a label in your own tree, in one of two forms: path creates the label at that path along with any missing ancestor (`acme/site` creates acme too when it does not exist yet); name creates one label under the label whose id is parent, or at the top level when parent is omitted or null. Sending path together with name or parent is a 400. Names follow the handle rule — lowercase letters, digits and hyphens, 1–32 chars, starting with a letter or digit — and are unique among their siblings only, so `acme/docs` and `personal/docs` can both exist; a path is at most 3 names deep. Labels are per user: a parent id from another account’s tree is a 404. Unknown keys are ignored. The reply is the new label as GET /api/labels lists it.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"path":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}(/[a-z0-9][a-z0-9-]{0,31}){0,2}$","description":"The full path to create, e.g. `acme/site` — at most 3 names joined with `/`; missing ancestors are created with it. Send either path, or name (with parent), never both."},"name":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$","description":"The new label’s name: lowercase letters, digits and hyphens, 1–32 chars, starting with a letter or digit."},"parent":{"type":"string","description":"With name: the id of the label to create it under. Omitted or null creates a top-level label. Not allowed with path."},"color":{"type":"string","enum":["blue","green","purple","orange","cyan","red","gold","magenta","lime","grey"],"description":"Only when this call creates the top-level label — the one named, or the missing top level of path; anywhere else it is a 400. A new top-level label without one gets blue. Sublabels show their top-level label’s colour."}}}}}},"responses":{"201":{"description":"Label created — its row, with its resolved colour and drop count","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Label"}}}},"400":{"description":"No JSON object body, a field of the wrong JSON type, path sent with name or parent, an invalid name or path, a path deeper than 3 levels, an unknown colour, or a colour where the top level already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No label with that parent id in your labels","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"A label with that path already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/labels/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Label id from GET /api/labels. Always resolved in your own labels, so another account’s id is a 404"}],"patch":{"operationId":"updateLabel","summary":"Rename, move or recolour a label","description":"Renames, moves and recolours one of your labels, in any combination, checked against the end state; at least one field is required and unknown keys are ignored. Drops store label ids, so no drop is rewritten: the label and every sublabel under it show their new paths on the next read. A label moved to the top level keeps the colour it was showing unless color says otherwise; one moved under a parent takes on that parent’s colour. The reply is the label as GET /api/labels now lists it: new path, resolved colour, drop count.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$","description":"New name — the same rule as on create; a name a sibling at the destination already has is a 409."},"parent":{"type":"string","description":"Move under the label with this id; null moves it to the top level; omitted leaves it where it is. A move under itself or one of its sublabels is a 400, and so is one that would nest any label — its sublabels count — deeper than 3 levels."},"color":{"type":"string","enum":["blue","green","purple","orange","cyan","red","gold","magenta","lime","grey"],"description":"New colour — top-level labels only: on a label that is, or by this move becomes, a sublabel it is a 400. Sublabels show their top-level label’s colour."}}}}}},"responses":{"200":{"description":"The label’s updated row","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Label"}}}},"400":{"description":"No field given, no JSON object body, a field of the wrong JSON type, an invalid name or unknown colour, a colour on a sublabel, a move under itself or one of its sublabels, or a move that would nest labels deeper than 3 levels","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No label with that id — or no label with the given parent id — in your labels","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"A label at the destination already has that name","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"deleteLabel","summary":"Delete a label","description":"Deletes one of your labels. Its sublabels and its drops move up to its parent: once acme/site is deleted, a drop labelled acme/site reads as acme, and acme/site/v2 becomes acme/v2. Deleting a top-level label turns its sublabels into top-level labels in its colour and takes it off its drops. A moved sublabel whose name is already taken where it lands merges into that label, its sublabels and drops with it, and cannot be split apart again. No drop is rewritten here: each drop resolves the change when it is read and stores the result on its next edit. The reply says where everything went.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Deleted — the label’s id and where its sublabels and drops moved","content":{"application/json":{"schema":{"type":"object","required":["id","movedTo"],"properties":{"id":{"type":"string","description":"The deleted label’s id"},"movedTo":{"type":["object","null"],"required":["id","path"],"description":"The parent its sublabels and drops moved to; null for a top-level label","properties":{"id":{"type":"string"},"path":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}(/[a-z0-9][a-z0-9-]{0,31}){0,2}$"}}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No label with that id in your labels","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/trash":{"get":{"operationId":"listTrash","summary":"List your deleted drops","description":"The drops you deleted that are still inside the 30-day window, newest deletion first, each with the trash id to act on and the expiresAt after which it may be reclaimed. Your own trash only: this route reads no owner= or all=1 — presenting one is not an error — so there is no admin reach-in and another account’s trash id simply does not exist for you. Listing also sweeps — entries past their expiry are purged before the reply is built. The sweep is opportunistic (it runs here and on every delete; nothing is scheduled), so an account nobody touches can hold expired entries past the window.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Your trashed drops","content":{"application/json":{"schema":{"type":"object","required":["trash"],"properties":{"trash":{"type":"array","items":{"$ref":"#/components/schemas/TrashEntry"}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/trash/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Trash id from GET /api/trash. Always resolved in your own namespace, so another account’s id is a 404"}],"post":{"operationId":"restoreDrop","summary":"Restore a deleted drop","description":"Brings the drop back exactly as it was — content, description, labels, access code, gallery and archived flags all survive the round trip (a label deleted in the meantime reads as its parent, as on a live drop). It returns to the slug it was deleted from unless you name another one in the body. If that slug has been taken since (you reused it, which the delete freed on purpose), the restore is a 409 and the entry stays in the trash, so you can retry with a different slug. Restoring to the original slug also re-honors unlock cookies issued before the delete: the access code never changed, so neither did the cookie. The optional slug travels as JSON or as a form field (multipart/form-data or application/x-www-form-urlencoded).","security":[{"bearerAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"slug":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,63}$","description":"Restore under this slug instead of the original. Omitted, blank, or no body at all = the original slug"}}}},"multipart/form-data":{"schema":{"type":"object","properties":{"slug":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,63}$"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"slug":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,63}$"}}}}}},"responses":{"200":{"description":"Restored — the drop info plus its public url","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DropResult"}}}},"400":{"description":"Unreadable body or an invalid target slug","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No trash entry with that id in your trash","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Target slug already taken — the entry stays in the trash; retry with another slug","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"purgeTrashEntry","summary":"Delete a trashed drop forever","description":"Removes one trash entry, its content and its view statistics now, without waiting out the retention window. There is no undo for this one.","security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Purged"},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No trash entry with that id in your trash","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/export":{"get":{"operationId":"exportAccount","summary":"Download everything you own as a zip","description":"One archive of all your content in its three states, under top-level folders: live/<slug>/…, archived/<slug>/… and deleted/<trashId>/… for trash entries still inside the retention window (the sweep runs first, so nothing already expired comes back). Content files only — each drop’s bookkeeping meta.json is left out, since it carries the access-code material, so the export holds no descriptions or labels; gallery thumbnails are not part of any drop. Your own content only: no owner=, no all=1. The zip is assembled in memory, so a very large account makes for a slow, memory-hungry request. An empty account still yields a valid, empty zip.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The zip, as an attachment named dropper-export-{handle}.zip (cache-control: private, no-store). An account with no content yields a valid, empty zip","content":{"application/zip":{"schema":{"type":"string","contentMediaType":"application/zip"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/thumbnails/{handle}/{slug}":{"get":{"operationId":"getDropThumbnail","summary":"Fetch the gallery thumbnail for a drop","description":"A server-rendered PNG preview (~800×533, rendered headlessly at a 1200×800 viewport) of a passwordless drop, generated in the background after create, content replacement, password clear, and unarchive. Open to any authenticated user — it backs the gallery, so it is not limited to your own drops. Password-protected and archived drops never have a thumbnail: this route answers 404 for them even if a stale file lingers on disk, and so it does for every drop of a user whose pages an admin hid. A 404 also covers drops whose thumbnail has not been generated (yet, or at all — render failures are silent) — fall back to a placeholder.","security":[{"bearerAuth":[]}],"parameters":[{"name":"handle","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"description":"Handle of the user who owns the drop"},{"name":"slug","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,63}$"},"description":"The drop slug"}],"responses":{"200":{"description":"The thumbnail (cache-control: private, max-age=60)","content":{"image/png":{"schema":{"type":"string","contentMediaType":"image/png"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown drop, password-protected or archived drop, a drop of a user whose pages an admin hid, or no thumbnail generated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/me":{"get":{"operationId":"getIdentity","summary":"Who you are: the account and the credential behind this request","description":"Answers any credential the drop routes accept — a personal API key, an OAuth access token (an MCP connector’s or dropper login’s), or a signed-in session — with the account it belongs to (handle, name, email, role) and which credential answered. role is the live one, re-read from DROPPER_ADMIN_EMAILS on every request. credential.kind is api_key (with the key’s name and masked start as the API Keys page shows them, its creation and its expiry — null when it never expires), oauth (with the token’s granted scopes) or session. Never the user id, the key itself, its id or hash, or any token. Always your own identity: there is no owner= reach-in, and one given is ignored. A bearer that is neither a live key nor an OAuth access token is a hard 401, never a fall-through to the session cookie. Always cache-control: private, no-store. This is what dropper whoami prints.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The identity (cache-control: private, no-store)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Identity"}}}},"401":{"description":"Missing or invalid credential — no key, OAuth access token or session, or a bearer that is neither a key nor a token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The account is suspended ({\"error\":\"account suspended\"} — the credential itself is valid, the account behind it is not), or a connector token was never granted the drops scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/profile":{"patch":{"operationId":"renameHandle","summary":"Rename your handle (moves your whole /d/{user}/ namespace)","description":"Changes your handle and moves your whole /d/<handle>/ namespace with it (the dashboard Profile page is the intended way in). Consequences are immediate: every URL under the old handle hard-404s (no redirects), viewers of protected drops re-enter the access code, and the freed handle becomes claimable by anyone. The handle is lowercased before validation; renaming to your current handle is a no-op 200. Session-only: a signed-in dashboard session is required, and — unlike the drop routes — presenting any bearer, an API key or an MCP access token, even a valid one, is a hard 401, so a leaked credential cannot release your namespace for takeover. Existing sessions and API keys keep working afterwards and reflect the new handle on their next request.","security":[{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["handle"],"properties":{"handle":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$","description":"The new handle (lowercase letters, digits, hyphens; max 32 chars)"}}}}}},"responses":{"200":{"description":"Handle renamed (or already yours) — returns the normalized new handle","content":{"application/json":{"schema":{"type":"object","required":["handle"],"properties":{"handle":{"type":"string","description":"The normalized new handle"}}}}}},"400":{"description":"Missing or invalid handle","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in — or any bearer was presented (this endpoint is session-only)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"Handle already taken","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/connections":{"get":{"operationId":"listConnections","summary":"List the OAuth apps connected to your account","description":"The apps you have authorized through the MCP connector flow (claude.ai and anything else that runs OAuth against Dropper), newest grant first, with the client display name and the scopes you granted. Session-only: presenting any bearer — an API key or an MCP access token, even a valid one — is a hard 401, so a connector cannot enumerate your other grants.","security":[{"cookieAuth":[]}],"responses":{"200":{"description":"Your connected apps","content":{"application/json":{"schema":{"type":"object","required":["connections"],"properties":{"connections":{"type":"array","items":{"$ref":"#/components/schemas/ConnectedApp"}}}}}}},"401":{"description":"Not signed in — or any bearer was presented (this endpoint is session-only)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"operationId":"disconnectApp","summary":"Disconnect an OAuth app","description":"Revokes the consent named by `id` and purges that app's stored tokens for your account, so its refresh token stops working immediately. Access tokens already issued are self-contained and stay valid until they expire (within the hour). The next time the app asks for access you will be shown the consent screen again. An unknown id — or another user's — is a 404 and deletes nothing. Session-only, like the listing.","security":[{"cookieAuth":[]}],"parameters":[{"name":"id","in":"query","required":true,"schema":{"type":"string"},"description":"The connection (consent) id from GET /api/connections"}],"responses":{"200":{"description":"App disconnected","content":{"application/json":{"schema":{"type":"object","required":["id","clientId"],"properties":{"id":{"type":"string"},"clientId":{"type":"string"}}}}}},"400":{"description":"Missing id query parameter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in — or any bearer was presented (this endpoint is session-only)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such connection for this account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/admin/users":{"get":{"operationId":"adminListUsers","summary":"List every user with their usage (admin only)","description":"The roster behind the Admin › Users page: every account in the auth database with its role (computed live from DROPPER_ADMIN_EMAILS, never a cached column), joined date, last sign-in (cookie sign-ins only — API-key and connector use never counts; accounts older than the column fall back to their newest session, null means never), drops (count; public, protected and archived counts; content bytes — the sum of each live drop’s bytes, archived included, meta.json and thumbnails excluded; the trash is counted separately), API keys (count, enabled, last used), connected apps, active sessions and views over the last 30 UTC days (30 UTC-day buckets ending now — the first starts at UTC midnight 29 days back, the last is today so far — so approximate as a rolling 30 days), plus the handle directories under the drops directory that no account claims (orphans — left behind by a deleted account or an interrupted rename; reported, never acted on). The views query degrades on its own: if the statistics table misbehaves the listing still answers with viewsAvailable false and every views30d null, the rest of the listing intact. Metadata only: no drop ids, no key material, no session tokens, no better-auth user ids — handles are the identifiers. suspendedAt is set while an account is suspended; pagesHidden reflects the <handle>/.pages-hidden marker on disk. Admin-only and session-only: a signed-in admin session is required — any bearer, an API key or an MCP access token, is a hard 401, and a non-admin session is a 403 before anything is looked at.","security":[{"cookieAuth":[]}],"responses":{"200":{"description":"The roster, users sorted by handle (cache-control: private, no-store)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminUsersResponse"}}}},"401":{"description":"Not signed in — or any bearer was presented (this endpoint is session-only)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Signed in, but not an admin","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/admin/users/{handle}":{"parameters":[{"name":"handle","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"description":"The user’s handle, exactly as the roster shows it — never normalised (an uppercase letter is a 400, not a lookup)"}],"get":{"operationId":"adminGetUser","summary":"Inspect one user (admin only)","description":"The same summary as the listing plus the account’s drops as metadata (slug as text, kind, files, bytes, created, updated, the public / protected / archived flags and 30-day views — no url, no thumbnail, no file list), its API keys (name and the masked start the Keys page shows, never the key), its connected apps (client name, scopes, granted date) and its active sessions (created, last active, expires, IP, user agent — no token). Trash is counts only: the admin never reaches into another account’s trash. The handle is matched exactly, never normalised; an orphan directory is not a user (404). Admin-only and session-only, like the listing.","security":[{"cookieAuth":[]}],"responses":{"200":{"description":"The user detail — drops, API keys and connected apps newest first, sessions active only (cache-control: private, no-store)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminUserDetail"}}}},"400":{"description":"Invalid handle","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in — or any bearer was presented (this endpoint is session-only)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Signed in, but not an admin","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No user with that handle","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"patch":{"operationId":"adminSetUserSuspended","summary":"Suspend or unsuspend a user (admin only)","description":"The one account action the dashboard has. Suspending stamps suspendedAt — from that moment the account cannot sign in and every credential of theirs, API keys, OAuth tokens (connectors and dropper login) and any surviving session, answers 403 account suspended on every route — then deletes their sessions and their OAuth access and refresh tokens (consents stay, so a re-authorise after unsuspend skips the consent screen; API keys stay in place and are simply refused) and, last, applies hidePages — true writes the <handle>/.pages-hidden marker so every /d/ URL, thumbnail and gallery entry of theirs answers 404 exactly like an unknown slug; false removes it while the account stays suspended; omitted leaves it as it is, so a repeat suspend never silently flips pages. Repeating suspended: true re-runs the purge and keeps the original timestamp. Unsuspending removes the marker first, then clears the stamp: cookie sign-in and API keys work again with nothing to re-mint, and connectors and dropper login must re-authorise. hidePages must be omitted or false with suspended: false. Unsuspending an active account is a no-op 200 that still clears a leftover marker (the pages-hidden-but-active case a database-only restore can leave). Admins by the live DROPPER_ADMIN_EMAILS — the caller included — cannot be suspended (400): remove the email from the env first. Accepted residual: an MCP access token already minted passes the hosted transport’s stateless check for the rest of its hour but is refused at the web API.","security":[{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["suspended"],"properties":{"suspended":{"type":"boolean","description":"true suspends, false unsuspends. A JSON boolean — the string \"true\" is a 400"},"hidePages":{"type":"boolean","description":"With suspended: true — true takes their pages offline (writes the pages-hidden marker), false brings them back (removes it), omitted leaves them as they are. With suspended: false it must be omitted or false (unsuspending always brings the pages back)"}}}}}},"responses":{"200":{"description":"The user’s updated summary (cache-control: private, no-store) — suspendedAt, pagesHidden and sessions reflect the result","content":{"application/json":{"schema":{"type":"object","required":["user"],"properties":{"user":{"$ref":"#/components/schemas/AdminUserSummary"}}}}}},"400":{"description":"Invalid handle; a body without a JSON boolean suspended; hidePages not a boolean, or true with suspended: false; or the target is an admin (admins cannot be suspended)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Not signed in — or any bearer was presented (this endpoint is session-only)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Signed in, but not an admin","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No user with that handle","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/version":{"get":{"operationId":"getVersion","summary":"The deployment’s version and release stamp (public)","description":"The version, build time and commit baked into the running image — what the dashboard sidebar shows at the bottom. Public: no credential of any kind is required or consulted. A tagged release reads MAJOR.MINOR.PATCH; a branch build (staging) reads 0.0.0-<branch>.<short sha>; a plain local build reads dev with releasedAt and commit null. Always cache-control: no-store.","security":[],"responses":{"200":{"description":"The version info — releasedAt and commit are null when the image carries no stamp (cache-control: no-store)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VersionInfo"}}}}}}},"/d/{user}/{slug}/{path}":{"get":{"operationId":"serveDrop","summary":"Serve drop content (public)","description":"Serves the dropped page and its assets from the owner’s namespace. The drop root — and any directory inside it — serves the first of index.html, index.md and README.md that exists there, exact spellings and index.html first, so every drop made before index.md existed resolves as it always did. So that relative assets resolve, the slash-less drop root always redirects to its trailing slash (308), and so does a directory inside the drop that holds one of those entry pages; a directory without one is a 404 with or without the slash. Protected drops answer 401 — page-like requests get a styled unlock page, assets get a bare 401. Archived and deleted drops — and every drop of a user whose pages an admin hid — answer 404, indistinguishably from a slug that was never used. Unprotected content answers cache-control: private, max-age=60 — a browser may reuse it for a minute, but no shared or CDN cache ever stores it; protected content is private, no-store. The drop-root meta.json is reserved and never served. A .md file has two faces behind one URL: it renders as a styled HTML page — table of contents, syntax-highlighted code, Mermaid diagrams — when Accept lists text/html and does not list text/markdown, and serves the stored bytes otherwise, so curl (*/*), a request with no Accept, and an agent that names text/markdown all keep exactly the stored bytes and their text/plain type. ?raw=1 forces the stored bytes whatever Accept says, and is the form to prefer in a script or a cache key: it is a different URL rather than a second representation of one. Both .md variants carry Vary: Accept; nothing else this route serves does. The raw contract never depends on the renderer — a .md over 1 MB, one whose rendered page would exceed 4 MB, and a render that throws all fall back to the stored bytes with the normal headers and a 200, never a 500 or a truncated body.","parameters":[{"name":"user","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"description":"Handle of the user who owns the drop"},{"name":"slug","in":"path","required":true,"schema":{"type":"string"},"description":"The drop slug"},{"name":"path","in":"path","required":true,"schema":{"type":"string"},"description":"File path inside the drop; empty means the drop’s entry page"},{"name":"raw","in":"query","required":false,"schema":{"type":"string"},"description":"The literal value 1 forces the stored bytes for a .md file, whatever Accept says; any other value (raw=true, raw=, a bare raw) is ignored and the Accept rule applies. Ignored entirely on every other path — no header changes, no Vary."}],"responses":{"200":{"description":"The file, with its content type — or, for a .md negotiated as a page, the rendered HTML (text/html; charset=utf-8, plus Vary: Accept on both .md variants)","content":{"text/html":{},"text/plain; charset=utf-8":{},"*/*":{}}},"308":{"description":"Redirect adding a trailing slash — always for the drop root, and for a directory only when it holds an entry page"},"401":{"description":"Protected and not unlocked — unlock page or bare 401"},"404":{"description":"Unknown user, drop, or file — or an archived drop, or a drop of a user whose pages an admin hid"}}}},"/d/{user}/{slug}/":{"get":{"operationId":"serveDropRoot","summary":"Serve the drop root (public)","description":"Serves the drop’s entry page — the first of index.html, index.md and README.md present at its root. A Markdown entry renders as a styled page for browsers (titled with the drop’s display name minus a trailing .md, never from an h1 in the document) and serves its source to everything else, exactly as a nested .md does. Protected drops answer 401 with the unlock page until unlocked; archived drops answer 404.","parameters":[{"name":"user","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"description":"Handle of the user who owns the drop"},{"name":"slug","in":"path","required":true,"schema":{"type":"string"},"description":"The drop slug"}],"responses":{"200":{"description":"The drop’s entry page"},"401":{"description":"Protected and not unlocked — the unlock page"},"404":{"description":"Unknown or archived drop, or a drop of a user whose pages an admin hid"}}},"post":{"operationId":"unlockDrop","summary":"Unlock a protected drop (viewer)","description":"The unlock page posts the access code here. A correct code sets a path-scoped, HttpOnly HMAC cookie valid for 30 days — derived from the current password hash, so rotating or clearing the code revokes every issued cookie. Attempts are throttled per drop (20 per minute, shared across viewers).","parameters":[{"name":"user","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,31}$"},"description":"Handle of the user who owns the drop"},{"name":"slug","in":"path","required":true,"schema":{"type":"string"},"description":"The drop slug"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["password"],"properties":{"password":{"type":"string","maxLength":256,"description":"The access code (trimmed; max 256 chars)"}}}}}},"responses":{"204":{"description":"Unlocked — Set-Cookie scoped to /d/{user}/{slug}, Max-Age 30 days"},"400":{"description":"Drop is not protected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Wrong access code","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Unknown or archived drop, or a drop of a user whose pages an admin hid"},"413":{"description":"Body over 4 KB","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Too many attempts — wait up to a minute","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}