@gotcos/glasses-server 6.25.0 → 6.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## 6.26.0
2
+
3
+ Chunked, resumable upload: video is no longer limited by what fits in one request.
4
+
5
+ - A video can now be uploaded in pieces, so length is bounded by storage rather than by a
6
+ single request. A 3-minute 4K clip is roughly 570 MB and could never fit a one-shot
7
+ limit; it now transfers as a sequence of 8 MiB chunks, losslessly, and is compressed
8
+ afterwards for storage.
9
+ - Interrupted uploads resume. The phone asks the server what it actually received and
10
+ continues from there rather than trusting its own count, because a chunk whose
11
+ acknowledgement was lost makes the client's number wrong. Resume covers network drops,
12
+ which is the common case; a server restart clears in-flight uploads and the phone starts
13
+ over cleanly rather than resuming onto nothing.
14
+ - Cancelling or giving up releases the server's slot and staging disk immediately instead
15
+ of holding them for four hours. Without this, a handful of give-ups on a poor connection
16
+ could make new uploads unavailable until the sessions expired.
17
+ - Assembly is verified before anything is published: the reassembled size must match what
18
+ the phone declared, a chunk that arrives out of order is refused rather than appended,
19
+ and a partial write is rejected rather than producing a correctly-sized file with a hole
20
+ in it.
21
+ - Finalizing a chunked upload runs the same validation, size cap, atomic publish and
22
+ background compression as a single-shot upload — one path, so the safety rules cannot
23
+ drift between them. Only video is allowed the larger chunked ceiling; documents and
24
+ images keep the existing limit, because reading a multi-gigabyte text file into memory
25
+ would fail in a far worse way than refusing it.
26
+ - `GET /api/health` advertises chunked availability and the chunk size, so the phone
27
+ decides from what this server actually supports rather than from a built-in assumption.
28
+
1
29
  ## 6.25.0
2
30
 
3
31
  Large video uploads: a 100 MiB cap, streamed to disk, compressed in the background.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.25.0",
3
+ "version": "6.26.0",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,6 +55,7 @@ import {
55
55
  } from './image-safety.js'
56
56
  import {
57
57
  prepareRichMediaFromFile,
58
+ type MediaTransferMode,
58
59
  type PreparedRichMediaFile,
59
60
  } from './rich-media-safety.js'
60
61
  import { VIDEO_COMPRESSION_LABEL, compressVideoFile } from './video-compression.js'
@@ -230,6 +231,10 @@ export interface IngestRichMediaFileInput {
230
231
  declaredMime?: string
231
232
  capturedAt?: string
232
233
  sessionId?: string
234
+ /** How the bytes arrived. Omitted means single_shot, so /api/media/file keeps its
235
+ * existing ceiling; chunked finalize passes 'chunked' so a multi-hundred-MB video
236
+ * is judged against the chunked cap instead of being refused AFTER transfer. */
237
+ transfer?: MediaTransferMode
233
238
  }
234
239
 
235
240
  /** Handle for a streaming upload's staging file. `dispose()` is idempotent and
@@ -520,6 +525,10 @@ export class MediaStore {
520
525
  label: input.label,
521
526
  declaredMime: input.declaredMime,
522
527
  byteLength: input.byteLength,
528
+ // Chunked finalize must be judged against the chunked ceiling, not the
529
+ // single-shot one it used to inherit. Defaults to single_shot, so
530
+ // /api/media/file is unchanged.
531
+ transfer: input.transfer,
523
532
  })
524
533
  return this.publishPreparedRichMedia(input, prepared)
525
534
  }
@@ -390,16 +390,47 @@ async function processVideo(
390
390
  /** Production entry point: validate an already-staged upload file in place.
391
391
  * The returned `originalPath` is the caller's own staged file — this module
392
392
  * never moves, renames, or deletes it. */
393
+ /**
394
+ * How the bytes arrived. This decides the ceiling, and it has to be threaded in
395
+ * rather than assumed.
396
+ *
397
+ * THE DEFECT THIS FIXES. finalize for a chunked upload calls this same function —
398
+ * deliberately, so validation, the cap, the atomic rename and compression are not
399
+ * forked. But it inherited the SINGLE-SHOT cap, so a 200 MB video transferred all
400
+ * 25 chunks and was then refused at finalize with a raw 413. That is worse than the
401
+ * feature not existing: before it, the same file was refused in milliseconds with a
402
+ * readable message. Two independent reviewers found this, and the suite already
403
+ * contained the proof (prepareRichMediaFromFile rejects above the cap) without ever
404
+ * wiring it to the chunked route.
405
+ */
406
+ export type MediaTransferMode = 'single_shot' | 'chunked'
407
+
408
+ /**
409
+ * The applicable ceiling. Per-kind on purpose, and NOT a flat chunked number.
410
+ *
411
+ * Only VIDEO gets the chunked ceiling. Documents and images stay at the single-shot
412
+ * cap even when chunked, because the text path does
413
+ * `capText(decodeStrictUtf8(readFileSync(sourcePath)))` — a whole-file Buffer plus a
414
+ * whole-file JS string. A 2 GiB .txt would allocate 2 GiB and then throw
415
+ * ERR_STRING_TOO_LONG at V8's ~512 MB string limit, inside the request, in the
416
+ * process that also owns the G2 bridge and whisper. Streaming the upload only to
417
+ * blow up reading it back would defeat the entire point of the rewrite.
418
+ */
419
+ export function mediaCeilingBytes(isVideo: boolean, transfer: MediaTransferMode = 'single_shot'): number {
420
+ if (!isVideo) return MAX_OTHER_MEDIA_BYTES
421
+ return transfer === 'chunked' ? MAX_CHUNKED_MEDIA_BYTES : MAX_VIDEO_MEDIA_BYTES
422
+ }
423
+
393
424
  export async function prepareRichMediaFromFile(
394
425
  sourcePath: string,
395
- options: { label?: string; declaredMime?: string; byteLength: number },
426
+ options: { label?: string; declaredMime?: string; byteLength: number; transfer?: MediaTransferMode },
396
427
  ): Promise<PreparedRichMediaFile> {
397
428
  if (options.byteLength === 0) throw new RichMediaSafetyError('corrupt_attachment', 'attachment is empty')
398
429
  const head = readHead(sourcePath)
399
430
  // Two caps now, so the cap check has to know WHAT it is looking at. The
400
431
  // classification is byte-authoritative for exactly that reason.
401
432
  const isVideo = isVideoUploadHead(head)
402
- const cap = isVideo ? MAX_VIDEO_MEDIA_BYTES : MAX_OTHER_MEDIA_BYTES
433
+ const cap = mediaCeilingBytes(isVideo, options.transfer)
403
434
  if (options.byteLength > cap) {
404
435
  throw new RichMediaSafetyError('attachment_too_large', `attachment exceeds ${cap} byte limit`)
405
436
  }
@@ -0,0 +1,409 @@
1
+ // Chunked upload sessions (contract §2 wire shape, §6 v1 decisions).
2
+ //
3
+ // A chunked upload is ONE staging file appended across many requests, then handed
4
+ // to the EXISTING ingest. This module owns only the bookkeeping — which upload,
5
+ // how many bytes are committed, which index comes next, when it expires. It never
6
+ // validates media, never writes into assets/, and never ingests: forking any of
7
+ // that would be a second place for the safety rules to rot (contract §6).
8
+ //
9
+ // Time and filesystem are injectable so the route tests can drive expiry and a
10
+ // failed write without real timers or a real disk fault. media-store.ts already
11
+ // uses that constructor-dependency style for compressVideoFile.
12
+
13
+ import { randomBytes } from 'node:crypto'
14
+ import { closeSync, ftruncateSync, openSync, statSync, writeSync } from 'node:fs'
15
+ import { getMediaStore, STAGED_TTL_MS, type MediaStagingFile } from './media-store.js'
16
+ import { MAX_CHUNKED_MEDIA_BYTES, MEDIA_CHUNK_BYTES } from './rich-media-safety.js'
17
+
18
+ /** An in-flight chunked upload IS an unsubmitted upload, so it retires on the
19
+ * same retention clock as one — contract §2, "expire on the existing
20
+ * quarantine/retention clock". Fixed from init rather than sliding: the
21
+ * advertised 2 GiB ceiling at the client's own 250 KiB/s floor is ~2.4 hours,
22
+ * so 4 hours covers a legitimate worst case, while a sliding window would let
23
+ * a slow drip hold that disk indefinitely. */
24
+ export const CHUNKED_UPLOAD_TTL_MS = STAGED_TTL_MS
25
+
26
+ /** Every live session may hold up to `chunkedMaxBytes` of staging file, so this
27
+ * count is a DISK bound, not a throughput one. The client sends one chunk at a
28
+ * time from one device (§6, "sequential and in-order"); this leaves headroom
29
+ * for a retry and a second device without letting a looping client reserve
30
+ * unbounded disk. */
31
+ export const MAX_CONCURRENT_CHUNKED_UPLOADS = 8
32
+
33
+ const UPLOAD_ID_PATTERN = /^u_[0-9a-f]{24}$/
34
+
35
+ /** Ids are minted here, so they are validated here. A value that cannot have
36
+ * come from this module is treated as unknown (404) rather than reaching the
37
+ * registry map — the id is a path component. */
38
+ export function isValidUploadId(value: unknown): value is string {
39
+ return typeof value === 'string' && UPLOAD_ID_PATTERN.test(value)
40
+ }
41
+
42
+ export type UploadSessionErrorCode =
43
+ | 'upload_not_found'
44
+ | 'chunk_out_of_order'
45
+ | 'attachment_too_large'
46
+ | 'incomplete_upload'
47
+ | 'upload_size_mismatch'
48
+ | 'invalid_total_bytes'
49
+ | 'chunk_bytes_required'
50
+ | 'chunked_upload_unavailable'
51
+ | 'upload_staging_failed'
52
+
53
+ /** `detail` is merged into the JSON error body by the route, so a 409 can carry
54
+ * `expectedIndex` and a 400 can carry the byte counts without the route
55
+ * re-deriving them from state it would have to look up again. */
56
+ export class UploadSessionError extends Error {
57
+ constructor(
58
+ readonly code: UploadSessionErrorCode,
59
+ message: string,
60
+ readonly detail: Readonly<Record<string, number>> = {},
61
+ ) {
62
+ super(message)
63
+ this.name = 'UploadSessionError'
64
+ }
65
+ }
66
+
67
+ export interface UploadSession {
68
+ uploadId: string
69
+ /** Absolute path inside the media store's tmp/. Boot reconcile rm -rf's that
70
+ * directory, which is exactly why resume is within a boot only (§6). */
71
+ stagingPath: string
72
+ /** The client's DECLARED total from init, already bounded to the ceiling. */
73
+ totalBytes: number
74
+ receivedBytes: number
75
+ nextIndex: number
76
+ mime?: string
77
+ label?: string
78
+ capturedAt?: string
79
+ sessionId?: string
80
+ createdAtMs: number
81
+ expiresAtMs: number
82
+ /** Removes the staging file. Idempotent, and a no-op once ingest has moved
83
+ * the file out — same handle contract as MediaStore.createStagingFile(). */
84
+ dispose: () => void
85
+ }
86
+
87
+ /** The resume-probe view (contract §2 GET). `expiresAt` is ISO 8601 to match
88
+ * every other expiresAt on the media wire (MediaAttachmentRef.expiresAt). */
89
+ export interface UploadSessionProgress {
90
+ uploadId: string
91
+ totalBytes: number
92
+ receivedBytes: number
93
+ nextIndex: number
94
+ expiresAt: string
95
+ }
96
+
97
+ export interface CreateUploadInput {
98
+ /** Untrusted client claim. Validated here because the ceiling is this
99
+ * module's business. */
100
+ totalBytes: unknown
101
+ mime?: string
102
+ label?: string
103
+ capturedAt?: string
104
+ sessionId?: string
105
+ }
106
+
107
+ /** Filesystem seam. `writeAt` MUST leave the file exactly `position +
108
+ * bytes.length` bytes long — the truncation is load-bearing, see appendChunk. */
109
+ export interface UploadSessionFs {
110
+ create: (path: string) => void
111
+ writeAt: (path: string, bytes: Buffer, position: number) => void
112
+ sizeOf: (path: string) => number
113
+ }
114
+
115
+ /**
116
+ * fs.writeSync MAY RETURN SHORT, and discarding the count is the one path that defeats
117
+ * finalize's assembled-size re-verification: writeAt then ftruncates to the ASSUMED
118
+ * length, so the file ends up exactly the expected size with a zero-filled hole where
119
+ * the unwritten tail belongs. The size check passes and a silently corrupt video is
120
+ * published.
121
+ *
122
+ * Throwing is the correct response and needs no new client handling: the session
123
+ * counters commit only after writeAt returns, so a throw leaves them untouched and the
124
+ * client's retry at the same nextIndex overwrites from the same offset.
125
+ *
126
+ * Extracted rather than inlined so it is directly testable — inline, no test could
127
+ * reach it without stubbing a module-level fs import, and a mutation removing it passed.
128
+ */
129
+ export function assertFullWrite(written: number, expected: number): void {
130
+ if (written !== expected) {
131
+ throw new Error(`short chunk write: ${written} of ${expected} bytes`)
132
+ }
133
+ }
134
+
135
+ /** The real implementation, exported so a test can DECORATE it (inject one
136
+ * failing write and delegate the rest) rather than re-implement it — a fake that
137
+ * restates writeAt would pass forever while this drifted. */
138
+ export const defaultUploadSessionFs: UploadSessionFs = {
139
+ create: (path) => {
140
+ // Created eagerly and synchronously so the file provably exists before init
141
+ // answers: an upload whose staging file appears later cannot be swept, and
142
+ // a resume probe would report progress against a path that is not there.
143
+ closeSync(openSync(path, 'w', 0o600))
144
+ },
145
+ writeAt: (path, bytes, position) => {
146
+ const fd = openSync(path, 'r+')
147
+ try {
148
+ // fs.writeSync MAY RETURN SHORT. Discarding the count and then truncating to
149
+ // the assumed length is the one path that defeats finalize's assembled-size
150
+ // re-verification: the file ends up exactly the expected length with a
151
+ // zero-filled hole where the unwritten tail should be, so the size check
152
+ // passes and a silently corrupt video is published. Throwing instead leaves
153
+ // the counters untouched (they commit only after this returns), so the client's
154
+ // retry at the same nextIndex overwrites from the same offset — the existing
155
+ // idempotency path, no new client handling required.
156
+ assertFullWrite(writeSync(fd, bytes, 0, bytes.length, position), bytes.length)
157
+ // Truncate to the exact committed length. A previous attempt at this same
158
+ // index may have written MORE bytes here (it is retried after a reconnect,
159
+ // and the last chunk is short), which would otherwise leave a tail beyond
160
+ // the counters and make the finalize size check fail on a healthy upload.
161
+ ftruncateSync(fd, position + bytes.length)
162
+ } finally {
163
+ closeSync(fd)
164
+ }
165
+ },
166
+ sizeOf: (path) => statSync(path).size,
167
+ }
168
+
169
+ export interface UploadSessionRegistryOptions {
170
+ createStagingFile?: () => MediaStagingFile
171
+ now?: () => number
172
+ fs?: Partial<UploadSessionFs>
173
+ ttlMs?: number
174
+ /** The assembled ceiling — contract's `chunkedMaxBytes`. Test seam. */
175
+ maxBytes?: number
176
+ /** Per-chunk ceiling — contract's `chunkBytes`. Test seam. */
177
+ maxChunkBytes?: number
178
+ maxConcurrent?: number
179
+ }
180
+
181
+ export class UploadSessionRegistry {
182
+ private readonly sessions = new Map<string, UploadSession>()
183
+ private readonly createStaging: () => MediaStagingFile
184
+ private readonly now: () => number
185
+ private readonly fs: UploadSessionFs
186
+ private readonly ttlMs: number
187
+ private readonly maxBytes: number
188
+ private readonly maxChunkBytes: number
189
+ private readonly maxConcurrent: number
190
+
191
+ constructor(options: UploadSessionRegistryOptions = {}) {
192
+ this.createStaging = options.createStagingFile ?? (() => getMediaStore().createStagingFile())
193
+ this.now = options.now ?? Date.now
194
+ this.fs = { ...defaultUploadSessionFs, ...options.fs }
195
+ this.ttlMs = options.ttlMs ?? CHUNKED_UPLOAD_TTL_MS
196
+ this.maxBytes = options.maxBytes ?? MAX_CHUNKED_MEDIA_BYTES
197
+ this.maxChunkBytes = options.maxChunkBytes ?? MEDIA_CHUNK_BYTES
198
+ this.maxConcurrent = options.maxConcurrent ?? MAX_CONCURRENT_CHUNKED_UPLOADS
199
+ }
200
+
201
+ create(input: CreateUploadInput): UploadSession {
202
+ this.sweepExpired()
203
+ const totalBytes = input.totalBytes
204
+ if (typeof totalBytes !== 'number' || !Number.isSafeInteger(totalBytes) || totalBytes <= 0) {
205
+ throw new UploadSessionError('invalid_total_bytes', 'totalBytes must be a positive integer')
206
+ }
207
+ // The declared size is a CLAIM (§6). Bounding it here is the cheap refusal;
208
+ // appendChunk still enforces it as bytes actually arrive.
209
+ if (totalBytes > this.maxBytes) {
210
+ throw new UploadSessionError(
211
+ 'attachment_too_large',
212
+ `declared size exceeds ${this.maxBytes} byte ceiling`,
213
+ { maxBytes: this.maxBytes },
214
+ )
215
+ }
216
+ if (this.sessions.size >= this.maxConcurrent) {
217
+ throw new UploadSessionError('chunked_upload_unavailable', 'too many uploads in flight')
218
+ }
219
+
220
+ const staged = this.createStaging()
221
+ try {
222
+ this.fs.create(staged.path)
223
+ } catch (err) {
224
+ staged.dispose()
225
+ throw new UploadSessionError(
226
+ 'upload_staging_failed',
227
+ `staging file could not be created: ${err instanceof Error ? err.message : 'unknown error'}`,
228
+ )
229
+ }
230
+ const startedAt = this.now()
231
+ const session: UploadSession = {
232
+ uploadId: `u_${randomBytes(12).toString('hex')}`,
233
+ stagingPath: staged.path,
234
+ totalBytes,
235
+ receivedBytes: 0,
236
+ nextIndex: 0,
237
+ ...(input.mime ? { mime: input.mime } : {}),
238
+ ...(input.label ? { label: input.label } : {}),
239
+ ...(input.capturedAt ? { capturedAt: input.capturedAt } : {}),
240
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
241
+ createdAtMs: startedAt,
242
+ expiresAtMs: startedAt + this.ttlMs,
243
+ dispose: staged.dispose,
244
+ }
245
+ this.sessions.set(session.uploadId, session)
246
+ return session
247
+ }
248
+
249
+ /** Resume probe. Sweeps first, so an expired upload reads as unknown rather
250
+ * than reporting progress that can never be finalized. */
251
+ peek(uploadId: string): UploadSession | undefined {
252
+ this.sweepExpired()
253
+ if (!isValidUploadId(uploadId)) return undefined
254
+ return this.sessions.get(uploadId)
255
+ }
256
+
257
+ appendChunk(uploadId: string, index: number, bytes: Buffer): UploadSession {
258
+ const session = this.require(uploadId)
259
+ if (bytes.length === 0) {
260
+ // A zero-byte chunk would advance nextIndex without progress, so a client
261
+ // looping on it would never finish and never fail.
262
+ throw new UploadSessionError('chunk_bytes_required', 'chunk body is empty')
263
+ }
264
+ // Repairable, so the session survives: the client can re-send this index at
265
+ // the advertised chunk size.
266
+ if (bytes.length > this.maxChunkBytes) {
267
+ throw new UploadSessionError(
268
+ 'attachment_too_large',
269
+ `chunk exceeds ${this.maxChunkBytes} byte chunk size`,
270
+ { maxBytes: this.maxChunkBytes, expectedIndex: session.nextIndex },
271
+ )
272
+ }
273
+ if (!Number.isSafeInteger(index) || index !== session.nextIndex) {
274
+ // Strictly in-order (§6). A re-send of an ALREADY-committed index lands
275
+ // here too and gets the recovery information it needs rather than a blind
276
+ // second append of the same bytes.
277
+ throw new UploadSessionError('chunk_out_of_order', `expected chunk ${session.nextIndex}`, {
278
+ expectedIndex: session.nextIndex,
279
+ })
280
+ }
281
+ // "whichever is lower" (§6). create() already bounds totalBytes to maxBytes,
282
+ // so the DECLARED value is the operative half and the ceiling half is
283
+ // unreachable through the public API — measured: replacing this min() with
284
+ // `session.totalBytes` alone leaves the whole suite green, while replacing it
285
+ // with `this.maxBytes` alone fails. It stays as the contract's literal rule,
286
+ // and as what keeps this correct if init's bound ever loosens.
287
+ const limit = Math.min(session.totalBytes, this.maxBytes)
288
+ if (session.receivedBytes + bytes.length > limit) {
289
+ // Fatal, not repairable: the assembled file can no longer match what was
290
+ // declared, so the upload is dropped and its disk released immediately.
291
+ this.drop(uploadId)
292
+ throw new UploadSessionError('attachment_too_large', `upload exceeds ${limit} declared bytes`, {
293
+ maxBytes: limit,
294
+ })
295
+ }
296
+
297
+ this.fs.writeAt(session.stagingPath, bytes, session.receivedBytes)
298
+ // Committed only after the write RETURNED. A throw above leaves the counters
299
+ // untouched, so the retry of this same index writes at the same offset and
300
+ // overwrites whatever the failed attempt left — that positional write, not
301
+ // an open(path,'a'), is what makes re-sending the chunk at nextIndex
302
+ // idempotent instead of duplicating bytes.
303
+ session.receivedBytes += bytes.length
304
+ session.nextIndex += 1
305
+ return session
306
+ }
307
+
308
+ /** Completeness first (non-destructive: `incomplete_upload` means keep going),
309
+ * then hand the session over and forget it. The caller owns the staging file
310
+ * from that moment, so a duplicate finalize is a clean 404 rather than a
311
+ * second ingest of the same bytes. */
312
+ finalize(uploadId: string): UploadSession {
313
+ const session = this.require(uploadId)
314
+ if (session.receivedBytes !== session.totalBytes) {
315
+ throw new UploadSessionError('incomplete_upload', 'upload is not complete', {
316
+ receivedBytes: session.receivedBytes,
317
+ totalBytes: session.totalBytes,
318
+ })
319
+ }
320
+ // §6: re-verify the ASSEMBLED size before ingest. The counters are ours and
321
+ // the file is the thing being ingested — when they disagree, the counters
322
+ // are the ones that cannot be trusted, so refuse rather than ingest a
323
+ // truncated or over-long body.
324
+ let actualBytes: number
325
+ try {
326
+ actualBytes = this.fs.sizeOf(session.stagingPath)
327
+ } catch (err) {
328
+ this.drop(uploadId)
329
+ throw new UploadSessionError(
330
+ 'upload_size_mismatch',
331
+ `assembled upload could not be measured: ${err instanceof Error ? err.message : 'unknown error'}`,
332
+ )
333
+ }
334
+ if (actualBytes !== session.totalBytes) {
335
+ this.drop(uploadId)
336
+ throw new UploadSessionError('upload_size_mismatch', 'assembled size does not match the declared total', {
337
+ receivedBytes: actualBytes,
338
+ totalBytes: session.totalBytes,
339
+ })
340
+ }
341
+ this.sessions.delete(uploadId)
342
+ return session
343
+ }
344
+
345
+ /** Forget the session AND release its staging file. */
346
+ drop(uploadId: string): boolean {
347
+ const session = this.sessions.get(uploadId)
348
+ if (!session) return false
349
+ this.sessions.delete(uploadId)
350
+ try { session.dispose() } catch { /* already moved or gone */ }
351
+ return true
352
+ }
353
+
354
+ /** Lazy sweep on every access rather than a second interval timer: tmp/ is
355
+ * wiped by MediaStore boot reconcile, so the only case a timer would add is a
356
+ * server that never touches media again before restarting. */
357
+ sweepExpired(now = this.now()): number {
358
+ let dropped = 0
359
+ for (const [uploadId, session] of this.sessions) {
360
+ if (session.expiresAtMs <= now) {
361
+ this.sessions.delete(uploadId)
362
+ try { session.dispose() } catch { /* already moved or gone */ }
363
+ dropped++
364
+ }
365
+ }
366
+ return dropped
367
+ }
368
+
369
+ progressOf(session: UploadSession): UploadSessionProgress {
370
+ return {
371
+ uploadId: session.uploadId,
372
+ totalBytes: session.totalBytes,
373
+ receivedBytes: session.receivedBytes,
374
+ nextIndex: session.nextIndex,
375
+ expiresAt: new Date(session.expiresAtMs).toISOString(),
376
+ }
377
+ }
378
+
379
+ size(): number {
380
+ return this.sessions.size
381
+ }
382
+
383
+ private require(uploadId: string): UploadSession {
384
+ const session = this.peek(uploadId)
385
+ // A wiped, expired, or never-existent upload is the SAME answer (§6): 404,
386
+ // never a partial success. A restart legitimately produces this.
387
+ if (!session) throw new UploadSessionError('upload_not_found', 'unknown or expired upload')
388
+ return session
389
+ }
390
+ }
391
+
392
+ // ── Default singleton ────────────────────────────────────────────────────────
393
+
394
+ let defaultRegistry: UploadSessionRegistry | null = null
395
+
396
+ export function getUploadSessions(): UploadSessionRegistry {
397
+ if (!defaultRegistry) defaultRegistry = new UploadSessionRegistry()
398
+ return defaultRegistry
399
+ }
400
+
401
+ /** Test hook — mirrors _setMediaStoreForTests so a route test can install a
402
+ * registry with a fake clock and tiny ceilings. Returns the previous one. */
403
+ export function _setUploadSessionsForTests(
404
+ registry: UploadSessionRegistry | null,
405
+ ): UploadSessionRegistry | null {
406
+ const prev = defaultRegistry
407
+ defaultRegistry = registry
408
+ return prev
409
+ }
@@ -2,6 +2,10 @@
2
2
  // content endpoints backed by server/lib/media-store.ts.
3
3
  //
4
4
  // POST /api/media — upload images (base64 JSON batch)
5
+ // POST /api/media/upload/init — open a chunked upload (contract §2)
6
+ // PUT /api/media/upload/:id/:n — append chunk n (raw bytes, in order)
7
+ // GET /api/media/upload/:id — resume probe, safe to poll
8
+ // POST /api/media/upload/:id/finalize — assemble, then the SAME ingest
5
9
  // POST /api/media/reserve — bind staged media to a queue item
6
10
  // POST /api/media/associate — bind media to a run/message (replay-safe)
7
11
  // POST /api/media/release — drop staged/reserved media (cancel path)
@@ -42,20 +46,28 @@ import {
42
46
  import {
43
47
  MAX_OTHER_MEDIA_BYTES,
44
48
  MAX_VIDEO_MEDIA_BYTES,
49
+ MEDIA_CHUNK_BYTES,
45
50
  MEDIA_SNIFF_BYTES,
46
51
  RichMediaSafetyError,
47
52
  isVideoUploadHead,
48
53
  } from '../lib/rich-media-safety.js'
54
+ import {
55
+ UploadSessionError,
56
+ getUploadSessions,
57
+ isValidUploadId,
58
+ type UploadSessionErrorCode,
59
+ } from '../lib/upload-session.js'
49
60
 
50
61
  // Route-scoped parser: 16 MB covers the max valid batch after base64 + JSON
51
62
  // overhead. Mounted only for /api/media in server/index.ts — the global
52
63
  // server limit is unchanged.
53
64
  export const mediaBodyParser = json({ limit: '16mb' })
54
65
 
55
- /** Chunked upload (contract §2) is not registered on this router yet, so health
56
- * must advertise it as unavailable a client told `true` would try endpoints
57
- * that 404. Flip this in the SAME change that adds the routes below. */
58
- export const MEDIA_CHUNKED_UPLOAD_ENABLED = false
66
+ /** Chunked upload (contract §2) is registered on this router below, so health may
67
+ * advertise it. The flag lives beside the routes it describes precisely so the
68
+ * two cannot drift: flipping it without mounting them sends clients to 404s,
69
+ * and mounting them without flipping it advertises nothing. */
70
+ export const MEDIA_CHUNKED_UPLOAD_ENABLED = true
59
71
 
60
72
  /** One streamed upload body, staged on disk. Held in a WeakMap keyed by the
61
73
  * request so the handoff stays private to this module instead of widening the
@@ -198,6 +210,106 @@ function takeStagedUploadBody(req: Request): StagedUploadBody | undefined {
198
210
  return staged
199
211
  }
200
212
 
213
+ /** One chunk's raw bytes, held per-request like the streamed single-shot body. */
214
+ const chunkBodies = new WeakMap<Request, Buffer>()
215
+
216
+ export interface MediaChunkParserOptions {
217
+ /** Per-chunk ceiling — the contract's advertised `chunkBytes`. Test seam. */
218
+ maxChunkBytes?: number
219
+ }
220
+
221
+ /** Raw-bytes parser for PUT /api/media/upload/:uploadId/:index.
222
+ *
223
+ * This one BUFFERS where the single-shot parser streams, and the difference is
224
+ * deliberate: a chunk is bounded by the advertised chunkBytes (8 MiB), and
225
+ * holding the whole chunk before a single byte reaches the staging file is what
226
+ * makes the append atomic per chunk. A dropped connection therefore cannot
227
+ * leave a half-written chunk on disk, which is what lets the client re-send the
228
+ * chunk at `nextIndex` safely (§6). Streaming straight to the file would trade
229
+ * 8 MiB of memory for a partial-write recovery problem on every reconnect.
230
+ *
231
+ * Mounted as route-level middleware on the PUT below, so init/finalize keep the
232
+ * JSON parser and this can never see them. It is also safe to mount with
233
+ * app.use('/api/media/upload', …) ahead of the JSON parsers — the guard below
234
+ * makes a second pass a no-op. */
235
+ export function createMediaChunkBodyParser(options: MediaChunkParserOptions = {}) {
236
+ const maxChunkBytes = options.maxChunkBytes ?? MEDIA_CHUNK_BYTES
237
+
238
+ return function mediaChunkUploadParser(req: Request, res: Response, next: NextFunction): void {
239
+ if (req.method !== 'PUT') {
240
+ next()
241
+ return
242
+ }
243
+ if (chunkBodies.has(req)) {
244
+ next()
245
+ return
246
+ }
247
+
248
+ const refuse = (status: number, body: Record<string, unknown>): void => {
249
+ res.status(status).json(body)
250
+ res.once('finish', () => req.destroy())
251
+ }
252
+
253
+ // An upstream JSON parser already drained this body — verified: a PUT sent as
254
+ // application/json arrives here with req.body set and readableEnded true.
255
+ // Attaching 'end' to a finished stream would HANG the request, so convert it
256
+ // into a legible refusal instead. Chunks must be raw bytes.
257
+ if (req.readableEnded || req.body !== undefined) {
258
+ refuse(400, {
259
+ error: 'chunk_bytes_required',
260
+ detail: 'chunk body must be sent as raw bytes (application/octet-stream)',
261
+ })
262
+ return
263
+ }
264
+
265
+ const declaredLength = Number(req.header('content-length'))
266
+ if (Number.isFinite(declaredLength) && declaredLength > maxChunkBytes) {
267
+ refuse(413, { error: 'attachment_too_large', maxBytes: maxChunkBytes })
268
+ return
269
+ }
270
+
271
+ const parts: Buffer[] = []
272
+ let received = 0
273
+ let settled = false
274
+
275
+ req.on('data', (chunk: Buffer) => {
276
+ if (settled) return
277
+ received += chunk.length
278
+ if (received > maxChunkBytes) {
279
+ settled = true
280
+ parts.length = 0
281
+ refuse(413, { error: 'attachment_too_large', maxBytes: maxChunkBytes })
282
+ return
283
+ }
284
+ parts.push(chunk)
285
+ })
286
+ const abandon = (): void => {
287
+ if (settled) return
288
+ settled = true
289
+ parts.length = 0
290
+ }
291
+ req.once('aborted', abandon)
292
+ req.once('error', abandon)
293
+ req.once('end', () => {
294
+ if (settled) return
295
+ settled = true
296
+ chunkBodies.set(req, Buffer.concat(parts))
297
+ next()
298
+ })
299
+ }
300
+ }
301
+
302
+ /** Chunk parser at the advertised chunk size. */
303
+ export const mediaChunkBodyParser = createMediaChunkBodyParser()
304
+
305
+ function takeChunkBody(req: Request): Buffer | undefined {
306
+ const bytes = chunkBodies.get(req)
307
+ // `!== undefined`, not truthiness: a zero-length chunk is a real (rejected)
308
+ // body, and the registry owns that refusal.
309
+ if (bytes !== undefined) chunkBodies.delete(req)
310
+ return bytes
311
+ }
312
+
201
313
  export const mediaRouter = Router()
202
314
 
203
315
  const MEDIA_ERROR_STATUS: Record<string, number> = {
@@ -223,7 +335,25 @@ const SAFETY_ERROR_STATUS: Record<string, number> = {
223
335
  video_too_long: 400,
224
336
  }
225
337
 
338
+ const UPLOAD_ERROR_STATUS: Record<UploadSessionErrorCode, number> = {
339
+ upload_not_found: 404,
340
+ chunk_out_of_order: 409,
341
+ attachment_too_large: 413,
342
+ incomplete_upload: 400,
343
+ upload_size_mismatch: 400,
344
+ invalid_total_bytes: 400,
345
+ chunk_bytes_required: 400,
346
+ chunked_upload_unavailable: 503,
347
+ upload_staging_failed: 500,
348
+ }
349
+
226
350
  function sendMediaError(res: Response, err: unknown): void {
351
+ // Chunked-upload failures go through the SAME funnel as every other media
352
+ // error, so there is one place that decides the error body's shape.
353
+ if (err instanceof UploadSessionError) {
354
+ res.status(UPLOAD_ERROR_STATUS[err.code] ?? 500).json({ error: err.code, ...err.detail })
355
+ return
356
+ }
227
357
  if (err instanceof MediaStoreError) {
228
358
  res.status(MEDIA_ERROR_STATUS[err.code] ?? 500).json({ error: err.code })
229
359
  return
@@ -344,6 +474,152 @@ mediaRouter.post('/media/file', async (req: Request, res: Response) => {
344
474
  }
345
475
  })
346
476
 
477
+ // ── Chunked upload (contract §2) ──────────────────────────────────────────────
478
+ //
479
+ // Registered ahead of the GET /media/:id reads below so a future single-segment
480
+ // param route can never shadow /media/upload/…. Compression does not unlock
481
+ // length — only this does: a 3-minute 4K original is ~570 MB and cannot pass any
482
+ // sane single-shot cap.
483
+
484
+ /** Read the label the same way POST /media/file does. The two paths must agree,
485
+ * or the same phone video gets a different label depending on its size. */
486
+ function uploadLabelFrom(req: Request): string | undefined {
487
+ const rawLabel = safeString(req.header('x-cos-filename'), 360)
488
+ if (!rawLabel) return undefined
489
+ try { return decodeURIComponent(rawLabel).slice(0, 120) } catch { return rawLabel.slice(0, 120) }
490
+ }
491
+
492
+ mediaRouter.post('/media/upload/init', (req: Request, res: Response) => {
493
+ try {
494
+ const body = req.body ?? {}
495
+ const session = getUploadSessions().create({
496
+ totalBytes: body.totalBytes,
497
+ // MIME arrives in the BODY, not Content-Type: this request's Content-Type
498
+ // is application/json. It stays a hint either way — finalize sniffs the
499
+ // assembled bytes.
500
+ mime: safeString(body.mime, 120),
501
+ label: uploadLabelFrom(req),
502
+ capturedAt: safeString(req.header('x-cos-captured-at'), 40),
503
+ sessionId: safeString(req.header('x-cos-session-id'), 64),
504
+ })
505
+ res.json({
506
+ uploadId: session.uploadId,
507
+ chunkBytes: MEDIA_CHUNK_BYTES,
508
+ receivedBytes: session.receivedBytes,
509
+ })
510
+ } catch (err) {
511
+ sendMediaError(res, err)
512
+ }
513
+ })
514
+
515
+ mediaRouter.put('/media/upload/:uploadId/:index', mediaChunkBodyParser, (req: Request, res: Response) => {
516
+ const bytes = takeChunkBody(req)
517
+ try {
518
+ if (!isValidUploadId(req.params.uploadId)) {
519
+ // A malformed id cannot name a session this server minted, so it is the
520
+ // same answer as an expired one — never a partial success (§6).
521
+ throw new UploadSessionError('upload_not_found', 'unknown or expired upload')
522
+ }
523
+ if (bytes === undefined) {
524
+ throw new UploadSessionError('chunk_bytes_required', 'chunk body was not read as raw bytes')
525
+ }
526
+ // Parsed strictly, then compared: a non-numeric segment is definitionally
527
+ // not the expected index, so it answers 409 with the index to send.
528
+ const rawIndex = req.params.index
529
+ const index = typeof rawIndex === 'string' && /^\d{1,9}$/.test(rawIndex)
530
+ ? Number(rawIndex)
531
+ : Number.NaN
532
+ const session = getUploadSessions().appendChunk(req.params.uploadId, index, bytes)
533
+ res.json({ receivedBytes: session.receivedBytes, nextIndex: session.nextIndex })
534
+ } catch (err) {
535
+ sendMediaError(res, err)
536
+ }
537
+ })
538
+
539
+ mediaRouter.get('/media/upload/:uploadId', (req: Request, res: Response) => {
540
+ const sessions = getUploadSessions()
541
+ const session = isValidUploadId(req.params.uploadId)
542
+ ? sessions.peek(req.params.uploadId)
543
+ : undefined
544
+ if (!session) {
545
+ res.status(404).json({ error: 'upload_not_found' })
546
+ return
547
+ }
548
+ res.json(sessions.progressOf(session))
549
+ })
550
+
551
+ /**
552
+ * Abandon an upload and release its resources NOW.
553
+ *
554
+ * WHY THIS EXISTS. Without it an abandoned session held one of only
555
+ * MAX_CONCURRENT_CHUNKED_UPLOADS (8) slots plus up to chunkedMaxBytes of tmp/ for the
556
+ * full 4-hour TTL, with no way to release it early. The client's recovery ladder fires
557
+ * on a flaky phone link BY DESIGN, so give-ups are expected traffic rather than an edge
558
+ * case: eight of them in one bad session — plausible on a large file — then made init
559
+ * answer 503 for up to four hours, with the user seeing a feature that simply stopped
560
+ * working and no way to clear it.
561
+ *
562
+ * IDEMPOTENT BY CONSTRUCTION. An unknown, already-cancelled, or expired id answers 200,
563
+ * not 404. The client calls this best-effort while giving up on something that already
564
+ * failed; making it fail again would invite a retry loop over a request whose only job
565
+ * is to release resources. `dropped` reports whether this call was the one that freed
566
+ * it, for logs — never as a signal the client must act on.
567
+ *
568
+ * A malformed id is still 400: that is a client bug worth surfacing, not a resource to
569
+ * release.
570
+ */
571
+ mediaRouter.delete('/media/upload/:uploadId', (req: Request, res: Response) => {
572
+ if (!isValidUploadId(req.params.uploadId)) {
573
+ res.status(400).json({ error: 'invalid_upload_id' })
574
+ return
575
+ }
576
+ // drop() forgets the session AND disposes its staging file, so the slot and the disk
577
+ // are both released here rather than waiting on the TTL sweep.
578
+ const dropped = getUploadSessions().drop(req.params.uploadId)
579
+ res.json({ ok: true, dropped })
580
+ })
581
+
582
+ mediaRouter.post('/media/upload/:uploadId/finalize', async (req: Request, res: Response) => {
583
+ let claimed: { stagingPath: string; dispose: () => void } | null = null
584
+ try {
585
+ if (!isValidUploadId(req.params.uploadId)) {
586
+ throw new UploadSessionError('upload_not_found', 'unknown or expired upload')
587
+ }
588
+ // Throws `incomplete_upload` WITHOUT consuming the session, so a client that
589
+ // finalized early can keep sending chunks. Anything it returns is a session
590
+ // this request now owns.
591
+ const session = getUploadSessions().finalize(req.params.uploadId)
592
+ claimed = session
593
+ // The SAME ingest as POST /media/file — validation, the per-kind cap from
594
+ // magic bytes, the atomic rename, and compression scheduling all stay in one
595
+ // place. A second ingest path would be a second place for those to rot.
596
+ const attachment = await getMediaStore().ingestRichMediaFromFile({
597
+ sourcePath: session.stagingPath,
598
+ byteLength: session.totalBytes,
599
+ label: session.label,
600
+ declaredMime: session.mime,
601
+ capturedAt: session.capturedAt,
602
+ sessionId: session.sessionId,
603
+ // The one line that makes chunked upload actually work. Without it this
604
+ // inherits the SINGLE-SHOT per-kind cap, so every chunked video transferred
605
+ // in full and was then refused at finalize with a raw 413 — strictly worse
606
+ // than the pre-flight refusal it replaced. Only video gets the chunked
607
+ // ceiling; documents stay at the single-shot cap, because the text path reads
608
+ // the whole file into a Buffer and a JS string.
609
+ transfer: 'chunked',
610
+ })
611
+ res.json({ attachment })
612
+ } catch (err) {
613
+ sendMediaError(res, err)
614
+ } finally {
615
+ // No-op once ingest moved the file into the asset dir. The case that matters
616
+ // is a REJECTED assembly (unsupported format, over the per-kind cap): ingest
617
+ // is atomic, so nothing is half-published, and this is what stops the
618
+ // assembled body leaking in tmp/.
619
+ claimed?.dispose()
620
+ }
621
+ })
622
+
347
623
  // ── Lifecycle ────────────────────────────────────────────────────────────────
348
624
 
349
625
  mediaRouter.post('/media/reserve', async (req: Request, res: Response) => {