@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.
- package/CHANGELOG.md +80 -0
- package/README.md +31 -0
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +35 -7
- package/packages/cli/package.json +1 -1
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +9 -1
- package/packages/protocol/src/voice-recording.ts +107 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/routes.ts +7 -1
- package/src/central/artifacts/blob-store.ts +6 -1
- package/src/central/artifacts/blob-support.ts +3 -1
- package/src/central/concierge/board-render.ts +34 -11
- package/src/central/concierge/index.ts +1 -1
- package/src/central/concierge/notifier.ts +2 -1
- package/src/central/config.ts +4 -0
- package/src/central/csp.ts +55 -0
- package/src/central/http.ts +85 -9
- package/src/central/profiles/content.ts +16 -0
- package/src/central/recording/clock.ts +76 -0
- package/src/central/recording/concierge-taps.ts +104 -0
- package/src/central/recording/export.ts +215 -0
- package/src/central/recording/index.ts +658 -0
- package/src/central/recording/ingest.ts +209 -0
- package/src/central/recording/retention.ts +71 -0
- package/src/central/recording/routes.ts +281 -0
- package/src/central/recording/schema.ts +232 -0
- package/src/central/recording/settings.ts +35 -0
- package/src/central/recording/store.ts +316 -0
- package/src/central/recording/taps.ts +105 -0
- package/src/central/recording/zip.ts +138 -0
- package/src/central/registry.ts +68 -3
- package/src/central/spoke-ws.ts +2 -0
- package/src/spoke/session-context.ts +28 -8
- package/src/spoke/spoke.ts +3 -0
- package/src/spoke/tracker.ts +6 -1
- package/src/status.ts +31 -5
- package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-CSotAWob.js} +1 -1
- package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-DPHjiWce.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
- package/web/dist/assets/{Library-BXbJvTUS.js → Library-DTLEyekn.js} +2 -2
- package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
- package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-C-5lYeeM.js} +1 -1
- package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
- package/web/dist/assets/index-6mdQ7eUY.js +34 -0
- package/web/dist/assets/index-C5OmwA1y.css +1 -0
- package/web/dist/assets/{live-OgQWPrSY.js → live-CDWcyavI.js} +1 -1
- package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
- package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-CNFdTDTr.js} +1 -1
- package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-C45jPvYX.js} +4 -56
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-sA9a8vYJ.js} +1 -1
- package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
- package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
- package/web/dist/index.html +5 -16
- package/web/dist/theme-boot.js +12 -0
- package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
- package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
- package/web/dist/assets/index-BabrNDB3.css +0 -1
- 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
|
+
}
|