@hostr/cli 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +58 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3834,7 +3834,7 @@ import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
|
|
|
3834
3834
|
import { homedir } from "node:os";
|
|
3835
3835
|
import { join } from "node:path";
|
|
3836
3836
|
var DEFAULT_API_BASE = "https://api.hostr.sh";
|
|
3837
|
-
var CLI_VERSION = "0.
|
|
3837
|
+
var CLI_VERSION = "0.3.0";
|
|
3838
3838
|
var configDir = (env = process.env) => env.HOSTR_CONFIG_DIR ?? join(homedir(), ".config", "hostr");
|
|
3839
3839
|
async function loadConfig(dir = configDir()) {
|
|
3840
3840
|
try {
|
|
@@ -3907,6 +3907,7 @@ function makeClient(opts) {
|
|
|
3907
3907
|
{ params: { name: o.name, default: o.default ? "true" : void 0 }, rawBody: { text: vtt, contentType: "text/vtt" } }
|
|
3908
3908
|
),
|
|
3909
3909
|
subtitleDelete: (id, lang) => req2("DELETE", `/v1/assets/${id}/subtitles/${lang}`),
|
|
3910
|
+
assetAudio: (id) => req2("POST", `/v1/assets/${id}/audio`),
|
|
3910
3911
|
assetDelete: (id) => req2("DELETE", `/v1/assets/${id}`),
|
|
3911
3912
|
usage: (params = {}) => req2("GET", "/v1/usage", { params }),
|
|
3912
3913
|
events: (params = {}) => req2("GET", "/v1/events", { params }),
|
|
@@ -5278,6 +5279,15 @@ async function createUpload(opts) {
|
|
|
5278
5279
|
});
|
|
5279
5280
|
}
|
|
5280
5281
|
|
|
5282
|
+
// src/audio-backfill.ts
|
|
5283
|
+
var BACKFILL_STATUSES = ["playable", "ready"];
|
|
5284
|
+
function isBackfillCandidate(item) {
|
|
5285
|
+
return typeof item.status === "string" && BACKFILL_STATUSES.includes(item.status);
|
|
5286
|
+
}
|
|
5287
|
+
function needsAudio(rec) {
|
|
5288
|
+
return rec["audio"] === void 0;
|
|
5289
|
+
}
|
|
5290
|
+
|
|
5281
5291
|
// src/tail.ts
|
|
5282
5292
|
async function tailLoop(opts) {
|
|
5283
5293
|
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
@@ -5358,6 +5368,51 @@ expires ${fmtWhen(new Date(t.expiresAt))}
|
|
|
5358
5368
|
t
|
|
5359
5369
|
);
|
|
5360
5370
|
}
|
|
5371
|
+
async function assetsAudio(ctx, id, opts) {
|
|
5372
|
+
if (!id && !opts.missing)
|
|
5373
|
+
fail(ctx.out, new Error("give an asset id, or --missing to sweep the account"));
|
|
5374
|
+
if (id && opts.missing)
|
|
5375
|
+
fail(ctx.out, new Error("pass an asset id or --missing, not both"));
|
|
5376
|
+
if (id) {
|
|
5377
|
+
const r = await ctx.client.assetAudio(id);
|
|
5378
|
+
return ctx.out.print(
|
|
5379
|
+
`queued audio extraction for ${r.assetId}
|
|
5380
|
+
watch for video.audio_ready (hostr tail --follow --type video.audio_ready)`,
|
|
5381
|
+
r
|
|
5382
|
+
);
|
|
5383
|
+
}
|
|
5384
|
+
const candidates = [];
|
|
5385
|
+
let cursor;
|
|
5386
|
+
do {
|
|
5387
|
+
const page2 = await ctx.client.assets({ limit: 200, cursor });
|
|
5388
|
+
for (const item of page2.items)
|
|
5389
|
+
if (isBackfillCandidate(item)) candidates.push({ assetId: String(item.assetId), status: String(item.status) });
|
|
5390
|
+
cursor = page2.nextCursor ?? void 0;
|
|
5391
|
+
} while (cursor);
|
|
5392
|
+
const rows = [];
|
|
5393
|
+
for (const c of candidates) {
|
|
5394
|
+
const rec = await ctx.client.asset(c.assetId);
|
|
5395
|
+
if (!needsAudio(rec)) continue;
|
|
5396
|
+
try {
|
|
5397
|
+
await ctx.client.assetAudio(c.assetId);
|
|
5398
|
+
rows.push({ assetId: c.assetId, status: c.status, result: "queued" });
|
|
5399
|
+
} catch (e) {
|
|
5400
|
+
const code = e instanceof ApiError ? String(e.body?.error ?? `http_${e.status}`) : e.message;
|
|
5401
|
+
rows.push({ assetId: c.assetId, status: c.status, result: code });
|
|
5402
|
+
}
|
|
5403
|
+
}
|
|
5404
|
+
const queued = rows.filter((r) => r.result === "queued").length;
|
|
5405
|
+
ctx.out.print(
|
|
5406
|
+
(rows.length ? table(rows, [
|
|
5407
|
+
{ key: "assetId", label: "ASSET" },
|
|
5408
|
+
{ key: "status", label: "STATUS" },
|
|
5409
|
+
{ key: "result", label: "RESULT" }
|
|
5410
|
+
]) : `no assets are missing audio (${candidates.length} checked)`) + `
|
|
5411
|
+
|
|
5412
|
+
${queued} queued of ${candidates.length} ${BACKFILL_STATUSES.join("/")} assets` + (queued ? "\nwatch for video.audio_ready (hostr tail --follow --type video.audio_ready)" : ""),
|
|
5413
|
+
{ scanned: candidates.length, queued, items: rows }
|
|
5414
|
+
);
|
|
5415
|
+
}
|
|
5361
5416
|
async function assetsDelete(ctx, id) {
|
|
5362
5417
|
const r = await ctx.client.assetDelete(id);
|
|
5363
5418
|
ctx.out.print(`deleted ${r.assetId} (${r.objectsDeleted} objects purged)`, r);
|
|
@@ -5447,7 +5502,7 @@ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4 } from
|
|
|
5447
5502
|
import { join as join3 } from "node:path";
|
|
5448
5503
|
|
|
5449
5504
|
// ../../docs/integrate.md
|
|
5450
|
-
var integrate_default = '# hostr \u2014 integration guide\n\n<!-- GENERATED FILE \u2014 DO NOT EDIT.\n Source: apps/docs/src/content/docs/** (pages with `agentGuide: true`)\n packages/openapi/src/spec.ts (the route table)\n Regenerate: pnpm build:agent-guide \xB7 CI checks this with --check -->\n\nhostr is self-hosted video infrastructure: resumable upload \u2192 transcode \u2192\nHLS delivery, with signed playback URLs. This document is the complete,\nself-contained integration reference. It is written to be executed by a\ncoding agent; every command has a `--json` mode and every flow is\nverifiable from the terminal.\n\n```\nAPI base https://api.hostr.sh (override: HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\nDocs https://docs.hostr.sh \xB7 GET {API base}/llms-full.txt (this file)\n```\n\nRun `npx @hostr/cli` in your app\'s repo. With no arguments it reports what is\nconfigured and names the single next step \u2014 authorize, prepare the repo, mint\nan application key \u2014 and it is the intended entry point for a coding agent.\nThis document is the *reference*: consult it while implementing, section by\nsection, rather than reading it front to back.\n\n## Quickstart\n\nBases you will need:\n\n```\nAPI base https://api.hostr.sh (override with HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\n```\n\n### 1. Authorize\n\n```bash\nnpx @hostr/cli login\n```\n\nThis opens your browser and mints the CLI\'s own key once you approve it. Nothing\nis pasted, which matters when a coding agent is driving your terminal \u2014 a key\ntyped into an agent\'s session lands in its context and its transcript.\n\nThe key appears on the dashboard Keys page as `CLI \u2014 <hostname>` and is revocable\nlike any other. For CI, `--key <k>` or `HOSTR_API_KEY` skips the browser.\n\nConfirm it worked:\n\n```bash\nnpx @hostr/cli whoami --json\n```\n\n### 2. Upload\n\n```bash\nnpx @hostr/cli upload video.mp4 --wait --json\n```\n\n`--wait` blocks until the transcode reaches `ready` or `failed`. Without it the\ncommand returns as soon as the bytes are verified in storage.\n\n```json\n{ "assetId": "9f8c1e2a4b6d47f0a1b2c3d4e5f60718", "status": "ready", "bytes": 104857600 }\n```\n\nThe upload is resumable. Kill it mid-transfer and run it again \u2014 it asks storage\nwhich parts landed and continues from there.\n\n### 3. Play it\n\nMint a playback token, then build the URL:\n\n```bash\nnpx @hostr/cli assets token 9f8c1e2a4b6d47f0a1b2c3d4e5f60718 --json\n```\n\n```json\n{ "playback": "eyJhbGci...", "thumbnail": "...", "storyboard": "...", "expiresAt": 1756134000000 }\n```\n\n```\nhttps://stream.hostr.sh/{assetId}.m3u8?token={playback}\n```\n\nHand that to any HLS player. Safari and iOS play it natively; elsewhere use\nhls.js.\n\nThe token is checked once, when playback starts. It does not need to stay valid\nfor the whole viewing session, and the default one-hour lifetime is not a\none-hour viewing limit.\n\n### 4. Wire up events, not polling\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nThe signing secret is generated, written to `.env`, and never printed \u2014 only the\nvariable name is. Then verify signatures before trusting a delivery: see\n[Verifying webhooks](#verifying-webhook-signatures).\n\nProve your endpoint end to end without uploading anything else:\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\n### No Node available?\n\nEvery flow here has a raw-HTTP equivalent. Start at\n[Upload from a server](#upload-from-a-server).\n\n## Authentication\n\nEvery call sends `Authorization: Bearer <credential>` over HTTPS. There are three\ncredential classes and **no shared master key**.\n\n### API keys\n\n`hostr_sk_live_\u2026`, minted per account. SHA-256-hashed at rest; the plaintext is\nshown exactly once, at mint. Verified in constant time and cached about 60 seconds\nin the edge Worker \u2014 so **revocation takes up to a minute** to take effect.\n\nScopes are a ladder, each rung including the ones below:\n\n| Scope | Grants |\n|---|---|\n| `read` | GETs only \u2014 asset status, listing, usage, events |\n| `upload` | read, plus the whole upload lifecycle |\n| `playback` | upload, plus minting playback tokens |\n| `full` | everything: deletion, key management, webhook config |\n\n**`playback` is what an application key should be.** It can ingest video and it\ncan let viewers watch, but it cannot delete assets or mint more keys. Reach for\n`full` only where you actually manage the account.\n\n**Keep keys server-side.** Never ship one in a browser or a mobile app.\n\nRotate by creating the new key and revoking the old one:\n\n```bash\nnpx @hostr/cli keys create --name ci --scopes upload --write-env .env\nnpx @hostr/cli keys revoke <old-id>\n```\n\nRevoking the key that makes the request is allowed \u2014 it is rotation\'s last step.\n\n### Upload tokens\n\nA stateless HMAC scoped to a single `assetId`, returned as `uploadToken` from\n`POST /v1/uploads`. **This is the only credential an untrusted client should ever\nhold.** It can drive that one upload and nothing else \u2014 it grants nothing on\nplayback, and nothing on any other asset.\n\nTokens expire. For uploads that outlive one, re-mint in place with\n`POST /v1/uploads/:id/token` (an API key with `upload` scope, not the expiring\ntoken itself) \u2014 no progress is lost. The SDK does this for you through its\n`onTokenExpired` hook.\n\n### Service key\n\nThe internal transcoder credential. Never issued to tenants; documented only so\nits one route is not mistaken for a public one.\n\n### Login without pasting a key\n\n```bash\nnpx @hostr/cli login\n```\n\nBrowser-authorized, using the RFC 8628 device flow. The CLI never accepts a\npasted key from a human or an agent, for the reason given in the\n[quickstart](#quickstart). `--key` and `HOSTR_API_KEY` remain the escape\nhatch for CI and other headless callers.\n\nCredential precedence everywhere: `--key` flag, then `HOSTR_API_KEY`, then the\nsaved profile.\n\n### Auth errors\n\n| Status | Error | Meaning |\n|---|---|---|\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `account_suspended` | Contact the operator |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n\nThat 401 conflation is deliberate: distinguishing "wrong key" from "not your\nasset" would let anyone with a valid key enumerate other accounts\' asset ids.\n\n**`control_plane_unavailable` fails closed for API keys only.** If the control\nplane is unreachable, key-authenticated calls 503 \u2014 but in-flight uploads using\nupload tokens keep working, and playback is untouched, because the media Worker\nnever queries it.\n\n## Assets and lifecycle\n\nAn asset is created when you open an upload and lives until you erase it.\n\n```\nuploading -> uploaded -> queued -> processing -> ready\n \\-> failed\n \\-> aborted (deleted, from anywhere)\n```\n\n| Status | Means |\n|---|---|\n| `uploading` | Multipart session open, parts landing |\n| `uploaded` | Bytes verified against storage; part count and total size both agree |\n| `queued` | Handed to the transcode queue |\n| `processing` | Encoding. Carries live `progress` |\n| `ready` | HLS renditions are live and playable |\n| `failed` | Transcode failed. `error` is always present |\n| `aborted` | Upload abandoned before completion |\n| `deleted` | Erased. Terminal and irreversible |\n\n### Reading progress\n\nWhile `processing`, the record carries\n`progress: { encodedSec, totalSec, speed }`, where `speed` is the encode rate as\na multiple of realtime. Outside `processing` it is null or absent \u2014 a terminal\nstatus never shows a stale bar.\n\nThat is enough for a real percentage instead of a spinner, which matters more\nthan it sounds: most "the transcode is slow" reports turn out to be missing\nprogress feedback rather than slow encoding.\n\n### Prefer events to polling\n\n`GET /v1/assets/:id` returns the authoritative record, and\n`GET /v1/assets?limit=&status=&cursor=` lists your assets newest-first with\nkeyset pagination. But polling is the fallback, not the design \u2014\n[webhooks](#webhooks-and-the-event-log) push the same transitions.\n\n### playbackId is its own field\n\nWhen an asset reaches `ready`, both the record and the `video.ready` event carry\n`playbackId` and `durationSec`.\n\n`playbackId` equals `assetId` today. **Store it as its own field anyway.** It is\nnamed separately so the two can diverge later without forcing a migration on\nevery consumer.\n\n### Deletion\n\n`DELETE /v1/assets/:id` purges the source and every rendition from storage, frees\nthe storage quota, and emits `video.deleted`. Idempotent \u2014 repeat calls report\n`deleted` again with `objectsDeleted: 0`.\n\nNew playback tokens stop minting immediately. Tokens already issued can replay\nedge-cached segments for at most a few hours until they expire. **There is no\nundelete.**\n\n## Quotas and limits\n\nAccounts carry three ceilings, visible any time:\n\n```bash\nnpx @hostr/cli whoami --json # or: GET /v1/account\n```\n\n```json\n{\n "limits": { "maxAssets": 10000, "maxSourceBytes": 5497558138880, "maxUploadsPerDay": 500 },\n "usage": { "assetCount": 1711, "storageBytes": 742381129728, "uploadsToday": 12 }\n}\n```\n\n| Limit | Counts |\n|---|---|\n| `maxAssets` | Assets held at once. Deletion frees this immediately |\n| `maxSourceBytes` | Total source bytes stored |\n| `maxUploadsPerDay` | Upload sessions created per UTC day |\n\n### Hitting one\n\nQuotas are checked when the upload is created, not when bytes arrive. A tripped\nquota is a `403` naming which one and its ceiling:\n\n```json\n{ "error": "quota_storage", "limit": 5497558138880 }\n```\n\nThe three codes are `quota_uploads_daily`, `quota_assets`, and `quota_storage`.\n\n**This means the account needs raising, not that the request was malformed.**\nDistinguish it from a `400` in your error handling \u2014 retrying a quota error\nwithout changing anything will fail identically forever.\n\n### Request-shaped limits\n\nThese are properties of the API rather than of your plan:\n\n| Limit | Value |\n|---|---|\n| Parts per presign request | 100 |\n| Parts per upload | 10,000 (a storage limit; raise `partSize` for very large files) |\n| Subtitle file size | 2,000,000 bytes |\n| Playback token lifetime | 60 s to 86,400 s, default 3,600 |\n| Event page size | 1 to 200, default 50 |\n\nExceeding the part ceiling for a given part size is `413 file_too_large`, and the\nresponse carries the maximum byte count that part size can address.\n\n### Usage over time\n\n```\nGET /v1/usage?from=2026-08-01&to=2026-08-25 (read)\n -> { from, to, days: [{ day, uploads, bytesUploaded, encodedSeconds }] }\n```\n\nDefaults to the last 30 days. Dates are UTC and must be `YYYY-MM-DD`; anything\nelse is `400 bad_date`.\n\n### Not your fault\n\nTwo failures look like integration bugs and are not:\n\n`503 control_plane_unavailable` is transient \u2014 retry with backoff. In-flight\nuploads holding upload tokens are unaffected, and playback is untouched.\n\n`500 signing_not_configured` means the playback signing key is not loaded on the\nAPI deployment. It is operator-side and not retryable from a client. Report it\nrather than working around it.\n\n## Upload from a server\n\nFour calls. Bytes go straight to storage via presigned URLs and never transit\nhostr\'s compute.\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,3]} (at most 100 per call)\n -> { urls: [{ partNumber, url }], expiresIn }\n\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nPOST /v1/uploads/:id/complete {"sha256":"<hex of the whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\n```\n\nPlus `GET /v1/uploads/:id` for resume and `DELETE /v1/uploads/:id` to abort.\n\n### The rules that bite\n\n**Every part is exactly `partSize` bytes except the last.** Use the `partSize`\nthe server returned, not one you picked.\n\n**`size` must be exact.** Completion verifies both the part count and the total\nbyte count against what actually landed. Mismatches are `409`:\n\n- `incomplete_upload` \u2014 `missing` lists the absent part numbers, first 50. Upload\n them and complete again.\n- `size_mismatch` \u2014 `declaredSize` and `receivedBytes` are both reported. Abort\n and restart; the file you declared is not the file you sent.\n\nPart count alone would accept N parts of the wrong sizes, which is why both are\nchecked.\n\n**Request presigned URLs only for the parts you are about to send.** They expire.\nMinting all of them up front for a large file wastes most of them.\n\n**Completion is idempotent.** A client that crashed after bytes landed but before\nit saw the response can safely retry and will get the stored result back rather\nthan a conflict.\n\n### Worked example\n\n```bash\nAPI=https://api.hostr.sh\nKEY=$HOSTR_API_KEY\nFILE=video.mp4\nSIZE=$(wc -c < "$FILE")\n\n# 1. Create the session.\nCREATE=$(curl -sS -X POST "$API/v1/uploads" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"filename\\":\\"$FILE\\",\\"contentType\\":\\"video/mp4\\",\\"size\\":$SIZE}")\n\nASSET=$(echo "$CREATE" | jq -r .assetId)\nPART_SIZE=$(echo "$CREATE" | jq -r .partSize)\nPARTS=$(echo "$CREATE" | jq -r .expectedParts)\n\n# 2. Split, presign, and PUT each part.\nsplit -b "$PART_SIZE" "$FILE" part-\ni=1\nfor p in part-*; do\n URL=$(curl -sS -X POST "$API/v1/uploads/$ASSET/parts" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"partNumbers\\":[$i]}" | jq -r \'.urls[0].url\')\n curl -sS -X PUT --data-binary "@$p" "$URL" -o /dev/null\n i=$((i + 1))\ndone\n\n# 3. Finalise.\ncurl -sS -X POST "$API/v1/uploads/$ASSET/complete" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"sha256\\":\\"$(shasum -a 256 "$FILE" | cut -d\' \' -f1)\\"}"\n```\n\nBatch the presign calls in real code \u2014 up to 100 part numbers per request.\n\n### Send the sha256\n\nThe `etag` completion returns is a *composite* multipart ETag: a hash of hashes,\nand dependent on the part size you used. It is not a content identity and cannot\nbe compared against a hash you computed locally.\n\nIf you want real integrity, send `sha256` \u2014 the lowercase hex SHA-256 of the\nwhole file. It is stored as object metadata and is what an independent\nverification would check against. A malformed value is ignored rather than\nrejected, since retrying the call cannot fix it.\n\n### Credentials\n\n`POST /v1/uploads` needs an API key with `upload` scope. The remaining upload\nroutes accept **either** that key or the `uploadToken` returned at creation.\n\nRe-minting a token (`POST /v1/uploads/:id/token`) requires the API key \u2014 not the\nexpiring token, which would otherwise be able to renew itself forever.\n\nUploading from a browser or mobile client? Do not ship the API key. See\n[Upload from a browser](#upload-from-a-browser).\n\n## Upload from a browser\n\nCreate the upload on your server, hand the client a narrow credential, and let it\nPUT parts directly. Upload bytes never touch your servers.\n\n### Forward only the client-safe fields\n\n`POST /v1/uploads` returns more than a client should see:\n\n```json\n{\n "assetId": "...", // safe\n "uploadToken": "...", // safe\n "tokenExpiresAt": 1756130400000, // safe\n "partSize": 8388608, // safe\n "expectedParts": 13, // safe\n\n "key": "acct_7Kd2/.../source.mp4", // storage internal \u2014 do NOT forward\n "uploadId": "2~aBcDeF..." // storage internal \u2014 do NOT forward\n}\n```\n\nForward exactly `assetId`, `uploadToken`, `tokenExpiresAt`, `partSize`, and\n`expectedParts`. Passing the whole body through leaks your storage layout to\nevery client.\n\nThe `uploadToken` is scoped to that one asset and grants nothing else \u2014 no other\nasset, and nothing on playback. That is why it is safe to hand out and an API key\nis not.\n\n### Bucket CORS is a hard prerequisite\n\nPart PUTs go **directly to object storage**, not through the API. The source\nbucket must allow `PUT` from every origin your uploading page runs on, and must\nexpose the `ETag` response header:\n\n```json\n{\n "AllowedOrigins": ["https://yourapp.example", "http://localhost:3000"],\n "AllowedMethods": ["PUT"],\n "AllowedHeaders": ["*"],\n "ExposeHeaders": ["ETag"]\n}\n```\n\n`ExposeHeaders: ["ETag"]` is not optional \u2014 the client verifies each part\'s ETag\nand cannot read it otherwise.\n\n### The failure mode, stated plainly\n\n**A missing origin is silent from the API\'s perspective.** The upload session\nexists, zero parts ever land, and the client cycles running/stalled as though the\nnetwork were flaky. A CORS-blocked PUT is indistinguishable from a network error\nto `XMLHttpRequest` \u2014 the browser deliberately withholds the detail.\n\n**First move when a browser upload stalls at 0%: check the preflight, not the\nWorker.** Open the network tab and look for the `OPTIONS` request.\n\nThis one cost real time on the first customer integration, which is why the SDK\nnow surfaces it itself. After three consecutive network-class part failures with\n**zero bytes ever confirmed** for the session, it emits:\n\n```js\nupload.on(\'diagnostic\', d => {\n // { code: \'systemic-transport-failure\', consecutiveNetworkFailures, bytesEverConfirmed, hint }\n console.warn(d.hint);\n});\n```\n\nZero-ever-confirmed is what separates this from ordinary mid-upload network loss.\nIt is **advisory, never terminal**: a client that is genuinely offline from byte\nzero looks identical, so the upload keeps retrying regardless. The event is the\nhook for telling the user to check their connection \u2014 or for telling *you* to\ncheck bucket CORS.\n\n### Token expiry mid-upload\n\nLong uploads outlive their token. Handle it on your server:\n\n```js\nconst upload = createUpload({\n file,\n assetId,\n uploadToken,\n onTokenExpired: async () => {\n const r = await fetch(\'/api/hostr/refresh-token\', {\n method: \'POST\',\n body: JSON.stringify({ assetId }),\n });\n return (await r.json()).uploadToken; // your server calls POST /v1/uploads/:id/token\n },\n});\n```\n\nThe SDK re-mints and retries in place. Nothing is lost even without this \u2014 a\nkilled client resumes from what storage reports on the next launch \u2014 but the hook\navoids the interruption.\n\nSee the [SDK reference](#sdk-reference) for the full option surface.\n\n## Resume and integrity\n\n### Resume asks storage, not the client\n\n```\nGET /v1/uploads/:id\n -> { status, partSize, expectedParts, uploadedParts, parts, bytesUploaded }\n```\n\n`uploadedParts` is part numbers only; `parts` is the richer form, each entry\ncarrying `partNumber`, `size`, and `etag`.\n\nThe API derives this by listing what is actually in storage, not by reading\nbookkeeping the client sent. That is the whole design: a crash, a refresh, a\ndropped connection, or a client that lied all converge on the same truth. Resume\nis simply "send the parts that are not in that list".\n\nParts are listed only while `uploading`. Once completed or aborted the list is\nempty, because there is nothing left to resume.\n\n### Three layers, three different claims\n\nEach layer proves something the others do not.\n\n**Per-part ETag.** Storage returns an ETag on every part PUT. The client compares\nit against the part it just sent, so a corrupted or truncated part is caught\nimmediately rather than at the end of a multi-gigabyte upload.\n\n**Part count and total size at completion.** Completion refuses to finalise\nunless the number of parts and the total byte count both match what was declared\nat creation. Count alone would accept N parts of the wrong sizes.\n\n**Whole-file SHA-256, if you send it.** This is the only one that proves content\nidentity.\n\n### Why the ETag is not enough\n\nThe ETag on a completed multipart object is a **hash of the part hashes**, then\nthe part count \u2014 `b8f1c0d9e2a34567-13`. Two consequences:\n\n- It cannot be compared against a hash you compute locally.\n- The same file uploaded with a different `partSize` produces a different ETag.\n\nSo it is a useful transfer check and a useless content identity. Send `sha256`\n(lowercase hex of the whole file) at completion if identity matters; it is stored\nas object metadata.\n\nIndependent verification, deriving both from the bytes actually in storage rather\nthan from anything the client claimed:\n\n```bash\npnpm verify:upload <assetId>\n```\n\n### Completion is idempotent\n\nA client that crashed after the bytes landed but before it saw the response\nretries and gets the stored `etag` and `bytes` back, not a `409`. Any genuinely\nunfinished status still conflicts with `not_uploading`.\n\nThis matters more than it sounds: the window between "storage has everything" and\n"the client knows" is exactly where flaky mobile connections die.\n\n### Aborting\n\n```\nDELETE /v1/uploads/:id\n```\n\nAn in-flight multipart session holds part storage that is invisible in normal\nlistings. Abandoning an upload without aborting leaks it. The SDK and CLI do this\nfor you; a hand-rolled client should too.\n\n## Webhooks and the event log\n\nhostr POSTs lifecycle events to one endpoint per account so you do not have to\npoll.\n\n```\nPUT /v1/webhooks {"url": "https://yourapp.example/hostr-webhook"} (full)\n -> { url, enabled, secret: "whsec_..." } # secret returned ONLY when generated, ONLY once\nGET /v1/webhooks -> { url, enabled, createdAt } # never the secret\nDELETE /v1/webhooks -> disables; delivery history is retained\n```\n\nOmit `secret` in the PUT to have one generated, or supply your own to bring an\nexisting one. Configuration is cached about 60 seconds in the Worker, the same\npropagation window as key revocation.\n\nA URL configured without a secret logs and skips: **unsigned events are never\nsent.**\n\n### The log is written first\n\nEvery event becomes a durable row **before** delivery is attempted. A consumer\noutage cannot lose one.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n**The webhook is the wake-up signal; the log is the ledger.** Delivery is\nat-least-once \u2014 three attempts with short backoff, each recorded and inspectable.\nA consumer that must not miss anything reconciles against `GET /v1/events` (or\n`GET /v1/assets/:id`) rather than trusting that every POST arrived.\n\n`npx @hostr/cli tail --follow` polls the log for you.\n\n### Building a consumer that behaves\n\n**Verify the signature before parsing.** See\n[Verifying webhooks](#verifying-webhook-signatures) \u2014 use the raw body bytes.\n\n**Dedupe on `id`.** It is the durable event row\'s id, and a redelivery reuses it\ndeliberately so your dedupe still works.\n\n**Respond `2xx` fast.** Do the real work asynchronously. Anything else is\nretried.\n\n**Expect at-least-once, and design for it.** `video.ready` and `video.errored`\nfire on the status *transition* only, so a transcoder re-posting a terminal state\ncannot duplicate them \u2014 but the network can still deliver the same event twice.\n\n### Testing locally\n\nBoth `https` and `http` endpoints are accepted, including private addresses.\nhostr does not block internal ranges, so a local receiver works in development\nexactly as production does.\n\n```bash\nnpx @hostr/cli webhooks test # redelivers the newest event to your endpoint\n```\n\nSee the [events reference](#event-reference) for every type and payload.\n\n## Verifying webhook signatures\n\nEvery delivery carries a `hostr-signature` header:\n\n```\nhostr-signature: t=1756130400,v1=3b1f2c9d8e7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c\n```\n\n`v1` is `HMAC-SHA256(secret, "<t>.<rawBody>")`, hex-encoded. `t` is a unix\ntimestamp in **seconds**. This is the Stripe scheme, and Mux\'s.\n\n### Implementation\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\n\nexport function verifyHostrWebhook(secret, header, rawBody, toleranceSec = 300) {\n const m = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? \'\');\n if (!m) return false;\n if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSec) return false;\n const expected = createHmac(\'sha256\', secret).update(`${m[1]}.${rawBody}`).digest(\'hex\');\n return expected.length === m[2].length &&\n timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));\n}\n```\n\nWired into Express, where getting the raw body needs care:\n\n```js\nimport express from \'express\';\n\nconst app = express();\n\napp.post(\'/hostr-webhook\',\n express.raw({ type: \'application/json\' }), // NOT express.json()\n (req, res) => {\n const raw = req.body.toString(\'utf8\');\n if (!verifyHostrWebhook(process.env.HOSTR_WEBHOOK_SECRET, req.get(\'hostr-signature\'), raw)) {\n return res.status(400).end();\n }\n const event = JSON.parse(raw); // parse only after verifying\n res.status(200).end(); // ack fast\n queue.push(event); // then do the work\n });\n```\n\n### The three ways this goes wrong\n\n**Using the parsed body instead of the raw bytes.** `JSON.parse` followed by\n`JSON.stringify` does not reproduce the original bytes \u2014 key order, whitespace,\nand number formatting can all shift. The HMAC is over what was actually sent.\nRegister the raw body parser on this route specifically.\n\n**A plain `===` on the hex digest.** String comparison short-circuits on the\nfirst differing character, which leaks timing information. Use\n`timingSafeEqual`, and compare lengths first \u2014 it throws on mismatched lengths.\n\n**Ignoring `t`.** Without a bounded timestamp window a captured delivery can be\nreplayed at any time in the future. Five minutes is a reasonable tolerance.\n\n### Do not confuse the units\n\n`t` is unix **seconds**. Elsewhere in the API, `tokenExpiresAt` and `expiresAt`\nare epoch **milliseconds**. That is deliberate \u2014 each convention matches its home\n(Stripe\'s for signatures, Mux\'s for token bundles) rather than being made falsely\nuniform. Mixing them up produces a timestamp roughly 55,000 years out, which at\nleast fails loudly.\n\n### Getting the secret\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nWritten straight to the file at mode 0600; only the variable name is printed. The\nsecret is returned **once**, in the `PUT /v1/webhooks` response, and never again\n\u2014 `GET /v1/webhooks` deliberately does not include it. Lost it? Set a new one.\n\n### Proving it end to end\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\nRedelivers the newest stored event to your endpoint, so you can confirm the whole\npath without uploading anything.\n\nIf your endpoint was down when an event fired, nothing was lost \u2014 see\n[Webhooks and the event log](#webhooks-and-the-event-log).\n\n## Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `playback`-scoped\nkey.\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"user-123"} (body optional)\n -> { playback, thumbnail, storyboard, expiresAt }\n```\n\n```bash\nnpx @hostr/cli assets token <assetId> --json\n```\n\nField names mirror Mux\'s bundle, so a consumer migrating swaps its base URL and\nkeeps its client code.\n\n### Build the URL\n\n```\nhttps://stream.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\nHand it to any HLS player. Safari and iOS play HLS natively; elsewhere use\nhls.js.\n\n```html\n<video id="v" controls></video>\n<script type="module">\n import Hls from \'https://cdn.jsdelivr.net/npm/hls.js@1/+esm\';\n const src = `https://stream.hostr.sh/${playbackId}.m3u8?token=${token}`;\n const v = document.getElementById(\'v\');\n if (v.canPlayType(\'application/vnd.apple.mpegurl\')) v.src = src;\n else if (Hls.isSupported()) { const h = new Hls(); h.loadSource(src); h.attachMedia(v); }\n</script>\n```\n\n### Mint timing, and what to gate on\n\nMinting works any time after the asset exists \u2014 it 404s only for unknown or\ndeleted assets. **The stream itself 404s until the asset is `ready`.**\n\nSo gate your UI on `video.ready` or the asset\'s status, never on whether minting\nsucceeded. A successful mint for a still-encoding asset is expected behaviour,\nnot a green light.\n\n### The TTL is not a viewing limit\n\nThe token is checked **once**, at the master playlist. After that the session\ncarries its own internally-managed credentials, so a viewer who starts playback\nat minute 59 of a one-hour token watches the whole video.\n\n`ttlSec` accepts 60 to 86,400 and defaults to 3,600. It only needs to be valid\nwhen playback *starts*.\n\n### viewerId\n\nOptional, opaque, and carried inside the token. It enables per-viewer concurrency\nobservation. Concurrency is currently observed rather than enforced \u2014 see\n[Tokens and signed playback](https://docs.hostr.sh/concepts/playback/).\n\n### Things that will confuse you once\n\n**Non-browser user agents are rejected** with `403 blocked_user_agent` \u2014\ncurl, wget, yt-dlp, vlc, and ffmpeg UAs are blocked as a casual-ripping speed\nbump. A server-side smoke test must send a browser-like `User-Agent` or hit\n`/health`, which is exempt. Expect it; do not debug it.\n\n**Signed URLs are not DRM.** They stop link-sharing and hotlinking, not an\nentitled viewer recording their screen.\n\n**Deleted assets stop minting immediately**, but tokens already issued can replay\nedge-cached segments for a few hours until they expire.\n\n### Poster and scrub previews\n\nThe same mint response covers both, each URL taking its matching token:\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSee [Thumbnails and storyboards](#thumbnails-and-storyboards).\n\n## Thumbnails and storyboards\n\n`POST /v1/assets/:id/tokens` returns three tokens, not one. The `thumbnail` and\n`storyboard` values drive the poster image and the scrub preview.\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSame host, same query-token shape as the playlist. Each URL takes its **own**\ntoken \u2014 they are minted with different audiences and are not interchangeable.\n\n### Poster\n\nA single JPEG extracted during encoding. Use it as the `poster` attribute:\n\n```html\n<video poster="https://stream.hostr.sh/abc123/thumbnail.jpg?token=..." controls></video>\n```\n\n### Storyboard\n\nA standard "thumbnails" WebVTT track. Cues point into a tiled sprite using\n`#xywh=` fragments, and **the sprite URLs inside the VTT are pre-signed for you**\n\u2014 you do not mint anything extra.\n\nThat is the convention mux-player\'s `storyboard-src`, media-chrome\'s preview\ntrack, and most other players already consume:\n\n```html\n<mux-player\n storyboard-src="https://stream.hostr.sh/abc123/storyboard.vtt?token=..."\n></mux-player>\n```\n\n### Treat both as optional\n\nAssets encoded before storyboards existed 404 on that URL, and scrubbing simply\nshows no preview. Players handle this gracefully by design \u2014 do not add error\nhandling that turns a missing preview into a broken player.\n\n## Subtitles\n\nHuman-authored WebVTT tracks, attachable any time after the asset is `ready`.\n\n```\nPUT /v1/assets/:id/subtitles/:lang?name=English&default=true (body: raw WebVTT, upload scope)\nGET /v1/assets/:id/subtitles -> { assetId, tracks: [{ lang, name, default, rev }] }\nDELETE /v1/assets/:id/subtitles/:lang\n```\n\n```bash\nnpx @hostr/cli subtitles set <assetId> subs.vtt --lang en --name English --default\nnpx @hostr/cli subtitles list <assetId>\nnpx @hostr/cli subtitles rm <assetId> en\n```\n\nThe PUT body is **raw WebVTT, not JSON**. It must start with `WEBVTT`, and is\ncapped at 2,000,000 bytes.\n\n```bash\ncurl -X PUT "https://api.hostr.sh/v1/assets/$ASSET/subtitles/en?name=English&default=true" \\\n -H "authorization: Bearer $HOSTR_API_KEY" \\\n -H \'content-type: text/vtt\' \\\n --data-binary @subs.vtt\n```\n\n### Rules\n\n**`:lang` is a BCP 47 tag** \u2014 `en`, `pt-BR`. One track per tag; PUT replaces.\nAnything that is not a valid tag is `400 bad_language`.\n\n**The asset must be `ready`.** Before that it is `409 not_ready` \u2014 the track\nplaylist needs the encoded duration, which does not exist until the encode\nreports in.\n\n**`default=true` demotes any previous default.** At most one default per asset.\n\n**Write scope is `upload`, not `playback`.** Subtitles are content, not\nentitlement.\n\n### How they reach the player\n\nTracks are injected into the master playlist at serve time as an `EXT-X-MEDIA`\nsubtitles group. The next master fetch after a PUT includes them.\n\n**No re-encode. No new playback URL. No token change.** Players pick them up in\nthe captions menu automatically.\n\nTracks are stored under a content-hash revision, so replacing one is cache-safe\neven though segments are served with a one-year immutable cache. A viewer already\nmid-session keeps the track they started with; the replacement applies to the next\nplayback session.\n\n### Deleting\n\nRemoval delists the track first \u2014 that is what makes it invisible to players. The\nunderlying versioned objects become unreachable and are purged with the asset, so\nleftovers cost nothing.\n\n`404 subtitle_not_found` means no track for that tag; `404 asset_not_found` means\nno such asset. They are distinct codes.\n\n## Event reference\n\nDelivered body: `{ id, type, createdAt, data }`. `id` is the durable event row\'s\nid \u2014 **dedupe on it**, and note that a redelivery reuses it deliberately.\n\n| Type | Fired when | `data` highlights |\n|---|---|---|\n| `upload.created` | Multipart session opened | `assetId`, `filename`, `declaredSize` |\n| `video.uploaded` | Bytes verified in storage, transcode queued | `assetId`, `key`, `bytes`, `etag`, `sha256?` |\n| `video.ready` | HLS renditions live | `assetId`, `playbackId`, `durationSec?`, `renditions?` |\n| `video.errored` | Transcode failed | `assetId`, `error` (always present) |\n| `video.deleted` | Asset erased, playback over | `assetId` |\n| `video.input.non_standard` | The source needed a judgement call | `assetId`, `input`, `findings[]` |\n\n`video.ready` and `video.errored` fire on the status **transition** only, so a\ntranscoder re-posting a terminal state cannot duplicate them.\n\n`error` on `video.errored` is always present. An optional error field makes every\nconsumer invent its own generic message, which is worse than a bad message.\n\n### video.input.non_standard\n\nAdvisory. The transcode proceeds normally and a `video.ready` still follows. It\nfires **at most once per asset**, and only when there is something to say \u2014 a\nfile with nothing unusual about it generates no traffic.\n\n| Finding code | Meaning |\n|---|---|\n| `hdr_tags_on_8bit` | Source carries HLG/PQ tags but is 8-bit. HDR is a 10-bit format, so the tag is stale \u2014 treated as SDR, pixels untouched |\n| `hdr_source` | Genuine 10-bit HDR. The ladder outputs 8-bit SDR; no tone mapping is applied |\n| `wide_gamut_tags` | BT.2020 primaries without an HDR transfer; output is BT.709, colour not converted |\n| `variable_frame_rate` | Container rate and measured rate disagree; normalised to CFR |\n| `frame_rate_changed` | Source rate was not a broadcast standard and was snapped to the nearest |\n\n`data.input` carries the full probe \u2014 `container`, `videoCodec`, `pixFmt`,\n`bitDepth`, `width`, `height`, `declaredFps`, `averageFps`, `targetFps`,\n`isVariableFrameRate`, `colorSpace`, `colorTransfer`, `colorPrimaries`.\n\nThe same block is on the asset record as `input`, so it can be read back at any\ntime without replaying events.\n\n### Delivery\n\nThree attempts with short backoff, each recorded and inspectable. Every event is\nwritten to the durable log **before** the first attempt.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim\nGET /v1/events?order=desc&limit=1 newest first\nPOST /v1/events/:id/redeliver replay to the current endpoint\n```\n\nSignature verification: [Verifying webhooks](#verifying-webhook-signatures).\n\n## Error reference\n\nEvery non-2xx body is `{ "error": "<code>", ... }`. **Switch on `error`, never on\nthe message** \u2014 codes are stable, prose is not.\n\n| Status | Error | Meaning and action |\n|---|---|---|\n| 400 | `size_required` | `size` missing or not positive on `POST /v1/uploads` |\n| 400 | `partNumbers_required` | Empty or non-array `partNumbers` |\n| 400 | `too_many_parts_per_request` | More than 100 part numbers; `max` names the cap |\n| 400 | `part_out_of_range` | A part number below 1 or above `expectedParts` |\n| 400 | `bad_language` | Not a BCP 47 tag |\n| 400 | `not_webvtt` | Subtitle body does not start with `WEBVTT` |\n| 400 | `bad_status` | Status outside the allowed set (internal route) |\n| 400 | `bad_scopes` | A scope outside `read`/`upload`/`playback`/`full` |\n| 400 | `bad_date` | A date parameter was not `YYYY-MM-DD` |\n| 400 | `authorization_pending` | Device login not yet approved \u2014 keep polling |\n| 400 | `slow_down` | Polling too fast \u2014 back off, keep polling |\n| 400 | `expired_token` | Device code expired. Start over |\n| 400 | `access_denied` | Device login was declined |\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `quota_uploads_daily` | Daily upload cap; `limit` is the ceiling |\n| 403 | `quota_assets` | Asset count cap; `limit` is the ceiling |\n| 403 | `quota_storage` | Storage cap; `limit` is the ceiling |\n| 403 | `account_suspended` | Contact the operator |\n| 403 | `accountId_service_only` | Only the service key may name an owning account |\n| 403 | `blocked_user_agent` | Media host only \u2014 send a browser-like `User-Agent` |\n| 404 | `asset_not_found` | No such asset, or it has been deleted |\n| 404 | `subtitle_not_found` | No track for that language tag |\n| 404 | `event_not_found` | No such event for this account |\n| 404 | `key_not_found` | No such live key for this account |\n| 404 | `not_configured` | No webhook endpoint configured |\n| 404 | `account_not_found` | Key authenticated but its account row is gone |\n| 409 | `incomplete_upload` | Parts missing; `missing` lists them. Upload and re-complete |\n| 409 | `size_mismatch` | Declared size does not match bytes landed. Abort and restart |\n| 409 | `not_uploading` | Upload already finished or aborted |\n| 409 | `not_ready` | Asset is not `ready`, so its duration is unknown |\n| 409 | `no_webhook_endpoint` | Nothing configured or enabled to redeliver to |\n| 413 | `file_too_large` | Too many parts at this part size; raise `partSize` |\n| 413 | `subtitle_too_large` | Over 2,000,000 bytes |\n| 429 | `rate_limited` | `POST /v1/device/code` only. It is unauthenticated, so it is capped per IP. A real login makes one call \u2014 if you see this, something is looping |\n| 500 | `signing_not_configured` | Operator-side: the playback signing key is not loaded. Not your fault, not retryable. Report it |\n| 500 | `internal` | Unexpected. `message` carries truncated detail |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n\n### Retry guidance\n\n**Retry with backoff:** `503 control_plane_unavailable`, `500 internal`,\n`400 authorization_pending`, `400 slow_down`.\n\n**Fix and retry:** the `409` upload conflicts \u2014 they tell you exactly what is\nwrong (`missing` part numbers, or both byte counts).\n\n**Do not retry unchanged:** every `400`, `401`, `403`, and `404`. A quota `403`\nin particular will fail identically forever until the account limit is raised.\n\n### The 401 conflation is deliberate\n\n`unauthorized` covers both "bad credential" and "not your asset". Distinguishing\nthem would let anyone holding a valid key enumerate other accounts\' asset ids by\nwatching which error came back.\n\n## CLI reference\n\n```bash\nnpx @hostr/cli <command>\n```\n\nA single-file, zero-dependency bundle published as `hostr`. **Every command takes\n`--json`** \u2014 one JSON document on stdout, NDJSON for `tail --follow`, errors as\nJSON on stderr with exit code 1. Nothing needs prose parsing.\n\nRun it with **no arguments** for setup status and the single next step. That is\nthe intended entry point, for humans and agents alike.\n\n### Credentials\n\nPrecedence, highest first: `--key <k>`, then `HOSTR_API_KEY`, then the saved\nprofile.\n\nAPI base, highest first: `--api <url>`, then `HOSTR_API_BASE`, then the profile,\nthen production.\n\nProfiles live in `~/.config/hostr/config.json`, cleartext at mode 0600 \u2014 the\n`gh` and `wrangler` precedent.\n\n### Authentication\n\n```bash\nhostr login # browser-authorized; nothing is pasted\nhostr login --profile staging # name the profile\nhostr login --key hostr_sk_live_... # escape hatch for CI; skips the browser\nhostr whoami # account, plan, limits, live usage\n```\n\n`login` with no key opens your browser and uses the device-authorization flow.\nThe code and URL are printed to **stderr**, so `--json` stdout stays a single\nparseable document.\n\n### Uploading\n\n```bash\nhostr upload video.mp4 # returns when bytes are verified in storage\nhostr upload video.mp4 --wait # blocks until transcode is ready or failed\nhostr upload video.mp4 --content-type video/quicktime\nhostr upload # no file: opens a local drag-drop page\n```\n\nWraps the SDK\'s Node adapter, so it carries the same resume and integrity\nguarantees. Kill it mid-transfer and re-run to continue.\n\n### Assets\n\n```bash\nhostr assets list --status ready --limit 20 --cursor <cursor>\nhostr assets get <id>\nhostr assets token <id> --ttl 3600 --viewer-id user-123 # playback scope\nhostr assets delete <id> # full scope\n```\n\n### Subtitles\n\n```bash\nhostr subtitles list <assetId>\nhostr subtitles set <assetId> subs.vtt --lang en --name English --default\nhostr subtitles rm <assetId> en\n```\n\n`--lang` is required and must be a BCP 47 tag. The asset must be `ready`.\n\n### Events\n\n```bash\nhostr tail # one page of the durable log\nhostr tail --follow # poll every 2s, NDJSON under --json\nhostr tail --type video.ready --limit 50 --after <cursor>\n```\n\n### Keys\n\n```bash\nhostr keys list\nhostr keys create --name ci --scopes upload --write-env .env\nhostr keys revoke <id>\n```\n\n`--scopes` takes a comma-separated list and **defaults to `playback`** \u2014 the\nright rung for an application key. `revoke` takes the `id` from `keys list`, not\nthe `keyId`.\n\n### Webhooks\n\n```bash\nhostr webhooks get\nhostr webhooks set https://app.example/hook --write-env .env\nhostr webhooks set https://app.example/hook --secret whsec_... # bring your own\nhostr webhooks delete\nhostr webhooks test # redeliver the newest event \u2014 proves your endpoint\n```\n\n### Repo setup\n\n```bash\nhostr init # docs/hostr.md + AGENTS.md section + .env.example\nhostr init --dir ../other-repo\n```\n\nIdempotent. Re-running replaces the content between the `AGENTS.md` markers\nrather than appending.\n\n### Usage\n\n```bash\nhostr usage --from 2026-08-01 --to 2026-08-25\n```\n\nDefaults to the last 30 days.\n\n### --write-env\n\n```bash\nhostr keys create --scopes upload --write-env .env\nhostr webhooks set https://app.example/hook --write-env .env\n```\n\nWrites the secret into that file at mode 0600 and prints **only the variable\nname** \u2014 in `--json` mode too. Nothing else in the CLI ever prints a credential.\n\nThe point is that an agent driving your terminal can orchestrate a credential\nwithout the value ever entering its context or its transcript.\n\n## SDK reference\n\n```bash\nnpm install @hostr/upload\n```\n\nFour entry points, each bundled independently:\n\n| Entry | Platform | What the adapter supplies |\n|---|---|---|\n| `@hostr/upload` | **Trusted backend only** | `createUploadSession`, `mintUploadToken` \u2014 needs your API key |\n| `@hostr/upload/browser` | Web | XHR transport (for progress events), `localStorage` session store |\n| `@hostr/upload/node` | Node, scripts | `fetch` transport, filesystem session store |\n| `@hostr/upload/react-native` | Expo / RN | Native staged-parts transport; survives backgrounding |\n\nThe engine, retry policy, and integrity checks are one shared core. Adapters only\nsupply platform I/O: read bytes, PUT bytes, persist a few KB of JSON.\n\n### Browser\n\n```js\nimport { createUpload } from \'@hostr/upload/browser\';\n\n// createUploadSession runs on YOUR SERVER \u2014 it holds the API key.\n// The browser receives only { endpoint, assetId, uploadToken }.\nconst upload = await createUpload({ endpoint, uploadToken, assetId, file });\n\nupload.on(\'progress\', p => {\n const pct = (p.bytesConfirmed / p.totalBytes * 100).toFixed(1);\n console.log(`${p.partsDone}/${p.partsTotal} parts, ${pct}%`);\n});\nupload.on(\'statechange\', s => console.log(s));\n// idle -> running -> (stalled <-> running)* -> completing -> complete\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\nSession state persists in `localStorage`, keyed by `name:size:lastModified`.\nReload the page, pick the same file, and `createUpload` resumes automatically \u2014\nthere is no explicit resume call.\n\n### Node\n\n```js\nimport { createUploadSession, createUpload } from \'@hostr/upload/node\';\n\nconst session = await createUploadSession({\n endpoint, apiKey, filename: \'video.mp4\', contentType: \'video/mp4\', size,\n});\n\nconst upload = await createUpload({\n endpoint, uploadToken: session.uploadToken, assetId: session.assetId,\n file: \'/path/to/video.mp4\', // a path, not an open handle\n stateDir: \'./upload-state\', // survives a process restart\n});\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\n### Take the endpoint from your backend\'s response\n\n**One rule for clients of a proxying backend:** take the hostr `endpoint` from\nyour backend\'s create-upload response, never from separate client config. Have\nyour backend include its own `HOSTR_API_BASE` in what it returns.\n\nAn upload token is only valid on the hostr instance that minted it. Endpoint\ndrift fails as a bare mid-flow `401` with nothing pointing at the cause;\nreturning the endpoint alongside the session makes the mistake structurally\nimpossible. Learned from the first consumer integration.\n\n### Events\n\n| Event | Payload |\n|---|---|\n| `progress` | `{ partsDone, partsTotal, bytesConfirmed, totalBytes, partStates }` |\n| `statechange` | `\'idle\' \\| \'running\' \\| \'stalled\' \\| \'completing\' \\| \'complete\'` |\n| `diagnostic` | `{ code, consecutiveNetworkFailures, bytesEverConfirmed, hint }` |\n\n`partStates` maps part number to `\'inflight\'` or `\'done\'` \u2014 enough for a per-part\nprogress grid.\n\nThe `diagnostic` event fires once per failure streak, on every adapter. Today its\nonly code is `systemic-transport-failure`, which almost always means bucket CORS\n\u2014 see [Upload from a browser](#upload-from-a-browser). It is **advisory, never\nterminal**: the upload keeps retrying regardless.\n\n### Token refresh\n\n```js\nconst upload = await createUpload({\n endpoint, assetId, uploadToken, file,\n onTokenExpired: async () => (await refreshFromYourServer(assetId)).uploadToken,\n});\n```\n\nYour server calls `POST /v1/uploads/:id/token`. The SDK retries in place; nothing\nis lost. Even without the hook a killed client resumes from what storage reports\non next launch.\n\n### Integrity\n\nThree layers, each proving something different \u2014 per-part ETag, part count and\ntotal size at completion, and an optional whole-file SHA-256. See\n[Resume and integrity](#resume-and-integrity) for what each one does and\ndoes not prove.\n\n### Import error classes from the entry you actually use\n\nEach entry is bundled independently, so `IntegrityError` from\n`@hostr/upload/node` is not the same class object as the one from\n`@hostr/upload/browser`. An `instanceof` check across entries fails. Import from\nthe entry your code uses.\n\n## Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| GET | `/health` | \u2014 | Liveness check |\n| GET | `/llms.txt` | \u2014 | Agent documentation index |\n| GET | `/llms-full.txt` | \u2014 | Agent integration guide |\n| POST | `/v1/device/code` | \u2014 | Begin browser authorization |\n| POST | `/v1/device/token` | \u2014 | Exchange an approved device code for a key |\n| POST | `/v1/uploads` | upload | Create an asset and begin a multipart upload |\n| GET | `/v1/uploads/:id` | upload | Upload status and parts already stored |\n| DELETE | `/v1/uploads/:id` | upload | Abort an upload |\n| POST | `/v1/uploads/:id/parts` | upload | Get presigned PUT URLs for parts |\n| POST | `/v1/uploads/:id/complete` | upload | Finalise the upload and queue transcoding |\n| POST | `/v1/uploads/:id/token` | upload | Re-mint an upload token |\n| GET | `/v1/assets` | read | List your assets |\n| GET | `/v1/assets/:id` | read | Get one asset |\n| DELETE | `/v1/assets/:id` | full | Erase an asset |\n| POST | `/v1/assets/:id/tokens` | playback | Mint playback credentials |\n| POST | `/v1/assets/:id/status` | \u2014 | Report transcode status |\n| GET | `/v1/assets/:id/subtitles` | read | List subtitle tracks |\n| PUT | `/v1/assets/:id/subtitles/:lang` | upload | Attach or replace a subtitle track |\n| DELETE | `/v1/assets/:id/subtitles/:lang` | upload | Remove a subtitle track |\n| GET | `/v1/account` | read | Whoami |\n| GET | `/v1/usage` | read | Daily usage |\n| GET | `/v1/events` | read | Tail the durable event log |\n| POST | `/v1/events/:id/redeliver` | full | Replay a stored event |\n| GET | `/v1/webhooks` | full | Get the webhook endpoint |\n| PUT | `/v1/webhooks` | full | Configure the webhook endpoint |\n| DELETE | `/v1/webhooks` | full | Disable the webhook endpoint |\n| GET | `/v1/keys` | full | List key metadata |\n| POST | `/v1/keys` | full | Mint an API key |\n| DELETE | `/v1/keys/:id` | full | Revoke a key |\n\nGenerated from the same OpenAPI description the API is checked against.\nFull reference with schemas and examples: https://docs.hostr.sh/reference/api/\n';
|
|
5505
|
+
var integrate_default = '# hostr \u2014 integration guide\n\n<!-- GENERATED FILE \u2014 DO NOT EDIT.\n Source: apps/docs/src/content/docs/** (pages with `agentGuide: true`)\n packages/openapi/src/spec.ts (the route table)\n Regenerate: pnpm build:agent-guide \xB7 CI checks this with --check -->\n\nhostr is self-hosted video infrastructure: resumable upload \u2192 transcode \u2192\nHLS delivery, with signed playback URLs. This document is the complete,\nself-contained integration reference. It is written to be executed by a\ncoding agent; every command has a `--json` mode and every flow is\nverifiable from the terminal.\n\n```\nAPI base https://api.hostr.sh (override: HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\nDocs https://docs.hostr.sh \xB7 GET {API base}/llms-full.txt (this file)\n```\n\nRun `npx @hostr/cli` in your app\'s repo. With no arguments it reports what is\nconfigured and names the single next step \u2014 authorize, prepare the repo, mint\nan application key \u2014 and it is the intended entry point for a coding agent.\nThis document is the *reference*: consult it while implementing, section by\nsection, rather than reading it front to back.\n\n## Quickstart\n\nBases you will need:\n\n```\nAPI base https://api.hostr.sh (override with HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\n```\n\n### 1. Authorize\n\n```bash\nnpx @hostr/cli login\n```\n\nThis opens your browser and mints the CLI\'s own key once you approve it. Nothing\nis pasted, which matters when a coding agent is driving your terminal \u2014 a key\ntyped into an agent\'s session lands in its context and its transcript.\n\nThe key appears on the dashboard Keys page as `CLI \u2014 <hostname>` and is revocable\nlike any other. For CI, `--key <k>` or `HOSTR_API_KEY` skips the browser.\n\nConfirm it worked:\n\n```bash\nnpx @hostr/cli whoami --json\n```\n\n### 2. Upload\n\n```bash\nnpx @hostr/cli upload video.mp4 --wait --json\n```\n\n`--wait` blocks until the transcode reaches `ready` or `failed`. Without it the\ncommand returns as soon as the bytes are verified in storage.\n\n```json\n{ "assetId": "9f8c1e2a4b6d47f0a1b2c3d4e5f60718", "status": "ready", "bytes": 104857600 }\n```\n\nThe upload is resumable. Kill it mid-transfer and run it again \u2014 it asks storage\nwhich parts landed and continues from there.\n\n### 3. Play it\n\nMint a playback token, then build the URL:\n\n```bash\nnpx @hostr/cli assets token 9f8c1e2a4b6d47f0a1b2c3d4e5f60718 --json\n```\n\n```json\n{ "playback": "eyJhbGci...", "thumbnail": "...", "storyboard": "...", "expiresAt": 1756134000000 }\n```\n\n```\nhttps://stream.hostr.sh/{assetId}.m3u8?token={playback}\n```\n\nHand that to any HLS player. Safari and iOS play it natively; elsewhere use\nhls.js.\n\nThe token is checked once, when playback starts. It does not need to stay valid\nfor the whole viewing session, and the default one-hour lifetime is not a\none-hour viewing limit.\n\n### 4. Wire up events, not polling\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nThe signing secret is generated, written to `.env`, and never printed \u2014 only the\nvariable name is. Then verify signatures before trusting a delivery: see\n[Verifying webhooks](#verifying-webhook-signatures).\n\nProve your endpoint end to end without uploading anything else:\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\n### No Node available?\n\nEvery flow here has a raw-HTTP equivalent. Start at\n[Upload from a server](#upload-from-a-server).\n\n## Authentication\n\nEvery call sends `Authorization: Bearer <credential>` over HTTPS. There are three\ncredential classes and **no shared master key**.\n\n### API keys\n\n`hostr_sk_live_\u2026`, minted per account. SHA-256-hashed at rest; the plaintext is\nshown exactly once, at mint. Verified in constant time and cached about 60 seconds\nin the edge Worker \u2014 so **revocation takes up to a minute** to take effect.\n\nScopes are a ladder, each rung including the ones below:\n\n| Scope | Grants |\n|---|---|\n| `read` | GETs only \u2014 asset status, listing, usage, events |\n| `upload` | read, plus the whole upload lifecycle |\n| `playback` | upload, plus minting playback tokens |\n| `full` | everything: deletion, key management, webhook config |\n\n**`playback` is what an application key should be.** It can ingest video and it\ncan let viewers watch, but it cannot delete assets or mint more keys. Reach for\n`full` only where you actually manage the account.\n\n**Keep keys server-side.** Never ship one in a browser or a mobile app.\n\nRotate by creating the new key and revoking the old one:\n\n```bash\nnpx @hostr/cli keys create --name ci --scopes upload --write-env .env\nnpx @hostr/cli keys revoke <old-id>\n```\n\nRevoking the key that makes the request is allowed \u2014 it is rotation\'s last step.\n\n### Upload tokens\n\nA stateless HMAC scoped to a single `assetId`, returned as `uploadToken` from\n`POST /v1/uploads`. **This is the only credential an untrusted client should ever\nhold.** It can drive that one upload and nothing else \u2014 it grants nothing on\nplayback, and nothing on any other asset.\n\nTokens expire. For uploads that outlive one, re-mint in place with\n`POST /v1/uploads/:id/token` (an API key with `upload` scope, not the expiring\ntoken itself) \u2014 no progress is lost. The SDK does this for you through its\n`onTokenExpired` hook.\n\n### Service key\n\nThe internal transcoder credential. Never issued to tenants; documented only so\nits one route is not mistaken for a public one.\n\n### Login without pasting a key\n\n```bash\nnpx @hostr/cli login\n```\n\nBrowser-authorized, using the RFC 8628 device flow. The CLI never accepts a\npasted key from a human or an agent, for the reason given in the\n[quickstart](#quickstart). `--key` and `HOSTR_API_KEY` remain the escape\nhatch for CI and other headless callers.\n\nCredential precedence everywhere: `--key` flag, then `HOSTR_API_KEY`, then the\nsaved profile.\n\n### Auth errors\n\n| Status | Error | Meaning |\n|---|---|---|\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `account_suspended` | Contact the operator |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n\nThat 401 conflation is deliberate: distinguishing "wrong key" from "not your\nasset" would let anyone with a valid key enumerate other accounts\' asset ids.\n\n**`control_plane_unavailable` fails closed for API keys only.** If the control\nplane is unreachable, key-authenticated calls 503 \u2014 but in-flight uploads using\nupload tokens keep working, and playback is untouched, because the media Worker\nnever queries it.\n\n## Assets and lifecycle\n\nAn asset is created when you open an upload and lives until you erase it.\n\n```\nuploading -> uploaded -> queued -> processing -> playable -> ready\n \\-> failed\n \\-> aborted (deleted, from anywhere)\n```\n\n| Status | Means |\n|---|---|\n| `uploading` | Multipart session open, parts landing |\n| `uploaded` | Bytes verified against storage; part count and total size both agree |\n| `queued` | Handed to the transcode queue |\n| `processing` | Encoding. Carries live `progress` |\n| `playable` | Watchable now, at one quality. The rest of the ladder is still encoding |\n| `ready` | HLS renditions are live and playable |\n| `failed` | Transcode failed. `error` is always present |\n| `aborted` | Upload abandoned before completion \u2014 you aborted it, or it was collected after 14 days idle |\n| `deleted` | Erased. Terminal and irreversible |\n\n### Status only moves forward\n\nStatus only ever moves forward. Both encode passes report independently and\nqueue delivery is at-least-once, so a late or redelivered report that would move\na `playable` or `ready` asset backwards is ignored \u2014 including a failure. An asset\nthat is watchable is never marked `failed`; the error is recorded on the record\ninstead. Once `playable`, an asset stays playable.\n\n### Reading progress\n\nWhile `processing` or `playable`, the record carries\n`progress: { encodedSec, totalSec, speed }`, where `speed` is the encode rate as\na multiple of realtime. While `playable` it tracks the remaining qualities.\nOutside those two it is null or absent \u2014 a terminal status never shows a stale\nbar.\n\nThat is enough for a real percentage instead of a spinner, which matters more\nthan it sounds: most "the transcode is slow" reports turn out to be missing\nprogress feedback rather than slow encoding.\n\n### Abandoned uploads are collected after 14 days\n\nAn upload that shows no activity for **14 days** is moved to `aborted`, its\nmultipart session is released, and an `upload.aborted` event fires carrying\n`reason: "abandoned"`. The asset\'s `error` says the same thing in words. An\n`aborted` asset stops counting against your asset quota \u2014 it holds no bytes.\n\n"Activity" means a call about that upload: asking for the next batch of\npresigned part URLs, or re-minting its upload token. Sending bytes to the\npresigned URLs is not visible to the API, so a client that goes quiet for a\nfortnight without asking for anything is what gets collected. In practice an\nuploader hits the API at least twice a day, because presigned part URLs expire\nin at most 12 hours.\n\nThe window is deliberately double the 7-day upload token lifetime. An upload\nwhose token expired is not lost \u2014 re-mint at `POST /v1/uploads/:id/token` and\nresume \u2014 so a shorter window would destroy uploads that were still resumable.\nIf you know an upload is finished with, `DELETE /v1/uploads/:id` releases the\nstorage immediately rather than waiting.\n\n### Prefer events to polling\n\n`GET /v1/assets/:id` returns the authoritative record, and\n`GET /v1/assets?limit=&status=&cursor=` lists your assets newest-first with\nkeyset pagination. But polling is the fallback, not the design \u2014\n[webhooks](#webhooks-and-the-event-log) push the same transitions.\n\n### playbackId is its own field\n\nWhen an asset reaches `ready`, both the record and the `video.ready` event carry\n`playbackId` and `durationSec`.\n\n`playbackId` equals `assetId` today. **Store it as its own field anyway.** It is\nnamed separately so the two can diverge later without forcing a migration on\nevery consumer.\n\n### Deletion\n\n`DELETE /v1/assets/:id` purges the source and every rendition from storage, frees\nthe storage quota, and emits `video.deleted`. Idempotent \u2014 repeat calls report\n`deleted` again with `objectsDeleted: 0`.\n\nNew playback tokens stop minting immediately. Tokens already issued can replay\nedge-cached segments for at most a few hours until they expire. **There is no\nundelete.**\n\n## Quotas and limits\n\nAccounts carry three ceilings, visible any time:\n\n```bash\nnpx @hostr/cli whoami --json # or: GET /v1/account\n```\n\n```json\n{\n "limits": { "maxAssets": 10000, "maxSourceBytes": 5497558138880, "maxUploadsPerDay": 500 },\n "usage": { "assetCount": 1711, "storageBytes": 742381129728, "uploadsToday": 12 }\n}\n```\n\n| Limit | Counts |\n|---|---|\n| `maxAssets` | Assets held at once. Deletion frees this immediately |\n| `maxSourceBytes` | Total source bytes stored |\n| `maxUploadsPerDay` | Upload sessions created per UTC day |\n\n### Hitting one\n\nQuotas are checked when the upload is created, not when bytes arrive. A tripped\nquota is a `403` naming which one and its ceiling:\n\n```json\n{ "error": "quota_storage", "limit": 5497558138880 }\n```\n\nThe three codes are `quota_uploads_daily`, `quota_assets`, and `quota_storage`.\n\n**This means the account needs raising, not that the request was malformed.**\nDistinguish it from a `400` in your error handling \u2014 retrying a quota error\nwithout changing anything will fail identically forever.\n\n### Request-shaped limits\n\nThese are properties of the API rather than of your plan:\n\n| Limit | Value |\n|---|---|\n| Parts per presign request | 100 |\n| Parts per upload | 10,000 (a storage limit; raise `partSize` for very large files) |\n| Subtitle file size | 2,000,000 bytes |\n| Playback token lifetime | 60 s to 86,400 s, default 3,600 |\n| Event page size | 1 to 200, default 50 |\n\nExceeding the part ceiling for a given part size is `413 file_too_large`, and the\nresponse carries the maximum byte count that part size can address.\n\n### Usage over time\n\n```\nGET /v1/usage?from=2026-08-01&to=2026-08-25 (read)\n -> { from, to, days: [{ day, uploads, bytesUploaded, encodedSeconds }] }\n```\n\nDefaults to the last 30 days. Dates are UTC and must be `YYYY-MM-DD`; anything\nelse is `400 bad_date`.\n\n### Not your fault\n\nTwo failures look like integration bugs and are not:\n\n`503 control_plane_unavailable` is transient \u2014 retry with backoff. In-flight\nuploads holding upload tokens are unaffected, and playback is untouched.\n\n`500 signing_not_configured` means the playback signing key is not loaded on the\nAPI deployment. It is operator-side and not retryable from a client. Report it\nrather than working around it.\n\n## Upload from a server\n\nFour calls. Bytes go straight to storage via presigned URLs and never transit\nhostr\'s compute.\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,3]} (at most 100 per call)\n -> { urls: [{ partNumber, url }], expiresIn }\n\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nPOST /v1/uploads/:id/complete {"sha256":"<hex of the whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\n```\n\nPlus `GET /v1/uploads/:id` for resume and `DELETE /v1/uploads/:id` to abort.\n\n### The rules that bite\n\n**Every part is exactly `partSize` bytes except the last.** Use the `partSize`\nthe server returned, not one you picked.\n\n**`size` must be exact.** Completion verifies both the part count and the total\nbyte count against what actually landed. Mismatches are `409`:\n\n- `incomplete_upload` \u2014 `missing` lists the absent part numbers, first 50. Upload\n them and complete again.\n- `size_mismatch` \u2014 `declaredSize` and `receivedBytes` are both reported. Abort\n and restart; the file you declared is not the file you sent.\n\nPart count alone would accept N parts of the wrong sizes, which is why both are\nchecked.\n\n**Request presigned URLs only for the parts you are about to send.** They expire.\nMinting all of them up front for a large file wastes most of them.\n\n**Completion is idempotent.** A client that crashed after bytes landed but before\nit saw the response can safely retry and will get the stored result back rather\nthan a conflict.\n\n### Worked example\n\n```bash\nAPI=https://api.hostr.sh\nKEY=$HOSTR_API_KEY\nFILE=video.mp4\nSIZE=$(wc -c < "$FILE")\n\n# 1. Create the session.\nCREATE=$(curl -sS -X POST "$API/v1/uploads" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"filename\\":\\"$FILE\\",\\"contentType\\":\\"video/mp4\\",\\"size\\":$SIZE}")\n\nASSET=$(echo "$CREATE" | jq -r .assetId)\nPART_SIZE=$(echo "$CREATE" | jq -r .partSize)\nPARTS=$(echo "$CREATE" | jq -r .expectedParts)\n\n# 2. Split, presign, and PUT each part.\nsplit -b "$PART_SIZE" "$FILE" part-\ni=1\nfor p in part-*; do\n URL=$(curl -sS -X POST "$API/v1/uploads/$ASSET/parts" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"partNumbers\\":[$i]}" | jq -r \'.urls[0].url\')\n curl -sS -X PUT --data-binary "@$p" "$URL" -o /dev/null\n i=$((i + 1))\ndone\n\n# 3. Finalise.\ncurl -sS -X POST "$API/v1/uploads/$ASSET/complete" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"sha256\\":\\"$(shasum -a 256 "$FILE" | cut -d\' \' -f1)\\"}"\n```\n\nBatch the presign calls in real code \u2014 up to 100 part numbers per request.\n\n### Send the sha256\n\nThe `etag` completion returns is a *composite* multipart ETag: a hash of hashes,\nand dependent on the part size you used. It is not a content identity and cannot\nbe compared against a hash you computed locally.\n\nIf you want real integrity, send `sha256` \u2014 the lowercase hex SHA-256 of the\nwhole file. It is stored as object metadata and is what an independent\nverification would check against. A malformed value is ignored rather than\nrejected, since retrying the call cannot fix it.\n\n### Credentials\n\n`POST /v1/uploads` needs an API key with `upload` scope. The remaining upload\nroutes accept **either** that key or the `uploadToken` returned at creation.\n\nRe-minting a token (`POST /v1/uploads/:id/token`) requires the API key \u2014 not the\nexpiring token, which would otherwise be able to renew itself forever.\n\nUploading from a browser or mobile client? Do not ship the API key. See\n[Upload from a browser](#upload-from-a-browser).\n\n## Upload from a browser\n\nCreate the upload on your server, hand the client a narrow credential, and let it\nPUT parts directly. Upload bytes never touch your servers.\n\n### Forward only the client-safe fields\n\n`POST /v1/uploads` returns more than a client should see:\n\n```json\n{\n "assetId": "...", // safe\n "uploadToken": "...", // safe\n "tokenExpiresAt": 1756130400000, // safe\n "partSize": 8388608, // safe\n "expectedParts": 13, // safe\n\n "key": "acct_7Kd2/.../source.mp4", // storage internal \u2014 do NOT forward\n "uploadId": "2~aBcDeF..." // storage internal \u2014 do NOT forward\n}\n```\n\nForward exactly `assetId`, `uploadToken`, `tokenExpiresAt`, `partSize`, and\n`expectedParts`. Passing the whole body through leaks your storage layout to\nevery client.\n\nThe `uploadToken` is scoped to that one asset and grants nothing else \u2014 no other\nasset, and nothing on playback. That is why it is safe to hand out and an API key\nis not.\n\n### Bucket CORS is a hard prerequisite\n\nPart PUTs go **directly to object storage**, not through the API. The source\nbucket must allow `PUT` from every origin your uploading page runs on, and must\nexpose the `ETag` response header:\n\n```json\n{\n "AllowedOrigins": ["https://yourapp.example", "http://localhost:3000"],\n "AllowedMethods": ["PUT"],\n "AllowedHeaders": ["*"],\n "ExposeHeaders": ["ETag"]\n}\n```\n\n`ExposeHeaders: ["ETag"]` is not optional \u2014 the client verifies each part\'s ETag\nand cannot read it otherwise.\n\n### The failure mode, stated plainly\n\n**A missing origin is silent from the API\'s perspective.** The upload session\nexists, zero parts ever land, and the client cycles running/stalled as though the\nnetwork were flaky. A CORS-blocked PUT is indistinguishable from a network error\nto `XMLHttpRequest` \u2014 the browser deliberately withholds the detail.\n\n**First move when a browser upload stalls at 0%: check the preflight, not the\nWorker.** Open the network tab and look for the `OPTIONS` request.\n\nThis one cost real time on the first customer integration, which is why the SDK\nnow surfaces it itself. After three consecutive network-class part failures with\n**zero bytes ever confirmed** for the session, it emits:\n\n```js\nupload.on(\'diagnostic\', d => {\n // { code: \'systemic-transport-failure\', consecutiveNetworkFailures, bytesEverConfirmed, hint }\n console.warn(d.hint);\n});\n```\n\nZero-ever-confirmed is what separates this from ordinary mid-upload network loss.\nIt is **advisory, never terminal**: a client that is genuinely offline from byte\nzero looks identical, so the upload keeps retrying regardless. The event is the\nhook for telling the user to check their connection \u2014 or for telling *you* to\ncheck bucket CORS.\n\n### Token expiry mid-upload\n\nLong uploads outlive their token. Handle it on your server:\n\n```js\nconst upload = createUpload({\n file,\n assetId,\n uploadToken,\n onTokenExpired: async () => {\n const r = await fetch(\'/api/hostr/refresh-token\', {\n method: \'POST\',\n body: JSON.stringify({ assetId }),\n });\n return (await r.json()).uploadToken; // your server calls POST /v1/uploads/:id/token\n },\n});\n```\n\nThe SDK re-mints and retries in place. Nothing is lost even without this \u2014 a\nkilled client resumes from what storage reports on the next launch \u2014 but the hook\navoids the interruption.\n\nSee the [SDK reference](#sdk-reference) for the full option surface.\n\n## Resume and integrity\n\n### Resume asks storage, not the client\n\n```\nGET /v1/uploads/:id\n -> { status, partSize, expectedParts, uploadedParts, parts, bytesUploaded }\n```\n\n`uploadedParts` is part numbers only; `parts` is the richer form, each entry\ncarrying `partNumber`, `size`, and `etag`.\n\nThe API derives this by listing what is actually in storage, not by reading\nbookkeeping the client sent. That is the whole design: a crash, a refresh, a\ndropped connection, or a client that lied all converge on the same truth. Resume\nis simply "send the parts that are not in that list".\n\nParts are listed only while `uploading`. Once completed or aborted the list is\nempty, because there is nothing left to resume.\n\nResume has an outer limit: an upload with no activity for 14 days is collected\nand moved to `aborted`. That window is twice the upload token\'s lifetime, so an\nexpired token never costs you the upload \u2014 re-mint at\n`POST /v1/uploads/:id/token` and carry on. See\n[Assets and lifecycle](#assets-and-lifecycle).\n\n### Three layers, three different claims\n\nEach layer proves something the others do not.\n\n**Per-part ETag.** Storage returns an ETag on every part PUT. The client compares\nit against the part it just sent, so a corrupted or truncated part is caught\nimmediately rather than at the end of a multi-gigabyte upload.\n\n**Part count and total size at completion.** Completion refuses to finalise\nunless the number of parts and the total byte count both match what was declared\nat creation. Count alone would accept N parts of the wrong sizes.\n\n**Whole-file SHA-256, if you send it.** This is the only one that proves content\nidentity.\n\n### Why the ETag is not enough\n\nThe ETag on a completed multipart object is a **hash of the part hashes**, then\nthe part count \u2014 `b8f1c0d9e2a34567-13`. Two consequences:\n\n- It cannot be compared against a hash you compute locally.\n- The same file uploaded with a different `partSize` produces a different ETag.\n\nSo it is a useful transfer check and a useless content identity. Send `sha256`\n(lowercase hex of the whole file) at completion if identity matters; it is stored\nas object metadata.\n\nIndependent verification, deriving both from the bytes actually in storage rather\nthan from anything the client claimed:\n\n```bash\npnpm verify:upload <assetId>\n```\n\n### Completion is idempotent\n\nA client that crashed after the bytes landed but before it saw the response\nretries and gets the stored `etag` and `bytes` back, not a `409`. Any genuinely\nunfinished status still conflicts with `not_uploading`.\n\nThis matters more than it sounds: the window between "storage has everything" and\n"the client knows" is exactly where flaky mobile connections die.\n\n### Aborting\n\n```\nDELETE /v1/uploads/:id\n```\n\nAn in-flight multipart session holds part storage that is invisible in normal\nlistings. Abandoning an upload without aborting leaks it. The SDK and CLI do this\nfor you; a hand-rolled client should too.\n\n## Webhooks and the event log\n\nhostr POSTs lifecycle events to one endpoint per account so you do not have to\npoll.\n\n```\nPUT /v1/webhooks {"url": "https://yourapp.example/hostr-webhook"} (full)\n -> { url, enabled, secret: "whsec_..." } # secret returned ONLY when generated, ONLY once\nGET /v1/webhooks -> { url, enabled, createdAt } # never the secret\nDELETE /v1/webhooks -> disables; delivery history is retained\n```\n\nOmit `secret` in the PUT to have one generated, or supply your own to bring an\nexisting one. Configuration is cached about 60 seconds in the Worker, the same\npropagation window as key revocation.\n\nA URL configured without a secret logs and skips: **unsigned events are never\nsent.**\n\n### The log is written first\n\nEvery event becomes a durable row **before** delivery is attempted. A consumer\noutage cannot lose one.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n**The webhook is the wake-up signal; the log is the ledger.** Delivery is\nat-least-once \u2014 three attempts with short backoff, each recorded and inspectable.\nA consumer that must not miss anything reconciles against `GET /v1/events` (or\n`GET /v1/assets/:id`) rather than trusting that every POST arrived.\n\n`npx @hostr/cli tail --follow` polls the log for you.\n\n### Building a consumer that behaves\n\n**Verify the signature before parsing.** See\n[Verifying webhooks](#verifying-webhook-signatures) \u2014 use the raw body bytes.\n\n**Dedupe on `id`.** It is the durable event row\'s id, and a redelivery reuses it\ndeliberately so your dedupe still works.\n\n**Respond `2xx` fast.** Do the real work asynchronously. Anything else is\nretried.\n\n**Expect at-least-once, and design for it.** `video.ready` and `video.errored`\nfire on the status *transition* only, so a transcoder re-posting a terminal state\ncannot duplicate them \u2014 but the network can still deliver the same event twice.\n\n### Testing locally\n\nBoth `https` and `http` endpoints are accepted, including private addresses.\nhostr does not block internal ranges, so a local receiver works in development\nexactly as production does.\n\n```bash\nnpx @hostr/cli webhooks test # redelivers the newest event to your endpoint\n```\n\nSee the [events reference](#event-reference) for every type and payload.\n\n## Verifying webhook signatures\n\nEvery delivery carries a `hostr-signature` header:\n\n```\nhostr-signature: t=1756130400,v1=3b1f2c9d8e7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c\n```\n\n`v1` is `HMAC-SHA256(secret, "<t>.<rawBody>")`, hex-encoded. `t` is a unix\ntimestamp in **seconds**. This is the Stripe scheme, and Mux\'s.\n\n### Implementation\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\n\nexport function verifyHostrWebhook(secret, header, rawBody, toleranceSec = 300) {\n const m = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? \'\');\n if (!m) return false;\n if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSec) return false;\n const expected = createHmac(\'sha256\', secret).update(`${m[1]}.${rawBody}`).digest(\'hex\');\n return expected.length === m[2].length &&\n timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));\n}\n```\n\nWired into Express, where getting the raw body needs care:\n\n```js\nimport express from \'express\';\n\nconst app = express();\n\napp.post(\'/hostr-webhook\',\n express.raw({ type: \'application/json\' }), // NOT express.json()\n (req, res) => {\n const raw = req.body.toString(\'utf8\');\n if (!verifyHostrWebhook(process.env.HOSTR_WEBHOOK_SECRET, req.get(\'hostr-signature\'), raw)) {\n return res.status(400).end();\n }\n const event = JSON.parse(raw); // parse only after verifying\n res.status(200).end(); // ack fast\n queue.push(event); // then do the work\n });\n```\n\n### The three ways this goes wrong\n\n**Using the parsed body instead of the raw bytes.** `JSON.parse` followed by\n`JSON.stringify` does not reproduce the original bytes \u2014 key order, whitespace,\nand number formatting can all shift. The HMAC is over what was actually sent.\nRegister the raw body parser on this route specifically.\n\n**A plain `===` on the hex digest.** String comparison short-circuits on the\nfirst differing character, which leaks timing information. Use\n`timingSafeEqual`, and compare lengths first \u2014 it throws on mismatched lengths.\n\n**Ignoring `t`.** Without a bounded timestamp window a captured delivery can be\nreplayed at any time in the future. Five minutes is a reasonable tolerance.\n\n### Do not confuse the units\n\n`t` is unix **seconds**. Elsewhere in the API, `tokenExpiresAt` and `expiresAt`\nare epoch **milliseconds**. That is deliberate \u2014 each convention matches its home\n(Stripe\'s for signatures, Mux\'s for token bundles) rather than being made falsely\nuniform. Mixing them up produces a timestamp roughly 55,000 years out, which at\nleast fails loudly.\n\n### Getting the secret\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nWritten straight to the file at mode 0600; only the variable name is printed. The\nsecret is returned **once**, in the `PUT /v1/webhooks` response, and never again\n\u2014 `GET /v1/webhooks` deliberately does not include it. Lost it? Set a new one.\n\n### Proving it end to end\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\nRedelivers the newest stored event to your endpoint, so you can confirm the whole\npath without uploading anything.\n\nIf your endpoint was down when an event fired, nothing was lost \u2014 see\n[Webhooks and the event log](#webhooks-and-the-event-log).\n\n## Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `playback`-scoped\nkey.\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"user-123"} (body optional)\n -> { playback, thumbnail, storyboard, expiresAt }\n```\n\n```bash\nnpx @hostr/cli assets token <assetId> --json\n```\n\nField names mirror Mux\'s bundle, so a consumer migrating swaps its base URL and\nkeeps its client code.\n\n### Build the URL\n\n```\nhttps://stream.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\nHand it to any HLS player. Safari and iOS play HLS natively; elsewhere use\nhls.js.\n\n```html\n<video id="v" controls></video>\n<script type="module">\n import Hls from \'https://cdn.jsdelivr.net/npm/hls.js@1/+esm\';\n const src = `https://stream.hostr.sh/${playbackId}.m3u8?token=${token}`;\n const v = document.getElementById(\'v\');\n if (v.canPlayType(\'application/vnd.apple.mpegurl\')) v.src = src;\n else if (Hls.isSupported()) { const h = new Hls(); h.loadSource(src); h.attachMedia(v); }\n</script>\n```\n\n### Mint timing, and what to gate on\n\nMinting works any time after the asset exists \u2014 it 404s only for unknown or\ndeleted assets. **The stream itself 404s until the asset is `ready`.**\n\nSo gate your UI on `video.ready` or the asset\'s status, never on whether minting\nsucceeded. A successful mint for a still-encoding asset is expected behaviour,\nnot a green light.\n\n### The TTL is not a viewing limit\n\nThe token is checked **once**, at the master playlist. After that the session\ncarries its own internally-managed credentials, so a viewer who starts playback\nat minute 59 of a one-hour token watches the whole video.\n\n`ttlSec` accepts 60 to 86,400 and defaults to 3,600. It only needs to be valid\nwhen playback *starts*.\n\n### viewerId\n\nOptional, opaque, and carried inside the token. It enables per-viewer concurrency\nobservation. Concurrency is currently observed rather than enforced \u2014 see\n[Tokens and signed playback](https://docs.hostr.sh/concepts/playback/).\n\n### Things that will confuse you once\n\n**Non-browser user agents are rejected** with `403 blocked_user_agent` \u2014\ncurl, wget, yt-dlp, vlc, and ffmpeg UAs are blocked as a casual-ripping speed\nbump. A server-side smoke test must send a browser-like `User-Agent` or hit\n`/health`, which is exempt. Expect it; do not debug it.\n\n**Signed URLs are not DRM.** They stop link-sharing and hotlinking, not an\nentitled viewer recording their screen.\n\n**Deleted assets stop minting immediately**, but tokens already issued can replay\nedge-cached segments for a few hours until they expire.\n\n### Poster and scrub previews\n\nThe same mint response covers both, each URL taking its matching token:\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSee [Thumbnails and storyboards](#thumbnails-and-storyboards).\n\n### Audio only, for transcription\n\nThe `playback` token also opens an audio-only file, available seconds into the\nencode \u2014 before the asset is playable:\n\n```\nhttps://stream.hostr.sh/{playbackId}/audio.m4a?token={playback}\n```\n\nSee [Audio for speech-to-text](#audio-for-speech-to-text).\n\n## Thumbnails and storyboards\n\n`POST /v1/assets/:id/tokens` returns three tokens, not one. The `thumbnail` and\n`storyboard` values drive the poster image and the scrub preview.\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSame host, same query-token shape as the playlist. Each URL takes its **own**\ntoken \u2014 they are minted with different audiences and are not interchangeable.\n\n### Poster\n\nA single JPEG extracted during encoding. Use it as the `poster` attribute:\n\n```html\n<video poster="https://stream.hostr.sh/abc123/thumbnail.jpg?token=..." controls></video>\n```\n\n### Storyboard\n\nA standard "thumbnails" WebVTT track. Cues point into a tiled sprite using\n`#xywh=` fragments, and **the sprite URLs inside the VTT are pre-signed for you**\n\u2014 you do not mint anything extra.\n\nThat is the convention mux-player\'s `storyboard-src`, media-chrome\'s preview\ntrack, and most other players already consume:\n\n```html\n<mux-player\n storyboard-src="https://stream.hostr.sh/abc123/storyboard.vtt?token=..."\n></mux-player>\n```\n\n### Treat both as optional\n\nAssets encoded before storyboards existed 404 on that URL, and scrubbing simply\nshows no preview. Players handle this gracefully by design \u2014 do not add error\nhandling that turns a missing preview into a broken player.\n\n## Audio for speech-to-text\n\nTranscription services want one audio file URL, not an HLS playlist. hostr\nmakes one per asset \u2014 `audio.m4a`, a single AAC track in an MP4 container \u2014\nand makes it **first**, ahead of every video rendition. A twenty-minute\nsource has its audio file within seconds of the job starting, minutes before\n`video.playable`.\n\n```\nhttps://stream.hostr.sh/{playbackId}/audio.m4a?token={playback}\n```\n\nIt takes the **`playback`** token from `POST /v1/assets/:id/tokens` \u2014 the same\none that opens the master playlist. There is no fourth token to mint.\n\n### When it exists\n\nListen for **`video.audio_ready`**. It fires once, the moment the file is in\nthe media bucket, and carries `assetId`, `playbackId`, `durationSec` and an\n`audio` block (`bytes`, `codec`, `channels`, `sampleRate`, `copied`).\n\n```json\n{\n "id": "evt_01j\u2026",\n "type": "video.audio_ready",\n "createdAt": "2026-09-08T10:14:07.412Z",\n "data": {\n "assetId": "9f8c1e2a\u2026",\n "playbackId": "9f8c1e2a\u2026",\n "status": "processing",\n "durationSec": 1842.4,\n "audio": { "bytes": 29478912, "codec": "aac", "channels": 2, "sampleRate": 48000, "copied": true }\n }\n}\n```\n\nThe same block is on the asset record as `audio`. It is **absent** until the\ntranscoder has looked at the source and **`null`** when the source has no\naudio stream \u2014 in which case `video.audio_ready` never fires, and a poller can\ntell "no audio" from "not yet" without waiting.\n\nBefore the file exists the URL is a plain `404`.\n\n### Assets you uploaded before this existed\n\nEvery upload since 2026-09-09 gets its audio file automatically. Assets\nencoded before that have none, and nothing will produce one on its own \u2014 so a\ncatalogue uploaded before you added transcription cannot be captioned until\nyou ask for it. Ask once per asset:\n\n```\nPOST /v1/assets/{id}/audio\n```\n\nIt requires the `upload` scope (this is content generation, the same rung as\nattaching a subtitle track) and answers `202 {"assetId": "\u2026", "queued": true}`.\nThe job publishes `audio.m4a` and nothing else \u2014 no re-encode of the ladder,\nno new playlists, no change to the asset\'s status \u2014 and then fires\n`video.audio_ready` exactly as a fresh upload would, so the consumer you\nalready wrote needs no second code path.\n\nTwo refusals, both `409`: `already_has_audio` when the record already carries\nan `audio` block, and `not_encoded` when the asset has no finished source\nobject yet (an upload still in flight, or aborted). Calling it twice is safe\nregardless \u2014 a job that finds the file already in the bucket reports it\ninstead of re-encoding.\n\nTo sweep a whole account, the CLI does the paging and the per-asset check:\n\n```bash\nnpx @hostr/cli assets audio --missing # every playable/ready asset with no audio\nnpx @hostr/cli assets audio <assetId> # just this one\n```\n\n`--missing` skips assets whose `audio` is `null` \u2014 the transcoder already\nlooked and the source is silent, so there is nothing to extract.\n\n### Handing it to a transcriber\n\nMint a token whose lifetime covers the fetch, then pass the URL straight\nthrough. AssemblyAI, for example:\n\n```bash\nTOKEN=$(npx @hostr/cli assets token "$ASSET" --ttl 3600 --json | jq -r .playback)\n\ncurl -X POST https://api.assemblyai.com/v2/transcript \\\n -H "authorization: $ASSEMBLYAI_API_KEY" \\\n -H \'content-type: application/json\' \\\n -d "{\\"audio_url\\":\\"https://stream.hostr.sh/$ASSET/audio.m4a?token=$TOKEN\\"}"\n```\n\nThe token only has to be valid when the fetch **starts**, but give it a few\nminutes of headroom: services queue downloads. `Range` requests are honoured\n(`206`, `Accept-Ranges: bytes`), so a fetcher that streams or resumes works.\n\n**The user-agent blocklist does not apply to this URL.** Playlists reject\ncurl-like user agents as a casual-ripping speed bump; a transcription\nservice\'s fetcher is exactly the kind of client that rule would catch, so the\naudio file is exempt. The token is still required.\n\n### What is in the file\n\n- **AAC source** \u2014 the stream is copied untouched: the source\'s channel count,\n sample rate and bitrate, no decode. `copied: true`.\n- **Anything else** \u2014 encoded once with the ladder\'s own audio settings\n (AAC, stereo, 48 kHz, 128 kbps), so what the transcriber hears is what the\n viewer hears. `copied: false`.\n\nEither way the `moov` atom is at the front, and the object is versioned with\nthe asset and immutable.\n\n### Then attach the result\n\nWhen the transcript comes back, attach it with\n`PUT /v1/assets/:id/subtitles/:lang` (raw WebVTT). It is accepted as soon as the\nasset has a known duration, which `video.audio_ready` guarantees \u2014 so a\ntranscript that comes back before the asset is `playable` attaches without\nwaiting or retrying. See [Subtitles](#subtitles).\n\n## Subtitles\n\nHuman-authored WebVTT tracks, attachable any time after the asset is `ready`.\n\n```\nPUT /v1/assets/:id/subtitles/:lang?name=English&default=true (body: raw WebVTT, upload scope)\nGET /v1/assets/:id/subtitles -> { assetId, tracks: [{ lang, name, default, rev }] }\nDELETE /v1/assets/:id/subtitles/:lang\n```\n\n```bash\nnpx @hostr/cli subtitles set <assetId> subs.vtt --lang en --name English --default\nnpx @hostr/cli subtitles list <assetId>\nnpx @hostr/cli subtitles rm <assetId> en\n```\n\nThe PUT body is **raw WebVTT, not JSON**. It must start with `WEBVTT`, and is\ncapped at 2,000,000 bytes.\n\n```bash\ncurl -X PUT "https://api.hostr.sh/v1/assets/$ASSET/subtitles/en?name=English&default=true" \\\n -H "authorization: Bearer $HOSTR_API_KEY" \\\n -H \'content-type: text/vtt\' \\\n --data-binary @subs.vtt\n```\n\n### Rules\n\n**`:lang` is a BCP 47 tag** \u2014 `en`, `pt-BR`. One track per tag; PUT replaces.\nAnything that is not a valid tag is `400 bad_language`.\n\n**The asset must have a known duration.** Before that it is `409 not_ready` \u2014\nthe track playlist needs it. It is known from `video.audio_ready` (seconds\ninto the job, once the transcoder has probed the source) and from `playable`\nonwards; a track attached while the ladder is still encoding appears the\nmoment the master playlist is served, because tracks are injected at serve\ntime, not baked into the encode.\n\n**`default=true` demotes any previous default.** At most one default per asset.\n\n**Write scope is `upload`, not `playback`.** Subtitles are content, not\nentitlement.\n\n### How they reach the player\n\nTracks are injected into the master playlist at serve time as an `EXT-X-MEDIA`\nsubtitles group. The next master fetch after a PUT includes them.\n\n**No re-encode. No new playback URL. No token change.** Players pick them up in\nthe captions menu automatically.\n\nTracks are stored under a content-hash revision, so replacing one is cache-safe\neven though segments are served with a one-year immutable cache. A viewer already\nmid-session keeps the track they started with; the replacement applies to the next\nplayback session.\n\n### Deleting\n\nRemoval delists the track first \u2014 that is what makes it invisible to players. The\nunderlying versioned objects become unreachable and are purged with the asset, so\nleftovers cost nothing.\n\n`404 subtitle_not_found` means no track for that tag; `404 asset_not_found` means\nno such asset. They are distinct codes.\n\n## Event reference\n\nDelivered body: `{ id, type, createdAt, data }`. `id` is the durable event row\'s\nid \u2014 **dedupe on it**, and note that a redelivery reuses it deliberately.\n\n| Type | Fired when | `data` highlights |\n|---|---|---|\n| `upload.created` | Multipart session opened | `assetId`, `filename`, `declaredSize` |\n| `upload.aborted` | Session ended without completing | `assetId`, `reason` (`client_abort` \\| `abandoned`), `error?` |\n| `video.uploaded` | Bytes verified in storage, transcode queued | `assetId`, `key`, `bytes`, `etag`, `sha256?` |\n| `video.audio_ready` | `audio.m4a` exists \u2014 seconds in, before anything is playable | `assetId`, `playbackId`, `durationSec`, `audio` |\n| `video.playable` | Watchable at one quality, ladder still encoding | `assetId`, `playbackId`, `durationSec?`, `renditions?` |\n| `video.ready` | Full HLS ladder live | `assetId`, `playbackId`, `durationSec?`, `renditions?` |\n| `video.errored` | Transcode failed | `assetId`, `error` (always present) |\n| `video.deleted` | Asset erased, playback over | `assetId` |\n| `video.input.non_standard` | The source needed a judgement call | `assetId`, `input`, `findings[]` |\n\n`upload.aborted`, `video.playable`, `video.ready` and `video.errored` fire on the\nstatus **transition** only, so a repeated abort, or a transcoder re-posting a\nstate, cannot duplicate them.\n\n`upload.aborted` has one meaning \u2014 this upload ended without completing \u2014 and\n`reason` says how it got there: `client_abort` for `DELETE /v1/uploads/:id`, or\n`abandoned` for an upload the platform collected after 14 days idle (see\n[Assets and lifecycle](#assets-and-lifecycle)). Treat both as terminal; the reason\nis for your own reporting, not for deciding whether the upload is over.\n\n`video.audio_ready` fires **once per asset**, on the first report that carries\nthe audio file, and never for a source with no audio stream \u2014 the record\'s\n`audio` field is `null` there. It is the cue to start speech-to-text: see\n[Audio for speech-to-text](#audio-for-speech-to-text).\n\n`video.playable` carries the same fields as `video.ready`, so publishing as early\nas possible is a matter of switching which one you listen for. Its `renditions`\nlists the single quality available so far; `video.ready` then lists the full\nladder. `video.errored` fires only when nothing is watchable \u2014 a ladder that\nfails after the asset is already playable does not fire it.\n\n`error` on `video.errored` is always present. An optional error field makes every\nconsumer invent its own generic message, which is worse than a bad message.\n\n### video.input.non_standard\n\nAdvisory. The transcode proceeds normally and a `video.ready` still follows. It\nfires **at most once per asset**, and only when there is something to say \u2014 a\nfile with nothing unusual about it generates no traffic.\n\n| Finding code | Meaning |\n|---|---|\n| `hdr_tags_on_8bit` | Source carries HLG/PQ tags but is 8-bit. HDR is a 10-bit format, so the tag is stale \u2014 treated as SDR, pixels untouched |\n| `hdr_source` | Genuine 10-bit HDR. The ladder outputs 8-bit SDR; no tone mapping is applied |\n| `wide_gamut_tags` | BT.2020 primaries without an HDR transfer; output is BT.709, colour not converted |\n| `variable_frame_rate` | Container rate and measured rate disagree; normalised to CFR |\n| `frame_rate_changed` | Source rate was not a broadcast standard and was snapped to the nearest |\n\n`data.input` carries the full probe \u2014 `container`, `videoCodec`, `pixFmt`,\n`bitDepth`, `width`, `height`, `declaredFps`, `averageFps`, `targetFps`,\n`isVariableFrameRate`, `colorSpace`, `colorTransfer`, `colorPrimaries`.\n\nThe same block is on the asset record as `input`, so it can be read back at any\ntime without replaying events.\n\n### Delivery\n\nThree attempts with short backoff, each recorded and inspectable. Every event is\nwritten to the durable log **before** the first attempt.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim\nGET /v1/events?order=desc&limit=1 newest first\nPOST /v1/events/:id/redeliver replay to the current endpoint\n```\n\nSignature verification: [Verifying webhooks](#verifying-webhook-signatures).\n\n## Error reference\n\nEvery non-2xx body is `{ "error": "<code>", ... }`. **Switch on `error`, never on\nthe message** \u2014 codes are stable, prose is not.\n\n| Status | Error | Meaning and action |\n|---|---|---|\n| 400 | `size_required` | `size` missing or not positive on `POST /v1/uploads` |\n| 400 | `partNumbers_required` | Empty or non-array `partNumbers` |\n| 400 | `too_many_parts_per_request` | More than 100 part numbers; `max` names the cap |\n| 400 | `part_out_of_range` | A part number below 1 or above `expectedParts` |\n| 400 | `bad_language` | Not a BCP 47 tag |\n| 400 | `not_webvtt` | Subtitle body does not start with `WEBVTT` |\n| 400 | `bad_status` | Status outside the allowed set (internal route) |\n| 400 | `bad_scopes` | A scope outside `read`/`upload`/`playback`/`full` |\n| 400 | `bad_date` | A date parameter was not `YYYY-MM-DD` |\n| 400 | `authorization_pending` | Device login not yet approved \u2014 keep polling |\n| 400 | `slow_down` | Polling too fast \u2014 back off, keep polling |\n| 400 | `expired_token` | Device code expired. Start over |\n| 400 | `access_denied` | Device login was declined |\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `quota_uploads_daily` | Daily upload cap; `limit` is the ceiling |\n| 403 | `quota_assets` | Asset count cap; `limit` is the ceiling |\n| 403 | `quota_storage` | Storage cap; `limit` is the ceiling |\n| 403 | `account_suspended` | Contact the operator |\n| 403 | `accountId_service_only` | Only the service key may name an owning account |\n| 403 | `blocked_user_agent` | Media host only \u2014 send a browser-like `User-Agent` |\n| 404 | `asset_not_found` | No such asset, or it has been deleted |\n| 404 | `subtitle_not_found` | No track for that language tag |\n| 404 | `event_not_found` | No such event for this account |\n| 404 | `key_not_found` | No such live key for this account |\n| 404 | `not_configured` | No webhook endpoint configured |\n| 404 | `account_not_found` | Key authenticated but its account row is gone |\n| 409 | `incomplete_upload` | Parts missing; `missing` lists them. Upload and re-complete |\n| 409 | `size_mismatch` | Declared size does not match bytes landed. Abort and restart |\n| 409 | `not_uploading` | Upload already finished or aborted |\n| 409 | `not_ready` | Asset is not `ready`, so its duration is unknown |\n| 409 | `no_webhook_endpoint` | Nothing configured or enabled to redeliver to |\n| 413 | `file_too_large` | Too many parts at this part size; raise `partSize` |\n| 413 | `subtitle_too_large` | Over 2,000,000 bytes |\n| 429 | `rate_limited` | `POST /v1/device/code` only. It is unauthenticated, so it is capped per IP. A real login makes one call \u2014 if you see this, something is looping |\n| 500 | `signing_not_configured` | Operator-side: the playback signing key is not loaded. Not your fault, not retryable. Report it |\n| 500 | `internal` | Unexpected. `message` carries truncated detail |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n\n### Retry guidance\n\n**Retry with backoff:** `503 control_plane_unavailable`, `500 internal`,\n`400 authorization_pending`, `400 slow_down`.\n\n**Fix and retry:** the `409` upload conflicts \u2014 they tell you exactly what is\nwrong (`missing` part numbers, or both byte counts).\n\n**Do not retry unchanged:** every `400`, `401`, `403`, and `404`. A quota `403`\nin particular will fail identically forever until the account limit is raised.\n\n### The 401 conflation is deliberate\n\n`unauthorized` covers both "bad credential" and "not your asset". Distinguishing\nthem would let anyone holding a valid key enumerate other accounts\' asset ids by\nwatching which error came back.\n\n## CLI reference\n\n```bash\nnpx @hostr/cli <command>\n```\n\nA single-file, zero-dependency bundle published as `hostr`. **Every command takes\n`--json`** \u2014 one JSON document on stdout, NDJSON for `tail --follow`, errors as\nJSON on stderr with exit code 1. Nothing needs prose parsing.\n\nRun it with **no arguments** for setup status and the single next step. That is\nthe intended entry point, for humans and agents alike.\n\n### Credentials\n\nPrecedence, highest first: `--key <k>`, then `HOSTR_API_KEY`, then the saved\nprofile.\n\nAPI base, highest first: `--api <url>`, then `HOSTR_API_BASE`, then the profile,\nthen production.\n\nProfiles live in `~/.config/hostr/config.json`, cleartext at mode 0600 \u2014 the\n`gh` and `wrangler` precedent.\n\n### Authentication\n\n```bash\nhostr login # browser-authorized; nothing is pasted\nhostr login --profile staging # name the profile\nhostr login --key hostr_sk_live_... # escape hatch for CI; skips the browser\nhostr whoami # account, plan, limits, live usage\n```\n\n`login` with no key opens your browser and uses the device-authorization flow.\nThe code and URL are printed to **stderr**, so `--json` stdout stays a single\nparseable document.\n\n### Uploading\n\n```bash\nhostr upload video.mp4 # returns when bytes are verified in storage\nhostr upload video.mp4 --wait # blocks until transcode is ready or failed\nhostr upload video.mp4 --content-type video/quicktime\nhostr upload # no file: opens a local drag-drop page\n```\n\nWraps the SDK\'s Node adapter, so it carries the same resume and integrity\nguarantees. Kill it mid-transfer and re-run to continue.\n\n### Assets\n\n```bash\nhostr assets list --status ready --limit 20 --cursor <cursor>\nhostr assets get <id>\nhostr assets token <id> --ttl 3600 --viewer-id user-123 # playback scope\nhostr assets delete <id> # full scope\n```\n\n### Subtitles\n\n```bash\nhostr subtitles list <assetId>\nhostr subtitles set <assetId> subs.vtt --lang en --name English --default\nhostr subtitles rm <assetId> en\n```\n\n`--lang` is required and must be a BCP 47 tag. The asset must be `ready`.\n\n### Events\n\n```bash\nhostr tail # one page of the durable log\nhostr tail --follow # poll every 2s, NDJSON under --json\nhostr tail --type video.ready --limit 50 --after <cursor>\n```\n\n### Keys\n\n```bash\nhostr keys list\nhostr keys create --name ci --scopes upload --write-env .env\nhostr keys revoke <id>\n```\n\n`--scopes` takes a comma-separated list and **defaults to `playback`** \u2014 the\nright rung for an application key. `revoke` takes the `id` from `keys list`, not\nthe `keyId`.\n\n### Webhooks\n\n```bash\nhostr webhooks get\nhostr webhooks set https://app.example/hook --write-env .env\nhostr webhooks set https://app.example/hook --secret whsec_... # bring your own\nhostr webhooks delete\nhostr webhooks test # redeliver the newest event \u2014 proves your endpoint\n```\n\n### Repo setup\n\n```bash\nhostr init # docs/hostr.md + AGENTS.md section + .env.example\nhostr init --dir ../other-repo\n```\n\nIdempotent. Re-running replaces the content between the `AGENTS.md` markers\nrather than appending.\n\n### Usage\n\n```bash\nhostr usage --from 2026-08-01 --to 2026-08-25\n```\n\nDefaults to the last 30 days.\n\n### --write-env\n\n```bash\nhostr keys create --scopes upload --write-env .env\nhostr webhooks set https://app.example/hook --write-env .env\n```\n\nWrites the secret into that file at mode 0600 and prints **only the variable\nname** \u2014 in `--json` mode too. Nothing else in the CLI ever prints a credential.\n\nThe point is that an agent driving your terminal can orchestrate a credential\nwithout the value ever entering its context or its transcript.\n\n## SDK reference\n\n```bash\nnpm install @hostr/upload\n```\n\nFour entry points, each bundled independently:\n\n| Entry | Platform | What the adapter supplies |\n|---|---|---|\n| `@hostr/upload` | **Trusted backend only** | `createUploadSession`, `mintUploadToken` \u2014 needs your API key |\n| `@hostr/upload/browser` | Web | XHR transport (for progress events), `localStorage` session store |\n| `@hostr/upload/node` | Node, scripts | `fetch` transport, filesystem session store |\n| `@hostr/upload/react-native` | Expo / RN | Native staged-parts transport; survives backgrounding |\n\nThe engine, retry policy, and integrity checks are one shared core. Adapters only\nsupply platform I/O: read bytes, PUT bytes, persist a few KB of JSON.\n\n### Browser\n\n```js\nimport { createUpload } from \'@hostr/upload/browser\';\n\n// createUploadSession runs on YOUR SERVER \u2014 it holds the API key.\n// The browser receives only { endpoint, assetId, uploadToken }.\nconst upload = await createUpload({ endpoint, uploadToken, assetId, file });\n\nupload.on(\'progress\', p => {\n const pct = (p.bytesConfirmed / p.totalBytes * 100).toFixed(1);\n console.log(`${p.partsDone}/${p.partsTotal} parts, ${pct}%`);\n});\nupload.on(\'statechange\', s => console.log(s));\n// idle -> running -> (stalled <-> running)* -> completing -> complete\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\nSession state persists in `localStorage`, keyed by `name:size:lastModified`.\nReload the page, pick the same file, and `createUpload` resumes automatically \u2014\nthere is no explicit resume call.\n\n### Node\n\n```js\nimport { createUploadSession, createUpload } from \'@hostr/upload/node\';\n\nconst session = await createUploadSession({\n endpoint, apiKey, filename: \'video.mp4\', contentType: \'video/mp4\', size,\n});\n\nconst upload = await createUpload({\n endpoint, uploadToken: session.uploadToken, assetId: session.assetId,\n file: \'/path/to/video.mp4\', // a path, not an open handle\n stateDir: \'./upload-state\', // survives a process restart\n});\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\n### Take the endpoint from your backend\'s response\n\n**One rule for clients of a proxying backend:** take the hostr `endpoint` from\nyour backend\'s create-upload response, never from separate client config. Have\nyour backend include its own `HOSTR_API_BASE` in what it returns.\n\nAn upload token is only valid on the hostr instance that minted it. Endpoint\ndrift fails as a bare mid-flow `401` with nothing pointing at the cause;\nreturning the endpoint alongside the session makes the mistake structurally\nimpossible. Learned from the first consumer integration.\n\n### Events\n\n| Event | Payload |\n|---|---|\n| `progress` | `{ partsDone, partsTotal, bytesConfirmed, totalBytes, partStates }` |\n| `statechange` | `\'idle\' \\| \'running\' \\| \'stalled\' \\| \'completing\' \\| \'complete\'` |\n| `diagnostic` | `{ code, consecutiveNetworkFailures, bytesEverConfirmed, hint }` |\n\n`partStates` maps part number to `\'inflight\'` or `\'done\'` \u2014 enough for a per-part\nprogress grid.\n\nThe `diagnostic` event fires once per failure streak, on every adapter. Today its\nonly code is `systemic-transport-failure`, which almost always means bucket CORS\n\u2014 see [Upload from a browser](#upload-from-a-browser). It is **advisory, never\nterminal**: the upload keeps retrying regardless.\n\n### Token refresh\n\n```js\nconst upload = await createUpload({\n endpoint, assetId, uploadToken, file,\n onTokenExpired: async () => (await refreshFromYourServer(assetId)).uploadToken,\n});\n```\n\nYour server calls `POST /v1/uploads/:id/token`. The SDK retries in place; nothing\nis lost. Even without the hook a killed client resumes from what storage reports\non next launch.\n\n### Integrity\n\nThree layers, each proving something different \u2014 per-part ETag, part count and\ntotal size at completion, and an optional whole-file SHA-256. See\n[Resume and integrity](#resume-and-integrity) for what each one does and\ndoes not prove.\n\n### Import error classes from the entry you actually use\n\nEach entry is bundled independently, so `IntegrityError` from\n`@hostr/upload/node` is not the same class object as the one from\n`@hostr/upload/browser`. An `instanceof` check across entries fails. Import from\nthe entry your code uses.\n\n## Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| GET | `/health` | \u2014 | Liveness check |\n| GET | `/llms.txt` | \u2014 | Agent documentation index |\n| GET | `/llms-full.txt` | \u2014 | Agent integration guide |\n| POST | `/v1/device/code` | \u2014 | Begin browser authorization |\n| POST | `/v1/device/token` | \u2014 | Exchange an approved device code for a key |\n| POST | `/v1/uploads` | upload | Create an asset and begin a multipart upload |\n| GET | `/v1/uploads/:id` | upload | Upload status and parts already stored |\n| DELETE | `/v1/uploads/:id` | upload | Abort an upload |\n| POST | `/v1/uploads/:id/parts` | upload | Get presigned PUT URLs for parts |\n| POST | `/v1/uploads/:id/complete` | upload | Finalise the upload and queue transcoding |\n| POST | `/v1/uploads/:id/token` | upload | Re-mint an upload token |\n| GET | `/v1/assets` | read | List your assets |\n| GET | `/v1/assets/:id` | read | Get one asset |\n| DELETE | `/v1/assets/:id` | full | Erase an asset |\n| POST | `/v1/assets/:id/tokens` | playback | Mint playback credentials |\n| POST | `/v1/assets/:id/audio` | upload | Generate the audio-only rendition |\n| POST | `/v1/assets/:id/status` | \u2014 | Report transcode status |\n| GET | `/v1/assets/:id/subtitles` | read | List subtitle tracks |\n| PUT | `/v1/assets/:id/subtitles/:lang` | upload | Attach or replace a subtitle track |\n| DELETE | `/v1/assets/:id/subtitles/:lang` | upload | Remove a subtitle track |\n| GET | `/v1/account` | read | Whoami |\n| GET | `/v1/usage` | read | Daily usage |\n| GET | `/v1/events` | read | Tail the durable event log |\n| POST | `/v1/events/:id/redeliver` | full | Replay a stored event |\n| GET | `/v1/webhooks` | full | Get the webhook endpoint |\n| PUT | `/v1/webhooks` | full | Configure the webhook endpoint |\n| DELETE | `/v1/webhooks` | full | Disable the webhook endpoint |\n| GET | `/v1/keys` | full | List key metadata |\n| POST | `/v1/keys` | full | Mint an API key |\n| DELETE | `/v1/keys/:id` | full | Revoke a key |\n\nGenerated from the same OpenAPI description the API is checked against.\nFull reference with schemas and examples: https://docs.hostr.sh/reference/api/\n';
|
|
5451
5506
|
|
|
5452
5507
|
// src/init-content.ts
|
|
5453
5508
|
var BEGIN = "<!-- hostr:begin -->";
|
|
@@ -5668,6 +5723,7 @@ var assets = program2.command("assets").description("asset listing and status");
|
|
|
5668
5723
|
assets.command("list").option("--status <status>").option("--limit <n>").option("--cursor <cursor>").action(run(assetsList));
|
|
5669
5724
|
assets.command("get <id>").action(run((ctx, id) => assetsGet(ctx, id)));
|
|
5670
5725
|
assets.command("delete <id>").description("erase an asset: R2 objects purged, playback ends (requires full scope)").action(run((ctx, id) => assetsDelete(ctx, id)));
|
|
5726
|
+
assets.command("audio [id]").description("generate the audio-only rendition for an asset encoded before it existed (requires upload scope)").option("--missing", "sweep the account: every playable/ready asset with no audio yet").action(run((ctx, id, opts) => assetsAudio(ctx, id, opts)));
|
|
5671
5727
|
assets.command("token <id>").description("mint playback credentials (requires playback scope)").option("--ttl <sec>").option("--viewer-id <id>").action(run((ctx, id, opts) => assetsToken(ctx, id, opts)));
|
|
5672
5728
|
program2.command("tail").description("event log (durable; ascending). One page by default.").option("-f, --follow", "poll every 2s").option("--type <type>", "filter: upload.created | video.uploaded | video.ready | video.errored").option("--limit <n>").option("--after <cursor>", "start after this event id").action(run(tail));
|
|
5673
5729
|
program2.command("init").description("make this repo agent-ready: docs/hostr.md + AGENTS.md section + .env.example entries").option("--dir <dir>", "target directory (default: cwd)").action(async (opts) => {
|