@huynle/hub 0.8.2 → 0.8.3

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +35 -7
  6. package/packages/cli/package.json +1 -1
  7. package/packages/codex/package.json +1 -1
  8. package/packages/node/package.json +1 -1
  9. package/packages/preset/package.json +1 -1
  10. package/packages/protocol/package.json +1 -1
  11. package/packages/protocol/src/index.ts +9 -1
  12. package/packages/protocol/src/voice-recording.ts +107 -0
  13. package/packages/runtime-openshell/package.json +1 -1
  14. package/packages/runtime-sdk/package.json +1 -1
  15. package/packages/sdk/package.json +1 -1
  16. package/packages/spoke-sdk/package.json +1 -1
  17. package/src/central/agent/routes.ts +7 -1
  18. package/src/central/artifacts/blob-store.ts +6 -1
  19. package/src/central/artifacts/blob-support.ts +3 -1
  20. package/src/central/concierge/board-render.ts +34 -11
  21. package/src/central/concierge/index.ts +1 -1
  22. package/src/central/concierge/notifier.ts +2 -1
  23. package/src/central/config.ts +4 -0
  24. package/src/central/csp.ts +55 -0
  25. package/src/central/http.ts +85 -9
  26. package/src/central/profiles/content.ts +16 -0
  27. package/src/central/recording/clock.ts +76 -0
  28. package/src/central/recording/concierge-taps.ts +104 -0
  29. package/src/central/recording/export.ts +215 -0
  30. package/src/central/recording/index.ts +658 -0
  31. package/src/central/recording/ingest.ts +209 -0
  32. package/src/central/recording/retention.ts +71 -0
  33. package/src/central/recording/routes.ts +281 -0
  34. package/src/central/recording/schema.ts +232 -0
  35. package/src/central/recording/settings.ts +35 -0
  36. package/src/central/recording/store.ts +316 -0
  37. package/src/central/recording/taps.ts +105 -0
  38. package/src/central/recording/zip.ts +138 -0
  39. package/src/central/registry.ts +68 -3
  40. package/src/central/spoke-ws.ts +2 -0
  41. package/src/spoke/session-context.ts +28 -8
  42. package/src/spoke/spoke.ts +3 -0
  43. package/src/spoke/tracker.ts +6 -1
  44. package/src/status.ts +31 -5
  45. package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-CSotAWob.js} +1 -1
  46. package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-DPHjiWce.js} +1 -1
  47. package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
  48. package/web/dist/assets/{Library-BXbJvTUS.js → Library-DTLEyekn.js} +2 -2
  49. package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
  50. package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-C-5lYeeM.js} +1 -1
  51. package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
  52. package/web/dist/assets/index-6mdQ7eUY.js +34 -0
  53. package/web/dist/assets/index-C5OmwA1y.css +1 -0
  54. package/web/dist/assets/{live-OgQWPrSY.js → live-CDWcyavI.js} +1 -1
  55. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  56. package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-CNFdTDTr.js} +1 -1
  57. package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-C45jPvYX.js} +4 -56
  58. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  59. package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-sA9a8vYJ.js} +1 -1
  60. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  61. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  62. package/web/dist/index.html +5 -16
  63. package/web/dist/theme-boot.js +12 -0
  64. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
  65. package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
  66. package/web/dist/assets/index-BabrNDB3.css +0 -1
  67. package/web/dist/assets/index-g4V9mfSr.js +0 -34
@@ -0,0 +1,209 @@
1
+ // REC-01 event ingest (contract: Events). Console batches: idempotent by seq (seq ≤ consoleThrough dropped, a
2
+ // later first seq counted as a gap), one page blob per batch under index key c<firstNewSeq> written with
3
+ // ifVersion 0 (an existing key is a page stored before a crash: treated as stored), then the record CAS-updated.
4
+ // Hub events: buffered per pod and recording, written as pages of up to hubFlushEvents every hubFlushMs (and at
5
+ // end and drain) under h<podTag>-<n>; a pod that dies loses at most its last hubFlushMs of hub events.
6
+ import { type ConsoleEvent, type HubEventKind, RECORDING_LIMITS, type RecordingEvent } from "../../protocol"
7
+ import { BlobQuotaError } from "../artifacts/blob-store"
8
+ import type { Clock, Timers } from "../backend/types"
9
+ import { consoleRecord, hubRecord } from "./schema"
10
+ import { consoleKey, hubKey, type RecordingRecord, type RecordingStore } from "./store"
11
+
12
+ const L = RECORDING_LIMITS
13
+ /** Event pages may exceed the audio cap (events continue while audio is capped): headroom for every active recording. */
14
+ export const PAGE_QUOTA_BYTES = L.userBytes + L.activePerUser * L.eventBytesPerRecording
15
+ /** Room past PAGE_QUOTA_BYTES for the end markers of a recording whose user filled the page quota. */
16
+ export const CONTROL_HEADROOM_BYTES = 1024 * 1024
17
+ const CONTROL_KINDS = new Set<string>(["cap.reached", "recording.ended", "error"])
18
+ /** A page is the batch plus the envelope the hub adds (≈ 50 bytes per event). */
19
+ const PAGE_MAX_BYTES = L.batchBytes + L.eventsPerBatch * 128
20
+
21
+ const encoder = new TextEncoder()
22
+ export const ndjson = (events: readonly RecordingEvent[]) => encoder.encode(events.map((e) => JSON.stringify(e)).join("\n") + "\n")
23
+
24
+ export type ConsoleIngest =
25
+ | { readonly kind: "stored"; readonly record: RecordingRecord }
26
+ | { readonly kind: "limit" }
27
+ /** The user's event-page quota (PAGE_QUOTA_BYTES) is full. */
28
+ | { readonly kind: "quota" }
29
+ | { readonly kind: "gone" }
30
+
31
+ /** Stores a checked console batch for a recording that accepts appends. */
32
+ export async function ingestConsole(store: RecordingStore, userID: string, record: RecordingRecord, events: readonly ConsoleEvent[], now: Clock): Promise<ConsoleIngest> {
33
+ const fresh = events.filter((e) => e.seq > record.consoleThrough)
34
+ if (!fresh.length) return { kind: "stored", record }
35
+ const at = now()
36
+ const records = fresh.map((e) => consoleRecord(e, at))
37
+ const page = ndjson(records)
38
+ if (record.events + records.length > L.eventsPerRecording || record.eventBytes + page.length > L.eventBytesPerRecording) return { kind: "limit" }
39
+ const first = fresh[0]!.seq
40
+ const last = fresh.at(-1)!.seq
41
+ let ref
42
+ try {
43
+ ref = await store.putBlob(userID, page, "application/x-ndjson", PAGE_MAX_BYTES, PAGE_QUOTA_BYTES)
44
+ } catch (cause) {
45
+ if (cause instanceof BlobQuotaError) return { kind: "quota" }
46
+ throw cause
47
+ }
48
+ // An existing key: the page of a batch whose record update was lost (a crash); it holds these events.
49
+ if (!(await store.putIndex(userID, record.id, consoleKey(first), { hash: ref.hash, bytes: ref.bytes, first, last, at }))) {
50
+ const existing = await store.indexEntry<{ hash?: string }>(userID, record.id, consoleKey(first))
51
+ if (existing?.hash !== ref.hash) await store.dropIfUnindexed(userID, ref.hash, at)
52
+ }
53
+ const redacted = records.filter((e) => e.redacted?.length).length
54
+ const updated = await store.update(userID, record.id, (current) => {
55
+ if (current.state === "deleting" || current.consoleThrough >= last) return undefined
56
+ const gap = Math.max(0, first - (current.consoleThrough + 1))
57
+ return {
58
+ ...current,
59
+ consoleThrough: last,
60
+ consoleGaps: current.consoleGaps + gap,
61
+ events: current.events + records.length,
62
+ eventBytes: current.eventBytes + ref.bytes,
63
+ bytes: current.bytes + ref.bytes,
64
+ pages: current.pages + 1,
65
+ redactions: current.redactions + redacted,
66
+ lastEventAt: at,
67
+ }
68
+ })
69
+ // A deletion that ran meanwhile did not see this page: undo it (delete is real).
70
+ if (await store.undoIfDeleted(userID, record.id, consoleKey(first), ref.hash)) return { kind: "gone" }
71
+ if (!updated) return { kind: "gone" }
72
+ return { kind: "stored", record: updated.record }
73
+ }
74
+
75
+ interface Pending {
76
+ readonly userID: string
77
+ readonly recordingID: string
78
+ events: RecordingEvent[]
79
+ timer?: unknown
80
+ writing?: Promise<void>
81
+ }
82
+
83
+ export interface HubWriterOptions {
84
+ readonly store: RecordingStore
85
+ readonly now: Clock
86
+ /** Unique per process: page keys and event IDs never collide across pods or restarts. */
87
+ readonly podTag: string
88
+ readonly timers?: Timers
89
+ readonly flushMs?: number
90
+ readonly flushEvents?: number
91
+ /** A write failed (logged without content or IDs by the caller). */
92
+ readonly failed?: (cause: unknown) => void
93
+ /** A hub page would pass the recording's event caps, or the user's page quota is full: the recording ends (limit). */
94
+ readonly overLimit?: (userID: string, recordingID: string, scope: "recording" | "user") => void
95
+ }
96
+
97
+ const realTimers: Timers = {
98
+ set: (fn, ms) => {
99
+ const t = setTimeout(fn, ms)
100
+ ;(t as { unref?: () => void }).unref?.()
101
+ return t
102
+ },
103
+ clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
104
+ }
105
+
106
+ export class HubEventWriter {
107
+ private readonly pending = new Map<string, Pending>()
108
+ private n = 0
109
+ private pages = 0
110
+ private readonly timers: Timers
111
+
112
+ constructor(private readonly options: HubWriterOptions) {
113
+ this.timers = options.timers ?? realTimers
114
+ }
115
+
116
+ /** Buffers a hub event for one recording (scrubbed and cut now, written with the next page). */
117
+ add(userID: string, recordingID: string, kind: HubEventKind, fields: Record<string, unknown>, at = this.options.now()): void {
118
+ const key = `${userID}\n${recordingID}`
119
+ let p = this.pending.get(key)
120
+ if (!p) this.pending.set(key, (p = { userID, recordingID, events: [] }))
121
+ p.events.push(hubRecord(kind, fields, { id: `${this.options.podTag}.${++this.n}`, at }))
122
+ if (p.events.length >= (this.options.flushEvents ?? L.hubFlushEvents)) void this.flushOne(key)
123
+ else if (p.timer === undefined) p.timer = this.timers.set(() => void this.flushOne(key), this.options.flushMs ?? L.hubFlushMs)
124
+ }
125
+
126
+ /** Writes the buffered events of one recording (all when omitted). */
127
+ async flush(userID?: string, recordingID?: string): Promise<void> {
128
+ const keys = userID !== undefined && recordingID !== undefined ? [`${userID}\n${recordingID}`] : [...this.pending.keys()]
129
+ await Promise.all(keys.map((key) => this.flushOne(key)))
130
+ }
131
+
132
+ /** Drops what a recording buffered (it is being deleted). */
133
+ discard(userID: string, recordingID: string): void {
134
+ const p = this.pending.get(`${userID}\n${recordingID}`)
135
+ if (p?.timer !== undefined) this.timers.clear(p.timer)
136
+ this.pending.delete(`${userID}\n${recordingID}`)
137
+ }
138
+
139
+ private async flushOne(key: string): Promise<void> {
140
+ const p = this.pending.get(key)
141
+ if (!p) return
142
+ // One write at a time per recording, in order.
143
+ while (p.writing) await p.writing
144
+ if (p.timer !== undefined) this.timers.clear(p.timer)
145
+ p.timer = undefined
146
+ const events = p.events.splice(0, this.options.flushEvents ?? L.hubFlushEvents)
147
+ if (!events.length) {
148
+ if (this.pending.get(key) === p) this.pending.delete(key)
149
+ return
150
+ }
151
+ const run = this.write(p.userID, p.recordingID, events).catch((cause) => this.options.failed?.(cause))
152
+ p.writing = run
153
+ await run
154
+ p.writing = undefined
155
+ if (p.events.length) await this.flushOne(key)
156
+ else if (this.pending.get(key) === p && p.timer === undefined) this.pending.delete(key)
157
+ }
158
+
159
+ private async write(userID: string, recordingID: string, events: RecordingEvent[]): Promise<void> {
160
+ const { store } = this.options
161
+ const got = await store.get(userID, recordingID)
162
+ if (!got || got.value.state === "deleting") return
163
+ // An ended recording takes only what happened up to its end (its recording.ended included): a pod whose active
164
+ // index is stale (the bus message missed or late) never adds events after it.
165
+ const endedAt = got.value.state === "ended" ? got.value.endedAt : undefined
166
+ if (endedAt !== undefined) events = events.filter((e) => (e.at ?? 0) <= endedAt)
167
+ if (!events.length) return
168
+ let page = ndjson(events)
169
+ if (got.value.events + events.length > L.eventsPerRecording || got.value.eventBytes + page.length > L.eventBytesPerRecording) {
170
+ this.options.overLimit?.(userID, recordingID, "recording")
171
+ return
172
+ }
173
+ let ref
174
+ try {
175
+ ref = await store.putBlob(userID, page, "application/x-ndjson", PAGE_MAX_BYTES, PAGE_QUOTA_BYTES)
176
+ } catch (cause) {
177
+ if (!(cause instanceof BlobQuotaError)) throw cause
178
+ // The page quota is full: the end markers (cap.reached, recording.ended, errors) still go, in a small headroom,
179
+ // so the export says why the recording stopped; the rest is dropped.
180
+ const control = events.filter((e) => CONTROL_KINDS.has(e.kind))
181
+ if (control.length) {
182
+ events = control
183
+ page = ndjson(events)
184
+ ref = await store.putBlob(userID, page, "application/x-ndjson", PAGE_MAX_BYTES, PAGE_QUOTA_BYTES + CONTROL_HEADROOM_BYTES).catch(() => undefined)
185
+ }
186
+ if (!control.some((e) => e.kind === "recording.ended")) this.options.overLimit?.(userID, recordingID, "user")
187
+ if (!ref) return
188
+ }
189
+ const at = this.options.now()
190
+ const key = hubKey(this.options.podTag, ++this.pages)
191
+ await store.putIndex(userID, recordingID, key, { hash: ref.hash, bytes: ref.bytes, count: events.length, at })
192
+ const redacted = events.filter((e) => e.redacted?.length).length
193
+ await store.update(userID, recordingID, (current) =>
194
+ current.state === "deleting"
195
+ ? undefined
196
+ : {
197
+ ...current,
198
+ events: current.events + events.length,
199
+ eventBytes: current.eventBytes + ref.bytes,
200
+ bytes: current.bytes + ref.bytes,
201
+ hubPages: current.hubPages + 1,
202
+ pages: current.pages + 1,
203
+ redactions: current.redactions + redacted,
204
+ },
205
+ )
206
+ // A deletion that ran meanwhile did not see this page: undo it.
207
+ await store.undoIfDeleted(userID, recordingID, key, ref.hash)
208
+ }
209
+ }
@@ -0,0 +1,71 @@
1
+ // REC-01 retention sweep (contract: Limits and caps → Retention sweep). Runs on the `voice-recordings` leader
2
+ // every sweepMs (first run one interval after election) over every user: finishes deletions, deletes expired
3
+ // recordings (active ones are ended `stale` first), ends idle and over-long active ones. One user's failure is
4
+ // logged without IDs and the run goes on.
5
+ import { RECORDING_LIMITS } from "../../protocol"
6
+
7
+ export interface RetentionOptions {
8
+ readonly users: () => Promise<readonly string[]>
9
+ /** Sweeps one user; answers how many expired recordings it deleted. */
10
+ readonly sweepUser: (userID: string) => Promise<number>
11
+ readonly sweepMs?: number
12
+ readonly log?: (message: string) => void
13
+ }
14
+
15
+ export class RecordingRetention {
16
+ private timer: ReturnType<typeof setTimeout> | undefined
17
+ private running: Promise<number> | undefined
18
+ private started = false
19
+
20
+ constructor(private readonly options: RetentionOptions) {}
21
+
22
+ start(): void {
23
+ if (this.started) return
24
+ this.started = true
25
+ this.schedule()
26
+ }
27
+
28
+ stop(): void {
29
+ this.started = false
30
+ if (this.timer !== undefined) clearTimeout(this.timer)
31
+ this.timer = undefined
32
+ }
33
+
34
+ /** One pass over every user (concurrent calls share it); answers the expired recordings deleted. */
35
+ runOnce(): Promise<number> {
36
+ this.running ??= this.pass().finally(() => (this.running = undefined))
37
+ return this.running
38
+ }
39
+
40
+ /** Waits for a pass in progress. */
41
+ async idle(): Promise<void> {
42
+ await this.running?.catch(() => undefined)
43
+ }
44
+
45
+ private schedule(): void {
46
+ if (!this.started) return
47
+ this.timer = setTimeout(() => {
48
+ void this.runOnce().finally(() => this.schedule())
49
+ }, this.options.sweepMs ?? RECORDING_LIMITS.sweepMs)
50
+ ;(this.timer as { unref?: () => void }).unref?.()
51
+ }
52
+
53
+ private async pass(): Promise<number> {
54
+ let total = 0
55
+ let users: readonly string[] = []
56
+ try {
57
+ users = await this.options.users()
58
+ } catch (cause) {
59
+ this.options.log?.(`[hub] recording sweep could not list users: ${(cause as Error).message}`)
60
+ return 0
61
+ }
62
+ for (const userID of users) {
63
+ try {
64
+ total += await this.options.sweepUser(userID)
65
+ } catch (cause) {
66
+ this.options.log?.(`[hub] recording sweep failed for a user: ${(cause as Error).message}`)
67
+ }
68
+ }
69
+ return total
70
+ }
71
+ }
@@ -0,0 +1,281 @@
1
+ // REC-01 recording routes (contract: Setting and indicator, Recordings, Audio, List/read/export/delete). Every route
2
+ // is a console route with access "user": the user's cookie or device token only (the hub refuses session, app and
3
+ // act tokens before these run), so no agent, not even the Concierge, can read or export a recording, and there is
4
+ // no admin route. Every lookup is by (user.id, id); another user's, an unknown and a deleted ID answer 404 alike.
5
+ import { RECORDING_AUDIO_TYPE, RECORDING_LIMITS, type RecordingClient } from "../../protocol"
6
+ import { readBounded } from "../agent/body"
7
+ import { checkConsoleBatch } from "./schema"
8
+ import { audioKey, blobTenant, isSegmentNumber, summaryOf, type AudioEntry } from "./store"
9
+ import { exportEntries } from "./export"
10
+ import type { RecordingService, StartInput } from "./index"
11
+ import { zipStream } from "./zip"
12
+
13
+ const L = RECORDING_LIMITS
14
+ const NO_STORE = { "cache-control": "no-store" }
15
+ const PRIVATE = { "cache-control": "private, no-store", "x-content-type-options": "nosniff", "content-security-policy": "default-src 'none'; sandbox" }
16
+ const JSON_MAX = 64 * 1024
17
+
18
+ export interface RecordingRouteContext {
19
+ readonly request: Request
20
+ readonly url: URL
21
+ readonly params: Record<string, string>
22
+ readonly user: { readonly id: string }
23
+ readonly device: { readonly id: string; readonly name?: string }
24
+ }
25
+
26
+ export interface RecordingRoute {
27
+ readonly method: string
28
+ readonly path: string
29
+ handle(context: RecordingRouteContext): Promise<Response>
30
+ }
31
+
32
+ const fail = (status: number, error: string, code?: string, extra: Record<string, unknown> = {}) => Response.json({ error, ...(code ? { code } : {}), ...extra }, { status, headers: NO_STORE })
33
+ const notFound = () => fail(404, "Not found")
34
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
35
+
36
+ class BadInput extends Error {}
37
+
38
+ /** The body's bytes within `max` (413 limit beyond), or a Response to answer. */
39
+ async function bytesOf(request: Request, max: number): Promise<Uint8Array | Response> {
40
+ const read = await readBounded(request, max)
41
+ if (read.ok) return read.bytes
42
+ if (read.reason === "timeout") return fail(408, "Request body too slow")
43
+ return Response.json({ error: `Request body too large (at most ${max} bytes)`, code: "limit" }, { status: 413, headers: { ...NO_STORE, ...(read.close ? { connection: "close" } : {}) } })
44
+ }
45
+
46
+ async function jsonOf(request: Request, max = JSON_MAX): Promise<Record<string, unknown> | Response> {
47
+ const bytes = await bytesOf(request, max)
48
+ if (bytes instanceof Response) return bytes
49
+ let value: unknown
50
+ try {
51
+ value = bytes.length ? JSON.parse(Buffer.from(bytes).toString("utf8")) : {}
52
+ } catch {
53
+ return fail(400, "Invalid JSON", "invalid")
54
+ }
55
+ return isRecord(value) ? value : fail(400, "Expected a JSON object", "invalid")
56
+ }
57
+
58
+ function only(input: Record<string, unknown>, allowed: readonly string[], where = ""): void {
59
+ for (const key of Object.keys(input)) if (!allowed.includes(key)) throw new BadInput(`unknown field ${where}${/^[A-Za-z0-9_-]{1,40}$/.test(key) ? key : "?"}`)
60
+ }
61
+
62
+ const text = (v: unknown, max: number, name: string): string => {
63
+ if (typeof v !== "string" || !v || v.length > max) throw new BadInput(`${name} must be text of 1–${max} characters`)
64
+ return v
65
+ }
66
+
67
+ /** POST /api/recordings body (strict). */
68
+ function startInput(input: Record<string, unknown>): StartInput {
69
+ only(input, ["sessionID", "clientID", "anchor", "client"])
70
+ const sessionID = text(input.sessionID, 300, "sessionID")
71
+ const clientID = text(input.clientID, 100, "clientID")
72
+ if (!/^[A-Za-z0-9_-]+$/.test(clientID)) throw new BadInput("clientID must be an ID (letters, digits, - and _)")
73
+ let anchor: StartInput["anchor"]
74
+ if (input.anchor !== undefined) {
75
+ if (!isRecord(input.anchor)) throw new BadInput("anchor must be {perf, epoch}")
76
+ only(input.anchor, ["perf", "epoch"], "anchor.")
77
+ const { perf, epoch } = input.anchor
78
+ if (typeof perf !== "number" || !Number.isFinite(perf) || perf < 0 || typeof epoch !== "number" || !Number.isFinite(epoch)) throw new BadInput("anchor must be {perf, epoch}: finite numbers")
79
+ anchor = { perf, epoch }
80
+ }
81
+ let client: RecordingClient | undefined
82
+ if (input.client !== undefined) {
83
+ if (!isRecord(input.client)) throw new BadInput("client must be an object")
84
+ const c = input.client
85
+ only(c, ["app", "platform", "detector", "sensitivity", "cues", "background", "streaming", "timings"], "client.")
86
+ const label = (v: unknown, name: string) => (v === undefined ? undefined : text(v, 60, name))
87
+ const flag = (v: unknown, name: string) => {
88
+ if (v !== undefined && typeof v !== "boolean") throw new BadInput(`${name} must be true or false`)
89
+ return v as boolean | undefined
90
+ }
91
+ if (c.detector !== undefined && c.detector !== "silero" && c.detector !== "energy") throw new BadInput("client.detector must be silero or energy")
92
+ if (c.sensitivity !== undefined && !["low", "normal", "high"].includes(c.sensitivity as string)) throw new BadInput("client.sensitivity must be low, normal or high")
93
+ let timings: Record<string, number> | undefined
94
+ if (c.timings !== undefined) {
95
+ if (!isRecord(c.timings) || Object.keys(c.timings).length > 20) throw new BadInput("client.timings must be at most 20 numbers")
96
+ timings = {}
97
+ for (const [key, value] of Object.entries(c.timings)) {
98
+ if (!/^[A-Za-z0-9_]{1,40}$/.test(key) || typeof value !== "number" || !Number.isFinite(value)) throw new BadInput("client.timings must map names to numbers")
99
+ timings[key] = value
100
+ }
101
+ }
102
+ const entries = {
103
+ app: label(c.app, "client.app"),
104
+ platform: label(c.platform, "client.platform"),
105
+ detector: c.detector as RecordingClient["detector"],
106
+ sensitivity: c.sensitivity as RecordingClient["sensitivity"],
107
+ cues: flag(c.cues, "client.cues"),
108
+ background: flag(c.background, "client.background"),
109
+ streaming: flag(c.streaming, "client.streaming"),
110
+ timings,
111
+ }
112
+ client = Object.fromEntries(Object.entries(entries).filter(([, v]) => v !== undefined)) as RecordingClient
113
+ }
114
+ return { sessionID, clientID, ...(anchor ? { anchor } : {}), ...(client ? { client } : {}) }
115
+ }
116
+
117
+ const utcDate = (ms: number) => new Date(ms).toISOString().slice(0, 10)
118
+
119
+ /** A zip response; the export slot is released when the stream ends or is cancelled. */
120
+ function zipResponse(stream: ReadableStream<Uint8Array>, name: string, release: () => void): Response {
121
+ const reader = stream.getReader()
122
+ const body = new ReadableStream<Uint8Array>({
123
+ async pull(controller) {
124
+ try {
125
+ const { done, value } = await reader.read()
126
+ if (done) {
127
+ release()
128
+ controller.close()
129
+ } else controller.enqueue(value)
130
+ } catch (cause) {
131
+ release()
132
+ controller.error(cause)
133
+ }
134
+ },
135
+ async cancel(reason) {
136
+ release()
137
+ await reader.cancel(reason).catch(() => undefined)
138
+ },
139
+ })
140
+ return new Response(body, { headers: { ...PRIVATE, "content-type": "application/zip", "content-disposition": `attachment; filename="${name}"` } })
141
+ }
142
+
143
+ export function recordingRoutes(service: RecordingService, options: { readonly version: string; readonly now: () => number; readonly audit: (entry: { actor: string; action: string; target?: string; detail?: string }) => Promise<void> }): RecordingRoute[] {
144
+ const handled = (handle: RecordingRoute["handle"]): RecordingRoute["handle"] => async (context) => {
145
+ try {
146
+ return await handle(context)
147
+ } catch (cause) {
148
+ if (cause instanceof BadInput) return fail(400, cause.message, "invalid")
149
+ throw cause
150
+ }
151
+ }
152
+ const route = (method: string, path: string, handle: RecordingRoute["handle"]): RecordingRoute => ({ method, path, handle: handled(handle) })
153
+
154
+ const exportOf = async (userID: string, ids: "all" | string): Promise<Response> => {
155
+ const records = ids === "all" ? (await service.list(userID)).reverse() : [await service.visible(userID, ids)].filter((r) => r !== undefined)
156
+ if (ids !== "all" && !records.length) return notFound()
157
+ const release = service.exportSlot(userID)
158
+ if (!release) return fail(429, "Two exports are already running; wait for one to finish", "rate_limited")
159
+ await service.flush()
160
+ await options.audit({ actor: userID, action: "recording.export", target: ids, detail: String(records.length) })
161
+ const stream = zipStream(exportEntries(service.store, userID, records, ids !== "all", { version: options.version, now: options.now }))
162
+ return zipResponse(stream, `hub-voice-${ids}-${utcDate(options.now())}.zip`, release)
163
+ }
164
+
165
+ const segmentNumber = (raw: string | undefined): number => {
166
+ if (!raw || !/^\d{1,6}$/.test(raw)) throw new BadInput("segment must be a number 0–999999")
167
+ const n = Number(raw)
168
+ if (!isSegmentNumber(n)) throw new BadInput("segment must be a number 0–999999")
169
+ return n
170
+ }
171
+
172
+ return [
173
+ route("GET", "/api/settings/recording", async ({ user }) =>
174
+ Response.json({ settings: await service.settings.get(user.id), usage: await service.usage(user.id), limits: { retentionDays: L.retentionMs / 86_400_000, userBytes: L.userBytes, recordingBytes: L.recordingBytes } }, { headers: NO_STORE }),
175
+ ),
176
+ route("PUT", "/api/settings/recording", async ({ request, user, device }) => {
177
+ const input = await jsonOf(request)
178
+ if (input instanceof Response) return input
179
+ only(input, ["enabled", "expectedRevision"])
180
+ if (typeof input.enabled !== "boolean" || !Number.isSafeInteger(input.expectedRevision) || (input.expectedRevision as number) < 0) {
181
+ throw new BadInput("Expected {enabled: boolean, expectedRevision: number}")
182
+ }
183
+ const result = await service.setEnabled(user.id, device.id, input.enabled, input.expectedRevision as number)
184
+ if (!result.ok) return fail(409, "The setting changed meanwhile; reload it", "conflict", { currentRevision: result.currentRevision })
185
+ return Response.json({ settings: result.settings }, { headers: NO_STORE })
186
+ }),
187
+ route("POST", "/api/recordings", async ({ request, user, device }) => {
188
+ const input = await jsonOf(request)
189
+ if (input instanceof Response) return input
190
+ const started = await service.start(user.id, device, startInput(input))
191
+ switch (started.kind) {
192
+ case "off":
193
+ return fail(409, "Recording is off in Settings → Voice", "recording_off")
194
+ case "not_found":
195
+ return notFound()
196
+ case "rate_limited":
197
+ return fail(429, `At most ${L.activePerUser} recordings run at once`, "rate_limited", { limit: L.activePerUser })
198
+ case "limit":
199
+ return fail(429, `At most ${L.recordingsStored} recordings are kept; delete some in Settings → Voice`, "limit", { limit: L.recordingsStored })
200
+ }
201
+ const record = started.record
202
+ return Response.json({ recording: summaryOf(record), audio: record.audio, hubAt: options.now() }, { status: started.kind === "created" ? 201 : 200, headers: NO_STORE })
203
+ }),
204
+ route("GET", "/api/recordings", async ({ url, user }) => {
205
+ const limitRaw = url.searchParams.get("limit")
206
+ const limit = limitRaw === null ? 50 : Number(limitRaw)
207
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) throw new BadInput("limit must be 1–200")
208
+ const cursor = url.searchParams.get("cursor")
209
+ let start = 0
210
+ if (cursor !== null) {
211
+ const decoded = Number(Buffer.from(cursor, "base64url").toString("utf8"))
212
+ if (!Number.isSafeInteger(decoded) || decoded < 0 || cursor.length > 40) throw new BadInput("cursor is not valid")
213
+ start = decoded
214
+ }
215
+ const all = await service.list(user.id)
216
+ const page = all.slice(start, start + limit)
217
+ const next = start + limit < all.length ? Buffer.from(String(start + limit), "utf8").toString("base64url") : undefined
218
+ return Response.json({ recordings: page.map(summaryOf), usage: await service.usage(user.id), ...(next ? { next } : {}) }, { headers: NO_STORE })
219
+ }),
220
+ // Before /:id (the pattern would take "export" as an ID).
221
+ route("GET", "/api/recordings/export", ({ user }) => exportOf(user.id, "all")),
222
+ route("DELETE", "/api/recordings", async ({ user }) => Response.json({ deleted: await service.deleteAll(user.id) }, { headers: NO_STORE })),
223
+ route("GET", "/api/recordings/:id", async ({ params, user }) => {
224
+ const record = await service.visible(user.id, params.id!)
225
+ return record ? Response.json({ recording: summaryOf(record) }, { headers: NO_STORE }) : notFound()
226
+ }),
227
+ route("POST", "/api/recordings/:id/end", async ({ request, params, user }) => {
228
+ const record = await service.visible(user.id, params.id!)
229
+ if (!record) return notFound()
230
+ const input = await jsonOf(request)
231
+ if (input instanceof Response) return input
232
+ only(input, ["reason"])
233
+ if (input.reason !== undefined && input.reason !== "stopped" && input.reason !== "conversation_off") throw new BadInput("reason must be stopped or conversation_off")
234
+ const after = await service.end(user.id, record.id, (input.reason as "stopped" | "conversation_off" | undefined) ?? "stopped")
235
+ return after ? Response.json({ recording: summaryOf(after) }, { headers: NO_STORE }) : notFound()
236
+ }),
237
+ route("POST", "/api/recordings/:id/events", async ({ request, params, user, device }) => {
238
+ const appendable = await service.appendable(user.id, device.id, params.id!)
239
+ if (appendable.kind === "not_found") return notFound()
240
+ if (appendable.kind === "ended") return fail(409, "This recording has ended", "recording_ended", { reason: appendable.reason })
241
+ const input = await jsonOf(request, L.batchBytes)
242
+ if (input instanceof Response) return input
243
+ only(input, ["events"])
244
+ const checked = checkConsoleBatch(input.events)
245
+ if (!checked.ok) return checked.limit ? fail(413, checked.problem, "limit") : fail(400, checked.problem, "invalid", checked.seq !== undefined ? { seq: checked.seq } : {})
246
+ const appended = await service.appendConsole(user.id, appendable.record, checked.events)
247
+ if (appended.kind === "not_found") return notFound()
248
+ if (appended.kind === "limit") return fail(413, "This recording reached its event limit and has ended", "limit")
249
+ return Response.json({ through: appended.through, hubAt: options.now(), audio: appended.audio }, { headers: NO_STORE })
250
+ }),
251
+ route("PUT", "/api/recordings/:id/segments/:n", async ({ request, params, user, device }) => {
252
+ const appendable = await service.appendable(user.id, device.id, params.id!)
253
+ if (appendable.kind === "not_found") return notFound()
254
+ const n = segmentNumber(params.n)
255
+ if (appendable.kind === "ended") return fail(409, "This recording has ended", "recording_ended", { reason: appendable.reason })
256
+ const type = request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase()
257
+ if (type !== RECORDING_AUDIO_TYPE) return fail(415, `Segments are ${RECORDING_AUDIO_TYPE}`, "unsupported")
258
+ const bytes = await bytesOf(request, L.segmentBytes)
259
+ if (bytes instanceof Response) return bytes
260
+ if (!bytes.length) throw new BadInput("Empty segment")
261
+ const stored = await service.storeAudio(user.id, appendable.record, n, bytes, "put")
262
+ if (stored.kind === "cap") return fail(413, "Recording storage is full: audio is not saved", "limit")
263
+ if (stored.kind === "gone") return notFound()
264
+ if (stored.kind === "failed") return fail(503, "The segment could not be stored; retry", "unavailable")
265
+ return Response.json({ segment: { n, bytes: stored.entry.bytes, hash: stored.entry.hash } }, { status: stored.fresh ? 201 : 200, headers: NO_STORE })
266
+ }),
267
+ route("GET", "/api/recordings/:id/segments/:n", async ({ params, user }) => {
268
+ const record = await service.visible(user.id, params.id!)
269
+ if (!record) return notFound()
270
+ const n = segmentNumber(params.n)
271
+ const entry = await service.store.indexEntry<AudioEntry>(user.id, record.id, audioKey(n))
272
+ const blob = entry ? await service.store.blobs.get(blobTenant(user.id), entry.hash) : undefined
273
+ if (!blob) return notFound()
274
+ return new Response(blob.body, {
275
+ headers: { ...PRIVATE, "content-type": RECORDING_AUDIO_TYPE, "content-length": String(blob.bytes), "cross-origin-resource-policy": "same-origin", "content-disposition": `attachment; filename="${record.id}-${n}.wav"` },
276
+ })
277
+ }),
278
+ route("GET", "/api/recordings/:id/export", async ({ params, user }) => ((await service.visible(user.id, params.id!)) ? exportOf(user.id, params.id!) : notFound())),
279
+ route("DELETE", "/api/recordings/:id", async ({ params, user }) => ((await service.deleteOne(user.id, params.id!)) ? new Response(null, { status: 204 }) : notFound())),
280
+ ]
281
+ }