@hostr/cli 0.1.3 → 0.1.4
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 +45 -5
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3686,6 +3686,13 @@ input.onchange = () => [...input.files].forEach(go);
|
|
|
3686
3686
|
['dragleave','drop'].forEach(e => drop.addEventListener(e, ev => { ev.preventDefault(); drop.classList.remove('over'); }));
|
|
3687
3687
|
drop.addEventListener('drop', ev => [...ev.dataTransfer.files].forEach(go));
|
|
3688
3688
|
|
|
3689
|
+
// A bar that fills with no numbers beside it is indistinguishable from a hung
|
|
3690
|
+
// one \u2014 which is exactly how a 10-minute encode read before these existed.
|
|
3691
|
+
// Every long phase gets elapsed, rate, and a remaining estimate.
|
|
3692
|
+
const mmss = s => Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
|
|
3693
|
+
const left = s => s >= 90 ? '~' + Math.round(s / 60) + 'm left' : '~' + Math.max(1, Math.round(s)) + 's left';
|
|
3694
|
+
const size = b => b >= 1e9 ? (b / 1e9).toFixed(2) + ' GB' : (b / 1e6).toFixed(0) + ' MB';
|
|
3695
|
+
|
|
3689
3696
|
const local = (p, o) => fetch(p + '?s=' + S, o).then(r => r.json());
|
|
3690
3697
|
const api = (p, tok, body) => fetch(API + p, { method:'POST',
|
|
3691
3698
|
headers:{ authorization:'Bearer ' + tok, 'content-type':'application/json' },
|
|
@@ -3704,13 +3711,32 @@ async function go(file) {
|
|
|
3704
3711
|
// Part URLs in batches of 100 (the API's cap), uploads 4 at a time via
|
|
3705
3712
|
// XHR \u2014 fetch has no upload progress.
|
|
3706
3713
|
const done = new Array(expectedParts).fill(0);
|
|
3707
|
-
|
|
3714
|
+
// Cumulative average rather than an instantaneous rate: it smooths a
|
|
3715
|
+
// fluctuating connection instead of flickering between wild numbers, and
|
|
3716
|
+
// over a multi-minute upload it converges on the honest figure anyway.
|
|
3717
|
+
let upStart = 0;
|
|
3718
|
+
const paint = () => {
|
|
3719
|
+
const sent = done.reduce((a,b)=>a+b,0);
|
|
3720
|
+
bar.style.width = (sent / file.size * 100).toFixed(1) + '%';
|
|
3721
|
+
// Freeze once every byte is sent. R2 can take a beat to acknowledge the
|
|
3722
|
+
// last part, and paint() keeps firing through it \u2014 with sent pinned at
|
|
3723
|
+
// file.size and elapsed still growing, the rate visibly decays and reads
|
|
3724
|
+
// as a dying connection at the exact moment the upload succeeded.
|
|
3725
|
+
// 'finalizing\u2026' takes over from here anyway.
|
|
3726
|
+
if (!upStart || !sent || sent >= file.size) return;
|
|
3727
|
+
const el = (Date.now() - upStart) / 1000;
|
|
3728
|
+
const rate = sent / el;
|
|
3729
|
+
st.textContent = 'uploading \xB7 ' + size(sent) + ' / ' + size(file.size)
|
|
3730
|
+
+ ' \xB7 ' + (rate / 1e6).toFixed(1) + ' MB/s'
|
|
3731
|
+
+ (rate > 0 ? ' \xB7 ' + left((file.size - sent) / rate) : '');
|
|
3732
|
+
};
|
|
3708
3733
|
let urls = [];
|
|
3709
3734
|
for (let b = 1; b <= expectedParts; b += 100) {
|
|
3710
3735
|
const nums = []; for (let n = b; n < b + 100 && n <= expectedParts; n++) nums.push(n);
|
|
3711
3736
|
urls = urls.concat((await api('/v1/uploads/' + assetId + '/parts', uploadToken, { partNumbers: nums })).urls);
|
|
3712
3737
|
}
|
|
3713
3738
|
st.textContent = 'uploading\u2026';
|
|
3739
|
+
upStart = Date.now();
|
|
3714
3740
|
let idx = 0;
|
|
3715
3741
|
await Promise.all(Array.from({ length: 4 }, async function worker() {
|
|
3716
3742
|
while (idx < urls.length) {
|
|
@@ -3732,14 +3758,28 @@ async function go(file) {
|
|
|
3732
3758
|
|
|
3733
3759
|
el.insertAdjacentHTML('beforeend', '<div class="bar enc"><div></div></div>');
|
|
3734
3760
|
const enc = el.querySelectorAll('.bar > div')[1];
|
|
3735
|
-
st.textContent = '
|
|
3761
|
+
st.textContent = 'queued \xB7 waking the encoder\u2026';
|
|
3736
3762
|
for (;;) {
|
|
3737
3763
|
await new Promise(r => setTimeout(r, 2500));
|
|
3738
3764
|
const a = await local('/api/status/' + assetId);
|
|
3739
|
-
|
|
3765
|
+
const p = a.progress;
|
|
3766
|
+
if (p?.totalSec) enc.style.width = (p.encodedSec / p.totalSec * 100).toFixed(1) + '%';
|
|
3740
3767
|
if (a.status === 'ready') break;
|
|
3741
3768
|
if (a.status === 'failed') throw new Error('transcode failed: ' + (a.error || 'unknown'));
|
|
3742
|
-
|
|
3769
|
+
// Three distinct waits, and conflating them is what made this read as a
|
|
3770
|
+
// hang: the machine scales to zero so it may be booting; then it pulls
|
|
3771
|
+
// the source out of R2 and probes it; only then does progress exist.
|
|
3772
|
+
// Until the first progress post there is nothing to put in the bar, so
|
|
3773
|
+
// the label has to carry the state instead.
|
|
3774
|
+
st.textContent =
|
|
3775
|
+
p?.totalSec
|
|
3776
|
+
? 'transcoding \xB7 ' + mmss(p.encodedSec) + ' / ' + mmss(p.totalSec)
|
|
3777
|
+
+ (p.speed ? ' \xB7 ' + p.speed.toFixed(2) + '\xD7' : '')
|
|
3778
|
+
// speed is optional on the wire (the API forwards it only when it
|
|
3779
|
+
// is a number), so the estimate goes rather than reading NaN.
|
|
3780
|
+
+ (p.speed > 0 ? ' \xB7 ' + left((p.totalSec - p.encodedSec) / p.speed) : '')
|
|
3781
|
+
: a.status === 'processing' ? 'transcoding \xB7 reading the source\u2026'
|
|
3782
|
+
: 'queued \xB7 waking the encoder\u2026';
|
|
3743
3783
|
}
|
|
3744
3784
|
enc.style.width = '100%';
|
|
3745
3785
|
|
|
@@ -5227,7 +5267,7 @@ import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from
|
|
|
5227
5267
|
import { join as join3 } from "node:path";
|
|
5228
5268
|
|
|
5229
5269
|
// ../../docs/integrate.md
|
|
5230
|
-
var integrate_default = '# hostr \u2014 integration guide\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 GET {API base}/llms.txt \xB7 GET {API base}/llms-full.txt (this file)\n```\n\n## 0. TL;DR for an integrating agent\n\n```bash\nnpx @hostr/cli login --key $HOSTR_API_KEY # validate + save the key (or export HOSTR_API_KEY)\nnpx @hostr/cli init # write docs/hostr.md + AGENTS.md section + .env.example entries\nnpx @hostr/cli upload demo.mp4 --wait --json # -> { assetId, status: "ready", ... }\nnpx @hostr/cli upload # no file: opens a local drag-drop page in the browser\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --json # -> { secret: "whsec_..." } ONCE\nnpx @hostr/cli tail --follow # watch the event log live\n```\n\nThen in your app: store `assetId`/`playbackId` per video, mint playback\ntokens server-side (\xA76), and hand players\n`{Media host}/{playbackId}.m3u8?token=\u2026`. Verify webhooks with the snippet\nin \xA75. That is the whole integration.\n\n## 1. Authentication\n\nEvery API call sends `Authorization: Bearer <credential>`. There are two\ncredential kinds an integrator sees:\n\n- **API key** \u2014 `hostr_sk_live_\u2026`, minted per account in the hostr\n dashboard or via `POST /v1/keys` / `npx @hostr/cli keys create`. Scoped:\n - `read` \u2014 GETs only (status, listing, usage, events)\n - `upload` \u2014 read + the upload lifecycle\n - `full` \u2014 everything, including playback-token minting and key management\n Keys are hashed at rest; the plaintext is shown exactly once at mint.\n Rotation = create new + revoke old (`DELETE /v1/keys/:id`); revocation\n takes effect within ~60 s. **Keep keys server-side.** Never ship one in a\n browser or mobile app.\n- **Upload token** \u2014 short-lived HMAC scoped to a single upload\n (`uploadToken` in the create-upload response). This is the only\n credential an untrusted client (browser/mobile) should ever hold: it can\n drive that one upload and nothing else.\n\nAuth errors: `401 {"error":"unauthorized"}` (bad/missing/revoked key, or a\nresource you don\'t own \u2014 hostr does not reveal which),\n`403 {"error":"insufficient_scope","need":"upload"}`,\n`403 {"error":"account_suspended"}`,\n`503 {"error":"control_plane_unavailable"}` (transient \u2014 retry with backoff;\nin-flight uploads with upload tokens are unaffected).\n\n## 2. Upload\n\nThree ways, strongest first:\n\n### 2a. CLI (recommended \u2014 resumable, integrity-checked)\n\n```bash\nnpx @hostr/cli upload path/to/video.mp4 --json # returns when bytes are verified in R2\nnpx @hostr/cli upload path/to/video.mp4 --wait --json # returns when transcode is ready/failed\n```\n\nOutput: `{ "assetId": "\u2026", "status": "queued"|"ready", "bytes": n, "etag": "\u2026", "sha256": "\u2026" }`.\nThe CLI wraps hostr\'s upload SDK: chunked multipart direct to storage,\nresume after crash/restart (server-authoritative \u2014 it asks storage which\nparts landed), retry with backoff, and three-layer integrity verification.\nExit code 1 + JSON on stderr on failure.\n\n### 2b. Raw HTTP (any language, no SDK)\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n (forward ONLY assetId/uploadToken/partSize/expectedParts/tokenExpiresAt to untrusted clients)\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,...]} (\u2264100 per call)\n -> { urls: [{partNumber, url}], expiresIn }\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nGET /v1/uploads/:id -> { status, uploadedParts, parts, bytesUploaded } (resume)\nPOST /v1/uploads/:id/complete {"sha256":"<hex of whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\nDELETE /v1/uploads/:id abort\nPOST /v1/uploads/:id/token re-mint the upload token (API key with upload scope)\n```\n\nRules: every part is `partSize` bytes except the last; `size` must be\nexact \u2014 completion verifies both part count and total bytes against what\nactually landed and returns `409 incomplete_upload` (with the missing part\nnumbers) or `409 size_mismatch` otherwise. Completion is idempotent \u2014 a\ncrashed client retries it safely. Uploads are quota-gated:\n`403 {"error":"quota_uploads_daily"|"quota_assets"|"quota_storage","limit":n}`.\n\n### 2c. Browser/mobile clients\n\nCreate the upload server-side (2b), hand the client only\n`{assetId, uploadToken, partSize, expectedParts}`, and let it PUT parts\ndirectly to the presigned URLs \u2014 upload bytes never touch your servers.\n(A browser needs the storage bucket to allow your origin via CORS \u2014 if\npart PUTs stall at 0% with network-looking errors, that\'s the cause.)\n\n## 3. Asset lifecycle\n\n`uploading \u2192 uploaded \u2192 queued \u2192 processing \u2192 ready | failed` (plus\n`aborted`). Poll `GET /v1/assets/:id` (`read` scope) or \u2014 better \u2014 use\nwebhooks (\xA75). While `processing`, the asset carries live transcode\nprogress \u2014 `progress: { encodedSec, totalSec, speed }` (speed is the\nencode multiple of realtime; the field is null/absent outside\n`processing`) \u2014 so a UI can show a real percentage instead of a spinner. `GET /v1/assets?limit=&status=&cursor=` lists your assets\nnewest-first with keyset pagination (`nextCursor`).\n\nWhen `ready`, the asset record and the `video.ready` event both carry\n`playbackId` (equals `assetId` today \u2014 store it as its own field) and\n`durationSec`.\n\n## 4. Account, usage, keys\n\n```\nGET /v1/account -> { accountId, name, plan, limits, usage } (read)\nGET /v1/usage?from&to -> { days: [{day, uploads, bytesUploaded, encodedSeconds}] } (read)\nPOST /v1/keys {"name":"ci","scopes":["upload"]} -> { key: "hostr_sk_live_..." } ONCE (full)\nGET /v1/keys -> metadata only, never hashes (full)\nDELETE /v1/keys/:id -> { revoked: true } (full)\n```\n\n## 5. Webhooks + the event log\n\nConfigure one endpoint per account:\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 retained)\n```\n\nEvents \u2014 body `{ id, type, createdAt, data }`; `id` is the durable event\nrow\'s id (dedupe on it):\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`, `bytes`, `etag`, `sha256?` |\n| `video.ready` | HLS renditions live | `assetId`, `playbackId`, `durationSec?` |\n| `video.errored` | transcode failed | `assetId`, `error` (always present) |\n| `video.deleted` | asset erased; playback over | `assetId` |\n\nEndpoint URLs: both `https` and `http` are accepted, including private\naddresses \u2014 hostr does not currently block internal ranges, so a local\nreceiver works in development exactly like production.\n\nEvery delivery carries `hostr-signature`, a comma-separated list currently\ncontaining `t=<unix-seconds>` and `v1=<hex>` where\n`v1 = HMAC-SHA256(secret, "<t>.<rawBody>")` \u2014 the Stripe scheme. Parse it\nas a list (future fields may be added); verify before trusting\n(constant-time compare, bounded timestamp):\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// Use the RAW request body bytes \u2014 parse JSON only after verification.\n```\n\nDelivery is at-least-once (3 attempts with backoff, each recorded). The\nwebhook is the wake-up signal; the **durable event log** is the ledger:\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (e.g. the latest event) (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n`npx @hostr/cli tail --follow` polls this for you; `npx @hostr/cli webhooks test`\nredelivers the newest event to prove your endpoint end-to-end. Respond\n`2xx` fast (do the work async) \u2014 anything else is retried.\n\n## 6. Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `full` key:\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"user-123"} (optional body)\n -> { playback, thumbnail, storyboard, expiresAt } (expiresAt in ms)\nnpx @hostr/cli assets token <assetId> --json (CLI equivalent)\n```\n\nMinting works any time after the asset exists (it 404s only for unknown\nassets); the stream itself returns 404 until the asset is `ready` \u2014 gate\nyour UI on `video.ready` / asset status, not on whether minting succeeds.\n\nBuild the URL and hand it to any HLS player:\n\n```\nhttps://stream.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\n- Use hls.js in browsers without native HLS; Safari/iOS play it natively.\n- The token is checked once at the master playlist; the stream then works\n for the whole viewing session (segment credentials are handled\n internally). Default TTL 1 h \u2014 it only needs to be valid when playback\n *starts*.\n- `viewerId` enables per-viewer concurrency observation.\n- **Non-browser user agents are rejected** (`403 blocked_user_agent`):\n curl/wget/ffmpeg/vlc UAs are blocked as a casual-ripping speed bump. For\n server-side smoke tests send a browser-like `User-Agent`.\n- Signed URLs are not DRM: they stop link-sharing and hotlinking, not an\n entitled viewer recording their screen.\n- `DELETE /v1/assets/:id` erases an asset for good: source and renditions\n are purged from storage, the storage quota is freed, and `video.deleted`\n is emitted. Deletion is idempotent (repeat calls return `deleted`\n again). New playback tokens stop minting immediately; tokens already\n issued can replay edge-cached segments for at most a few hours until\n they expire. There is no undelete.\n\nThe token bundle also covers the poster image and scrub previews \u2014 same\nhost, same query-token shape, each URL taking its matching token from the\nmint response:\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nThe storyboard is a standard "thumbnails" WebVTT track (cues point into a\ntiled sprite with `#xywh=` fragments) \u2014 hand it to mux-player\'s\n`storyboard-src`, media-chrome\'s preview track, or any player that consumes\nthat convention; the sprite URLs inside the VTT are pre-signed for you.\nPlayers treat the track as optional: for assets encoded before storyboards\nexisted the URL 404s and scrubbing simply shows no preview.\n\n## 6b. Subtitles\n\nHuman-authored WebVTT tracks, attached any time after the asset is `ready`\n(409 `not_ready` before that \u2014 the playlist needs the duration):\n\n```\nPUT /v1/assets/:id/subtitles/:lang?name=English&default=true (body: raw WebVTT, upload scope)\nGET /v1/assets/:id/subtitles -> { tracks: [{ lang, name, default, rev }] }\nDELETE /v1/assets/:id/subtitles/:lang\nnpx @hostr/cli subtitles set <assetId> subs.vtt --lang en --name English --default\n```\n\n- `:lang` is a BCP 47 tag (`en`, `pt-BR`); one track per tag, PUT replaces.\n- Tracks appear in the master playlist automatically (`EXT-X-MEDIA`\n subtitles group) \u2014 the next master fetch after a PUT includes them, no\n re-encode, no new playback URL. Players show them in the captions menu.\n- `default=true` demotes any previous default.\n- Replacements propagate on the next playback session; a viewer mid-session\n keeps the track they started with.\n\n## 7. Error handling summary\n\n| status | error | meaning / action |\n|--------|------------------------------|------------------|\n| 401 | `unauthorized` | bad/revoked key, or not your resource \u2014 check key + assetId |\n| 403 | `insufficient_scope` | key scope too low; `need` names the required scope |\n| 403 | `quota_*` | account limit hit; `limit` is the ceiling |\n| 403 | `account_suspended` | contact the operator |\n| 409 | `incomplete_upload` | parts missing (`missing` lists them) \u2014 upload them, re-complete |\n| 409 | `size_mismatch` | declared size \u2260 bytes landed \u2014 abort and restart |\n| 409 | `not_uploading` | upload already finished/aborted |\n| 413 | `file_too_large` | raise `partSize` or split |\n| 503 | `control_plane_unavailable` | transient \u2014 retry with backoff |\n| 500 | `signing_not_configured` | operator-side: the playback signing key isn\'t loaded on this API deployment. Not your integration\'s fault and not retryable from the client \u2014 report it |\n\n## 8. Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| POST | `/v1/uploads` | upload | create upload session |\n| GET | `/v1/uploads/:id` | upload* | status + landed parts (resume) |\n| POST | `/v1/uploads/:id/parts` | upload* | presigned part URLs |\n| POST | `/v1/uploads/:id/complete` | upload* | verify + finalize |\n| POST | `/v1/uploads/:id/token` | upload | re-mint upload token |\n| DELETE | `/v1/uploads/:id` | upload* | abort |\n| GET | `/v1/assets` | read | list own assets |\n| GET | `/v1/assets/:id` | read | asset record |\n| POST | `/v1/assets/:id/tokens` | full | playback credentials |\n| DELETE | `/v1/assets/:id` | full | erase asset: R2 purged, quota freed, `video.deleted` emitted |\n| PUT | `/v1/assets/:id/subtitles/:lang` | upload | attach/replace WebVTT track |\n| GET | `/v1/assets/:id/subtitles` | read | list subtitle tracks |\n| DELETE | `/v1/assets/:id/subtitles/:lang` | upload | remove subtitle track |\n| GET | `/v1/account` | read | whoami: plan, limits, usage |\n| GET | `/v1/usage` | read | daily usage rows |\n| GET | `/v1/events` | read | durable event log (ascending tail; `order=desc` for newest-first) |\n| POST | `/v1/events/:id/redeliver` | full | replay a webhook |\n| GET/PUT/DELETE | `/v1/webhooks` | full | endpoint config |\n| POST/GET | `/v1/keys` | full | mint / list keys |\n| DELETE | `/v1/keys/:id` | full | revoke key |\n\n\\* also accepted: that upload\'s own `uploadToken`.\n\n## 9. CLI reference\n\n`npx @hostr/cli <cmd>` \u2014 every command takes `--json` (one JSON document on\nstdout; NDJSON for `tail --follow`; errors as JSON on stderr, exit 1).\nCredentials: `--key` flag > `HOSTR_API_KEY` env > saved profile\n(`hostr login`). API base: `--api` > `HOSTR_API_BASE` > profile > default.\n\n```\nlogin \xB7 whoami \xB7 usage \xB7 init\nkeys create|list|revoke\nwebhooks get|set|delete|test\nassets list|get \xB7 assets token <id> [--ttl <sec>] \xB7 upload <file> [--wait] \xB7 tail [--follow]\n```\n';
|
|
5270
|
+
var integrate_default = '# hostr \u2014 integration guide\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 GET {API base}/llms.txt \xB7 GET {API base}/llms-full.txt (this file)\n```\n\n## 0. TL;DR for an integrating agent\n\n```bash\nnpx @hostr/cli login --key $HOSTR_API_KEY # validate + save the key (or export HOSTR_API_KEY)\nnpx @hostr/cli init # write docs/hostr.md + AGENTS.md section + .env.example entries\nnpx @hostr/cli upload demo.mp4 --wait --json # -> { assetId, status: "ready", ... }\nnpx @hostr/cli upload # no file: opens a local drag-drop page in the browser\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --json # -> { secret: "whsec_..." } ONCE\nnpx @hostr/cli tail --follow # watch the event log live\n```\n\nThen in your app: store `assetId`/`playbackId` per video, mint playback\ntokens server-side (\xA76), and hand players\n`{Media host}/{playbackId}.m3u8?token=\u2026`. Verify webhooks with the snippet\nin \xA75. That is the whole integration.\n\n## 1. Authentication\n\nEvery API call sends `Authorization: Bearer <credential>`. There are two\ncredential kinds an integrator sees:\n\n- **API key** \u2014 `hostr_sk_live_\u2026`, minted per account in the hostr\n dashboard or via `POST /v1/keys` / `npx @hostr/cli keys create`. Scoped:\n - `read` \u2014 GETs only (status, listing, usage, events)\n - `upload` \u2014 read + the upload lifecycle\n - `full` \u2014 everything, including playback-token minting and key management\n Keys are hashed at rest; the plaintext is shown exactly once at mint.\n Rotation = create new + revoke old (`DELETE /v1/keys/:id`); revocation\n takes effect within ~60 s. **Keep keys server-side.** Never ship one in a\n browser or mobile app.\n- **Upload token** \u2014 short-lived HMAC scoped to a single upload\n (`uploadToken` in the create-upload response). This is the only\n credential an untrusted client (browser/mobile) should ever hold: it can\n drive that one upload and nothing else.\n\nAuth errors: `401 {"error":"unauthorized"}` (bad/missing/revoked key, or a\nresource you don\'t own \u2014 hostr does not reveal which),\n`403 {"error":"insufficient_scope","need":"upload"}`,\n`403 {"error":"account_suspended"}`,\n`503 {"error":"control_plane_unavailable"}` (transient \u2014 retry with backoff;\nin-flight uploads with upload tokens are unaffected).\n\n## 2. Upload\n\nThree ways, strongest first:\n\n### 2a. CLI (recommended \u2014 resumable, integrity-checked)\n\n```bash\nnpx @hostr/cli upload path/to/video.mp4 --json # returns when bytes are verified in R2\nnpx @hostr/cli upload path/to/video.mp4 --wait --json # returns when transcode is ready/failed\n```\n\nOutput: `{ "assetId": "\u2026", "status": "queued"|"ready", "bytes": n, "etag": "\u2026", "sha256": "\u2026" }`.\nThe CLI wraps hostr\'s upload SDK: chunked multipart direct to storage,\nresume after crash/restart (server-authoritative \u2014 it asks storage which\nparts landed), retry with backoff, and three-layer integrity verification.\nExit code 1 + JSON on stderr on failure.\n\n### 2b. Raw HTTP (any language, no SDK)\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n (forward ONLY assetId/uploadToken/partSize/expectedParts/tokenExpiresAt to untrusted clients)\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,...]} (\u2264100 per call)\n -> { urls: [{partNumber, url}], expiresIn }\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nGET /v1/uploads/:id -> { status, uploadedParts, parts, bytesUploaded } (resume)\nPOST /v1/uploads/:id/complete {"sha256":"<hex of whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\nDELETE /v1/uploads/:id abort\nPOST /v1/uploads/:id/token re-mint the upload token (API key with upload scope)\n```\n\nRules: every part is `partSize` bytes except the last; `size` must be\nexact \u2014 completion verifies both part count and total bytes against what\nactually landed and returns `409 incomplete_upload` (with the missing part\nnumbers) or `409 size_mismatch` otherwise. Completion is idempotent \u2014 a\ncrashed client retries it safely. Uploads are quota-gated:\n`403 {"error":"quota_uploads_daily"|"quota_assets"|"quota_storage","limit":n}`.\n\n### 2c. Browser/mobile clients\n\nCreate the upload server-side (2b), hand the client only\n`{assetId, uploadToken, partSize, expectedParts}`, and let it PUT parts\ndirectly to the presigned URLs \u2014 upload bytes never touch your servers.\n(A browser needs the storage bucket to allow your origin via CORS \u2014 if\npart PUTs stall at 0% with network-looking errors, that\'s the cause.)\n\n## 3. Asset lifecycle\n\n`uploading \u2192 uploaded \u2192 queued \u2192 processing \u2192 ready | failed` (plus\n`aborted`). Poll `GET /v1/assets/:id` (`read` scope) or \u2014 better \u2014 use\nwebhooks (\xA75). While `processing`, the asset carries live transcode\nprogress \u2014 `progress: { encodedSec, totalSec, speed }` (speed is the\nencode multiple of realtime; the field is null/absent outside\n`processing`) \u2014 so a UI can show a real percentage instead of a spinner. `GET /v1/assets?limit=&status=&cursor=` lists your assets\nnewest-first with keyset pagination (`nextCursor`).\n\nWhen `ready`, the asset record and the `video.ready` event both carry\n`playbackId` (equals `assetId` today \u2014 store it as its own field) and\n`durationSec`.\n\n## 4. Account, usage, keys\n\n```\nGET /v1/account -> { accountId, name, plan, limits, usage } (read)\nGET /v1/usage?from&to -> { days: [{day, uploads, bytesUploaded, encodedSeconds}] } (read)\nPOST /v1/keys {"name":"ci","scopes":["upload"]} -> { key: "hostr_sk_live_..." } ONCE (full)\nGET /v1/keys -> metadata only, never hashes (full)\nDELETE /v1/keys/:id -> { revoked: true } (full)\n```\n\n## 5. Webhooks + the event log\n\nConfigure one endpoint per account:\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 retained)\n```\n\nEvents \u2014 body `{ id, type, createdAt, data }`; `id` is the durable event\nrow\'s id (dedupe on it):\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`, `bytes`, `etag`, `sha256?` |\n| `video.ready` | HLS renditions live | `assetId`, `playbackId`, `durationSec?` |\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.input.non_standard` is advisory \u2014 the transcode proceeds normally and\na `video.ready` still follows. It fires at most once per asset, only when\nthere is something to say, and reports what the encoder had to assume:\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: `videoCodec`, `pixFmt`, `bitDepth`,\n`width`, `height`, `declaredFps`, `averageFps`, `targetFps`,\n`isVariableFrameRate`, `colorSpace`, `colorTransfer`, `colorPrimaries`. The\nsame block is on the asset record as `input`, so it can be read back at any\ntime without replaying events.\n\nEndpoint URLs: both `https` and `http` are accepted, including private\naddresses \u2014 hostr does not currently block internal ranges, so a local\nreceiver works in development exactly like production.\n\nEvery delivery carries `hostr-signature`, a comma-separated list currently\ncontaining `t=<unix-seconds>` and `v1=<hex>` where\n`v1 = HMAC-SHA256(secret, "<t>.<rawBody>")` \u2014 the Stripe scheme. Parse it\nas a list (future fields may be added); verify before trusting\n(constant-time compare, bounded timestamp):\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// Use the RAW request body bytes \u2014 parse JSON only after verification.\n```\n\nDelivery is at-least-once (3 attempts with backoff, each recorded). The\nwebhook is the wake-up signal; the **durable event log** is the ledger:\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (e.g. the latest event) (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n`npx @hostr/cli tail --follow` polls this for you; `npx @hostr/cli webhooks test`\nredelivers the newest event to prove your endpoint end-to-end. Respond\n`2xx` fast (do the work async) \u2014 anything else is retried.\n\n## 6. Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `full` key:\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"user-123"} (optional body)\n -> { playback, thumbnail, storyboard, expiresAt } (expiresAt in ms)\nnpx @hostr/cli assets token <assetId> --json (CLI equivalent)\n```\n\nMinting works any time after the asset exists (it 404s only for unknown\nassets); the stream itself returns 404 until the asset is `ready` \u2014 gate\nyour UI on `video.ready` / asset status, not on whether minting succeeds.\n\nBuild the URL and hand it to any HLS player:\n\n```\nhttps://stream.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\n- Use hls.js in browsers without native HLS; Safari/iOS play it natively.\n- The token is checked once at the master playlist; the stream then works\n for the whole viewing session (segment credentials are handled\n internally). Default TTL 1 h \u2014 it only needs to be valid when playback\n *starts*.\n- `viewerId` enables per-viewer concurrency observation.\n- **Non-browser user agents are rejected** (`403 blocked_user_agent`):\n curl/wget/ffmpeg/vlc UAs are blocked as a casual-ripping speed bump. For\n server-side smoke tests send a browser-like `User-Agent`.\n- Signed URLs are not DRM: they stop link-sharing and hotlinking, not an\n entitled viewer recording their screen.\n- `DELETE /v1/assets/:id` erases an asset for good: source and renditions\n are purged from storage, the storage quota is freed, and `video.deleted`\n is emitted. Deletion is idempotent (repeat calls return `deleted`\n again). New playback tokens stop minting immediately; tokens already\n issued can replay edge-cached segments for at most a few hours until\n they expire. There is no undelete.\n\nThe token bundle also covers the poster image and scrub previews \u2014 same\nhost, same query-token shape, each URL taking its matching token from the\nmint response:\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nThe storyboard is a standard "thumbnails" WebVTT track (cues point into a\ntiled sprite with `#xywh=` fragments) \u2014 hand it to mux-player\'s\n`storyboard-src`, media-chrome\'s preview track, or any player that consumes\nthat convention; the sprite URLs inside the VTT are pre-signed for you.\nPlayers treat the track as optional: for assets encoded before storyboards\nexisted the URL 404s and scrubbing simply shows no preview.\n\n## 6b. Subtitles\n\nHuman-authored WebVTT tracks, attached any time after the asset is `ready`\n(409 `not_ready` before that \u2014 the playlist needs the duration):\n\n```\nPUT /v1/assets/:id/subtitles/:lang?name=English&default=true (body: raw WebVTT, upload scope)\nGET /v1/assets/:id/subtitles -> { tracks: [{ lang, name, default, rev }] }\nDELETE /v1/assets/:id/subtitles/:lang\nnpx @hostr/cli subtitles set <assetId> subs.vtt --lang en --name English --default\n```\n\n- `:lang` is a BCP 47 tag (`en`, `pt-BR`); one track per tag, PUT replaces.\n- Tracks appear in the master playlist automatically (`EXT-X-MEDIA`\n subtitles group) \u2014 the next master fetch after a PUT includes them, no\n re-encode, no new playback URL. Players show them in the captions menu.\n- `default=true` demotes any previous default.\n- Replacements propagate on the next playback session; a viewer mid-session\n keeps the track they started with.\n\n## 7. Error handling summary\n\n| status | error | meaning / action |\n|--------|------------------------------|------------------|\n| 401 | `unauthorized` | bad/revoked key, or not your resource \u2014 check key + assetId |\n| 403 | `insufficient_scope` | key scope too low; `need` names the required scope |\n| 403 | `quota_*` | account limit hit; `limit` is the ceiling |\n| 403 | `account_suspended` | contact the operator |\n| 409 | `incomplete_upload` | parts missing (`missing` lists them) \u2014 upload them, re-complete |\n| 409 | `size_mismatch` | declared size \u2260 bytes landed \u2014 abort and restart |\n| 409 | `not_uploading` | upload already finished/aborted |\n| 413 | `file_too_large` | raise `partSize` or split |\n| 503 | `control_plane_unavailable` | transient \u2014 retry with backoff |\n| 500 | `signing_not_configured` | operator-side: the playback signing key isn\'t loaded on this API deployment. Not your integration\'s fault and not retryable from the client \u2014 report it |\n\n## 8. Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| POST | `/v1/uploads` | upload | create upload session |\n| GET | `/v1/uploads/:id` | upload* | status + landed parts (resume) |\n| POST | `/v1/uploads/:id/parts` | upload* | presigned part URLs |\n| POST | `/v1/uploads/:id/complete` | upload* | verify + finalize |\n| POST | `/v1/uploads/:id/token` | upload | re-mint upload token |\n| DELETE | `/v1/uploads/:id` | upload* | abort |\n| GET | `/v1/assets` | read | list own assets |\n| GET | `/v1/assets/:id` | read | asset record |\n| POST | `/v1/assets/:id/tokens` | full | playback credentials |\n| DELETE | `/v1/assets/:id` | full | erase asset: R2 purged, quota freed, `video.deleted` emitted |\n| PUT | `/v1/assets/:id/subtitles/:lang` | upload | attach/replace WebVTT track |\n| GET | `/v1/assets/:id/subtitles` | read | list subtitle tracks |\n| DELETE | `/v1/assets/:id/subtitles/:lang` | upload | remove subtitle track |\n| GET | `/v1/account` | read | whoami: plan, limits, usage |\n| GET | `/v1/usage` | read | daily usage rows |\n| GET | `/v1/events` | read | durable event log (ascending tail; `order=desc` for newest-first) |\n| POST | `/v1/events/:id/redeliver` | full | replay a webhook |\n| GET/PUT/DELETE | `/v1/webhooks` | full | endpoint config |\n| POST/GET | `/v1/keys` | full | mint / list keys |\n| DELETE | `/v1/keys/:id` | full | revoke key |\n\n\\* also accepted: that upload\'s own `uploadToken`.\n\n## 9. CLI reference\n\n`npx @hostr/cli <cmd>` \u2014 every command takes `--json` (one JSON document on\nstdout; NDJSON for `tail --follow`; errors as JSON on stderr, exit 1).\nCredentials: `--key` flag > `HOSTR_API_KEY` env > saved profile\n(`hostr login`). API base: `--api` > `HOSTR_API_BASE` > profile > default.\n\n```\nlogin \xB7 whoami \xB7 usage \xB7 init\nkeys create|list|revoke\nwebhooks get|set|delete|test\nassets list|get \xB7 assets token <id> [--ttl <sec>] \xB7 upload <file> [--wait] \xB7 tail [--follow]\n```\n';
|
|
5231
5271
|
|
|
5232
5272
|
// src/init-content.ts
|
|
5233
5273
|
var BEGIN = "<!-- hostr:begin -->";
|