@devultur/self-host 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -38,9 +38,10 @@ import {
38
38
  storageKey,
39
39
  storagePrefix,
40
40
  updateRequestState,
41
+ verifyJobLocation,
41
42
  verifyJobTicket,
42
43
  verifyViewerToken
43
- } from "./chunk-VF4KBW3I.js";
44
+ } from "./chunk-PQO3X57I.js";
44
45
  import {
45
46
  ImageTransformsMode,
46
47
  WORKER_DECODABLE_TYPES,
@@ -67,39 +68,18 @@ import {
67
68
  SHA256_HEADER as SHA256_HEADER3,
68
69
  SHA256_VERIFIED_HEADER as SHA256_VERIFIED_HEADER2
69
70
  } from "@devultur/core";
70
- import { Hono as Hono10 } from "hono";
71
+ import { Hono as Hono11 } from "hono";
71
72
  import { cors } from "hono/cors";
72
73
  import { logger } from "hono/logger";
73
74
 
74
- // src/plugin.ts
75
- var PHASES = ["beforeAuth", "auth", "afterAuth"];
76
- function assertComposable(plugins) {
77
- const names = /* @__PURE__ */ new Set();
78
- for (const plugin of plugins) {
79
- if (names.has(plugin.name)) throw new Error(`createWorker: two plugins are named "${plugin.name}"`);
80
- names.add(plugin.name);
81
- }
82
- if (!plugins.some((plugin) => (plugin.middleware?.auth?.length ?? 0) > 0)) {
83
- throw new Error("createWorker: no plugin authenticates requests. Add workerSecretAuth() or your own auth plugin.");
84
- }
85
- }
86
- function composePlugins(plugins) {
87
- assertComposable(plugins);
88
- return {
89
- middleware: PHASES.flatMap((phase) => plugins.flatMap((plugin) => plugin.middleware?.[phase] ?? [])),
90
- routes: plugins.flatMap((plugin) => plugin.routes ?? []),
91
- hooks: {
92
- issueCredential: plugins.flatMap((plugin) => plugin.issueCredential ? [plugin.issueCredential] : []),
93
- beforeLinkRedeem: plugins.flatMap((plugin) => plugin.beforeLinkRedeem ? [plugin.beforeLinkRedeem] : [])
94
- }
95
- };
96
- }
75
+ // src/object-lock/guard.ts
76
+ import { decodeEncryptionMeta, effectiveUntil, isVideoRawKey } from "@devultur/core";
97
77
 
98
78
  // src/plugin-hooks.ts
99
79
  var PLUGIN_HOOKS_VARIABLE = "pluginHooks";
100
- var NO_HOOKS = { issueCredential: [], beforeLinkRedeem: [] };
80
+ var NO_HOOKS = { issueCredential: [], beforeLinkRedeem: [], authorizeLockChange: [], onLockAudit: [] };
101
81
  function getPluginHooks(context) {
102
- return context.get(PLUGIN_HOOKS_VARIABLE) ?? NO_HOOKS;
82
+ return { ...NO_HOOKS, ...context.get(PLUGIN_HOOKS_VARIABLE) };
103
83
  }
104
84
  var earliest = (current, next) => current === void 0 ? next : next === void 0 ? current : Math.min(current, next);
105
85
  async function decideCredential(context, request) {
@@ -115,11 +95,19 @@ async function decideCredential(context, request) {
115
95
  }
116
96
  async function gateLinkRedemption(context, link) {
117
97
  for (const hook of getPluginHooks(context).beforeLinkRedeem) {
118
- const refusal = await hook(context, link);
119
- if (refusal) return refusal;
98
+ const refusal2 = await hook(context, link);
99
+ if (refusal2) return refusal2;
120
100
  }
121
101
  return null;
122
102
  }
103
+ async function decideLockChange(context, request) {
104
+ const hooks = getPluginHooks(context).authorizeLockChange;
105
+ for (const hook of hooks) {
106
+ const decision = await hook(context, request);
107
+ if (!decision.allow) return decision;
108
+ }
109
+ return { allow: true, isApproved: hooks.length > 0 };
110
+ }
123
111
  function cappedExpiry(requested, cap, toUnit) {
124
112
  return cap === void 0 ? requested : Math.min(requested, toUnit(cap));
125
113
  }
@@ -127,6 +115,575 @@ function withPluginClaims(claims, decision) {
127
115
  return { ...decision.claims, ...claims };
128
116
  }
129
117
 
118
+ // src/object-lock/audit.ts
119
+ var LOCK_AUDIT_PREFIX = "__lock-audit/";
120
+ function auditObjectKey(root, at, id = crypto.randomUUID()) {
121
+ return `${root}${LOCK_AUDIT_PREFIX}${at.toISOString().slice(0, 10)}/${at.getTime()}-${id}.json`;
122
+ }
123
+ async function deliver(context, root, record) {
124
+ const body = JSON.stringify(record);
125
+ const stored = context.env.MEDIA_BUCKET.put(auditObjectKey(root, new Date(record.at)), body, {
126
+ onlyIf: new Headers({ "If-None-Match": "*" }),
127
+ httpMetadata: { contentType: "application/json" }
128
+ });
129
+ const hooks = getPluginHooks(context).onLockAudit.map((hook) => hook(context, record));
130
+ const results = await Promise.allSettled([stored, ...hooks]);
131
+ for (const result of results) {
132
+ if (result.status === "rejected") console.error("lock audit delivery failed:", result.reason);
133
+ }
134
+ }
135
+ async function inBackground(context, task) {
136
+ let executionCtx;
137
+ try {
138
+ executionCtx = context.executionCtx;
139
+ } catch {
140
+ }
141
+ if (!executionCtx) return task;
142
+ executionCtx.waitUntil(task);
143
+ }
144
+ async function recordLockAudit(context, input) {
145
+ const { root = getRequestState(context).storagePrefix, actor, ...event } = input;
146
+ const record = {
147
+ at: (/* @__PURE__ */ new Date()).toISOString(),
148
+ ...event,
149
+ actor: actor === void 0 ? getRequestState(context).context ?? null : actor
150
+ };
151
+ console.log(JSON.stringify({ event: "lock.audit", ...record }));
152
+ await inBackground(context, deliver(context, root, record));
153
+ }
154
+
155
+ // src/object-lock/sidecar.ts
156
+ import { isIsoInstant, validateLock as validateLock2 } from "@devultur/core";
157
+
158
+ // src/object-lock/state.ts
159
+ import { isLocked, validateLock } from "@devultur/core";
160
+ var LOCK_METADATA_KEY = "lock";
161
+ function encodeLockMetadata(lock) {
162
+ if (!lock || !lock.retention && !lock.hold) return void 0;
163
+ return JSON.stringify(lock);
164
+ }
165
+ function readLockMetadata(raw) {
166
+ if (raw === void 0) return void 0;
167
+ try {
168
+ const validation = validateLock(JSON.parse(raw));
169
+ if (validation.ok) return { lock: validation.lock, isUnreadable: false };
170
+ } catch {
171
+ }
172
+ return { lock: { hold: true }, isUnreadable: true };
173
+ }
174
+ function lockStateOf(object) {
175
+ const stored = readLockMetadata(object.customMetadata?.[LOCK_METADATA_KEY]);
176
+ if (!stored) return null;
177
+ return {
178
+ lock: stored.lock,
179
+ ...object.uploaded ? { uploadedAt: object.uploaded } : {},
180
+ ...stored.isUnreadable ? { isUnreadable: true } : {}
181
+ };
182
+ }
183
+ function isStateLocked(state, now) {
184
+ try {
185
+ return isLocked(state.lock, now, { uploadedAt: state.uploadedAt, holdReleasedAt: state.holdReleasedAt });
186
+ } catch {
187
+ return true;
188
+ }
189
+ }
190
+
191
+ // src/object-lock/sidecar.ts
192
+ var LOCK_SIDECAR_PREFIX = "__lock/";
193
+ function objectVersionOf(object) {
194
+ return object.version ?? `${object.etag}@${object.uploaded?.toISOString() ?? ""}`;
195
+ }
196
+ var sidecarKey = (root, key) => `${root}${LOCK_SIDECAR_PREFIX}${key}`;
197
+ function parseSidecar(raw) {
198
+ try {
199
+ const parsed = JSON.parse(raw);
200
+ const lock = validateLock2(parsed.lock);
201
+ if (!lock.ok || typeof parsed.revision !== "number" || typeof parsed.objectVersion !== "string") return null;
202
+ if (parsed.holdReleasedAt !== void 0 && !isIsoInstant(parsed.holdReleasedAt)) return null;
203
+ return { ...parsed, lock: lock.lock };
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+ async function readSidecar(bucket, root, key) {
209
+ const object = await bucket.get(sidecarKey(root, key));
210
+ if (!object) return null;
211
+ const sidecar = parseSidecar(await object.text());
212
+ if (sidecar) return { sidecar, etag: object.etag };
213
+ const unreadable = { revision: 0, objectVersion: "*", lock: { hold: true }, updatedAt: "" };
214
+ return { sidecar: unreadable, etag: object.etag, isUnreadable: true };
215
+ }
216
+ async function writeSidecar(bucket, root, key, sidecar, previous) {
217
+ const onlyIf = previous ? { etagMatches: previous.etag } : new Headers({ "If-None-Match": "*" });
218
+ const stored = await bucket.put(sidecarKey(root, key), JSON.stringify(sidecar), {
219
+ onlyIf,
220
+ httpMetadata: { contentType: "application/json" }
221
+ });
222
+ return stored !== null;
223
+ }
224
+ async function effectiveLock(bucket, root, key, object) {
225
+ const stored = await readSidecar(bucket, root, key);
226
+ const bound = stored?.sidecar.objectVersion;
227
+ const applies = stored && (bound === "*" || bound === objectVersionOf(object));
228
+ if (stored && applies) {
229
+ const { lock, holdReleasedAt } = stored.sidecar;
230
+ const state2 = {
231
+ lock,
232
+ ...object.uploaded ? { uploadedAt: object.uploaded } : {},
233
+ ...holdReleasedAt ? { holdReleasedAt: new Date(holdReleasedAt) } : {},
234
+ ...stored.isUnreadable ? { isUnreadable: true } : {}
235
+ };
236
+ return { state: state2, source: "sidecar", stored };
237
+ }
238
+ const state = lockStateOf(object);
239
+ return { state, source: state ? "upload" : "none", stored };
240
+ }
241
+ function unreadableLockRefusal(state, key) {
242
+ if (!state?.isUnreadable) return null;
243
+ return Response.json(
244
+ {
245
+ error: `The lock record of ${key} can't be read; it stays held until an operator repairs it`,
246
+ code: "lock_record_unreadable",
247
+ key
248
+ },
249
+ { status: 409 }
250
+ );
251
+ }
252
+ var VIDEO_STORAGE_KEY = /^(.*?)((?:public|private)\/video\/[^/]+\/.+)$/;
253
+ function splitVideoStorageKey(storageKey2) {
254
+ const match = VIDEO_STORAGE_KEY.exec(storageKey2);
255
+ return match ? { root: match[1] ?? "", key: match[2] ?? "" } : null;
256
+ }
257
+
258
+ // src/object-lock/guard.ts
259
+ var RESERVED_LOCK_PREFIXES = ["__lock/", "__lock-audit/"];
260
+ function isReservedLockKey(key) {
261
+ const normalized = `${key.replace(/^\/+/, "")}/`;
262
+ return RESERVED_LOCK_PREFIXES.some((prefix) => normalized.startsWith(prefix));
263
+ }
264
+ function reservedKeyResponse(context, key) {
265
+ return context.json({ error: `${key} is under a reserved prefix`, code: "reserved_key", key }, 400);
266
+ }
267
+ function routeKeyOf(path) {
268
+ const key = path.split("/").slice(3).join("/");
269
+ try {
270
+ return decodeURIComponent(key);
271
+ } catch {
272
+ return key;
273
+ }
274
+ }
275
+ var rejectReservedLockPaths = async (context, next) => {
276
+ const key = routeKeyOf(context.req.path);
277
+ if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
278
+ await next();
279
+ };
280
+ var VIDEO_KEY = /^((?:public|private)\/video\/[^/]+)(?:\/|$)/;
281
+ function lockSubjectOf(key) {
282
+ if (isVariantKey(key)) return null;
283
+ if (isVideoRawKey(key)) return { kind: "original", key };
284
+ const video = VIDEO_KEY.exec(key);
285
+ if (video) return { kind: "video", rawPrefix: `${video[1]}/raw/` };
286
+ return { kind: "original", key };
287
+ }
288
+ function protectsDerived(state) {
289
+ const derivatives = state.lock.retention?.derivatives ?? "inherit";
290
+ return derivatives === "inherit";
291
+ }
292
+ async function lockedOriginal(context, root, key, now) {
293
+ const object = await context.env.MEDIA_BUCKET.head(`${root}${key}`);
294
+ const state = object ? (await effectiveLock(context.env.MEDIA_BUCKET, root, key, object)).state : null;
295
+ return state && isStateLocked(state, now) ? { key, state } : null;
296
+ }
297
+ async function lockedRaw(context, root, rawPrefix, now) {
298
+ let cursor;
299
+ do {
300
+ const listing = await context.env.MEDIA_BUCKET.list({ prefix: `${root}${rawPrefix}`, cursor });
301
+ for (const listed of listing.objects) {
302
+ const key = listed.key.slice(root.length);
303
+ const object = await context.env.MEDIA_BUCKET.head(listed.key);
304
+ const state = object ? (await effectiveLock(context.env.MEDIA_BUCKET, root, key, object)).state : null;
305
+ if (state && protectsDerived(state) && isStateLocked(state, now)) return { key, state };
306
+ }
307
+ cursor = listing.truncated ? listing.cursor : void 0;
308
+ } while (cursor);
309
+ return null;
310
+ }
311
+ function retentionActive(context, locked, op) {
312
+ const { lock, uploadedAt, holdReleasedAt } = locked.state;
313
+ let until;
314
+ try {
315
+ until = effectiveUntil(lock, { uploadedAt, holdReleasedAt });
316
+ } catch {
317
+ }
318
+ const verb = op === "delete" ? "deleted" : "overwritten";
319
+ return context.json(
320
+ {
321
+ error: `${locked.key} is retained and can't be ${verb}`,
322
+ code: "retention_active",
323
+ key: locked.key,
324
+ ...lock.retention ? { mode: lock.retention.mode } : {},
325
+ ...until ? { until } : {},
326
+ hold: lock.hold === true
327
+ },
328
+ 409
329
+ );
330
+ }
331
+ async function assertWritable(context, keys, op, root = getRequestState(context).storagePrefix) {
332
+ const reserved = keys.find(isReservedLockKey);
333
+ if (reserved !== void 0) return reservedKeyResponse(context, reserved);
334
+ const now = /* @__PURE__ */ new Date();
335
+ const checked = /* @__PURE__ */ new Set();
336
+ for (const key of keys) {
337
+ const subject = lockSubjectOf(key);
338
+ if (!subject) continue;
339
+ const subjectId = subject.kind === "original" ? subject.key : subject.rawPrefix;
340
+ if (checked.has(subjectId)) continue;
341
+ checked.add(subjectId);
342
+ const locked = subject.kind === "original" ? await lockedOriginal(context, root, subject.key, now) : await lockedRaw(context, root, subject.rawPrefix, now);
343
+ if (!locked) continue;
344
+ const requested = key === locked.key ? [key] : [key, locked.key];
345
+ await recordLockAudit(context, {
346
+ action: op === "delete" ? "delete_rejected" : "overwrite_rejected",
347
+ keys: requested,
348
+ before: locked.state.lock,
349
+ after: null,
350
+ outcome: "rejected",
351
+ reason: "retention_active",
352
+ root
353
+ });
354
+ return retentionActive(context, locked, op);
355
+ }
356
+ return null;
357
+ }
358
+ var DELETE_BATCH_SIZE = 1e3;
359
+ async function guardedDeletePrefix(context, prefix) {
360
+ const root = getRequestState(context).storagePrefix;
361
+ const storageKeys = [];
362
+ let cursor;
363
+ do {
364
+ const listing = await context.env.MEDIA_BUCKET.list({ prefix: storageKey(context, prefix), cursor });
365
+ storageKeys.push(...listing.objects.map((object) => object.key));
366
+ cursor = listing.truncated ? listing.cursor : void 0;
367
+ } while (cursor);
368
+ const keys = storageKeys.map((key) => key.slice(root.length));
369
+ const blocked = await assertWritable(context, keys, "delete");
370
+ if (blocked) return { blocked };
371
+ for (let start = 0; start < storageKeys.length; start += DELETE_BATCH_SIZE) {
372
+ await context.env.MEDIA_BUCKET.delete(storageKeys.slice(start, start + DELETE_BATCH_SIZE));
373
+ }
374
+ return { deletedCount: storageKeys.length };
375
+ }
376
+ async function ownKeysOf(keysBucket, fullKey) {
377
+ const keys = [];
378
+ let cursor;
379
+ do {
380
+ const listing = await keysBucket.list({ prefix: `${fullKey}/`, delimiter: "/", cursor });
381
+ keys.push(...listing.objects);
382
+ cursor = listing.truncated ? listing.cursor : void 0;
383
+ } while (cursor);
384
+ return keys;
385
+ }
386
+ function isKeyOfDeletedVersion(key, deleted) {
387
+ return key.uploaded.getTime() <= deleted.uploaded.getTime();
388
+ }
389
+ async function deleteKeysFor(context, objectKey, deleted) {
390
+ const blocked = await assertWritable(context, [objectKey], "delete");
391
+ if (blocked) return { blocked };
392
+ const keysBucket = context.env.KEYS_BUCKET;
393
+ if (!keysBucket) return { deletedCount: 0 };
394
+ const fullKey = storageKey(context, objectKey);
395
+ const ownKeys = new Set(
396
+ (await ownKeysOf(keysBucket, fullKey)).filter((key) => isKeyOfDeletedVersion(key, deleted)).map((key) => key.key)
397
+ );
398
+ const meta = decodeEncryptionMeta(deleted.customMetadata?.enc);
399
+ if (meta) ownKeys.add(`${fullKey}/${meta.keyId}`);
400
+ if (ownKeys.size > 0) await keysBucket.delete([...ownKeys]);
401
+ const variantKeyCount = await deletePrefix(keysBucket, variantPrefix(fullKey));
402
+ return { deletedCount: variantKeyCount + ownKeys.size };
403
+ }
404
+ async function deleteFolderKeys(context, videoFolder) {
405
+ const blocked = await assertWritable(context, [videoFolder], "delete");
406
+ if (blocked) return { blocked };
407
+ if (!context.env.KEYS_BUCKET) return { deletedCount: 0 };
408
+ return { deletedCount: await deletePrefix(context.env.KEYS_BUCKET, `${storageKey(context, videoFolder)}/`) };
409
+ }
410
+
411
+ // src/object-lock/lock-route.ts
412
+ import { canChange as canChange2, effectiveUntil as effectiveUntil2 } from "@devultur/core";
413
+ import { Hono } from "hono";
414
+
415
+ // src/object-lock/change.ts
416
+ import { canChange, validateLock as validateLock3 } from "@devultur/core";
417
+ var BODY_FIELDS = /* @__PURE__ */ new Set(["mode", "until", "period", "derivatives", "remove", "bypassGovernance"]);
418
+ function nextLock(before, body) {
419
+ const unknown = Object.keys(body).filter((field) => !BODY_FIELDS.has(field));
420
+ if (unknown.length > 0) return { ok: false, errors: unknown.map((field) => `${field}: unknown field`) };
421
+ if (body.remove !== void 0 && body.remove !== true) return { ok: false, errors: ["remove: must be true"] };
422
+ const hold = before?.hold === true;
423
+ if (body.remove === true) return { ok: true, lock: { hold } };
424
+ const current = before?.retention;
425
+ const isExpiryChanged = body.until !== void 0 || body.period !== void 0;
426
+ const retention = {
427
+ mode: body.mode ?? current?.mode,
428
+ ...isExpiryChanged ? {
429
+ ...body.until !== void 0 ? { until: body.until } : {},
430
+ ...body.period !== void 0 ? { period: body.period } : {}
431
+ } : {
432
+ ...current?.until ? { until: current.until } : {},
433
+ ...current?.period ? { period: current.period } : {}
434
+ },
435
+ derivatives: body.derivatives ?? current?.derivatives ?? "inherit"
436
+ };
437
+ const validation = validateLock3({ retention, hold });
438
+ if (!validation.ok) return validation;
439
+ return { ok: true, lock: validation.lock };
440
+ }
441
+ var timingOf = (state) => ({
442
+ uploadedAt: state?.uploadedAt,
443
+ holdReleasedAt: state?.holdReleasedAt
444
+ });
445
+ function classifyChange(state, after, now) {
446
+ const before = state?.lock ?? {};
447
+ const isActive = canChange(before, { hold: before.hold }, { privileged: false, now, timing: timingOf(state) });
448
+ if (isActive.ok) return after.retention ? "set" : "remove";
449
+ if (!after.retention) return "remove";
450
+ const change = canChange(before, after, { privileged: false, now, timing: timingOf(state) });
451
+ return change.ok ? "extend" : "shorten";
452
+ }
453
+
454
+ // src/object-lock/hold.ts
455
+ import { validateLock as validateLock4 } from "@devultur/core";
456
+ var HOLD_PAGE_SIZE = 50;
457
+ var isKeyList = (value) => Array.isArray(value) && value.every((key) => typeof key === "string" && key.length > 0);
458
+ async function targetsOf(context, body) {
459
+ if (body.keys === void 0 === (body.prefix === void 0)) return { error: "pass either keys or prefix" };
460
+ if (body.keys !== void 0) {
461
+ if (!isKeyList(body.keys) || body.keys.length === 0) return { error: "keys must be a non-empty list of keys" };
462
+ if (body.keys.length > HOLD_PAGE_SIZE) return { error: `keys may list at most ${HOLD_PAGE_SIZE} keys` };
463
+ if (body.keys.some(isReservedLockKey)) return { error: "keys may not be under a reserved prefix" };
464
+ return { keys: [...new Set(body.keys)] };
465
+ }
466
+ if (typeof body.prefix !== "string" || body.prefix === "" || isReservedLockKey(body.prefix)) {
467
+ return { error: "prefix must be a non-empty key prefix outside the reserved ones" };
468
+ }
469
+ const cursor = typeof body.cursor === "string" ? body.cursor : void 0;
470
+ const listing = await context.env.MEDIA_BUCKET.list({
471
+ prefix: storageKey(context, body.prefix),
472
+ limit: HOLD_PAGE_SIZE,
473
+ ...cursor ? { cursor } : {}
474
+ });
475
+ const root = getRequestState(context).storagePrefix;
476
+ const keys = listing.objects.map((object) => object.key.slice(root.length)).filter((key) => !isVariantKey(key) && !isReservedLockKey(key));
477
+ return { keys, prefix: body.prefix, ...listing.truncated ? { cursor: listing.cursor } : {} };
478
+ }
479
+ async function applyHold(context, key, hold, now) {
480
+ const root = getRequestState(context).storagePrefix;
481
+ const object = await context.env.MEDIA_BUCKET.head(storageKey(context, key));
482
+ if (!object) return { updated: false, reason: "not_found" };
483
+ const effective = await effectiveLock(context.env.MEDIA_BUCKET, root, key, object);
484
+ const before = effective.state?.lock ?? { hold: false };
485
+ if (before.hold === true === hold) return { updated: false, reason: "unchanged" };
486
+ if (effective.state?.isUnreadable) {
487
+ await recordLockAudit(context, {
488
+ action: "release",
489
+ keys: [key],
490
+ before,
491
+ after: { ...before, hold },
492
+ outcome: "rejected",
493
+ reason: "lock_record_unreadable"
494
+ });
495
+ return { updated: false, reason: "lock_record_unreadable" };
496
+ }
497
+ const validated = validateLock4({ ...before, hold });
498
+ const after = validated.ok ? validated.lock : { ...before, hold };
499
+ const previous = effective.source === "sidecar" ? effective.stored?.sidecar : void 0;
500
+ const holdReleasedAt = hold ? previous?.holdReleasedAt : now.toISOString();
501
+ const sidecar = {
502
+ revision: (effective.stored?.sidecar.revision ?? 0) + 1,
503
+ objectVersion: objectVersionOf(object),
504
+ lock: after,
505
+ ...holdReleasedAt ? { holdReleasedAt } : {},
506
+ updatedAt: now.toISOString()
507
+ };
508
+ const action = hold ? "hold" : "release";
509
+ if (!await writeSidecar(context.env.MEDIA_BUCKET, root, key, sidecar, effective.stored)) {
510
+ await recordLockAudit(context, {
511
+ action,
512
+ keys: [key],
513
+ before,
514
+ after,
515
+ outcome: "rejected",
516
+ reason: "lock_conflict"
517
+ });
518
+ return { updated: false, reason: "lock_conflict" };
519
+ }
520
+ await recordLockAudit(context, { action, keys: [key], before, after, outcome: "applied" });
521
+ return { updated: true };
522
+ }
523
+ async function handleHold(context) {
524
+ const body = await context.req.json().catch(() => null) ?? {};
525
+ if (typeof body.hold !== "boolean")
526
+ return context.json({ error: "hold must be a boolean", code: "invalid_hold" }, 400);
527
+ const targets = await targetsOf(context, body);
528
+ if ("error" in targets) return context.json({ error: targets.error, code: "invalid_hold" }, 400);
529
+ const hold = body.hold;
530
+ const decision = await decideLockChange(context, {
531
+ action: hold ? "hold" : "release",
532
+ keys: targets.keys,
533
+ ...targets.prefix ? { prefix: targets.prefix } : {},
534
+ before: null,
535
+ after: { hold }
536
+ });
537
+ if (!decision.allow) {
538
+ await recordLockAudit(context, {
539
+ action: hold ? "hold" : "release",
540
+ keys: targets.keys,
541
+ before: null,
542
+ after: { hold },
543
+ outcome: "rejected",
544
+ reason: "hook_refused"
545
+ });
546
+ return decision.response;
547
+ }
548
+ const now = /* @__PURE__ */ new Date();
549
+ const updated = [];
550
+ const skipped = [];
551
+ for (const key of targets.keys) {
552
+ const outcome = await applyHold(context, key, hold, now);
553
+ if (outcome.updated) updated.push(key);
554
+ else skipped.push({ key, reason: outcome.reason });
555
+ }
556
+ return context.json({ updated, skipped, ...targets.cursor ? { cursor: targets.cursor } : {} });
557
+ }
558
+
559
+ // src/object-lock/lock-route.ts
560
+ function lockView(key, effective) {
561
+ const lock = effective.state?.lock;
562
+ let until;
563
+ try {
564
+ until = lock ? effectiveUntil2(lock, timingOf(effective.state)) : void 0;
565
+ } catch {
566
+ }
567
+ return {
568
+ key,
569
+ mode: lock?.retention?.mode ?? null,
570
+ until: until ?? null,
571
+ period: lock?.retention?.period ?? null,
572
+ hold: lock?.hold === true,
573
+ derivatives: lock?.retention ? lock.retention.derivatives ?? "inherit" : null,
574
+ source: effective.source,
575
+ revision: effective.source === "sidecar" ? effective.stored?.sidecar.revision ?? null : null
576
+ };
577
+ }
578
+ async function loadTarget(context) {
579
+ const key = context.req.param("key") ?? "";
580
+ const root = getRequestState(context).storagePrefix;
581
+ const object = await context.env.MEDIA_BUCKET.head(storageKey(context, key));
582
+ if (!object) return { key, root, object: null, effective: null };
583
+ return { key, root, object, effective: await effectiveLock(context.env.MEDIA_BUCKET, root, key, object) };
584
+ }
585
+ var lockRoute = new Hono();
586
+ lockRoute.use("*", async (context, next) => {
587
+ if (!isAuthenticated(context)) return context.json({ error: "Unauthorized" }, 401);
588
+ await next();
589
+ });
590
+ lockRoute.post("/hold", handleHold);
591
+ lockRoute.get("/:key{.+}", async (context) => {
592
+ const { key, effective } = await loadTarget(context);
593
+ if (!effective) return context.json({ error: "Not found" }, 404);
594
+ return context.json(lockView(key, effective));
595
+ });
596
+ lockRoute.put("/:key{.+}", async (context) => {
597
+ const body = await context.req.json().catch(() => null) ?? {};
598
+ const { key, root, object, effective } = await loadTarget(context);
599
+ if (!object || !effective) return context.json({ error: "Not found" }, 404);
600
+ const before = effective.state?.lock ?? null;
601
+ const next = nextLock(before, body);
602
+ if (!next.ok) return context.json({ error: next.errors.join("; "), code: "invalid_lock" }, 400);
603
+ const now = /* @__PURE__ */ new Date();
604
+ const action = classifyChange(effective.state, next.lock, now);
605
+ const audit = { action, keys: [key], before, after: next.lock, root };
606
+ const unreadable = unreadableLockRefusal(effective.state, key);
607
+ if (unreadable) {
608
+ await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "lock_record_unreadable" });
609
+ return unreadable;
610
+ }
611
+ const decision = await decideLockChange(context, { action, keys: [key], before, after: next.lock });
612
+ if (!decision.allow) {
613
+ await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "hook_refused" });
614
+ return decision.response;
615
+ }
616
+ const isPrivileged = body.bypassGovernance === true && decision.isApproved;
617
+ const change = canChange2(before ?? {}, next.lock, {
618
+ privileged: isPrivileged,
619
+ now,
620
+ timing: timingOf(effective.state)
621
+ });
622
+ if (!change.ok) {
623
+ await recordLockAudit(context, { ...audit, outcome: "rejected", reason: change.reason });
624
+ return refusal(context, key, change.reason);
625
+ }
626
+ const sidecar = nextSidecar(effective, objectVersionOf(object), next.lock, now);
627
+ if (!await writeSidecar(context.env.MEDIA_BUCKET, root, key, sidecar, effective.stored)) {
628
+ await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "lock_conflict" });
629
+ return context.json({ error: `The lock of ${key} changed meanwhile; retry`, code: "lock_conflict", key }, 409);
630
+ }
631
+ await recordLockAudit(context, { ...audit, outcome: "applied", ...change.bypass ? { bypass: true } : {} });
632
+ const state = { ...effective.state ?? {}, lock: next.lock };
633
+ return context.json(lockView(key, { state, source: "sidecar", stored: { sidecar, etag: "" } }));
634
+ });
635
+ function nextSidecar(effective, objectVersion, lock, now) {
636
+ const previous = effective.source === "sidecar" ? effective.stored?.sidecar : void 0;
637
+ return {
638
+ revision: (effective.stored?.sidecar.revision ?? 0) + 1,
639
+ objectVersion,
640
+ lock,
641
+ ...previous?.holdReleasedAt ? { holdReleasedAt: previous.holdReleasedAt } : {},
642
+ updatedAt: now.toISOString()
643
+ };
644
+ }
645
+ function refusal(context, key, reason) {
646
+ if (reason === "lock_privilege_required") {
647
+ return context.json(
648
+ {
649
+ error: "Shortening or removing a governance retention needs bypassGovernance and an authorizeLockChange approval",
650
+ code: reason,
651
+ key
652
+ },
653
+ 403
654
+ );
655
+ }
656
+ return context.json({ error: `The compliance retention of ${key} can only be extended`, code: reason, key }, 409);
657
+ }
658
+
659
+ // src/plugin.ts
660
+ var PHASES = ["beforeAuth", "auth", "afterAuth"];
661
+ function assertComposable(plugins) {
662
+ const names = /* @__PURE__ */ new Set();
663
+ for (const plugin of plugins) {
664
+ if (names.has(plugin.name)) throw new Error(`createWorker: two plugins are named "${plugin.name}"`);
665
+ names.add(plugin.name);
666
+ }
667
+ if (!plugins.some((plugin) => (plugin.middleware?.auth?.length ?? 0) > 0)) {
668
+ throw new Error("createWorker: no plugin authenticates requests. Add workerSecretAuth() or your own auth plugin.");
669
+ }
670
+ }
671
+ function composePlugins(plugins) {
672
+ assertComposable(plugins);
673
+ return {
674
+ middleware: PHASES.flatMap((phase) => plugins.flatMap((plugin) => plugin.middleware?.[phase] ?? [])),
675
+ routes: plugins.flatMap((plugin) => plugin.routes ?? []),
676
+ hooks: {
677
+ issueCredential: plugins.flatMap((plugin) => plugin.issueCredential ? [plugin.issueCredential] : []),
678
+ beforeLinkRedeem: plugins.flatMap((plugin) => plugin.beforeLinkRedeem ? [plugin.beforeLinkRedeem] : []),
679
+ authorizeLockChange: plugins.flatMap(
680
+ (plugin) => plugin.authorizeLockChange ? [plugin.authorizeLockChange] : []
681
+ ),
682
+ onLockAudit: plugins.flatMap((plugin) => plugin.onLockAudit ? [plugin.onLockAudit] : [])
683
+ }
684
+ };
685
+ }
686
+
130
687
  // src/plugins/credential-policy.ts
131
688
  function credentialPolicy(options = {}) {
132
689
  return {
@@ -140,11 +697,11 @@ function credentialPolicy(options = {}) {
140
697
  }
141
698
 
142
699
  // src/plugins/download-links.ts
143
- import { Hono as Hono2 } from "hono";
700
+ import { Hono as Hono3 } from "hono";
144
701
 
145
702
  // src/routes/download.ts
146
703
  import { contentDispositionAttachment as contentDispositionAttachment2, relativeMediaPath, TokenScope } from "@devultur/core";
147
- import { Hono } from "hono";
704
+ import { Hono as Hono2 } from "hono";
148
705
 
149
706
  // src/download-link.ts
150
707
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -224,7 +781,7 @@ function addRouteResult(context, result) {
224
781
  import {
225
782
  AES_BLOCK_BYTES,
226
783
  createDecryptStream,
227
- decodeEncryptionMeta,
784
+ decodeEncryptionMeta as decodeEncryptionMeta2,
228
785
  decryptChunk,
229
786
  EXPIRES_AT_HEADER,
230
787
  REPR_DIGEST_HEADER,
@@ -242,7 +799,13 @@ var ObjectMeta = {
242
799
  /** Epoch ms. From then on the gate responds 410 (and an external sweeper may delete it). */
243
800
  EXPIRES_AT: "expiresAt"
244
801
  };
245
- var WORKER_METADATA_KEYS = /* @__PURE__ */ new Set(["security", "enc", ObjectMeta.EXPIRES_AT.toLowerCase(), "derivedfrom"]);
802
+ var WORKER_METADATA_KEYS = /* @__PURE__ */ new Set([
803
+ "security",
804
+ "enc",
805
+ ObjectMeta.EXPIRES_AT.toLowerCase(),
806
+ "derivedfrom",
807
+ LOCK_METADATA_KEY
808
+ ]);
246
809
  function isWorkerMetadataKey(key) {
247
810
  const lowerKey = key.toLowerCase();
248
811
  return lowerKey.startsWith(ObjectMeta.SHA256) || WORKER_METADATA_KEYS.has(lowerKey);
@@ -251,8 +814,8 @@ function allowedUploadMetadata(metadata) {
251
814
  if (!metadata) return {};
252
815
  return Object.fromEntries(Object.entries(metadata).filter(([key]) => !isWorkerMetadataKey(key)));
253
816
  }
254
- function targetFailure(context, failure3) {
255
- return context.json({ error: failure3.error, code: failure3.code }, failure3.status);
817
+ function targetFailure(context, failure4) {
818
+ return context.json({ error: failure4.error, code: failure4.code }, failure4.status);
256
819
  }
257
820
  function alreadyExists(context, key) {
258
821
  return context.json(
@@ -278,14 +841,19 @@ async function digestBody(body, declared, header) {
278
841
  if (expected && expected !== actual) return { mismatch: { expected, actual } };
279
842
  return { sha256: actual };
280
843
  }
844
+ function lockMetadata(lock) {
845
+ const encoded = encodeLockMetadata(lock);
846
+ return encoded ? { [LOCK_METADATA_KEY]: encoded } : {};
847
+ }
281
848
  function integrityMetadata(sha256, isVerified, expiresAt) {
282
849
  return {
283
850
  ...sha256 ? { [ObjectMeta.SHA256]: sha256, [ObjectMeta.SHA256_VERIFIED]: String(isVerified) } : {},
284
851
  ...expiresAt ? { [ObjectMeta.EXPIRES_AT]: String(expiresAt) } : {}
285
852
  };
286
853
  }
287
- function createOnlyCondition() {
288
- return new Headers({ "If-None-Match": "*" });
854
+ function expiryMetadataOf(customMetadata) {
855
+ const expiresAt = customMetadata?.[ObjectMeta.EXPIRES_AT];
856
+ return expiresAt ? { [ObjectMeta.EXPIRES_AT]: expiresAt } : {};
289
857
  }
290
858
  async function discardStaleVariants(bucket, pending) {
291
859
  if (!pending?.overwrite) return;
@@ -293,9 +861,13 @@ async function discardStaleVariants(bucket, pending) {
293
861
  }
294
862
 
295
863
  // src/routes/object-response.ts
296
- function isObjectExpired(customMetadata, now = Date.now()) {
864
+ function objectExpiresAt(customMetadata) {
297
865
  const expiresAt = Number(customMetadata?.[ObjectMeta.EXPIRES_AT]);
298
- return Number.isFinite(expiresAt) && expiresAt > 0 && expiresAt <= now;
866
+ return Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : void 0;
867
+ }
868
+ function isObjectExpired(customMetadata, now = Date.now()) {
869
+ const expiresAt = objectExpiresAt(customMetadata);
870
+ return expiresAt !== void 0 && expiresAt <= now;
299
871
  }
300
872
  function expiredResponse() {
301
873
  return Response.json({ error: "This file has expired", code: "expired" }, { status: 410 });
@@ -352,7 +924,7 @@ async function streamObject(input) {
352
924
  headers.set("ETag", object.httpEtag);
353
925
  headers.set("Accept-Ranges", "bytes");
354
926
  applyIntegrityHeaders(headers, object.customMetadata);
355
- const enc = decodeEncryptionMeta(object.customMetadata?.enc);
927
+ const enc = decodeEncryptionMeta2(object.customMetadata?.enc);
356
928
  const loaded = enc ? await loadStorageKey(input.keysBucket, input.fullKey, enc.keyId) : null;
357
929
  if (enc && !loaded) {
358
930
  return { response: Response.json({ error: "Decryption key unavailable" }, { status: 500 }), kind: "other" };
@@ -410,8 +982,8 @@ async function handleDownloadLink(context) {
410
982
  const object = await context.env.MEDIA_BUCKET.head(fullKey);
411
983
  if (!object) return context.json({ error: "Not found" }, 404);
412
984
  if (isObjectExpired(object.customMetadata)) return expiredResponse();
413
- const objectExpiresAt = Number(object.customMetadata?.expiresAt) || Infinity;
414
- const requestedExpiresAt = Math.min(Date.now() + parsed.expiresIn * 1e3, objectExpiresAt);
985
+ const objectExpiresAt2 = Number(object.customMetadata?.expiresAt) || Infinity;
986
+ const requestedExpiresAt = Math.min(Date.now() + parsed.expiresIn * 1e3, objectExpiresAt2);
415
987
  const decision = await decideCredential(context, {
416
988
  kind: "download-link",
417
989
  scopes: [TokenScope.MEDIA_READ],
@@ -456,7 +1028,7 @@ async function gateRedemption(context, stub) {
456
1028
  if (!peeked.ok) return relayLinkFailure(peeked);
457
1029
  return gateLinkRedemption(context, linkInfoOf(await peeked.json()));
458
1030
  }
459
- var download = new Hono();
1031
+ var download = new Hono2();
460
1032
  download.get("/:linkId", async (context) => {
461
1033
  if (context.req.method === "HEAD") return new Response(null, { status: 405, headers: { Allow: "GET" } });
462
1034
  const linkId = context.req.param("linkId");
@@ -490,7 +1062,7 @@ download.get("/:linkId", async (context) => {
490
1062
 
491
1063
  // src/plugins/download-links.ts
492
1064
  function downloadLinks() {
493
- const issue = new Hono2().post("/", handleDownloadLink);
1065
+ const issue = new Hono3().post("/", handleDownloadLink);
494
1066
  return {
495
1067
  name: "download-links",
496
1068
  routes: [
@@ -510,7 +1082,11 @@ function staticConfig(config) {
510
1082
  }
511
1083
 
512
1084
  // src/routes/token-upload.ts
513
- import { assetRelativePath as assetRelativePath2, normalizeAssetPrefix } from "@devultur/core";
1085
+ import {
1086
+ assetRelativePath as assetRelativePath2,
1087
+ normalizeAssetPrefix,
1088
+ validateLock as validateLock5
1089
+ } from "@devultur/core";
514
1090
 
515
1091
  // src/token-claims.ts
516
1092
  function audienceClaim(storagePrefix2) {
@@ -671,18 +1247,26 @@ function parseVisibility(value) {
671
1247
  if (value === void 0 || value === null) return "private";
672
1248
  return value === "public" || value === "private" ? value : null;
673
1249
  }
1250
+ function lockClaim(value) {
1251
+ if (value === void 0) return void 0;
1252
+ const validation = validateLock5(value);
1253
+ return validation.ok ? validation.lock : null;
1254
+ }
674
1255
  function uploadGrantFromClaims(payload) {
675
1256
  if (payload.typ !== UPLOAD_TOKEN_TYPE || typeof payload.res !== "string" || typeof payload.jti !== "string") {
676
1257
  return null;
677
1258
  }
678
1259
  if (payload.visibility !== "public" && payload.visibility !== "private") return null;
1260
+ const lock = lockClaim(payload.lock);
1261
+ if (lock === null) return null;
679
1262
  return {
680
1263
  tokenId: payload.jti,
681
1264
  pathPrefix: payload.res,
682
1265
  visibility: payload.visibility,
683
1266
  ...isPositiveInteger(payload.max_bytes) ? { maxBytes: payload.max_bytes } : {},
684
1267
  ...isPositiveInteger(payload.max_files) ? { maxFiles: payload.max_files } : {},
685
- ...isPositiveInteger(payload.file_ttl) ? { fileExpiresIn: payload.file_ttl } : {}
1268
+ ...isPositiveInteger(payload.file_ttl) ? { fileExpiresIn: payload.file_ttl } : {},
1269
+ ...lock ? { lock } : {}
686
1270
  };
687
1271
  }
688
1272
  var badRequest = (context, code, error) => context.json({ error, code }, 400);
@@ -703,6 +1287,8 @@ async function handleUploadToken(context) {
703
1287
  if (fileExpiresIn === null) {
704
1288
  return badRequest(context, "invalid_file_expires_in", "fileExpiresIn must be a positive number of seconds");
705
1289
  }
1290
+ const lock = body.lock === void 0 ? { ok: true, lock: void 0 } : validateLock5(body.lock);
1291
+ if (!lock.ok) return badRequest(context, "invalid_lock", lock.errors.join("; "));
706
1292
  const pathPrefix = assetRelativePath2(prefix);
707
1293
  const now = Math.floor(Date.now() / 1e3);
708
1294
  const requestedExp = now + (expiresIn ?? DEFAULT_UPLOAD_TOKEN_TTL_SECONDS);
@@ -726,7 +1312,8 @@ async function handleUploadToken(context) {
726
1312
  exp: expiresAt,
727
1313
  ...maxBytes ? { max_bytes: maxBytes } : {},
728
1314
  ...maxFiles ? { max_files: maxFiles } : {},
729
- ...fileExpiresIn ? { file_ttl: fileExpiresIn } : {}
1315
+ ...fileExpiresIn ? { file_ttl: fileExpiresIn } : {},
1316
+ ...lock.lock ? { lock: lock.lock } : {}
730
1317
  },
731
1318
  decision
732
1319
  );
@@ -738,7 +1325,8 @@ async function handleUploadToken(context) {
738
1325
  expiresAt,
739
1326
  ...maxBytes ? { maxBytes } : {},
740
1327
  ...maxFiles ? { maxFiles } : {},
741
- ...fileExpiresIn ? { fileExpiresIn } : {}
1328
+ ...fileExpiresIn ? { fileExpiresIn } : {},
1329
+ ...lock.lock ? { lock: lock.lock } : {}
742
1330
  });
743
1331
  }
744
1332
 
@@ -821,9 +1409,46 @@ function defaultPlugins(config = {}) {
821
1409
 
822
1410
  // src/routes/assets.ts
823
1411
  import { ASSET_NAMESPACE, normalizeAssetPrefix as normalizeAssetPrefix2 } from "@devultur/core";
824
- import { Hono as Hono3 } from "hono";
1412
+ import { Hono as Hono4 } from "hono";
1413
+
1414
+ // src/object-lock/delivery.ts
1415
+ import { effectiveUntil as effectiveUntil3 } from "@devultur/core";
1416
+ var LOCK_UNTIL_HEADER = "x-devultur-lock-until";
1417
+ var LOCK_HOLD_HEADER = "x-devultur-lock-hold";
1418
+ async function lockHeaders(context, key, object) {
1419
+ const root = getRequestState(context).storagePrefix;
1420
+ const { state } = await effectiveLock(context.env.MEDIA_BUCKET, root, key, object);
1421
+ if (!state || !isStateLocked(state, /* @__PURE__ */ new Date())) return [];
1422
+ const headers = [];
1423
+ try {
1424
+ const until = effectiveUntil3(state.lock, timingOf(state));
1425
+ if (until) headers.push([LOCK_UNTIL_HEADER, until]);
1426
+ } catch {
1427
+ }
1428
+ if (state.lock.hold) headers.push([LOCK_HOLD_HEADER, "true"]);
1429
+ return headers;
1430
+ }
1431
+ async function retainedOf(bucket, location, object) {
1432
+ const { state } = await effectiveLock(bucket, location.root, location.key, object);
1433
+ if (!state || !isStateLocked(state, /* @__PURE__ */ new Date())) return null;
1434
+ let until;
1435
+ try {
1436
+ until = effectiveUntil3(state.lock, timingOf(state));
1437
+ } catch {
1438
+ }
1439
+ return { ...until ? { until } : {}, hold: state.lock.hold === true };
1440
+ }
1441
+ async function expiredRefusal(bucket, location, object, isOwnerRead) {
1442
+ if (!isObjectExpired(object.customMetadata)) return null;
1443
+ if (isOwnerRead && await retainedOf(bucket, location, object) !== null) return null;
1444
+ return expiredResponse();
1445
+ }
1446
+
1447
+ // src/routes/assets.ts
825
1448
  var MAX_ASSET_LIST_LIMIT = 1e3;
1449
+ var MAX_EXPIRED_LOOKUPS = 32;
826
1450
  var VISIBILITY_ORDER = ["private", "public"];
1451
+ var RESUME_AFTER_MARK = "~";
827
1452
  function parseListPrefix(raw) {
828
1453
  if (!raw?.trim()) return "";
829
1454
  return normalizeAssetPrefix2(raw);
@@ -834,7 +1459,12 @@ function parseListCursor(raw, visibilities) {
834
1459
  const visibility = raw.slice(0, separator);
835
1460
  if (separator < 0 || !visibilities.includes(visibility)) return null;
836
1461
  const cursor = raw.slice(separator + 1);
837
- return { visibility, ...cursor ? { cursor } : {} };
1462
+ if (!cursor.startsWith(RESUME_AFTER_MARK)) return { visibility, ...cursor ? { cursor } : {} };
1463
+ try {
1464
+ return { visibility, startAfter: decodeURIComponent(cursor.slice(RESUME_AFTER_MARK.length)) };
1465
+ } catch {
1466
+ return null;
1467
+ }
838
1468
  }
839
1469
  function parseLimit(raw) {
840
1470
  const limit = Number(raw);
@@ -861,7 +1491,32 @@ function nextCursor(listing, position, visibilities) {
861
1491
  const next = visibilities[visibilities.indexOf(position.visibility) + 1];
862
1492
  return next ? `${next}:` : null;
863
1493
  }
864
- var assets = new Hono3();
1494
+ var resumeCursor = (visibility, afterKey) => `${visibility}:${RESUME_AFTER_MARK}${encodeURIComponent(afterKey)}`;
1495
+ var expiredEntry = (entry, retained) => ({
1496
+ ...entry,
1497
+ expired: true,
1498
+ ...retained.until ? { retainedUntil: retained.until } : {},
1499
+ ...retained.hold ? { hold: true } : {}
1500
+ });
1501
+ async function listedEntries(bucket, listing, storageRoot, visibility) {
1502
+ const page = { objects: [] };
1503
+ let lookups = 0;
1504
+ let lastKey;
1505
+ for (const object of listing.objects) {
1506
+ const entry = toEntry(object, storageRoot, visibility);
1507
+ if (isObjectExpired(object.customMetadata)) {
1508
+ if (lookups === MAX_EXPIRED_LOOKUPS) return { ...page, cutAfter: lastKey };
1509
+ lookups += 1;
1510
+ const retained = await retainedOf(bucket, { root: storageRoot, key: entry.key }, object);
1511
+ if (retained) page.objects.push(expiredEntry(entry, retained));
1512
+ } else {
1513
+ page.objects.push(entry);
1514
+ }
1515
+ lastKey = entry.key;
1516
+ }
1517
+ return page;
1518
+ }
1519
+ var assets = new Hono4();
865
1520
  assets.get("/", async (context) => {
866
1521
  const prefix = parseListPrefix(context.req.query("prefix"));
867
1522
  if (prefix === null) {
@@ -879,23 +1534,251 @@ assets.get("/", async (context) => {
879
1534
  prefix: `${storageRoot}${position.visibility}/${ASSET_NAMESPACE}/${prefix}`,
880
1535
  limit: parseLimit(context.req.query("limit")),
881
1536
  ...position.cursor ? { cursor: position.cursor } : {},
1537
+ ...position.startAfter ? { startAfter: `${storageRoot}${position.startAfter}` } : {},
882
1538
  include: ["httpMetadata", "customMetadata"]
883
1539
  };
884
1540
  const listing = await context.env.MEDIA_BUCKET.list(listOptions);
885
- const objects = listing.objects.filter((object) => !isObjectExpired(object.customMetadata)).map((object) => toEntry(object, storageRoot, position.visibility));
886
- return context.json({ objects, cursor: nextCursor(listing, position, visibilities) });
1541
+ const page = await listedEntries(context.env.MEDIA_BUCKET, listing, storageRoot, position.visibility);
1542
+ const cursor = page.cutAfter ? resumeCursor(position.visibility, page.cutAfter) : nextCursor(listing, position, visibilities);
1543
+ return context.json({ objects: page.objects, cursor });
887
1544
  });
888
1545
 
889
1546
  // src/routes/captions.ts
890
1547
  import {
891
1548
  captionPath,
892
1549
  createDecryptStream as createDecryptStream2,
893
- decodeEncryptionMeta as decodeEncryptionMeta2,
1550
+ decodeEncryptionMeta as decodeEncryptionMeta5,
894
1551
  extractId,
895
1552
  resolveSecurity,
896
1553
  visibilityFromKey
897
1554
  } from "@devultur/core";
898
- import { Hono as Hono5 } from "hono";
1555
+ import { Hono as Hono6 } from "hono";
1556
+
1557
+ // src/keys-bucket.ts
1558
+ import { decodeEncryptionMeta as decodeEncryptionMeta3 } from "@devultur/core";
1559
+ var KEYS_BUCKET_MISSING_CODE = "keys_bucket_missing";
1560
+ function isKeysBucketMissing(shouldEncrypt, env) {
1561
+ return shouldEncrypt && !env.KEYS_BUCKET;
1562
+ }
1563
+ function keysBucketMissing(context) {
1564
+ console.error("[encrypt] security.encrypt is on but KEYS_BUCKET is not bound: refusing to store a private file");
1565
+ return context.json(
1566
+ { error: "Encryption at rest is on but KEYS_BUCKET is not bound", code: KEYS_BUCKET_MISSING_CODE },
1567
+ 503
1568
+ );
1569
+ }
1570
+ var ENCRYPTION_KEY_MISSING_CODE = "encryption_key_missing";
1571
+ async function hasEncryptionKey(keysBucket, objectStorageKey, enc) {
1572
+ const meta = decodeEncryptionMeta3(enc);
1573
+ if (!meta) return true;
1574
+ if (!keysBucket) return false;
1575
+ return await keysBucket.head(`${objectStorageKey}/${meta.keyId}`) !== null;
1576
+ }
1577
+ function encryptionKeyMissing(context, key) {
1578
+ console.error(`[encrypt] the encryption key of ${key} is gone: refusing to store an object nobody could decrypt`);
1579
+ return context.json(
1580
+ { error: `The encryption key of ${key} was deleted; upload it again`, code: ENCRYPTION_KEY_MISSING_CODE, key },
1581
+ 500
1582
+ );
1583
+ }
1584
+
1585
+ // src/object-lock/overwrite.ts
1586
+ import { decodeEncryptionMeta as decodeEncryptionMeta4, TranscodeStatus as TranscodeStatus2 } from "@devultur/core";
1587
+
1588
+ // src/transcode/status.ts
1589
+ import { TranscodeStatus } from "@devultur/core";
1590
+ var statusKey = (videoId) => `${KvPrefix.TRANSCODE_STATUS}:${videoId}`;
1591
+ function statusChangeFor(event) {
1592
+ switch (event.event) {
1593
+ case "transcode.started":
1594
+ return { status: TranscodeStatus.RUNNING };
1595
+ case "transcode.progress":
1596
+ return event.phase === "downloading" ? { status: TranscodeStatus.RUNNING } : null;
1597
+ case "transcode.complete":
1598
+ return { status: TranscodeStatus.COMPLETED };
1599
+ case "transcode.failed":
1600
+ return {
1601
+ status: TranscodeStatus.FAILED,
1602
+ ...typeof event.error === "string" ? { error: event.error } : {},
1603
+ ...typeof event.reason === "string" ? { reason: event.reason } : {},
1604
+ ...typeof event.retriable === "boolean" ? { retriable: event.retriable } : {}
1605
+ };
1606
+ default:
1607
+ return null;
1608
+ }
1609
+ }
1610
+ async function readStatus(kv, videoId) {
1611
+ return kv.get(statusKey(videoId), "json");
1612
+ }
1613
+ async function writeQueuedStatus(kv, videoId, job) {
1614
+ const record = { status: TranscodeStatus.QUEUED, ...job, updatedAt: Date.now() };
1615
+ await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
1616
+ }
1617
+ async function applyStatusEvent(kv, event) {
1618
+ const change = statusChangeFor(event);
1619
+ const videoId = typeof event.videoId === "string" ? event.videoId : "";
1620
+ if (!change || !videoId) return;
1621
+ const current = await readStatus(kv, videoId);
1622
+ if (!current) return;
1623
+ if (current.status === TranscodeStatus.COMPLETED) return;
1624
+ const record = {
1625
+ jobRef: current.jobRef,
1626
+ ...current.runner ? { runner: current.runner } : {},
1627
+ ...change,
1628
+ updatedAt: Date.now()
1629
+ };
1630
+ await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
1631
+ }
1632
+
1633
+ // src/transcode/raw-retention.ts
1634
+ import { isVideoRawKey as isVideoRawKey2 } from "@devultur/core";
1635
+ var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
1636
+ async function readRawDisposal(kv, videoId) {
1637
+ return kv.get(disposalKey(videoId), "json");
1638
+ }
1639
+ async function rememberRawDisposal(kv, videoId, disposal) {
1640
+ await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
1641
+ }
1642
+ async function disposeRawAfterTranscode(context, videoId) {
1643
+ const { env } = context;
1644
+ const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
1645
+ if (!disposal?.deleteOnComplete || !disposal.storageKey) return "kept";
1646
+ const raw = await env.MEDIA_BUCKET.head(disposal.storageKey);
1647
+ const location = splitVideoStorageKey(disposal.storageKey);
1648
+ const lock = raw && location ? await effectiveLock(env.MEDIA_BUCKET, location.root, location.key, raw) : null;
1649
+ if (lock?.state && isStateLocked(lock.state, /* @__PURE__ */ new Date())) {
1650
+ await recordLockAudit(context, {
1651
+ action: "original_retained",
1652
+ keys: [location?.key ?? disposal.storageKey],
1653
+ before: lock.state.lock,
1654
+ after: null,
1655
+ outcome: "skipped",
1656
+ root: location?.root ?? ""
1657
+ });
1658
+ return "retained";
1659
+ }
1660
+ await env.MEDIA_BUCKET.delete(disposal.storageKey);
1661
+ if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
1662
+ return "deleted";
1663
+ }
1664
+ var ORIGINAL_NOT_RETAINED = {
1665
+ error: "The original of this video is no longer retained, so it can't be re-encoded. Upload it again, or set `config.transcode.deleteOriginalOnSuccess` to false for future videos.",
1666
+ code: "original_not_retained"
1667
+ };
1668
+ var PRIOR_OUTPUTS_LIST_LIMIT = 100;
1669
+ async function hasPriorTranscodeOutputs(bucket, videoFolder) {
1670
+ const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
1671
+ return listing.objects.some((object) => !isVideoRawKey2(object.key));
1672
+ }
1673
+
1674
+ // src/object-lock/transcode-marker.ts
1675
+ var MARKER_NAME = "transcode-completed";
1676
+ var markerKey = (root, videoFolder) => sidecarKey(root, `${videoFolder}/${MARKER_NAME}`);
1677
+ async function markTranscodeCompleted(env, videoId, ticketLocation) {
1678
+ const location = ticketLocation ?? await rememberedLocation(env, videoId);
1679
+ if (!location) return;
1680
+ await env.MEDIA_BUCKET.put(markerKey(location.root, location.folder), (/* @__PURE__ */ new Date()).toISOString(), {
1681
+ httpMetadata: { contentType: "text/plain" }
1682
+ });
1683
+ }
1684
+ async function rememberedLocation(env, videoId) {
1685
+ const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
1686
+ const raw = disposal?.storageKey ? splitVideoStorageKey(disposal.storageKey) : null;
1687
+ const folder = raw?.key.split("/").slice(0, 3).join("/");
1688
+ return raw && folder ? { root: raw.root, folder } : null;
1689
+ }
1690
+ async function hasCompletedTranscode(bucket, root, videoFolder) {
1691
+ return await bucket.head(markerKey(root, videoFolder)) !== null;
1692
+ }
1693
+ async function forgetTranscodeCompleted(bucket, root, videoFolder) {
1694
+ await bucket.delete(markerKey(root, videoFolder));
1695
+ }
1696
+
1697
+ // src/object-lock/overwrite.ts
1698
+ function sameObject(object) {
1699
+ return {
1700
+ etagMatches: object.etag,
1701
+ // R2 keeps `uploaded` to the millisecond, so "before uploaded + 1 ms" matches exactly this write and refuses
1702
+ // any later one. Real R2 only: the in-memory bucket of the tests can't prove the precision.
1703
+ ...object.uploaded ? { uploadedBefore: new Date(object.uploaded.getTime() + 1) } : {}
1704
+ };
1705
+ }
1706
+ function overwriteOf(object) {
1707
+ const replacedEnc = object.customMetadata?.enc;
1708
+ return { onlyIf: sameObject(object), exists: true, ...replacedEnc ? { replacedEnc } : {} };
1709
+ }
1710
+ function createOnlyCondition() {
1711
+ return new Headers({ "If-None-Match": "*" });
1712
+ }
1713
+ async function checkWrite(context, key, fullKey = storageKey(context, key), options = {}) {
1714
+ if (isReservedLockKey(key)) return { blocked: reservedKeyResponse(context, key) };
1715
+ const object = await context.env.MEDIA_BUCKET.head(fullKey);
1716
+ if (!object) return { onlyIf: createOnlyCondition(), exists: false };
1717
+ if (options.isLockExempt) return overwriteOf(object);
1718
+ const root = fullKey.endsWith(key) ? fullKey.slice(0, fullKey.length - key.length) : void 0;
1719
+ const blocked = await assertWritable(context, [key], "overwrite", root);
1720
+ if (blocked) return { blocked };
1721
+ return overwriteOf(object);
1722
+ }
1723
+ async function deleteReplacedKey(context, fullKey, replacedEnc, writtenEnc) {
1724
+ const meta = decodeEncryptionMeta4(replacedEnc);
1725
+ if (!meta || !context.env.KEYS_BUCKET) return;
1726
+ if (decodeEncryptionMeta4(writtenEnc)?.keyId === meta.keyId) return;
1727
+ await context.env.KEYS_BUCKET.delete(`${fullKey}/${meta.keyId}`);
1728
+ }
1729
+ function writeConflict(context, key) {
1730
+ return context.json({ error: `${key} changed while it was being written; retry`, code: "write_conflict", key }, 409);
1731
+ }
1732
+ var WriteConflictError = class extends Error {
1733
+ constructor(key) {
1734
+ super(`${key} changed while it was being written`);
1735
+ this.key = key;
1736
+ this.name = "WriteConflictError";
1737
+ }
1738
+ key;
1739
+ };
1740
+ async function assertRetranscodable(context, video, raw) {
1741
+ const root = getRequestState(context).storagePrefix;
1742
+ const { state } = await effectiveLock(context.env.MEDIA_BUCKET, root, video.rawKey, raw);
1743
+ if (!state) return null;
1744
+ const isCompleted = await hasCompletedTranscode(context.env.MEDIA_BUCKET, root, video.folder) || (await readStatus(context.env.MEDIA_KV, video.id))?.status === TranscodeStatus2.COMPLETED;
1745
+ if (!isCompleted) return null;
1746
+ return assertWritable(context, [video.folder], "overwrite");
1747
+ }
1748
+ async function checkWrites(context, keys) {
1749
+ const checks = /* @__PURE__ */ new Map();
1750
+ for (const key of keys) {
1751
+ const check = await checkWrite(context, key);
1752
+ if ("blocked" in check) return check;
1753
+ checks.set(key, check);
1754
+ }
1755
+ return { checks };
1756
+ }
1757
+
1758
+ // src/object-lock/caption-writes.ts
1759
+ async function prepareCaptionWrites(context, locales, virtualKeyOf, source) {
1760
+ const writes = await checkWrites(context, locales.map(virtualKeyOf));
1761
+ if ("blocked" in writes) return writes;
1762
+ const storageKeyOf = (locale) => storageKey(context, virtualKeyOf(locale));
1763
+ const expiry = expiryMetadataOf(source.customMetadata);
1764
+ return {
1765
+ storageKeyOf,
1766
+ put: async (locale, body, options) => {
1767
+ const check = writes.checks.get(virtualKeyOf(locale));
1768
+ const customMetadata = { ...options.customMetadata, ...expiry };
1769
+ const stored = await context.env.MEDIA_BUCKET.put(storageKeyOf(locale), body, {
1770
+ ...options,
1771
+ ...Object.keys(customMetadata).length > 0 ? { customMetadata } : {},
1772
+ onlyIf: check?.onlyIf
1773
+ });
1774
+ if (stored === null) throw new WriteConflictError(virtualKeyOf(locale));
1775
+ await deleteReplacedKey(context, storageKeyOf(locale), check?.replacedEnc, customMetadata.enc);
1776
+ }
1777
+ };
1778
+ }
1779
+ function captionConflictResponse(context, error) {
1780
+ return error instanceof WriteConflictError ? writeConflict(context, error.key) : null;
1781
+ }
899
1782
 
900
1783
  // src/routes/captions-errors.ts
901
1784
  var CaptionsErrorCode = {
@@ -954,7 +1837,7 @@ function logCaptionsError(context, error) {
954
1837
  }
955
1838
 
956
1839
  // src/routes/webhooks.ts
957
- import { Hono as Hono4 } from "hono";
1840
+ import { Hono as Hono5 } from "hono";
958
1841
 
959
1842
  // src/transcode/metrics.ts
960
1843
  var OPERATOR_EVENT_FIELDS = ["encode", "posterKey", "posterFormats"];
@@ -1072,76 +1955,8 @@ async function loadOutputBaseUrl(kv, videoId, fallbackUrl) {
1072
1955
  return await kv.get(outputBaseKey(videoId)) ?? fallbackUrl;
1073
1956
  }
1074
1957
 
1075
- // src/transcode/raw-retention.ts
1076
- import { isVideoRawKey } from "@devultur/core";
1077
- var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
1078
- async function rememberRawDisposal(kv, videoId, disposal) {
1079
- await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
1080
- }
1081
- async function disposeRawAfterTranscode(env, videoId) {
1082
- const disposal = await env.MEDIA_KV.get(disposalKey(videoId), "json");
1083
- if (!disposal?.deleteOnComplete || !disposal.storageKey) return false;
1084
- await env.MEDIA_BUCKET.delete(disposal.storageKey);
1085
- if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
1086
- return true;
1087
- }
1088
- var ORIGINAL_NOT_RETAINED = {
1089
- error: "The original of this video is no longer retained, so it can't be re-encoded. Upload it again, or set `config.transcode.deleteOriginalOnSuccess` to false for future videos.",
1090
- code: "original_not_retained"
1091
- };
1092
- var PRIOR_OUTPUTS_LIST_LIMIT = 100;
1093
- async function hasPriorTranscodeOutputs(bucket, videoFolder) {
1094
- const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
1095
- return listing.objects.some((object) => !isVideoRawKey(object.key));
1096
- }
1097
-
1098
- // src/transcode/status.ts
1099
- import { TranscodeStatus } from "@devultur/core";
1100
- var statusKey = (videoId) => `${KvPrefix.TRANSCODE_STATUS}:${videoId}`;
1101
- function statusChangeFor(event) {
1102
- switch (event.event) {
1103
- case "transcode.started":
1104
- return { status: TranscodeStatus.RUNNING };
1105
- case "transcode.progress":
1106
- return event.phase === "downloading" ? { status: TranscodeStatus.RUNNING } : null;
1107
- case "transcode.complete":
1108
- return { status: TranscodeStatus.COMPLETED };
1109
- case "transcode.failed":
1110
- return {
1111
- status: TranscodeStatus.FAILED,
1112
- ...typeof event.error === "string" ? { error: event.error } : {},
1113
- ...typeof event.reason === "string" ? { reason: event.reason } : {},
1114
- ...typeof event.retriable === "boolean" ? { retriable: event.retriable } : {}
1115
- };
1116
- default:
1117
- return null;
1118
- }
1119
- }
1120
- async function readStatus(kv, videoId) {
1121
- return kv.get(statusKey(videoId), "json");
1122
- }
1123
- async function writeQueuedStatus(kv, videoId, job) {
1124
- const record = { status: TranscodeStatus.QUEUED, ...job, updatedAt: Date.now() };
1125
- await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
1126
- }
1127
- async function applyStatusEvent(kv, event) {
1128
- const change = statusChangeFor(event);
1129
- const videoId = typeof event.videoId === "string" ? event.videoId : "";
1130
- if (!change || !videoId) return;
1131
- const current = await readStatus(kv, videoId);
1132
- if (!current) return;
1133
- if (current.status === TranscodeStatus.COMPLETED) return;
1134
- const record = {
1135
- jobRef: current.jobRef,
1136
- ...current.runner ? { runner: current.runner } : {},
1137
- ...change,
1138
- updatedAt: Date.now()
1139
- };
1140
- await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
1141
- }
1142
-
1143
1958
  // src/routes/webhooks.ts
1144
- var webhooks = new Hono4();
1959
+ var webhooks = new Hono5();
1145
1960
  webhooks.post("/transcode", async (context) => {
1146
1961
  const webhookSecret = context.env.TRANSCODE_WEBHOOK_SECRET;
1147
1962
  if (!webhookSecret) {
@@ -1156,7 +1971,12 @@ webhooks.post("/transcode", async (context) => {
1156
1971
  const body = await context.req.json();
1157
1972
  await publishTranscodeJob(context, body);
1158
1973
  if (body.event === "transcode.complete" && body.videoId) {
1159
- if (await disposeRawAfterTranscode(context.env, body.videoId)) addRouteResult(context, { originalDeleted: true });
1974
+ const disposal = await disposeRawAfterTranscode(context, body.videoId);
1975
+ if (disposal === "deleted") addRouteResult(context, { originalDeleted: true });
1976
+ if (disposal === "retained") addRouteResult(context, { originalRetained: true });
1977
+ const ticket = context.req.header(JOB_TICKET_HEADER);
1978
+ const location = await verifyJobLocation(context.env.DELIVERY_SIGN_SECRET, ticket, body.videoId);
1979
+ await markTranscodeCompleted(context.env, body.videoId, location);
1160
1980
  await context.env.MEDIA_KV.put(
1161
1981
  `${KvPrefix.TRANSCODE_META}:${body.videoId}`,
1162
1982
  JSON.stringify({ duration: body.duration }),
@@ -1358,7 +2178,7 @@ async function transcodedDuration(kv, videoId) {
1358
2178
  const meta = await kv.get(`${KvPrefix.TRANSCODE_META}:${videoId}`, "json");
1359
2179
  return meta?.duration ?? void 0;
1360
2180
  }
1361
- var captions = new Hono5();
2181
+ var captions = new Hono6();
1362
2182
  captions.post("/", async (context) => {
1363
2183
  const body = await context.req.json();
1364
2184
  if (!body.key || !body.locales?.length) {
@@ -1382,7 +2202,7 @@ captions.post("/", async (context) => {
1382
2202
  }
1383
2203
  const filename = body.key.split("/").pop() ?? "audio.mp4";
1384
2204
  const webhookData = await resolveCaptionsWebhook(context, videoId, body.callbackUrl);
1385
- const rawEnc = decodeEncryptionMeta2(file.customMetadata?.enc);
2205
+ const rawEnc = decodeEncryptionMeta5(file.customMetadata?.enc);
1386
2206
  let audioBody = file.body;
1387
2207
  if (rawEnc) {
1388
2208
  const loaded = await loadStorageKey(context.env.KEYS_BUCKET, fullKey, rawEnc.keyId);
@@ -1392,12 +2212,15 @@ captions.post("/", async (context) => {
1392
2212
  audioBody = file.body.pipeThrough(createDecryptStream2(loaded.key, rawEnc.iv));
1393
2213
  }
1394
2214
  const visibility = visibilityFromKey(body.key) ?? "private";
1395
- const captionKey = (locale) => storageKey(context, `${visibility}/${captionPath(videoId, locale)}`);
2215
+ const captionVirtualKey = (locale) => `${visibility}/${captionPath(videoId, locale)}`;
1396
2216
  const resourceOverride = parseSecurityHeader(file.customMetadata?.security);
1397
2217
  const captionPolicy = resolveSecurity(resourceOverride, scope.config?.security);
1398
- const encryptCaptions = captionPolicy.encrypt && visibility === "private" && Boolean(context.env.KEYS_BUCKET);
2218
+ const encryptCaptions = Boolean(captionPolicy.encrypt) && visibility === "private";
2219
+ if (isKeysBucketMissing(encryptCaptions, context.env)) return keysBucketMissing(context);
2220
+ const writes = await prepareCaptionWrites(context, body.locales, captionVirtualKey, file);
2221
+ if ("blocked" in writes) return writes.blocked;
1399
2222
  const putCaption = async (locale, vtt) => {
1400
- const objectKey = captionKey(locale);
2223
+ const objectKey = writes.storageKeyOf(locale);
1401
2224
  const metadata = {};
1402
2225
  if (resourceOverride) metadata.security = JSON.stringify(resourceOverride);
1403
2226
  let bodyToStore = vtt;
@@ -1411,7 +2234,7 @@ captions.post("/", async (context) => {
1411
2234
  bodyToStore = result.body;
1412
2235
  metadata.enc = result.enc;
1413
2236
  }
1414
- await context.env.MEDIA_BUCKET.put(objectKey, bodyToStore, {
2237
+ await writes.put(locale, bodyToStore, {
1415
2238
  httpMetadata: { contentType: "text/vtt" },
1416
2239
  ...Object.keys(metadata).length > 0 ? { customMetadata: metadata } : {}
1417
2240
  });
@@ -1466,18 +2289,19 @@ captions.post("/", async (context) => {
1466
2289
  });
1467
2290
  } catch (err) {
1468
2291
  logCaptionsError(`job failed for ${videoId}`, err);
1469
- const failure3 = toCaptionsFailure(err);
2292
+ const failure4 = toCaptionsFailure(err);
1470
2293
  await forwardToClientWebhook(
1471
2294
  {
1472
2295
  event: "captions.failed",
1473
2296
  videoId,
1474
- error: failure3.error,
1475
- reason: failure3.reason,
1476
- retriable: failure3.retriable
2297
+ error: failure4.error,
2298
+ reason: failure4.reason,
2299
+ retriable: failure4.retriable
1477
2300
  },
1478
2301
  webhookData
1479
2302
  );
1480
- return context.json({ error: failure3.error, code: failure3.code }, failure3.status);
2303
+ const conflict = captionConflictResponse(context, err);
2304
+ return conflict ?? context.json({ error: failure4.error, code: failure4.code }, failure4.status);
1481
2305
  }
1482
2306
  });
1483
2307
  captions.get("/:transcriptId", async (context) => {
@@ -1498,16 +2322,15 @@ captions.get("/:transcriptId", async (context) => {
1498
2322
  // src/routes/media.ts
1499
2323
  import {
1500
2324
  computeFingerprint,
1501
- decodeEncryptionMeta as decodeEncryptionMeta4,
2325
+ decodeEncryptionMeta as decodeEncryptionMeta7,
1502
2326
  GENERATED_PRIVATE_CACHE_CONTROL,
1503
2327
  GENERATED_PUBLIC_CACHE_CONTROL,
1504
2328
  resolveSecurity as resolveSecurity2,
1505
2329
  SESSION_HEADER,
1506
2330
  toPrivateCacheControl,
1507
- videoBasePath,
1508
2331
  visibilityFromKey as visibilityFromKey2
1509
2332
  } from "@devultur/core";
1510
- import { Hono as Hono6 } from "hono";
2333
+ import { Hono as Hono7 } from "hono";
1511
2334
 
1512
2335
  // src/image/cloudflare.ts
1513
2336
  var IMAGE_RESIZING_VIA = "image-resizing";
@@ -1714,7 +2537,7 @@ function hasAvifSequenceBrand(bytes) {
1714
2537
  }
1715
2538
 
1716
2539
  // src/image/storage.ts
1717
- import { decodeEncryptionMeta as decodeEncryptionMeta3, decryptChunk as decryptChunk2 } from "@devultur/core";
2540
+ import { decodeEncryptionMeta as decodeEncryptionMeta6, decryptChunk as decryptChunk2 } from "@devultur/core";
1718
2541
  var HEADER_PROBE_BYTES = 256 * 1024;
1719
2542
  var passThrough = async (bytes) => bytes;
1720
2543
  var DerivedKind = {
@@ -1722,7 +2545,7 @@ var DerivedKind = {
1722
2545
  MEZZANINE: "mezzanine"
1723
2546
  };
1724
2547
  async function loadDecryptor(env, objectKey, customMetadata) {
1725
- const enc = decodeEncryptionMeta3(customMetadata?.enc);
2548
+ const enc = decodeEncryptionMeta6(customMetadata?.enc);
1726
2549
  if (!enc) return passThrough;
1727
2550
  const loaded = await loadStorageKey(env.KEYS_BUCKET, objectKey, enc.keyId);
1728
2551
  if (!loaded) return null;
@@ -1743,7 +2566,7 @@ async function storeDerived(env, storageKey2, body, contentType, original, kind)
1743
2566
  const customMetadata = { derivedFrom: kind };
1744
2567
  if (security) customMetadata.security = security;
1745
2568
  let storedBody = body;
1746
- if (decodeEncryptionMeta3(original.customMetadata?.enc)) {
2569
+ if (decodeEncryptionMeta6(original.customMetadata?.enc)) {
1747
2570
  if (!env.KEYS_BUCKET) throw new Error("Encrypted original without KEYS_BUCKET");
1748
2571
  const encrypted = await encryptForStorage(env.KEYS_BUCKET, storageKey2, body, security ? { security } : void 0);
1749
2572
  storedBody = encrypted.body;
@@ -1912,6 +2735,8 @@ import { edgeTtlSeconds, isAssetKey, sha256Hex as sha256Hex2 } from "@devultur/c
1912
2735
  var MAX_EDGE_TTL_SECONDS = 7 * 24 * 60 * 60;
1913
2736
  var MAX_EDGE_CACHE_FILL_BYTES = 100 * 1024 * 1024;
1914
2737
  var ORIGINAL_CACHE_CONTROL_HEADER = "x-devultur-cache-control";
2738
+ var EDGE_EXPIRES_HEADER = "x-devultur-edge-expires";
2739
+ var OWNER_HEADER_PREFIX = "x-devultur-lock";
1915
2740
  var CACHE_TAG_HEADER = "Cache-Tag";
1916
2741
  var TAG_HASH_LENGTH = 40;
1917
2742
  function publicEdgeCache() {
@@ -1935,35 +2760,112 @@ async function edgeCacheTags(storagePrefix2, virtualKey) {
1935
2760
  const objectTag = await edgeCacheTag(storagePrefix2, virtualKey);
1936
2761
  return prefixTag ? `${objectTag},${prefixTag}` : objectTag;
1937
2762
  }
2763
+ function isEdgeCopyExpired(hit, now = Date.now()) {
2764
+ const expiresAt = Number(hit.headers.get(EDGE_EXPIRES_HEADER));
2765
+ return Number.isFinite(expiresAt) && expiresAt > 0 && expiresAt <= now;
2766
+ }
2767
+ async function matchEdgeCopy(cache, request, options) {
2768
+ const hit = await cache.match(request);
2769
+ if (!hit || isEdgeCopyExpired(hit)) return null;
2770
+ return responseFromEdgeHit(hit, options);
2771
+ }
1938
2772
  function responseFromEdgeHit(hit, options) {
1939
2773
  const headers = new Headers(hit.headers);
1940
2774
  if (options.isFormatNegotiated) headers.set("Vary", "Accept");
1941
2775
  const original = headers.get(ORIGINAL_CACHE_CONTROL_HEADER);
1942
2776
  if (original) headers.set("Cache-Control", original);
1943
2777
  headers.delete(ORIGINAL_CACHE_CONTROL_HEADER);
2778
+ headers.delete(EDGE_EXPIRES_HEADER);
1944
2779
  headers.delete(CACHE_TAG_HEADER);
1945
2780
  return new Response(hit.body, { status: hit.status, headers });
1946
2781
  }
1947
2782
  function edgeTtlFor(cacheControl) {
1948
2783
  return edgeTtlSeconds(cacheControl, MAX_EDGE_TTL_SECONDS);
1949
2784
  }
1950
- async function putEdgeCopy(target, body, headers) {
2785
+ function edgeCopyTtl(target, now = Date.now()) {
1951
2786
  const ttl = edgeTtlFor(target.cacheControl);
2787
+ if (target.expiresAt === void 0) return ttl;
2788
+ return Math.max(0, Math.min(ttl, Math.floor((target.expiresAt - now) / 1e3)));
2789
+ }
2790
+ var carriesOwnerHeaders = (headers) => [...headers.keys()].some((name) => name.toLowerCase().startsWith(OWNER_HEADER_PREFIX));
2791
+ async function putEdgeCopy(target, body, headers) {
2792
+ const ttl = edgeCopyTtl(target);
2793
+ if (ttl <= 0 || carriesOwnerHeaders(headers)) {
2794
+ await body?.cancel();
2795
+ return;
2796
+ }
1952
2797
  const copyHeaders = new Headers(headers);
1953
2798
  copyHeaders.delete("Content-Range");
1954
2799
  copyHeaders.delete("Vary");
1955
2800
  copyHeaders.set(ORIGINAL_CACHE_CONTROL_HEADER, target.cacheControl);
1956
2801
  copyHeaders.set("Cache-Control", `public, max-age=${ttl}`);
2802
+ if (target.expiresAt !== void 0) copyHeaders.set(EDGE_EXPIRES_HEADER, String(target.expiresAt));
1957
2803
  copyHeaders.set(CACHE_TAG_HEADER, await edgeCacheTags(target.storagePrefix, target.virtualKey));
1958
2804
  await target.cache.put(target.cacheKey, new Response(body, { status: 200, headers: copyHeaders }));
1959
2805
  }
1960
2806
  async function fillEdgeCache(target, bucket, fullKey, headers) {
2807
+ if (edgeCopyTtl(target) <= 0 || carriesOwnerHeaders(headers)) return;
1961
2808
  const full = await bucket.get(fullKey);
1962
2809
  if (!full) return;
1963
2810
  const fullHeaders = new Headers(headers);
1964
2811
  fullHeaders.set("Content-Length", String(full.size));
1965
2812
  await putEdgeCopy(target, full.body, fullHeaders);
1966
2813
  }
2814
+ function cacheDelivered(waitUntil, target, delivered) {
2815
+ if (edgeCopyTtl(target) <= 0) return;
2816
+ if (delivered.kind === "plain-range" && delivered.size <= MAX_EDGE_CACHE_FILL_BYTES) {
2817
+ waitUntil(fillEdgeCache(target, delivered.bucket, delivered.servedKey, delivered.response.headers));
2818
+ }
2819
+ if (delivered.kind === "plain-full") {
2820
+ waitUntil(putEdgeCopy(target, delivered.response.clone().body, delivered.headers));
2821
+ }
2822
+ }
2823
+
2824
+ // src/routes/media-delete.ts
2825
+ import { videoBasePath } from "@devultur/core";
2826
+ async function handleVideoDelete(context) {
2827
+ const videoId = context.req.param("videoId") ?? "";
2828
+ const visibilityParam = context.req.query("visibility");
2829
+ const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
2830
+ const folders = visibilities.map((visibility) => `${visibility}/${videoBasePath(videoId)}`);
2831
+ const blocked = await assertWritable(context, folders, "delete");
2832
+ if (blocked) return blocked;
2833
+ let deletedCount = 0;
2834
+ let deletedKeyCount = 0;
2835
+ for (const folder of folders) {
2836
+ const deleted = await guardedDeletePrefix(context, `${folder}/`);
2837
+ if ("blocked" in deleted) return deleted.blocked;
2838
+ deletedCount += deleted.deletedCount;
2839
+ if (deleted.deletedCount > 0) {
2840
+ await forgetTranscodeCompleted(context.env.MEDIA_BUCKET, getRequestState(context).storagePrefix, folder);
2841
+ }
2842
+ if (!folder.startsWith("private/")) continue;
2843
+ const deletedKeys = await deleteFolderKeys(context, folder);
2844
+ if ("blocked" in deletedKeys) return deletedKeys.blocked;
2845
+ deletedKeyCount += deletedKeys.deletedCount;
2846
+ }
2847
+ return context.json({ videoId, deletedCount, deletedKeyCount });
2848
+ }
2849
+ async function handleKeyDelete(context) {
2850
+ const key = context.req.param("key") ?? "";
2851
+ const fullKey = storageKey(context, key);
2852
+ if (isVariantKey(key)) {
2853
+ return context.json({ error: "Not found" }, 404);
2854
+ }
2855
+ if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
2856
+ const exists = await context.env.MEDIA_BUCKET.head(fullKey);
2857
+ if (!exists) {
2858
+ return context.json({ error: "Not found" }, 404);
2859
+ }
2860
+ const blocked = await assertWritable(context, [key], "delete");
2861
+ if (blocked) return blocked;
2862
+ await context.env.MEDIA_BUCKET.delete(fullKey);
2863
+ const variants = await guardedDeletePrefix(context, variantPrefix(key));
2864
+ if ("blocked" in variants) return variants.blocked;
2865
+ const keys = await deleteKeysFor(context, key, exists);
2866
+ if ("blocked" in keys) return keys.blocked;
2867
+ return new Response(null, { status: 204 });
2868
+ }
1967
2869
 
1968
2870
  // src/routes/storyboard-vtt.ts
1969
2871
  import { createDecryptStream as createDecryptStream3 } from "@devultur/core";
@@ -1993,7 +2895,7 @@ function downloadDisposition(storedDisposition, key, filename) {
1993
2895
  if (storedDisposition?.startsWith("inline")) return `attachment${storedDisposition.slice("inline".length)}`;
1994
2896
  return contentDispositionAttachment(key.split("/").pop() || "download");
1995
2897
  }
1996
- var media = new Hono6();
2898
+ var media = new Hono7();
1997
2899
  media.get("/:key{.+}", async (context) => {
1998
2900
  const key = context.req.param("key");
1999
2901
  const fullKey = storageKey(context, key);
@@ -2033,24 +2935,28 @@ media.get("/:key{.+}", async (context) => {
2033
2935
  const accept = context.req.header("Accept");
2034
2936
  const edgeCacheKey = workerParams ? `${requestUrl.origin}/__variant/${workerVariantKey(fullKey, workerParams, accept)}` : requestUrl.toString();
2035
2937
  const isUncachedTransform = hasTransformQuery(requestUrl) && !workerParams;
2036
- const edgeCache = isPrivate || isOriginRequest || isUncachedTransform ? null : publicEdgeCache();
2938
+ const isEdgeCached = !isPrivate && !isOriginRequest && !isUncachedTransform && !isOwnerAccess;
2939
+ const edgeCache = isEdgeCached ? publicEdgeCache() : null;
2037
2940
  if (edgeCache) {
2038
2941
  const cacheRequest = workerParams ? new Request(edgeCacheKey, context.req.raw) : context.req.raw;
2039
- const hit = await edgeCache.match(cacheRequest);
2040
- if (hit) return responseFromEdgeHit(hit, { isFormatNegotiated });
2942
+ const hit = await matchEdgeCopy(edgeCache, cacheRequest, { isFormatNegotiated });
2943
+ if (hit) return hit;
2041
2944
  }
2042
2945
  let object = await context.env.MEDIA_BUCKET.get(fullKey);
2043
2946
  let servedKey = fullKey;
2044
2947
  if (!object) {
2045
2948
  return context.json({ error: "Not found" }, 404);
2046
2949
  }
2047
- if (isObjectExpired(object.customMetadata)) return expiredResponse();
2950
+ const location = { root: getRequestState(context).storagePrefix, key };
2951
+ const expired = await expiredRefusal(context.env.MEDIA_BUCKET, location, object, isOwnerAccess);
2952
+ if (expired) return expired;
2953
+ const expiresAt = objectExpiresAt(object.customMetadata);
2048
2954
  if (tokenPayload && await bindingMismatch(context, tokenPayload, object.customMetadata)) {
2049
2955
  return context.json({ error: "Session mismatch", code: "session_mismatch" }, 401);
2050
2956
  }
2051
2957
  let contentType = object.httpMetadata?.contentType ?? "application/octet-stream";
2052
2958
  const isPlaylist = key.endsWith(".m3u8");
2053
- const cacheControl = servedCacheControl(object.httpMetadata?.cacheControl, isPrivate);
2959
+ const cacheControl = servedCacheControl(object.httpMetadata?.cacheControl, isPrivate || isOwnerAccess);
2054
2960
  const downloadParam = requestUrl.searchParams.get("download");
2055
2961
  const isDownload = downloadParam !== null;
2056
2962
  if (isPlaylist && token2) {
@@ -2096,7 +3002,7 @@ media.get("/:key{.+}", async (context) => {
2096
3002
  });
2097
3003
  }
2098
3004
  if (token2 && isStoryboardVttKey(key) && object.body) {
2099
- const enc = decodeEncryptionMeta4(object.customMetadata?.enc);
3005
+ const enc = decodeEncryptionMeta7(object.customMetadata?.enc);
2100
3006
  const loaded = enc ? await loadStorageKey(context.env.KEYS_BUCKET, fullKey, enc.keyId) : null;
2101
3007
  if (enc && !loaded) return context.json({ error: "Decryption key unavailable" }, 500);
2102
3008
  const decryption = enc && loaded ? { key: loaded.key, iv: enc.iv } : null;
@@ -2111,6 +3017,7 @@ media.get("/:key{.+}", async (context) => {
2111
3017
  } else if (object.httpMetadata?.contentDisposition) {
2112
3018
  headers.set("Content-Disposition", object.httpMetadata.contentDisposition);
2113
3019
  }
3020
+ if (isOwnerAccess) for (const [name, value] of await lockHeaders(context, key, object)) headers.set(name, value);
2114
3021
  const { response, kind } = await streamObject({
2115
3022
  bucket: context.env.MEDIA_BUCKET,
2116
3023
  keysBucket: context.env.KEYS_BUCKET,
@@ -2119,56 +3026,25 @@ media.get("/:key{.+}", async (context) => {
2119
3026
  headers,
2120
3027
  range: context.req.header("Range")
2121
3028
  });
2122
- if (!edgeCache || edgeTtlFor(cacheControl) <= 0) return response;
3029
+ if (!edgeCache) return response;
2123
3030
  const target = {
2124
3031
  cache: edgeCache,
2125
3032
  cacheKey: edgeCacheKey,
2126
3033
  storagePrefix: getRequestState(context).storagePrefix,
2127
3034
  virtualKey: key,
2128
- cacheControl
3035
+ cacheControl,
3036
+ expiresAt
2129
3037
  };
2130
- if (kind === "plain-range" && object.size <= MAX_EDGE_CACHE_FILL_BYTES) {
2131
- context.executionCtx.waitUntil(fillEdgeCache(target, context.env.MEDIA_BUCKET, servedKey, response.headers));
2132
- }
2133
- if (kind === "plain-full") {
2134
- context.executionCtx.waitUntil(putEdgeCopy(target, response.clone().body, headers));
2135
- }
3038
+ const delivered = { bucket: context.env.MEDIA_BUCKET, servedKey, size: object.size, kind, response, headers };
3039
+ cacheDelivered((task) => context.executionCtx.waitUntil(task), target, delivered);
2136
3040
  return response;
2137
3041
  });
2138
3042
  function servedCacheControl(stored, isPrivate) {
2139
3043
  if (!stored) return isPrivate ? GENERATED_PRIVATE_CACHE_CONTROL : GENERATED_PUBLIC_CACHE_CONTROL;
2140
3044
  return isPrivate ? toPrivateCacheControl(stored) : stored;
2141
3045
  }
2142
- media.delete("/videos/:videoId", async (context) => {
2143
- const videoId = context.req.param("videoId");
2144
- const visibilityParam = context.req.query("visibility");
2145
- const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
2146
- let deletedCount = 0;
2147
- let deletedKeyCount = 0;
2148
- for (const visibility of visibilities) {
2149
- const prefix = storagePrefix(context, `${visibility}/${videoBasePath(videoId)}/`);
2150
- deletedCount += await deletePrefix(context.env.MEDIA_BUCKET, prefix);
2151
- if (visibility === "private" && context.env.KEYS_BUCKET) {
2152
- deletedKeyCount += await deletePrefix(context.env.KEYS_BUCKET, prefix);
2153
- }
2154
- }
2155
- return context.json({ videoId, deletedCount, deletedKeyCount });
2156
- });
2157
- media.delete("/:key{.+}", async (context) => {
2158
- const key = context.req.param("key");
2159
- const fullKey = storageKey(context, key);
2160
- if (isVariantKey(key)) {
2161
- return context.json({ error: "Not found" }, 404);
2162
- }
2163
- const exists = await context.env.MEDIA_BUCKET.head(fullKey);
2164
- if (!exists) {
2165
- return context.json({ error: "Not found" }, 404);
2166
- }
2167
- await context.env.MEDIA_BUCKET.delete(fullKey);
2168
- await deletePrefix(context.env.MEDIA_BUCKET, variantPrefix(fullKey));
2169
- if (context.env.KEYS_BUCKET) await deletePrefix(context.env.KEYS_BUCKET, `${fullKey}/`);
2170
- return new Response(null, { status: 204 });
2171
- });
3046
+ media.delete("/videos/:videoId", handleVideoDelete);
3047
+ media.delete("/:key{.+}", handleKeyDelete);
2172
3048
  function parseSecurityMeta(raw) {
2173
3049
  if (!raw) return void 0;
2174
3050
  try {
@@ -2217,9 +3093,9 @@ async function serveEncryptionKey(context, key, tokenPayload) {
2217
3093
 
2218
3094
  // src/routes/token.ts
2219
3095
  import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as TokenScope3 } from "@devultur/core";
2220
- import { Hono as Hono7 } from "hono";
3096
+ import { Hono as Hono8 } from "hono";
2221
3097
  var DEFAULT_VIEWER_TOKEN_TTL_SECONDS = 7200;
2222
- var token = new Hono7();
3098
+ var token = new Hono8();
2223
3099
  token.post("/", async (context) => {
2224
3100
  const body = await context.req.json();
2225
3101
  if (!body.userId) {
@@ -2281,7 +3157,7 @@ import {
2281
3157
  resolveSecurity as resolveSecurity3,
2282
3158
  resolveTranscodePreset,
2283
3159
  TokenScope as TokenScope4,
2284
- TranscodeStatus as TranscodeStatus2,
3160
+ TranscodeStatus as TranscodeStatus3,
2285
3161
  videoBasePath as videoBasePath2,
2286
3162
  videoHlsPath,
2287
3163
  videoMp4Dir,
@@ -2290,7 +3166,7 @@ import {
2290
3166
  videoThumbnailPath,
2291
3167
  visibilityFromKey as visibilityFromKey3
2292
3168
  } from "@devultur/core";
2293
- import { Hono as Hono8 } from "hono";
3169
+ import { Hono as Hono9 } from "hono";
2294
3170
 
2295
3171
  // src/transcode/plan.ts
2296
3172
  import { MAIN_VARIANT } from "@devultur/core";
@@ -3229,7 +4105,7 @@ function parseSecurityMeta2(raw) {
3229
4105
  }
3230
4106
  var SERVICE_TOKEN_TTL_SECONDS = 3600;
3231
4107
  var isVideoId = (videoId, key) => Boolean(videoId) && videoId !== key;
3232
- var transcode = new Hono8();
4108
+ var transcode = new Hono9();
3233
4109
  transcode.post("/", async (context) => {
3234
4110
  const body = await context.req.json();
3235
4111
  if (!body.key) {
@@ -3250,6 +4126,9 @@ transcode.post("/", async (context) => {
3250
4126
  if (!isVideoId(videoId, body.key)) {
3251
4127
  return context.json({ error: "Invalid key format" }, 400);
3252
4128
  }
4129
+ const video = { id: videoId, folder: `${visibility}/${videoBasePath2(videoId)}`, rawKey: body.key };
4130
+ const retained = await assertRetranscodable(context, video, object);
4131
+ if (retained) return retained;
3253
4132
  const scope = getRequestState(context);
3254
4133
  const transcodeConfig = scope.config?.transcode;
3255
4134
  const resolved = resolveTranscodePreset(body.preset, transcodeConfig?.presets);
@@ -3269,7 +4148,8 @@ transcode.post("/", async (context) => {
3269
4148
  const resourceSecurityHeader = rawOverride ? JSON.stringify(rawOverride) : "";
3270
4149
  const keyMetadata = rawOverride ? { security: resourceSecurityHeader } : void 0;
3271
4150
  const policy = resolveSecurity3(rawOverride, scope.config?.security);
3272
- const shouldEncrypt = policy.encrypt && visibility === "private" && Boolean(context.env.KEYS_BUCKET);
4151
+ const shouldEncrypt = Boolean(policy.encrypt) && visibility === "private";
4152
+ if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
3273
4153
  let encryptKeyB64 = "";
3274
4154
  let encryptKeyId = "";
3275
4155
  if (shouldEncrypt && context.env.KEYS_BUCKET) {
@@ -3325,7 +4205,8 @@ transcode.post("/", async (context) => {
3325
4205
  });
3326
4206
  const { ticket: jobTicket } = await signJobTicket(context.env.DELIVERY_SIGN_SECRET, {
3327
4207
  videoId,
3328
- ...scope.context ? { context: scope.context } : {}
4208
+ ...scope.context ? { context: scope.context } : {},
4209
+ location: { root: scope.storagePrefix, folder: video.folder }
3329
4210
  });
3330
4211
  const runner = createRunner(context.env);
3331
4212
  if (!runner) {
@@ -3386,7 +4267,7 @@ transcode.post("/", async (context) => {
3386
4267
  outputPrefix,
3387
4268
  thumbnailKey,
3388
4269
  preset,
3389
- status: TranscodeStatus2.QUEUED
4270
+ status: TranscodeStatus3.QUEUED
3390
4271
  });
3391
4272
  });
3392
4273
  transcode.get("/:jobId", async (context) => {
@@ -3399,12 +4280,12 @@ transcode.get("/:jobId", async (context) => {
3399
4280
  kv.get(`${KvPrefix.TRANSCODE_META}:${jobMeta.videoId}`, "json")
3400
4281
  ]);
3401
4282
  const ownRecord = record?.jobRef === jobId ? record : null;
3402
- const status = ownRecord?.status ?? (durationMeta ? TranscodeStatus2.COMPLETED : TranscodeStatus2.QUEUED);
3403
- if (status === TranscodeStatus2.FAILED) {
4283
+ const status = ownRecord?.status ?? (durationMeta ? TranscodeStatus3.COMPLETED : TranscodeStatus3.QUEUED);
4284
+ if (status === TranscodeStatus3.FAILED) {
3404
4285
  const { error, reason, retriable } = ownRecord ?? {};
3405
4286
  return context.json({ jobId, status, error, reason, retriable });
3406
4287
  }
3407
- if (status !== TranscodeStatus2.COMPLETED) return context.json({ jobId, status });
4288
+ if (status !== TranscodeStatus3.COMPLETED) return context.json({ jobId, status });
3408
4289
  return context.json({
3409
4290
  jobId,
3410
4291
  status,
@@ -3415,29 +4296,42 @@ transcode.get("/:jobId", async (context) => {
3415
4296
  });
3416
4297
 
3417
4298
  // src/routes/upload.ts
3418
- import {
3419
- decodeEncryptionMeta as decodeEncryptionMeta6,
3420
- MediaType as MediaType2,
3421
- resolveSecurity as resolveSecurity5,
3422
- resolveVisibility,
3423
- UploadRequestKind as UploadRequestKind2
3424
- } from "@devultur/core";
3425
- import { Hono as Hono9 } from "hono";
4299
+ import { MediaType as MediaType2, resolveSecurity as resolveSecurity5, resolveVisibility, UploadRequestKind as UploadRequestKind2 } from "@devultur/core";
4300
+ import { Hono as Hono10 } from "hono";
3426
4301
 
3427
- // src/routes/upload-multipart.ts
3428
- import {
3429
- AES_BLOCK_BYTES as AES_BLOCK_BYTES2,
3430
- classifyUploadRequest,
3431
- counterForBlock,
3432
- decodeEncryptionMeta as decodeEncryptionMeta5,
3433
- encryptBytes,
3434
- MULTIPART_PART_SIZE_BYTES,
3435
- MULTIPART_QUERY,
3436
- multipartPartCount,
3437
- multipartPartRange,
3438
- resolveSecurity as resolveSecurity4,
3439
- UploadRequestKind
3440
- } from "@devultur/core";
4302
+ // src/object-lock/upload-lock.ts
4303
+ import { canChange as canChange3, resolveLock, validateLock as validateLock6 } from "@devultur/core";
4304
+ var failure3 = (status, code, error) => ({ status, code, error });
4305
+ function requestedLock(lock, hold) {
4306
+ if (hold !== void 0 && typeof hold !== "boolean") return failure3(400, "invalid_lock", "hold must be a boolean");
4307
+ if (lock === void 0 && hold === void 0) return void 0;
4308
+ const validation = validateLock6(lock ?? {});
4309
+ if (!validation.ok) return failure3(400, "invalid_lock", validation.errors.join("; "));
4310
+ return { ...validation.lock, ...hold !== void 0 ? { hold } : {} };
4311
+ }
4312
+ function satisfiesMinimum(minimum, lock, now) {
4313
+ if (minimum.hold && !lock.hold) return false;
4314
+ const change = canChange3(minimum, lock, { privileged: false, now, timing: { uploadedAt: now } });
4315
+ return change.ok && !change.bypass;
4316
+ }
4317
+ function resolveUploadLock(input) {
4318
+ const requested = requestedLock(input.lock, input.hold);
4319
+ if (requested && "code" in requested) return requested;
4320
+ const minimum = input.grant?.lock;
4321
+ const resolution = resolveLock(requested ?? minimum, input.appDefault);
4322
+ if (!resolution.ok) return failure3(400, "invalid_lock", resolution.errors.join("; "));
4323
+ const lock = resolution.lock;
4324
+ if (minimum && !satisfiesMinimum(minimum, lock, input.now)) {
4325
+ return failure3(403, "lock_below_minimum", "The upload token requires a stricter lock than the request asks for");
4326
+ }
4327
+ return lock.retention || lock.hold ? lock : void 0;
4328
+ }
4329
+
4330
+ // src/object-lock/upload-write.ts
4331
+ import { decodeEncryptionMeta as decodeEncryptionMeta8, resolveLock as resolveLock2 } from "@devultur/core";
4332
+
4333
+ // src/object-lock/job-outputs.ts
4334
+ import { isBearerOf, TranscodeStatus as TranscodeStatus4 } from "@devultur/core";
3441
4335
 
3442
4336
  // src/routes/derived-upload.ts
3443
4337
  var DERIVED_ASSET_KEY = /^(public|private)\/video\/[A-Za-z0-9-]+\/(hls|mp4|thumbnails|storyboard)\/[^/]+$/;
@@ -3451,18 +4345,109 @@ var DERIVED_CONTENT_TYPES = /* @__PURE__ */ new Set([
3451
4345
  function isDerivedAssetKey(key) {
3452
4346
  return DERIVED_ASSET_KEY.test(key);
3453
4347
  }
4348
+ var RAW_LIST_LIMIT = 100;
4349
+ async function inheritedExpiresAt(context, key) {
4350
+ if (!isDerivedAssetKey(key)) return void 0;
4351
+ const videoFolder = key.split("/").slice(0, 3).join("/");
4352
+ const options = {
4353
+ prefix: `${getRequestState(context).storagePrefix}${videoFolder}/raw/`,
4354
+ limit: RAW_LIST_LIMIT,
4355
+ include: ["customMetadata"]
4356
+ };
4357
+ const listing = await context.env.MEDIA_BUCKET.list(options);
4358
+ const expiries = listing.objects.map((raw) => objectExpiresAt(raw.customMetadata)).filter((expiresAt) => expiresAt !== void 0);
4359
+ return expiries.length > 0 ? Math.min(...expiries) : void 0;
4360
+ }
3454
4361
  async function derivedAssetPending(context, key) {
3455
4362
  if (!isDerivedAssetKey(key) || !isAuthenticated(context)) return null;
3456
4363
  const body = await context.req.json().catch(() => null);
3457
4364
  const declared = typeof body?.contentType === "string" ? body.contentType : "";
3458
4365
  const security = parseSecurityHeader(context.req.header(RESOURCE_SECURITY_HEADER));
4366
+ const expiresAt = await inheritedExpiresAt(context, key);
3459
4367
  return {
3460
4368
  contentType: DERIVED_CONTENT_TYPES.has(declared) ? declared : "application/octet-stream",
3461
4369
  storageKey: storageKey(context, key),
3462
- ...security ? { security } : {}
4370
+ ...security ? { security } : {},
4371
+ ...expiresAt ? { expiresAt } : {}
3463
4372
  };
3464
4373
  }
3465
4374
 
4375
+ // src/object-lock/job-outputs.ts
4376
+ var VIDEO_ID = /^(?:public|private)\/video\/([^/]+)\//;
4377
+ var IN_PROGRESS = /* @__PURE__ */ new Set([TranscodeStatus4.QUEUED, TranscodeStatus4.RUNNING]);
4378
+ async function isInProgressJobOutput(context, key) {
4379
+ if (!isDerivedAssetKey(key)) return false;
4380
+ if (!isBearerOf(context.req.header("Authorization"), getRequestState(context).runnerCredential)) return false;
4381
+ const videoId = VIDEO_ID.exec(key)?.[1];
4382
+ if (!videoId) return false;
4383
+ const status = await readStatus(context.env.MEDIA_KV, videoId);
4384
+ return status !== null && IN_PROGRESS.has(status.status);
4385
+ }
4386
+
4387
+ // src/object-lock/upload-write.ts
4388
+ async function prepareUploadWrite(context, key, pending, fullKey) {
4389
+ const isLockExempt = await isInProgressJobOutput(context, key);
4390
+ const check = await checkWrite(context, key, fullKey, { isLockExempt });
4391
+ if ("blocked" in check) return check;
4392
+ const isCreateOnly = Boolean(pending?.isAsset && !pending.overwrite);
4393
+ if (isCreateOnly && check.exists) return { blocked: alreadyExists(context, key) };
4394
+ return check;
4395
+ }
4396
+ async function assetUploadConflict(context, key, fullKey, shouldOverwrite) {
4397
+ const check = await checkWrite(context, key, fullKey);
4398
+ if ("blocked" in check) return check.blocked;
4399
+ return check.exists && !shouldOverwrite ? alreadyExists(context, key) : null;
4400
+ }
4401
+ async function failedWrite(context, key, pending, enc) {
4402
+ const meta = decodeEncryptionMeta8(enc);
4403
+ const fullKey = pending?.storageKey ?? storageKey(context, key);
4404
+ if (context.env.KEYS_BUCKET && meta) await context.env.KEYS_BUCKET.delete(`${fullKey}/${meta.keyId}`);
4405
+ return pending?.isAsset && !pending.overwrite ? alreadyExists(context, key) : writeConflict(context, key);
4406
+ }
4407
+ function uploadLockOf(context, key, pending) {
4408
+ if (pending) return pending.lock;
4409
+ if (lockSubjectOf(key)?.kind !== "original") return void 0;
4410
+ const resolution = resolveLock2(void 0, getRequestState(context).config?.lock);
4411
+ if (!resolution.ok) {
4412
+ console.error("config.lock is invalid:", resolution.errors.join("; "));
4413
+ return { hold: true };
4414
+ }
4415
+ const { lock } = resolution;
4416
+ return lock.retention || lock.hold ? lock : void 0;
4417
+ }
4418
+ async function afterUploadWrite(context, key, pending, write, writtenEnc) {
4419
+ const fullKey = pending?.storageKey ?? storageKey(context, key);
4420
+ const root = fullKey.endsWith(key) ? fullKey.slice(0, -key.length) : getRequestState(context).storagePrefix;
4421
+ if (pending) await discardStaleVariants(context.env.MEDIA_BUCKET, pending);
4422
+ if (write.exists) await deleteReplacedKey(context, fullKey, write.replacedEnc, writtenEnc);
4423
+ const lock = uploadLockOf(context, key, pending);
4424
+ if (!lock) return;
4425
+ await recordLockAudit(context, {
4426
+ action: "set",
4427
+ keys: [key],
4428
+ before: null,
4429
+ after: lock,
4430
+ outcome: "applied",
4431
+ actor: pending ? pending.context ?? null : getRequestState(context).context ?? null,
4432
+ root
4433
+ });
4434
+ }
4435
+
4436
+ // src/routes/upload-multipart.ts
4437
+ import {
4438
+ AES_BLOCK_BYTES as AES_BLOCK_BYTES2,
4439
+ classifyUploadRequest,
4440
+ counterForBlock,
4441
+ decodeEncryptionMeta as decodeEncryptionMeta9,
4442
+ encryptBytes,
4443
+ MULTIPART_PART_SIZE_BYTES,
4444
+ MULTIPART_QUERY,
4445
+ multipartPartCount,
4446
+ multipartPartRange,
4447
+ resolveSecurity as resolveSecurity4,
4448
+ UploadRequestKind
4449
+ } from "@devultur/core";
4450
+
3466
4451
  // src/routes/upload-pending.ts
3467
4452
  var UPLOAD_PENDING_QUERY = "u";
3468
4453
  var PENDING_ID_BYTES = 16;
@@ -3620,15 +4605,19 @@ async function handleMultipartCreate(context, key, target) {
3620
4605
  if (pending.multipart) {
3621
4606
  return context.json({ uploadId: pending.multipart.uploadId, partSize: pending.multipart.partSize, partCount });
3622
4607
  }
3623
- if (await isTakenAsset(context, pending)) return alreadyExists(context, key);
4608
+ const policy = resolveSecurity4(pending.security, getRequestState(context).config?.security);
4609
+ const shouldEncrypt = shouldEncryptAtRest(policy, key);
4610
+ if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
4611
+ const write = await prepareUploadWrite(context, pending.key ?? key, pending, pending.storageKey);
4612
+ if ("blocked" in write) return write.blocked;
3624
4613
  const customMetadata = {
3625
4614
  ...allowedUploadMetadata(pending.uploadMetadata),
3626
- ...integrityMetadata(declaredSha256, false, pending.expiresAt)
4615
+ ...integrityMetadata(declaredSha256, false, pending.expiresAt),
4616
+ ...lockMetadata(pending.lock)
3627
4617
  };
3628
4618
  if (pending.security) customMetadata.security = JSON.stringify(pending.security);
3629
- const policy = resolveSecurity4(pending.security, getRequestState(context).config?.security);
3630
4619
  let enc;
3631
- if (context.env.KEYS_BUCKET && shouldEncryptAtRest(policy, key, true)) {
4620
+ if (shouldEncrypt && context.env.KEYS_BUCKET) {
3632
4621
  ({ enc } = await createObjectEncryption(context.env.KEYS_BUCKET, pending.storageKey, { ...customMetadata }));
3633
4622
  customMetadata.enc = enc;
3634
4623
  }
@@ -3661,7 +4650,7 @@ async function handleMultipartCreate(context, key, target) {
3661
4650
  }
3662
4651
  async function encryptPart(context, pending, multipart, partNumber, body) {
3663
4652
  if (!multipart.enc) return body;
3664
- const meta = decodeEncryptionMeta5(multipart.enc);
4653
+ const meta = decodeEncryptionMeta9(multipart.enc);
3665
4654
  const material = meta ? await loadStorageKey(context.env.KEYS_BUCKET, pending.storageKey, meta.keyId) : null;
3666
4655
  if (!meta || !material) return null;
3667
4656
  const startBlock = (partNumber - 1) * multipart.partSize / AES_BLOCK_BYTES2;
@@ -3698,18 +4687,20 @@ async function handleMultipartComplete(context, key) {
3698
4687
  const { pending, multipart, kvKey } = active;
3699
4688
  const parts = normalizeCompletedParts(multipart, body?.parts);
3700
4689
  if (!parts) return context.json({ error: "parts must list every part exactly once", code: "invalid_parts" }, 400);
3701
- if (await isTakenAsset(context, pending)) return alreadyExists(context, key);
4690
+ const write = await prepareUploadWrite(context, pending.key ?? key, pending, pending.storageKey);
4691
+ if ("blocked" in write) return write.blocked;
3702
4692
  const multipartUpload = context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId);
4693
+ if (!await hasEncryptionKey(context.env.KEYS_BUCKET, pending.storageKey, multipart.enc)) {
4694
+ await multipartUpload.abort();
4695
+ await context.env.MEDIA_KV.delete(kvKey);
4696
+ return encryptionKeyMissing(context, key);
4697
+ }
3703
4698
  const object = await multipartUpload.complete(parts);
3704
4699
  await context.env.MEDIA_KV.delete(kvKey);
3705
- await discardStaleVariants(context.env.MEDIA_BUCKET, pending);
4700
+ await afterUploadWrite(context, key, pending, write, multipart.enc);
3706
4701
  const integrity = multipart.sha256 ? { sha256: multipart.sha256, sha256Verified: false } : {};
3707
4702
  return context.json({ key, size: object.size, ...integrity });
3708
4703
  }
3709
- async function isTakenAsset(context, pending) {
3710
- if (!pending.isAsset || pending.overwrite) return false;
3711
- return Boolean(await context.env.MEDIA_BUCKET.head(pending.storageKey));
3712
- }
3713
4704
  async function checkPartSha256(context, multipart, body) {
3714
4705
  const partSha256 = headerSha256(context);
3715
4706
  if (partSha256 === null) {
@@ -3754,7 +4745,7 @@ async function handleMultipartAbort(context) {
3754
4745
  }
3755
4746
 
3756
4747
  // src/routes/upload.ts
3757
- var upload = new Hono9();
4748
+ var upload = new Hono10();
3758
4749
  upload.post("/", async (context) => {
3759
4750
  const body = await context.req.json();
3760
4751
  const isAssetRequest = body.path !== void 0;
@@ -3798,6 +4789,14 @@ upload.post("/", async (context) => {
3798
4789
  if (isUploadTargetError(sha256)) return targetFailure(context, sha256);
3799
4790
  const cacheControl = resolveObjectCacheControl(body.cacheControl, target, scope.config?.cacheRules);
3800
4791
  if (isUploadTargetError(cacheControl)) return targetFailure(context, cacheControl);
4792
+ const lock = resolveUploadLock({
4793
+ lock: body.lock,
4794
+ hold: body.hold,
4795
+ grant,
4796
+ appDefault: scope.config?.lock,
4797
+ now: Date.now()
4798
+ });
4799
+ if (isUploadTargetError(lock)) return targetFailure(context, lock);
3801
4800
  const maxSizeBytes = effectiveMaxBytes(uploadRule?.maxSizeBytes, grant);
3802
4801
  const declaredSize = Number(body.size);
3803
4802
  if (maxSizeBytes != null && Number.isFinite(declaredSize) && declaredSize > maxSizeBytes) {
@@ -3805,15 +4804,17 @@ upload.post("/", async (context) => {
3805
4804
  }
3806
4805
  const virtualKey = target.key;
3807
4806
  const fullKey = storageKey(context, virtualKey);
3808
- const shouldOverwrite = body.overwrite === true;
3809
- if (target.isAsset && !shouldOverwrite && await context.env.MEDIA_BUCKET.head(fullKey)) {
3810
- return alreadyExists(context, virtualKey);
4807
+ const resolvedSecurity = resolveSecurity5(body.security, scope.config?.security);
4808
+ if (isKeysBucketMissing(shouldEncryptAtRest(resolvedSecurity, virtualKey), context.env)) {
4809
+ return keysBucketMissing(context);
3811
4810
  }
4811
+ const shouldOverwrite = body.overwrite === true;
4812
+ const conflict = target.isAsset ? await assetUploadConflict(context, virtualKey, fullKey, shouldOverwrite) : null;
4813
+ if (conflict) return conflict;
3812
4814
  if (grant) {
3813
4815
  const exhausted = await claimTokenFileSlot(context.env.MEDIA_KV, grant);
3814
4816
  if (exhausted) return targetFailure(context, exhausted);
3815
4817
  }
3816
- const resolvedSecurity = resolveSecurity5(body.security, scope.config?.security);
3817
4818
  const pending = {
3818
4819
  contentType: body.contentType,
3819
4820
  storageKey: fullKey,
@@ -3828,7 +4829,8 @@ upload.post("/", async (context) => {
3828
4829
  ...expiresAt ? { expiresAt } : {},
3829
4830
  ...target.isAsset ? { isAsset: true, overwrite: shouldOverwrite } : {},
3830
4831
  ...cacheControl ? { cacheControl } : {},
3831
- ...scope.config?.uploadMetadata ? { uploadMetadata: scope.config.uploadMetadata } : {}
4832
+ ...scope.config?.uploadMetadata ? { uploadMetadata: scope.config.uploadMetadata } : {},
4833
+ ...lock ? { lock } : {}
3832
4834
  };
3833
4835
  const pendingId = newUploadPendingId();
3834
4836
  await context.env.MEDIA_KV.put(uploadPendingKvKey(pendingId), JSON.stringify(pending), {
@@ -3883,25 +4885,30 @@ upload.put("/:key{.+}", async (context) => {
3883
4885
  }
3884
4886
  if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
3885
4887
  const policy = resolveSecurity5(resourceSecurity, getRequestState(context).config?.security);
3886
- const shouldEncrypt = shouldEncryptAtRest(policy, key, Boolean(context.env.KEYS_BUCKET));
4888
+ const shouldEncrypt = shouldEncryptAtRest(policy, key);
4889
+ if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
3887
4890
  const customMetadata = {
3888
4891
  ...allowedUploadMetadata(pending?.uploadMetadata ?? getRequestState(context).config?.uploadMetadata),
3889
- ...integrityMetadata(digest.sha256, true, pending?.expiresAt)
4892
+ // A transcode output (no pending) expires with its video's raw.
4893
+ ...integrityMetadata(digest.sha256, true, pending?.expiresAt ?? await inheritedExpiresAt(context, key)),
4894
+ ...lockMetadata(uploadLockOf(context, key, pending))
3890
4895
  };
3891
4896
  if (resourceSecurity) customMetadata.security = JSON.stringify(resourceSecurity);
4897
+ const write = await prepareUploadWrite(context, pending?.key ?? key, pending, finalStorageKey);
4898
+ if ("blocked" in write) return write.blocked;
3892
4899
  let storedBody = body;
3893
4900
  if (shouldEncrypt && context.env.KEYS_BUCKET) {
3894
4901
  const result = await encryptForStorage(context.env.KEYS_BUCKET, finalStorageKey, body, customMetadata);
3895
4902
  storedBody = result.body;
3896
4903
  customMetadata.enc = result.enc;
3897
4904
  }
3898
- const isCreateOnly = Boolean(pending?.isAsset && !pending.overwrite);
4905
+ if (!await hasEncryptionKey(context.env.KEYS_BUCKET, finalStorageKey, customMetadata.enc)) {
4906
+ return encryptionKeyMissing(context, key);
4907
+ }
3899
4908
  const stored = await context.env.MEDIA_BUCKET.put(finalStorageKey, storedBody, {
3900
- ...isCreateOnly ? { onlyIf: createOnlyCondition() } : {},
3901
- // Standard except for the video raw when the config retains it ≥30 days (Infrequent Access, see
3902
- // `RAW_STORAGE_CLASS`): IA has a 30-day minimum and charges retrieval ($0.01/GB) on every read — the
3903
- // transcode's read, and the direct MP4 delivery if it isn't transcoded. With retention `off` the raw lives
3904
- // for minutes.
4909
+ onlyIf: write.onlyIf,
4910
+ // Standard, except a video raw the config keeps ≥30 days: Infrequent Access (30-day minimum, and a retrieval
4911
+ // charge on every read, the transcode's included; see `RAW_STORAGE_CLASS`).
3905
4912
  storageClass,
3906
4913
  httpMetadata: {
3907
4914
  contentType,
@@ -3910,23 +4917,16 @@ upload.put("/:key{.+}", async (context) => {
3910
4917
  },
3911
4918
  ...Object.keys(customMetadata).length > 0 ? { customMetadata } : {}
3912
4919
  });
3913
- if (isCreateOnly && !stored) {
3914
- await discardObjectKey(context.env.KEYS_BUCKET, finalStorageKey, customMetadata.enc);
3915
- return alreadyExists(context, key);
3916
- }
4920
+ if (stored === null) return failedWrite(context, key, pending, customMetadata.enc);
3917
4921
  if (resolved) await context.env.MEDIA_KV.delete(resolved.kvKey);
3918
- await discardStaleVariants(context.env.MEDIA_BUCKET, pending);
4922
+ await afterUploadWrite(context, key, pending, write, customMetadata.enc);
3919
4923
  return context.json({ key, size: body.byteLength, sha256: digest.sha256, sha256Verified: true });
3920
4924
  });
3921
- async function discardObjectKey(keysBucket, objectStorageKey, enc) {
3922
- const meta = decodeEncryptionMeta6(enc);
3923
- if (keysBucket && meta) await keysBucket.delete(`${objectStorageKey}/${meta.keyId}`);
3924
- }
3925
4925
 
3926
4926
  // src/index.ts
3927
4927
  function createWorker(options) {
3928
4928
  const { middleware, routes, hooks } = composePlugins(options?.plugins ?? defaultPlugins());
3929
- const app = new Hono10();
4929
+ const app = new Hono11();
3930
4930
  app.use("*", logger());
3931
4931
  app.use(
3932
4932
  "*",
@@ -3941,6 +4941,7 @@ function createWorker(options) {
3941
4941
  context.set(PLUGIN_HOOKS_VARIABLE, hooks);
3942
4942
  await next();
3943
4943
  });
4944
+ app.use("/v1/*", rejectReservedLockPaths);
3944
4945
  for (const handler of middleware) app.use("*", handler);
3945
4946
  app.get("/health", (c) => c.json({ status: "ok" }));
3946
4947
  app.post("/v1/metrics/playback", (c) => c.body(null, 204));
@@ -3951,6 +4952,7 @@ function createWorker(options) {
3951
4952
  app.route("/v1/transcode", transcode);
3952
4953
  app.route("/v1/captions", captions);
3953
4954
  app.route("/v1/token", token);
4955
+ app.route("/v1/lock", lockRoute);
3954
4956
  for (const route of routes) app.route(route.path, route.app);
3955
4957
  app.notFound((c) => c.json({ error: "Not found" }, 404));
3956
4958
  app.onError((err, c) => {
@@ -3961,10 +4963,20 @@ function createWorker(options) {
3961
4963
  }
3962
4964
  export {
3963
4965
  DownloadLink,
4966
+ ENCRYPTION_KEY_MISSING_CODE,
4967
+ HOLD_PAGE_SIZE,
4968
+ KEYS_BUCKET_MISSING_CODE,
4969
+ LOCK_AUDIT_PREFIX,
4970
+ LOCK_HOLD_HEADER,
4971
+ LOCK_METADATA_KEY,
4972
+ LOCK_SIDECAR_PREFIX,
4973
+ LOCK_UNTIL_HEADER,
3964
4974
  MAX_ASSET_LIST_LIMIT,
4975
+ MAX_EXPIRED_LOOKUPS,
3965
4976
  MAX_UPLOAD_TOKEN_TTL_SECONDS,
3966
4977
  MaxResolution,
3967
4978
  ObjectMeta,
4979
+ RESERVED_LOCK_PREFIXES,
3968
4980
  TOKEN_REJECTED_CODE,
3969
4981
  UPLOAD_TOKEN_TYPE,
3970
4982
  WorkerAuthErrorCode,