@devultur/self-host 1.0.3 → 1.2.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 { 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,599 @@ 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/object-keys.ts
156
+ import { decodeEncryptionMeta } from "@devultur/core";
157
+ var DELETE_BATCH_SIZE = 1e3;
158
+ async function listAll(bucket, options) {
159
+ const objects = [];
160
+ let cursor;
161
+ do {
162
+ const listing = await bucket.list({ ...options, ...cursor ? { cursor } : {} });
163
+ objects.push(...listing.objects);
164
+ cursor = listing.truncated ? listing.cursor : void 0;
165
+ } while (cursor);
166
+ return objects;
167
+ }
168
+ var isWrittenBy = (key, writtenBy) => key.uploaded.getTime() <= writtenBy.getTime();
169
+ async function keysWrittenBy(keysBucket, prefix, writtenBy) {
170
+ return (await listAll(keysBucket, { prefix })).filter((key) => isWrittenBy(key, writtenBy)).map((key) => key.key);
171
+ }
172
+ async function objectKeysToDelete(keysBucket, fullKey, deleted) {
173
+ const own = await listAll(keysBucket, { prefix: `${fullKey}/`, delimiter: "/" });
174
+ const keys = new Set(own.filter((key) => isWrittenBy(key, deleted.uploaded)).map((key) => key.key));
175
+ const meta = decodeEncryptionMeta(deleted.customMetadata?.enc);
176
+ if (meta) keys.add(`${fullKey}/${meta.keyId}`);
177
+ for (const variantKey of await listAll(keysBucket, { prefix: variantPrefix(fullKey) })) keys.add(variantKey.key);
178
+ return [...keys];
179
+ }
180
+ var deleteCallsFor = (keys) => Math.ceil(keys.length / DELETE_BATCH_SIZE);
181
+ async function deleteKeys(keysBucket, keys) {
182
+ for (let start = 0; start < keys.length; start += DELETE_BATCH_SIZE) {
183
+ await keysBucket.delete(keys.slice(start, start + DELETE_BATCH_SIZE));
184
+ }
185
+ return keys.length;
186
+ }
187
+ async function sweepObjectKeys(keysBucket, fullKey, deleted) {
188
+ return deleteKeys(keysBucket, await objectKeysToDelete(keysBucket, fullKey, deleted));
189
+ }
190
+
191
+ // src/object-lock/sidecar.ts
192
+ import { isIsoInstant, validateLock as validateLock2 } from "@devultur/core";
193
+
194
+ // src/object-lock/state.ts
195
+ import { effectiveUntil, isLocked, validateLock } from "@devultur/core";
196
+ var LOCK_METADATA_KEY = "lock";
197
+ function encodeLockMetadata(lock) {
198
+ if (!lock || !lock.retention && !lock.hold) return void 0;
199
+ return JSON.stringify(lock);
200
+ }
201
+ function readLockMetadata(raw) {
202
+ if (raw === void 0) return void 0;
203
+ try {
204
+ const validation = validateLock(JSON.parse(raw));
205
+ if (validation.ok) return { lock: validation.lock, isUnreadable: false };
206
+ } catch {
207
+ }
208
+ return { lock: { hold: true }, isUnreadable: true };
209
+ }
210
+ function lockStateOf(object) {
211
+ const stored = readLockMetadata(object.customMetadata?.[LOCK_METADATA_KEY]);
212
+ if (!stored) return null;
213
+ return {
214
+ lock: stored.lock,
215
+ ...object.uploaded ? { uploadedAt: object.uploaded } : {},
216
+ ...stored.isUnreadable ? { isUnreadable: true } : {}
217
+ };
218
+ }
219
+ function isStateLocked(state, now) {
220
+ try {
221
+ return isLocked(state.lock, now, { uploadedAt: state.uploadedAt, holdReleasedAt: state.holdReleasedAt });
222
+ } catch {
223
+ return true;
224
+ }
225
+ }
226
+ function untilOf(state) {
227
+ try {
228
+ return effectiveUntil(state.lock, { uploadedAt: state.uploadedAt, holdReleasedAt: state.holdReleasedAt });
229
+ } catch {
230
+ return void 0;
231
+ }
232
+ }
233
+
234
+ // src/object-lock/sidecar.ts
235
+ var LOCK_SIDECAR_PREFIX = "__lock/";
236
+ function objectVersionOf(object) {
237
+ return object.version ?? `${object.etag}@${object.uploaded?.toISOString() ?? ""}`;
238
+ }
239
+ var sidecarKey = (root, key) => `${root}${LOCK_SIDECAR_PREFIX}${key}`;
240
+ function parseSidecar(raw) {
241
+ try {
242
+ const parsed = JSON.parse(raw);
243
+ const lock = validateLock2(parsed.lock);
244
+ if (!lock.ok || typeof parsed.revision !== "number" || typeof parsed.objectVersion !== "string") return null;
245
+ if (parsed.holdReleasedAt !== void 0 && !isIsoInstant(parsed.holdReleasedAt)) return null;
246
+ return { ...parsed, lock: lock.lock };
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+ async function readSidecar(bucket, root, key) {
252
+ const object = await bucket.get(sidecarKey(root, key));
253
+ if (!object) return null;
254
+ const sidecar = parseSidecar(await object.text());
255
+ if (sidecar) return { sidecar, etag: object.etag };
256
+ const unreadable = { revision: 0, objectVersion: "*", lock: { hold: true }, updatedAt: "" };
257
+ return { sidecar: unreadable, etag: object.etag, isUnreadable: true };
258
+ }
259
+ async function writeSidecar(bucket, root, key, sidecar, previous) {
260
+ const onlyIf = previous ? { etagMatches: previous.etag } : new Headers({ "If-None-Match": "*" });
261
+ const stored = await bucket.put(sidecarKey(root, key), JSON.stringify(sidecar), {
262
+ onlyIf,
263
+ httpMetadata: { contentType: "application/json" }
264
+ });
265
+ return stored !== null;
266
+ }
267
+ async function effectiveLock(bucket, root, key, object) {
268
+ const stored = await readSidecar(bucket, root, key);
269
+ const bound = stored?.sidecar.objectVersion;
270
+ const applies = stored && (bound === "*" || bound === objectVersionOf(object));
271
+ if (stored && applies) {
272
+ const { lock, holdReleasedAt } = stored.sidecar;
273
+ const state2 = {
274
+ lock,
275
+ ...object.uploaded ? { uploadedAt: object.uploaded } : {},
276
+ ...holdReleasedAt ? { holdReleasedAt: new Date(holdReleasedAt) } : {},
277
+ ...stored.isUnreadable ? { isUnreadable: true } : {}
278
+ };
279
+ return { state: state2, source: "sidecar", stored };
280
+ }
281
+ const state = lockStateOf(object);
282
+ return { state, source: state ? "upload" : "none", stored };
283
+ }
284
+ function unreadableLockRefusal(state, key) {
285
+ if (!state?.isUnreadable) return null;
286
+ return Response.json(
287
+ {
288
+ error: `The lock record of ${key} can't be read; it stays held until an operator repairs it`,
289
+ code: "lock_record_unreadable",
290
+ key
291
+ },
292
+ { status: 409 }
293
+ );
294
+ }
295
+ var VIDEO_STORAGE_KEY = /^(.*?)((?:public|private)\/video\/[^/]+\/.+)$/;
296
+ function splitVideoStorageKey(storageKey2) {
297
+ const match = VIDEO_STORAGE_KEY.exec(storageKey2);
298
+ return match ? { root: match[1] ?? "", key: match[2] ?? "" } : null;
299
+ }
300
+
301
+ // src/object-lock/guard.ts
302
+ var RESERVED_LOCK_PREFIXES = ["__lock/", "__lock-audit/"];
303
+ function isReservedLockKey(key) {
304
+ const normalized = `${key.replace(/^\/+/, "")}/`;
305
+ return RESERVED_LOCK_PREFIXES.some((prefix) => normalized.startsWith(prefix));
306
+ }
307
+ function reservedKeyResponse(context, key) {
308
+ return context.json({ error: `${key} is under a reserved prefix`, code: "reserved_key", key }, 400);
309
+ }
310
+ function routeKeyOf(path) {
311
+ const key = path.split("/").slice(3).join("/");
312
+ try {
313
+ return decodeURIComponent(key);
314
+ } catch {
315
+ return key;
316
+ }
317
+ }
318
+ var rejectReservedLockPaths = async (context, next) => {
319
+ const key = routeKeyOf(context.req.path);
320
+ if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
321
+ await next();
322
+ };
323
+ var VIDEO_KEY = /^((?:public|private)\/video\/[^/]+)(?:\/|$)/;
324
+ function lockSubjectOf(key) {
325
+ if (isVariantKey(key)) return null;
326
+ if (isVideoRawKey(key)) return { kind: "original", key };
327
+ const video = VIDEO_KEY.exec(key);
328
+ if (video) return { kind: "video", rawPrefix: `${video[1]}/raw/` };
329
+ return { kind: "original", key };
330
+ }
331
+ function protectsDerived(state) {
332
+ const derivatives = state.lock.retention?.derivatives ?? "inherit";
333
+ return derivatives === "inherit";
334
+ }
335
+ async function lockedObject(bucket, root, key, object, now) {
336
+ const { state } = await effectiveLock(bucket, root, key, object);
337
+ return state && isStateLocked(state, now) ? { key, state } : null;
338
+ }
339
+ async function lockedOriginal(bucket, root, key, now) {
340
+ const object = await bucket.head(`${root}${key}`);
341
+ return object ? lockedObject(bucket, root, key, object, now) : null;
342
+ }
343
+ async function lockedRaw(bucket, root, rawPrefix, now) {
344
+ let cursor;
345
+ do {
346
+ const listing = await bucket.list({ prefix: `${root}${rawPrefix}`, cursor });
347
+ for (const listed of listing.objects) {
348
+ const key = listed.key.slice(root.length);
349
+ const object = await bucket.head(listed.key);
350
+ const state = object ? (await effectiveLock(bucket, root, key, object)).state : null;
351
+ if (state && protectsDerived(state) && isStateLocked(state, now)) return { key, state };
352
+ }
353
+ cursor = listing.truncated ? listing.cursor : void 0;
354
+ } while (cursor);
355
+ return null;
356
+ }
357
+ function findLockedOriginal(bucket, root, subject, now) {
358
+ return subject.kind === "original" ? lockedOriginal(bucket, root, subject.key, now) : lockedRaw(bucket, root, subject.rawPrefix, now);
359
+ }
360
+ function retentionActive(context, locked, op) {
361
+ const { lock } = locked.state;
362
+ const until = untilOf(locked.state);
363
+ const verb = op === "delete" ? "deleted" : "overwritten";
364
+ return context.json(
365
+ {
366
+ error: `${locked.key} is retained and can't be ${verb}`,
367
+ code: "retention_active",
368
+ key: locked.key,
369
+ ...lock.retention ? { mode: lock.retention.mode } : {},
370
+ ...until ? { until } : {},
371
+ hold: lock.hold === true
372
+ },
373
+ 409
374
+ );
375
+ }
376
+ async function assertWritable(context, keys, op, root = getRequestState(context).storagePrefix) {
377
+ const reserved = keys.find(isReservedLockKey);
378
+ if (reserved !== void 0) return reservedKeyResponse(context, reserved);
379
+ const now = /* @__PURE__ */ new Date();
380
+ const checked = /* @__PURE__ */ new Set();
381
+ for (const key of keys) {
382
+ const subject = lockSubjectOf(key);
383
+ if (!subject) continue;
384
+ const subjectId = subject.kind === "original" ? subject.key : subject.rawPrefix;
385
+ if (checked.has(subjectId)) continue;
386
+ checked.add(subjectId);
387
+ const locked = await findLockedOriginal(context.env.MEDIA_BUCKET, root, subject, now);
388
+ if (!locked) continue;
389
+ const requested = key === locked.key ? [key] : [key, locked.key];
390
+ await recordLockAudit(context, {
391
+ action: op === "delete" ? "delete_rejected" : "overwrite_rejected",
392
+ keys: requested,
393
+ before: locked.state.lock,
394
+ after: null,
395
+ outcome: "rejected",
396
+ reason: "retention_active",
397
+ root
398
+ });
399
+ return retentionActive(context, locked, op);
400
+ }
401
+ return null;
402
+ }
403
+ var DELETE_BATCH_SIZE2 = 1e3;
404
+ async function guardedDeletePrefix(context, prefix) {
405
+ const root = getRequestState(context).storagePrefix;
406
+ const storageKeys = [];
407
+ let cursor;
408
+ do {
409
+ const listing = await context.env.MEDIA_BUCKET.list({ prefix: storageKey(context, prefix), cursor });
410
+ storageKeys.push(...listing.objects.map((object) => object.key));
411
+ cursor = listing.truncated ? listing.cursor : void 0;
412
+ } while (cursor);
413
+ const keys = storageKeys.map((key) => key.slice(root.length));
414
+ const blocked = await assertWritable(context, keys, "delete");
415
+ if (blocked) return { blocked };
416
+ for (let start = 0; start < storageKeys.length; start += DELETE_BATCH_SIZE2) {
417
+ await context.env.MEDIA_BUCKET.delete(storageKeys.slice(start, start + DELETE_BATCH_SIZE2));
418
+ }
419
+ return { deletedCount: storageKeys.length };
420
+ }
421
+ async function deleteKeysFor(context, objectKey, deleted) {
422
+ const blocked = await assertWritable(context, [objectKey], "delete");
423
+ if (blocked) return { blocked };
424
+ const keysBucket = context.env.KEYS_BUCKET;
425
+ if (!keysBucket) return { deletedCount: 0 };
426
+ return { deletedCount: await sweepObjectKeys(keysBucket, storageKey(context, objectKey), deleted) };
427
+ }
428
+ async function deleteFolderKeys(context, videoFolder) {
429
+ const blocked = await assertWritable(context, [videoFolder], "delete");
430
+ if (blocked) return { blocked };
431
+ if (!context.env.KEYS_BUCKET) return { deletedCount: 0 };
432
+ return { deletedCount: await deletePrefix(context.env.KEYS_BUCKET, `${storageKey(context, videoFolder)}/`) };
433
+ }
434
+
435
+ // src/object-lock/lock-route.ts
436
+ import { canChange as canChange2, effectiveUntil as effectiveUntil2 } from "@devultur/core";
437
+ import { Hono } from "hono";
438
+
439
+ // src/object-lock/change.ts
440
+ import { canChange, validateLock as validateLock3 } from "@devultur/core";
441
+ var BODY_FIELDS = /* @__PURE__ */ new Set(["mode", "until", "period", "derivatives", "remove", "bypassGovernance"]);
442
+ function nextLock(before, body) {
443
+ const unknown = Object.keys(body).filter((field) => !BODY_FIELDS.has(field));
444
+ if (unknown.length > 0) return { ok: false, errors: unknown.map((field) => `${field}: unknown field`) };
445
+ if (body.remove !== void 0 && body.remove !== true) return { ok: false, errors: ["remove: must be true"] };
446
+ const hold = before?.hold === true;
447
+ if (body.remove === true) return { ok: true, lock: { hold } };
448
+ const current = before?.retention;
449
+ const isExpiryChanged = body.until !== void 0 || body.period !== void 0;
450
+ const retention = {
451
+ mode: body.mode ?? current?.mode,
452
+ ...isExpiryChanged ? {
453
+ ...body.until !== void 0 ? { until: body.until } : {},
454
+ ...body.period !== void 0 ? { period: body.period } : {}
455
+ } : {
456
+ ...current?.until ? { until: current.until } : {},
457
+ ...current?.period ? { period: current.period } : {}
458
+ },
459
+ derivatives: body.derivatives ?? current?.derivatives ?? "inherit"
460
+ };
461
+ const validation = validateLock3({ retention, hold });
462
+ if (!validation.ok) return validation;
463
+ return { ok: true, lock: validation.lock };
464
+ }
465
+ var timingOf = (state) => ({
466
+ uploadedAt: state?.uploadedAt,
467
+ holdReleasedAt: state?.holdReleasedAt
468
+ });
469
+ function classifyChange(state, after, now) {
470
+ const before = state?.lock ?? {};
471
+ const isActive = canChange(before, { hold: before.hold }, { privileged: false, now, timing: timingOf(state) });
472
+ if (isActive.ok) return after.retention ? "set" : "remove";
473
+ if (!after.retention) return "remove";
474
+ const change = canChange(before, after, { privileged: false, now, timing: timingOf(state) });
475
+ return change.ok ? "extend" : "shorten";
476
+ }
477
+
478
+ // src/object-lock/hold.ts
479
+ import { validateLock as validateLock4 } from "@devultur/core";
480
+ var HOLD_PAGE_SIZE = 50;
481
+ var isKeyList = (value) => Array.isArray(value) && value.every((key) => typeof key === "string" && key.length > 0);
482
+ async function targetsOf(context, body) {
483
+ if (body.keys === void 0 === (body.prefix === void 0)) return { error: "pass either keys or prefix" };
484
+ if (body.keys !== void 0) {
485
+ if (!isKeyList(body.keys) || body.keys.length === 0) return { error: "keys must be a non-empty list of keys" };
486
+ if (body.keys.length > HOLD_PAGE_SIZE) return { error: `keys may list at most ${HOLD_PAGE_SIZE} keys` };
487
+ if (body.keys.some(isReservedLockKey)) return { error: "keys may not be under a reserved prefix" };
488
+ return { keys: [...new Set(body.keys)] };
489
+ }
490
+ if (typeof body.prefix !== "string" || body.prefix === "" || isReservedLockKey(body.prefix)) {
491
+ return { error: "prefix must be a non-empty key prefix outside the reserved ones" };
492
+ }
493
+ const cursor = typeof body.cursor === "string" ? body.cursor : void 0;
494
+ const listing = await context.env.MEDIA_BUCKET.list({
495
+ prefix: storageKey(context, body.prefix),
496
+ limit: HOLD_PAGE_SIZE,
497
+ ...cursor ? { cursor } : {}
498
+ });
499
+ const root = getRequestState(context).storagePrefix;
500
+ const keys = listing.objects.map((object) => object.key.slice(root.length)).filter((key) => !isVariantKey(key) && !isReservedLockKey(key));
501
+ return { keys, prefix: body.prefix, ...listing.truncated ? { cursor: listing.cursor } : {} };
502
+ }
503
+ async function applyHold(context, key, hold, now) {
504
+ const root = getRequestState(context).storagePrefix;
505
+ const object = await context.env.MEDIA_BUCKET.head(storageKey(context, key));
506
+ if (!object) return { updated: false, reason: "not_found" };
507
+ const effective = await effectiveLock(context.env.MEDIA_BUCKET, root, key, object);
508
+ const before = effective.state?.lock ?? { hold: false };
509
+ if (before.hold === true === hold) return { updated: false, reason: "unchanged" };
510
+ if (effective.state?.isUnreadable) {
511
+ await recordLockAudit(context, {
512
+ action: "release",
513
+ keys: [key],
514
+ before,
515
+ after: { ...before, hold },
516
+ outcome: "rejected",
517
+ reason: "lock_record_unreadable"
518
+ });
519
+ return { updated: false, reason: "lock_record_unreadable" };
520
+ }
521
+ const validated = validateLock4({ ...before, hold });
522
+ const after = validated.ok ? validated.lock : { ...before, hold };
523
+ const previous = effective.source === "sidecar" ? effective.stored?.sidecar : void 0;
524
+ const holdReleasedAt = hold ? previous?.holdReleasedAt : now.toISOString();
525
+ const sidecar = {
526
+ revision: (effective.stored?.sidecar.revision ?? 0) + 1,
527
+ objectVersion: objectVersionOf(object),
528
+ lock: after,
529
+ ...holdReleasedAt ? { holdReleasedAt } : {},
530
+ updatedAt: now.toISOString()
531
+ };
532
+ const action = hold ? "hold" : "release";
533
+ if (!await writeSidecar(context.env.MEDIA_BUCKET, root, key, sidecar, effective.stored)) {
534
+ await recordLockAudit(context, {
535
+ action,
536
+ keys: [key],
537
+ before,
538
+ after,
539
+ outcome: "rejected",
540
+ reason: "lock_conflict"
541
+ });
542
+ return { updated: false, reason: "lock_conflict" };
543
+ }
544
+ await recordLockAudit(context, { action, keys: [key], before, after, outcome: "applied" });
545
+ return { updated: true };
546
+ }
547
+ async function handleHold(context) {
548
+ const body = await context.req.json().catch(() => null) ?? {};
549
+ if (typeof body.hold !== "boolean")
550
+ return context.json({ error: "hold must be a boolean", code: "invalid_hold" }, 400);
551
+ const targets = await targetsOf(context, body);
552
+ if ("error" in targets) return context.json({ error: targets.error, code: "invalid_hold" }, 400);
553
+ const hold = body.hold;
554
+ const decision = await decideLockChange(context, {
555
+ action: hold ? "hold" : "release",
556
+ keys: targets.keys,
557
+ ...targets.prefix ? { prefix: targets.prefix } : {},
558
+ before: null,
559
+ after: { hold }
560
+ });
561
+ if (!decision.allow) {
562
+ await recordLockAudit(context, {
563
+ action: hold ? "hold" : "release",
564
+ keys: targets.keys,
565
+ before: null,
566
+ after: { hold },
567
+ outcome: "rejected",
568
+ reason: "hook_refused"
569
+ });
570
+ return decision.response;
571
+ }
572
+ const now = /* @__PURE__ */ new Date();
573
+ const updated = [];
574
+ const skipped = [];
575
+ for (const key of targets.keys) {
576
+ const outcome = await applyHold(context, key, hold, now);
577
+ if (outcome.updated) updated.push(key);
578
+ else skipped.push({ key, reason: outcome.reason });
579
+ }
580
+ return context.json({ updated, skipped, ...targets.cursor ? { cursor: targets.cursor } : {} });
581
+ }
582
+
583
+ // src/object-lock/lock-route.ts
584
+ function lockView(key, effective) {
585
+ const lock = effective.state?.lock;
586
+ let until;
587
+ try {
588
+ until = lock ? effectiveUntil2(lock, timingOf(effective.state)) : void 0;
589
+ } catch {
590
+ }
591
+ return {
592
+ key,
593
+ mode: lock?.retention?.mode ?? null,
594
+ until: until ?? null,
595
+ period: lock?.retention?.period ?? null,
596
+ hold: lock?.hold === true,
597
+ derivatives: lock?.retention ? lock.retention.derivatives ?? "inherit" : null,
598
+ source: effective.source,
599
+ revision: effective.source === "sidecar" ? effective.stored?.sidecar.revision ?? null : null
600
+ };
601
+ }
602
+ async function loadTarget(context) {
603
+ const key = context.req.param("key") ?? "";
604
+ const root = getRequestState(context).storagePrefix;
605
+ const object = await context.env.MEDIA_BUCKET.head(storageKey(context, key));
606
+ if (!object) return { key, root, object: null, effective: null };
607
+ return { key, root, object, effective: await effectiveLock(context.env.MEDIA_BUCKET, root, key, object) };
608
+ }
609
+ var lockRoute = new Hono();
610
+ lockRoute.use("*", async (context, next) => {
611
+ if (!isAuthenticated(context)) return context.json({ error: "Unauthorized" }, 401);
612
+ await next();
613
+ });
614
+ lockRoute.post("/hold", handleHold);
615
+ lockRoute.get("/:key{.+}", async (context) => {
616
+ const { key, effective } = await loadTarget(context);
617
+ if (!effective) return context.json({ error: "Not found" }, 404);
618
+ return context.json(lockView(key, effective));
619
+ });
620
+ lockRoute.put("/:key{.+}", async (context) => {
621
+ const body = await context.req.json().catch(() => null) ?? {};
622
+ const { key, root, object, effective } = await loadTarget(context);
623
+ if (!object || !effective) return context.json({ error: "Not found" }, 404);
624
+ const before = effective.state?.lock ?? null;
625
+ const next = nextLock(before, body);
626
+ if (!next.ok) return context.json({ error: next.errors.join("; "), code: "invalid_lock" }, 400);
627
+ const now = /* @__PURE__ */ new Date();
628
+ const action = classifyChange(effective.state, next.lock, now);
629
+ const audit = { action, keys: [key], before, after: next.lock, root };
630
+ const unreadable = unreadableLockRefusal(effective.state, key);
631
+ if (unreadable) {
632
+ await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "lock_record_unreadable" });
633
+ return unreadable;
634
+ }
635
+ const decision = await decideLockChange(context, { action, keys: [key], before, after: next.lock });
636
+ if (!decision.allow) {
637
+ await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "hook_refused" });
638
+ return decision.response;
639
+ }
640
+ const isPrivileged = body.bypassGovernance === true && decision.isApproved;
641
+ const change = canChange2(before ?? {}, next.lock, {
642
+ privileged: isPrivileged,
643
+ now,
644
+ timing: timingOf(effective.state)
645
+ });
646
+ if (!change.ok) {
647
+ await recordLockAudit(context, { ...audit, outcome: "rejected", reason: change.reason });
648
+ return refusal(context, key, change.reason);
649
+ }
650
+ const sidecar = nextSidecar(effective, objectVersionOf(object), next.lock, now);
651
+ if (!await writeSidecar(context.env.MEDIA_BUCKET, root, key, sidecar, effective.stored)) {
652
+ await recordLockAudit(context, { ...audit, outcome: "rejected", reason: "lock_conflict" });
653
+ return context.json({ error: `The lock of ${key} changed meanwhile; retry`, code: "lock_conflict", key }, 409);
654
+ }
655
+ await recordLockAudit(context, { ...audit, outcome: "applied", ...change.bypass ? { bypass: true } : {} });
656
+ const state = { ...effective.state ?? {}, lock: next.lock };
657
+ return context.json(lockView(key, { state, source: "sidecar", stored: { sidecar, etag: "" } }));
658
+ });
659
+ function nextSidecar(effective, objectVersion, lock, now) {
660
+ const previous = effective.source === "sidecar" ? effective.stored?.sidecar : void 0;
661
+ return {
662
+ revision: (effective.stored?.sidecar.revision ?? 0) + 1,
663
+ objectVersion,
664
+ lock,
665
+ ...previous?.holdReleasedAt ? { holdReleasedAt: previous.holdReleasedAt } : {},
666
+ updatedAt: now.toISOString()
667
+ };
668
+ }
669
+ function refusal(context, key, reason) {
670
+ if (reason === "lock_privilege_required") {
671
+ return context.json(
672
+ {
673
+ error: "Shortening or removing a governance retention needs bypassGovernance and an authorizeLockChange approval",
674
+ code: reason,
675
+ key
676
+ },
677
+ 403
678
+ );
679
+ }
680
+ return context.json({ error: `The compliance retention of ${key} can only be extended`, code: reason, key }, 409);
681
+ }
682
+
683
+ // src/plugin.ts
684
+ var PHASES = ["beforeAuth", "auth", "afterAuth"];
685
+ function assertComposable(plugins) {
686
+ const names = /* @__PURE__ */ new Set();
687
+ for (const plugin of plugins) {
688
+ if (names.has(plugin.name)) throw new Error(`createWorker: two plugins are named "${plugin.name}"`);
689
+ names.add(plugin.name);
690
+ }
691
+ if (!plugins.some((plugin) => (plugin.middleware?.auth?.length ?? 0) > 0)) {
692
+ throw new Error("createWorker: no plugin authenticates requests. Add workerSecretAuth() or your own auth plugin.");
693
+ }
694
+ }
695
+ function composePlugins(plugins) {
696
+ assertComposable(plugins);
697
+ return {
698
+ middleware: PHASES.flatMap((phase) => plugins.flatMap((plugin) => plugin.middleware?.[phase] ?? [])),
699
+ routes: plugins.flatMap((plugin) => plugin.routes ?? []),
700
+ hooks: {
701
+ issueCredential: plugins.flatMap((plugin) => plugin.issueCredential ? [plugin.issueCredential] : []),
702
+ beforeLinkRedeem: plugins.flatMap((plugin) => plugin.beforeLinkRedeem ? [plugin.beforeLinkRedeem] : []),
703
+ authorizeLockChange: plugins.flatMap(
704
+ (plugin) => plugin.authorizeLockChange ? [plugin.authorizeLockChange] : []
705
+ ),
706
+ onLockAudit: plugins.flatMap((plugin) => plugin.onLockAudit ? [plugin.onLockAudit] : [])
707
+ }
708
+ };
709
+ }
710
+
130
711
  // src/plugins/credential-policy.ts
131
712
  function credentialPolicy(options = {}) {
132
713
  return {
@@ -140,11 +721,11 @@ function credentialPolicy(options = {}) {
140
721
  }
141
722
 
142
723
  // src/plugins/download-links.ts
143
- import { Hono as Hono2 } from "hono";
724
+ import { Hono as Hono3 } from "hono";
144
725
 
145
726
  // src/routes/download.ts
146
727
  import { contentDispositionAttachment as contentDispositionAttachment2, relativeMediaPath, TokenScope } from "@devultur/core";
147
- import { Hono } from "hono";
728
+ import { Hono as Hono2 } from "hono";
148
729
 
149
730
  // src/download-link.ts
150
731
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -224,7 +805,7 @@ function addRouteResult(context, result) {
224
805
  import {
225
806
  AES_BLOCK_BYTES,
226
807
  createDecryptStream,
227
- decodeEncryptionMeta,
808
+ decodeEncryptionMeta as decodeEncryptionMeta2,
228
809
  decryptChunk,
229
810
  EXPIRES_AT_HEADER,
230
811
  REPR_DIGEST_HEADER,
@@ -242,7 +823,13 @@ var ObjectMeta = {
242
823
  /** Epoch ms. From then on the gate responds 410 (and an external sweeper may delete it). */
243
824
  EXPIRES_AT: "expiresAt"
244
825
  };
245
- var WORKER_METADATA_KEYS = /* @__PURE__ */ new Set(["security", "enc", ObjectMeta.EXPIRES_AT.toLowerCase(), "derivedfrom"]);
826
+ var WORKER_METADATA_KEYS = /* @__PURE__ */ new Set([
827
+ "security",
828
+ "enc",
829
+ ObjectMeta.EXPIRES_AT.toLowerCase(),
830
+ "derivedfrom",
831
+ LOCK_METADATA_KEY
832
+ ]);
246
833
  function isWorkerMetadataKey(key) {
247
834
  const lowerKey = key.toLowerCase();
248
835
  return lowerKey.startsWith(ObjectMeta.SHA256) || WORKER_METADATA_KEYS.has(lowerKey);
@@ -251,8 +838,8 @@ function allowedUploadMetadata(metadata) {
251
838
  if (!metadata) return {};
252
839
  return Object.fromEntries(Object.entries(metadata).filter(([key]) => !isWorkerMetadataKey(key)));
253
840
  }
254
- function targetFailure(context, failure3) {
255
- return context.json({ error: failure3.error, code: failure3.code }, failure3.status);
841
+ function targetFailure(context, failure4) {
842
+ return context.json({ error: failure4.error, code: failure4.code }, failure4.status);
256
843
  }
257
844
  function alreadyExists(context, key) {
258
845
  return context.json(
@@ -278,14 +865,19 @@ async function digestBody(body, declared, header) {
278
865
  if (expected && expected !== actual) return { mismatch: { expected, actual } };
279
866
  return { sha256: actual };
280
867
  }
868
+ function lockMetadata(lock) {
869
+ const encoded = encodeLockMetadata(lock);
870
+ return encoded ? { [LOCK_METADATA_KEY]: encoded } : {};
871
+ }
281
872
  function integrityMetadata(sha256, isVerified, expiresAt) {
282
873
  return {
283
874
  ...sha256 ? { [ObjectMeta.SHA256]: sha256, [ObjectMeta.SHA256_VERIFIED]: String(isVerified) } : {},
284
875
  ...expiresAt ? { [ObjectMeta.EXPIRES_AT]: String(expiresAt) } : {}
285
876
  };
286
877
  }
287
- function createOnlyCondition() {
288
- return new Headers({ "If-None-Match": "*" });
878
+ function expiryMetadataOf(customMetadata) {
879
+ const expiresAt = customMetadata?.[ObjectMeta.EXPIRES_AT];
880
+ return expiresAt ? { [ObjectMeta.EXPIRES_AT]: expiresAt } : {};
289
881
  }
290
882
  async function discardStaleVariants(bucket, pending) {
291
883
  if (!pending?.overwrite) return;
@@ -293,9 +885,13 @@ async function discardStaleVariants(bucket, pending) {
293
885
  }
294
886
 
295
887
  // src/routes/object-response.ts
296
- function isObjectExpired(customMetadata, now = Date.now()) {
888
+ function objectExpiresAt(customMetadata) {
297
889
  const expiresAt = Number(customMetadata?.[ObjectMeta.EXPIRES_AT]);
298
- return Number.isFinite(expiresAt) && expiresAt > 0 && expiresAt <= now;
890
+ return Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : void 0;
891
+ }
892
+ function isObjectExpired(customMetadata, now = Date.now()) {
893
+ const expiresAt = objectExpiresAt(customMetadata);
894
+ return expiresAt !== void 0 && expiresAt <= now;
299
895
  }
300
896
  function expiredResponse() {
301
897
  return Response.json({ error: "This file has expired", code: "expired" }, { status: 410 });
@@ -352,7 +948,7 @@ async function streamObject(input) {
352
948
  headers.set("ETag", object.httpEtag);
353
949
  headers.set("Accept-Ranges", "bytes");
354
950
  applyIntegrityHeaders(headers, object.customMetadata);
355
- const enc = decodeEncryptionMeta(object.customMetadata?.enc);
951
+ const enc = decodeEncryptionMeta2(object.customMetadata?.enc);
356
952
  const loaded = enc ? await loadStorageKey(input.keysBucket, input.fullKey, enc.keyId) : null;
357
953
  if (enc && !loaded) {
358
954
  return { response: Response.json({ error: "Decryption key unavailable" }, { status: 500 }), kind: "other" };
@@ -410,8 +1006,8 @@ async function handleDownloadLink(context) {
410
1006
  const object = await context.env.MEDIA_BUCKET.head(fullKey);
411
1007
  if (!object) return context.json({ error: "Not found" }, 404);
412
1008
  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);
1009
+ const objectExpiresAt2 = Number(object.customMetadata?.expiresAt) || Infinity;
1010
+ const requestedExpiresAt = Math.min(Date.now() + parsed.expiresIn * 1e3, objectExpiresAt2);
415
1011
  const decision = await decideCredential(context, {
416
1012
  kind: "download-link",
417
1013
  scopes: [TokenScope.MEDIA_READ],
@@ -456,7 +1052,7 @@ async function gateRedemption(context, stub) {
456
1052
  if (!peeked.ok) return relayLinkFailure(peeked);
457
1053
  return gateLinkRedemption(context, linkInfoOf(await peeked.json()));
458
1054
  }
459
- var download = new Hono();
1055
+ var download = new Hono2();
460
1056
  download.get("/:linkId", async (context) => {
461
1057
  if (context.req.method === "HEAD") return new Response(null, { status: 405, headers: { Allow: "GET" } });
462
1058
  const linkId = context.req.param("linkId");
@@ -490,7 +1086,7 @@ download.get("/:linkId", async (context) => {
490
1086
 
491
1087
  // src/plugins/download-links.ts
492
1088
  function downloadLinks() {
493
- const issue = new Hono2().post("/", handleDownloadLink);
1089
+ const issue = new Hono3().post("/", handleDownloadLink);
494
1090
  return {
495
1091
  name: "download-links",
496
1092
  routes: [
@@ -510,7 +1106,11 @@ function staticConfig(config) {
510
1106
  }
511
1107
 
512
1108
  // src/routes/token-upload.ts
513
- import { assetRelativePath as assetRelativePath2, normalizeAssetPrefix } from "@devultur/core";
1109
+ import {
1110
+ assetRelativePath as assetRelativePath2,
1111
+ normalizeAssetPrefix,
1112
+ validateLock as validateLock5
1113
+ } from "@devultur/core";
514
1114
 
515
1115
  // src/token-claims.ts
516
1116
  function audienceClaim(storagePrefix2) {
@@ -671,18 +1271,26 @@ function parseVisibility(value) {
671
1271
  if (value === void 0 || value === null) return "private";
672
1272
  return value === "public" || value === "private" ? value : null;
673
1273
  }
1274
+ function lockClaim(value) {
1275
+ if (value === void 0) return void 0;
1276
+ const validation = validateLock5(value);
1277
+ return validation.ok ? validation.lock : null;
1278
+ }
674
1279
  function uploadGrantFromClaims(payload) {
675
1280
  if (payload.typ !== UPLOAD_TOKEN_TYPE || typeof payload.res !== "string" || typeof payload.jti !== "string") {
676
1281
  return null;
677
1282
  }
678
1283
  if (payload.visibility !== "public" && payload.visibility !== "private") return null;
1284
+ const lock = lockClaim(payload.lock);
1285
+ if (lock === null) return null;
679
1286
  return {
680
1287
  tokenId: payload.jti,
681
1288
  pathPrefix: payload.res,
682
1289
  visibility: payload.visibility,
683
1290
  ...isPositiveInteger(payload.max_bytes) ? { maxBytes: payload.max_bytes } : {},
684
1291
  ...isPositiveInteger(payload.max_files) ? { maxFiles: payload.max_files } : {},
685
- ...isPositiveInteger(payload.file_ttl) ? { fileExpiresIn: payload.file_ttl } : {}
1292
+ ...isPositiveInteger(payload.file_ttl) ? { fileExpiresIn: payload.file_ttl } : {},
1293
+ ...lock ? { lock } : {}
686
1294
  };
687
1295
  }
688
1296
  var badRequest = (context, code, error) => context.json({ error, code }, 400);
@@ -703,6 +1311,8 @@ async function handleUploadToken(context) {
703
1311
  if (fileExpiresIn === null) {
704
1312
  return badRequest(context, "invalid_file_expires_in", "fileExpiresIn must be a positive number of seconds");
705
1313
  }
1314
+ const lock = body.lock === void 0 ? { ok: true, lock: void 0 } : validateLock5(body.lock);
1315
+ if (!lock.ok) return badRequest(context, "invalid_lock", lock.errors.join("; "));
706
1316
  const pathPrefix = assetRelativePath2(prefix);
707
1317
  const now = Math.floor(Date.now() / 1e3);
708
1318
  const requestedExp = now + (expiresIn ?? DEFAULT_UPLOAD_TOKEN_TTL_SECONDS);
@@ -726,7 +1336,8 @@ async function handleUploadToken(context) {
726
1336
  exp: expiresAt,
727
1337
  ...maxBytes ? { max_bytes: maxBytes } : {},
728
1338
  ...maxFiles ? { max_files: maxFiles } : {},
729
- ...fileExpiresIn ? { file_ttl: fileExpiresIn } : {}
1339
+ ...fileExpiresIn ? { file_ttl: fileExpiresIn } : {},
1340
+ ...lock.lock ? { lock: lock.lock } : {}
730
1341
  },
731
1342
  decision
732
1343
  );
@@ -738,7 +1349,8 @@ async function handleUploadToken(context) {
738
1349
  expiresAt,
739
1350
  ...maxBytes ? { maxBytes } : {},
740
1351
  ...maxFiles ? { maxFiles } : {},
741
- ...fileExpiresIn ? { fileExpiresIn } : {}
1352
+ ...fileExpiresIn ? { fileExpiresIn } : {},
1353
+ ...lock.lock ? { lock: lock.lock } : {}
742
1354
  });
743
1355
  }
744
1356
 
@@ -821,9 +1433,46 @@ function defaultPlugins(config = {}) {
821
1433
 
822
1434
  // src/routes/assets.ts
823
1435
  import { ASSET_NAMESPACE, normalizeAssetPrefix as normalizeAssetPrefix2 } from "@devultur/core";
824
- import { Hono as Hono3 } from "hono";
1436
+ import { Hono as Hono4 } from "hono";
1437
+
1438
+ // src/object-lock/delivery.ts
1439
+ import { effectiveUntil as effectiveUntil3 } from "@devultur/core";
1440
+ var LOCK_UNTIL_HEADER = "x-devultur-lock-until";
1441
+ var LOCK_HOLD_HEADER = "x-devultur-lock-hold";
1442
+ async function lockHeaders(context, key, object) {
1443
+ const root = getRequestState(context).storagePrefix;
1444
+ const { state } = await effectiveLock(context.env.MEDIA_BUCKET, root, key, object);
1445
+ if (!state || !isStateLocked(state, /* @__PURE__ */ new Date())) return [];
1446
+ const headers = [];
1447
+ try {
1448
+ const until = effectiveUntil3(state.lock, timingOf(state));
1449
+ if (until) headers.push([LOCK_UNTIL_HEADER, until]);
1450
+ } catch {
1451
+ }
1452
+ if (state.lock.hold) headers.push([LOCK_HOLD_HEADER, "true"]);
1453
+ return headers;
1454
+ }
1455
+ async function retainedOf(bucket, location, object) {
1456
+ const { state } = await effectiveLock(bucket, location.root, location.key, object);
1457
+ if (!state || !isStateLocked(state, /* @__PURE__ */ new Date())) return null;
1458
+ let until;
1459
+ try {
1460
+ until = effectiveUntil3(state.lock, timingOf(state));
1461
+ } catch {
1462
+ }
1463
+ return { ...until ? { until } : {}, hold: state.lock.hold === true };
1464
+ }
1465
+ async function expiredRefusal(bucket, location, object, isOwnerRead) {
1466
+ if (!isObjectExpired(object.customMetadata)) return null;
1467
+ if (isOwnerRead && await retainedOf(bucket, location, object) !== null) return null;
1468
+ return expiredResponse();
1469
+ }
1470
+
1471
+ // src/routes/assets.ts
825
1472
  var MAX_ASSET_LIST_LIMIT = 1e3;
1473
+ var MAX_EXPIRED_LOOKUPS = 32;
826
1474
  var VISIBILITY_ORDER = ["private", "public"];
1475
+ var RESUME_AFTER_MARK = "~";
827
1476
  function parseListPrefix(raw) {
828
1477
  if (!raw?.trim()) return "";
829
1478
  return normalizeAssetPrefix2(raw);
@@ -834,7 +1483,12 @@ function parseListCursor(raw, visibilities) {
834
1483
  const visibility = raw.slice(0, separator);
835
1484
  if (separator < 0 || !visibilities.includes(visibility)) return null;
836
1485
  const cursor = raw.slice(separator + 1);
837
- return { visibility, ...cursor ? { cursor } : {} };
1486
+ if (!cursor.startsWith(RESUME_AFTER_MARK)) return { visibility, ...cursor ? { cursor } : {} };
1487
+ try {
1488
+ return { visibility, startAfter: decodeURIComponent(cursor.slice(RESUME_AFTER_MARK.length)) };
1489
+ } catch {
1490
+ return null;
1491
+ }
838
1492
  }
839
1493
  function parseLimit(raw) {
840
1494
  const limit = Number(raw);
@@ -861,7 +1515,32 @@ function nextCursor(listing, position, visibilities) {
861
1515
  const next = visibilities[visibilities.indexOf(position.visibility) + 1];
862
1516
  return next ? `${next}:` : null;
863
1517
  }
864
- var assets = new Hono3();
1518
+ var resumeCursor = (visibility, afterKey) => `${visibility}:${RESUME_AFTER_MARK}${encodeURIComponent(afterKey)}`;
1519
+ var expiredEntry = (entry, retained) => ({
1520
+ ...entry,
1521
+ expired: true,
1522
+ ...retained.until ? { retainedUntil: retained.until } : {},
1523
+ ...retained.hold ? { hold: true } : {}
1524
+ });
1525
+ async function listedEntries(bucket, listing, storageRoot, visibility) {
1526
+ const page = { objects: [] };
1527
+ let lookups = 0;
1528
+ let lastKey;
1529
+ for (const object of listing.objects) {
1530
+ const entry = toEntry(object, storageRoot, visibility);
1531
+ if (isObjectExpired(object.customMetadata)) {
1532
+ if (lookups === MAX_EXPIRED_LOOKUPS) return { ...page, cutAfter: lastKey };
1533
+ lookups += 1;
1534
+ const retained = await retainedOf(bucket, { root: storageRoot, key: entry.key }, object);
1535
+ if (retained) page.objects.push(expiredEntry(entry, retained));
1536
+ } else {
1537
+ page.objects.push(entry);
1538
+ }
1539
+ lastKey = entry.key;
1540
+ }
1541
+ return page;
1542
+ }
1543
+ var assets = new Hono4();
865
1544
  assets.get("/", async (context) => {
866
1545
  const prefix = parseListPrefix(context.req.query("prefix"));
867
1546
  if (prefix === null) {
@@ -879,23 +1558,251 @@ assets.get("/", async (context) => {
879
1558
  prefix: `${storageRoot}${position.visibility}/${ASSET_NAMESPACE}/${prefix}`,
880
1559
  limit: parseLimit(context.req.query("limit")),
881
1560
  ...position.cursor ? { cursor: position.cursor } : {},
1561
+ ...position.startAfter ? { startAfter: `${storageRoot}${position.startAfter}` } : {},
882
1562
  include: ["httpMetadata", "customMetadata"]
883
1563
  };
884
1564
  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) });
1565
+ const page = await listedEntries(context.env.MEDIA_BUCKET, listing, storageRoot, position.visibility);
1566
+ const cursor = page.cutAfter ? resumeCursor(position.visibility, page.cutAfter) : nextCursor(listing, position, visibilities);
1567
+ return context.json({ objects: page.objects, cursor });
887
1568
  });
888
1569
 
889
1570
  // src/routes/captions.ts
890
1571
  import {
891
1572
  captionPath,
892
1573
  createDecryptStream as createDecryptStream2,
893
- decodeEncryptionMeta as decodeEncryptionMeta2,
1574
+ decodeEncryptionMeta as decodeEncryptionMeta5,
894
1575
  extractId,
895
1576
  resolveSecurity,
896
1577
  visibilityFromKey
897
1578
  } from "@devultur/core";
898
- import { Hono as Hono5 } from "hono";
1579
+ import { Hono as Hono6 } from "hono";
1580
+
1581
+ // src/keys-bucket.ts
1582
+ import { decodeEncryptionMeta as decodeEncryptionMeta3 } from "@devultur/core";
1583
+ var KEYS_BUCKET_MISSING_CODE = "keys_bucket_missing";
1584
+ function isKeysBucketMissing(shouldEncrypt, env) {
1585
+ return shouldEncrypt && !env.KEYS_BUCKET;
1586
+ }
1587
+ function keysBucketMissing(context) {
1588
+ console.error("[encrypt] security.encrypt is on but KEYS_BUCKET is not bound: refusing to store a private file");
1589
+ return context.json(
1590
+ { error: "Encryption at rest is on but KEYS_BUCKET is not bound", code: KEYS_BUCKET_MISSING_CODE },
1591
+ 503
1592
+ );
1593
+ }
1594
+ var ENCRYPTION_KEY_MISSING_CODE = "encryption_key_missing";
1595
+ async function hasEncryptionKey(keysBucket, objectStorageKey, enc) {
1596
+ const meta = decodeEncryptionMeta3(enc);
1597
+ if (!meta) return true;
1598
+ if (!keysBucket) return false;
1599
+ return await keysBucket.head(`${objectStorageKey}/${meta.keyId}`) !== null;
1600
+ }
1601
+ function encryptionKeyMissing(context, key) {
1602
+ console.error(`[encrypt] the encryption key of ${key} is gone: refusing to store an object nobody could decrypt`);
1603
+ return context.json(
1604
+ { error: `The encryption key of ${key} was deleted; upload it again`, code: ENCRYPTION_KEY_MISSING_CODE, key },
1605
+ 500
1606
+ );
1607
+ }
1608
+
1609
+ // src/object-lock/overwrite.ts
1610
+ import { decodeEncryptionMeta as decodeEncryptionMeta4, TranscodeStatus as TranscodeStatus2 } from "@devultur/core";
1611
+
1612
+ // src/transcode/status.ts
1613
+ import { TranscodeStatus } from "@devultur/core";
1614
+ var statusKey = (videoId) => `${KvPrefix.TRANSCODE_STATUS}:${videoId}`;
1615
+ function statusChangeFor(event) {
1616
+ switch (event.event) {
1617
+ case "transcode.started":
1618
+ return { status: TranscodeStatus.RUNNING };
1619
+ case "transcode.progress":
1620
+ return event.phase === "downloading" ? { status: TranscodeStatus.RUNNING } : null;
1621
+ case "transcode.complete":
1622
+ return { status: TranscodeStatus.COMPLETED };
1623
+ case "transcode.failed":
1624
+ return {
1625
+ status: TranscodeStatus.FAILED,
1626
+ ...typeof event.error === "string" ? { error: event.error } : {},
1627
+ ...typeof event.reason === "string" ? { reason: event.reason } : {},
1628
+ ...typeof event.retriable === "boolean" ? { retriable: event.retriable } : {}
1629
+ };
1630
+ default:
1631
+ return null;
1632
+ }
1633
+ }
1634
+ async function readStatus(kv, videoId) {
1635
+ return kv.get(statusKey(videoId), "json");
1636
+ }
1637
+ async function writeQueuedStatus(kv, videoId, job) {
1638
+ const record = { status: TranscodeStatus.QUEUED, ...job, updatedAt: Date.now() };
1639
+ await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
1640
+ }
1641
+ async function applyStatusEvent(kv, event) {
1642
+ const change = statusChangeFor(event);
1643
+ const videoId = typeof event.videoId === "string" ? event.videoId : "";
1644
+ if (!change || !videoId) return;
1645
+ const current = await readStatus(kv, videoId);
1646
+ if (!current) return;
1647
+ if (current.status === TranscodeStatus.COMPLETED) return;
1648
+ const record = {
1649
+ jobRef: current.jobRef,
1650
+ ...current.runner ? { runner: current.runner } : {},
1651
+ ...change,
1652
+ updatedAt: Date.now()
1653
+ };
1654
+ await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
1655
+ }
1656
+
1657
+ // src/transcode/raw-retention.ts
1658
+ import { isVideoRawKey as isVideoRawKey2 } from "@devultur/core";
1659
+ var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
1660
+ async function readRawDisposal(kv, videoId) {
1661
+ return kv.get(disposalKey(videoId), "json");
1662
+ }
1663
+ async function rememberRawDisposal(kv, videoId, disposal) {
1664
+ await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
1665
+ }
1666
+ async function disposeRawAfterTranscode(context, videoId) {
1667
+ const { env } = context;
1668
+ const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
1669
+ if (!disposal?.deleteOnComplete || !disposal.storageKey) return "kept";
1670
+ const raw = await env.MEDIA_BUCKET.head(disposal.storageKey);
1671
+ const location = splitVideoStorageKey(disposal.storageKey);
1672
+ const lock = raw && location ? await effectiveLock(env.MEDIA_BUCKET, location.root, location.key, raw) : null;
1673
+ if (lock?.state && isStateLocked(lock.state, /* @__PURE__ */ new Date())) {
1674
+ await recordLockAudit(context, {
1675
+ action: "original_retained",
1676
+ keys: [location?.key ?? disposal.storageKey],
1677
+ before: lock.state.lock,
1678
+ after: null,
1679
+ outcome: "skipped",
1680
+ root: location?.root ?? ""
1681
+ });
1682
+ return "retained";
1683
+ }
1684
+ await env.MEDIA_BUCKET.delete(disposal.storageKey);
1685
+ if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
1686
+ return "deleted";
1687
+ }
1688
+ var ORIGINAL_NOT_RETAINED = {
1689
+ 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.",
1690
+ code: "original_not_retained"
1691
+ };
1692
+ var PRIOR_OUTPUTS_LIST_LIMIT = 100;
1693
+ async function hasPriorTranscodeOutputs(bucket, videoFolder) {
1694
+ const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
1695
+ return listing.objects.some((object) => !isVideoRawKey2(object.key));
1696
+ }
1697
+
1698
+ // src/object-lock/transcode-marker.ts
1699
+ var MARKER_NAME = "transcode-completed";
1700
+ var markerKey = (root, videoFolder) => sidecarKey(root, `${videoFolder}/${MARKER_NAME}`);
1701
+ async function markTranscodeCompleted(env, videoId, ticketLocation) {
1702
+ const location = ticketLocation ?? await rememberedLocation(env, videoId);
1703
+ if (!location) return;
1704
+ await env.MEDIA_BUCKET.put(markerKey(location.root, location.folder), (/* @__PURE__ */ new Date()).toISOString(), {
1705
+ httpMetadata: { contentType: "text/plain" }
1706
+ });
1707
+ }
1708
+ async function rememberedLocation(env, videoId) {
1709
+ const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
1710
+ const raw = disposal?.storageKey ? splitVideoStorageKey(disposal.storageKey) : null;
1711
+ const folder = raw?.key.split("/").slice(0, 3).join("/");
1712
+ return raw && folder ? { root: raw.root, folder } : null;
1713
+ }
1714
+ async function hasCompletedTranscode(bucket, root, videoFolder) {
1715
+ return await bucket.head(markerKey(root, videoFolder)) !== null;
1716
+ }
1717
+ async function forgetTranscodeCompleted(bucket, root, videoFolder) {
1718
+ await bucket.delete(markerKey(root, videoFolder));
1719
+ }
1720
+
1721
+ // src/object-lock/overwrite.ts
1722
+ function sameObject(object) {
1723
+ return {
1724
+ etagMatches: object.etag,
1725
+ // R2 keeps `uploaded` to the millisecond, so "before uploaded + 1 ms" matches exactly this write and refuses
1726
+ // any later one. Real R2 only: the in-memory bucket of the tests can't prove the precision.
1727
+ ...object.uploaded ? { uploadedBefore: new Date(object.uploaded.getTime() + 1) } : {}
1728
+ };
1729
+ }
1730
+ function overwriteOf(object) {
1731
+ const replacedEnc = object.customMetadata?.enc;
1732
+ return { onlyIf: sameObject(object), exists: true, ...replacedEnc ? { replacedEnc } : {} };
1733
+ }
1734
+ function createOnlyCondition() {
1735
+ return new Headers({ "If-None-Match": "*" });
1736
+ }
1737
+ async function checkWrite(context, key, fullKey = storageKey(context, key), options = {}) {
1738
+ if (isReservedLockKey(key)) return { blocked: reservedKeyResponse(context, key) };
1739
+ const object = await context.env.MEDIA_BUCKET.head(fullKey);
1740
+ if (!object) return { onlyIf: createOnlyCondition(), exists: false };
1741
+ if (options.isLockExempt) return overwriteOf(object);
1742
+ const root = fullKey.endsWith(key) ? fullKey.slice(0, fullKey.length - key.length) : void 0;
1743
+ const blocked = await assertWritable(context, [key], "overwrite", root);
1744
+ if (blocked) return { blocked };
1745
+ return overwriteOf(object);
1746
+ }
1747
+ async function deleteReplacedKey(context, fullKey, replacedEnc, writtenEnc) {
1748
+ const meta = decodeEncryptionMeta4(replacedEnc);
1749
+ if (!meta || !context.env.KEYS_BUCKET) return;
1750
+ if (decodeEncryptionMeta4(writtenEnc)?.keyId === meta.keyId) return;
1751
+ await context.env.KEYS_BUCKET.delete(`${fullKey}/${meta.keyId}`);
1752
+ }
1753
+ function writeConflict(context, key) {
1754
+ return context.json({ error: `${key} changed while it was being written; retry`, code: "write_conflict", key }, 409);
1755
+ }
1756
+ var WriteConflictError = class extends Error {
1757
+ constructor(key) {
1758
+ super(`${key} changed while it was being written`);
1759
+ this.key = key;
1760
+ this.name = "WriteConflictError";
1761
+ }
1762
+ key;
1763
+ };
1764
+ async function assertRetranscodable(context, video, raw) {
1765
+ const root = getRequestState(context).storagePrefix;
1766
+ const { state } = await effectiveLock(context.env.MEDIA_BUCKET, root, video.rawKey, raw);
1767
+ if (!state) return null;
1768
+ const isCompleted = await hasCompletedTranscode(context.env.MEDIA_BUCKET, root, video.folder) || (await readStatus(context.env.MEDIA_KV, video.id))?.status === TranscodeStatus2.COMPLETED;
1769
+ if (!isCompleted) return null;
1770
+ return assertWritable(context, [video.folder], "overwrite");
1771
+ }
1772
+ async function checkWrites(context, keys) {
1773
+ const checks = /* @__PURE__ */ new Map();
1774
+ for (const key of keys) {
1775
+ const check = await checkWrite(context, key);
1776
+ if ("blocked" in check) return check;
1777
+ checks.set(key, check);
1778
+ }
1779
+ return { checks };
1780
+ }
1781
+
1782
+ // src/object-lock/caption-writes.ts
1783
+ async function prepareCaptionWrites(context, locales, virtualKeyOf, source) {
1784
+ const writes = await checkWrites(context, locales.map(virtualKeyOf));
1785
+ if ("blocked" in writes) return writes;
1786
+ const storageKeyOf = (locale) => storageKey(context, virtualKeyOf(locale));
1787
+ const expiry = expiryMetadataOf(source.customMetadata);
1788
+ return {
1789
+ storageKeyOf,
1790
+ put: async (locale, body, options) => {
1791
+ const check = writes.checks.get(virtualKeyOf(locale));
1792
+ const customMetadata = { ...options.customMetadata, ...expiry };
1793
+ const stored = await context.env.MEDIA_BUCKET.put(storageKeyOf(locale), body, {
1794
+ ...options,
1795
+ ...Object.keys(customMetadata).length > 0 ? { customMetadata } : {},
1796
+ onlyIf: check?.onlyIf
1797
+ });
1798
+ if (stored === null) throw new WriteConflictError(virtualKeyOf(locale));
1799
+ await deleteReplacedKey(context, storageKeyOf(locale), check?.replacedEnc, customMetadata.enc);
1800
+ }
1801
+ };
1802
+ }
1803
+ function captionConflictResponse(context, error) {
1804
+ return error instanceof WriteConflictError ? writeConflict(context, error.key) : null;
1805
+ }
899
1806
 
900
1807
  // src/routes/captions-errors.ts
901
1808
  var CaptionsErrorCode = {
@@ -954,7 +1861,7 @@ function logCaptionsError(context, error) {
954
1861
  }
955
1862
 
956
1863
  // src/routes/webhooks.ts
957
- import { Hono as Hono4 } from "hono";
1864
+ import { Hono as Hono5 } from "hono";
958
1865
 
959
1866
  // src/transcode/metrics.ts
960
1867
  var OPERATOR_EVENT_FIELDS = ["encode", "posterKey", "posterFormats"];
@@ -1072,76 +1979,8 @@ async function loadOutputBaseUrl(kv, videoId, fallbackUrl) {
1072
1979
  return await kv.get(outputBaseKey(videoId)) ?? fallbackUrl;
1073
1980
  }
1074
1981
 
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
1982
  // src/routes/webhooks.ts
1144
- var webhooks = new Hono4();
1983
+ var webhooks = new Hono5();
1145
1984
  webhooks.post("/transcode", async (context) => {
1146
1985
  const webhookSecret = context.env.TRANSCODE_WEBHOOK_SECRET;
1147
1986
  if (!webhookSecret) {
@@ -1156,7 +1995,12 @@ webhooks.post("/transcode", async (context) => {
1156
1995
  const body = await context.req.json();
1157
1996
  await publishTranscodeJob(context, body);
1158
1997
  if (body.event === "transcode.complete" && body.videoId) {
1159
- if (await disposeRawAfterTranscode(context.env, body.videoId)) addRouteResult(context, { originalDeleted: true });
1998
+ const disposal = await disposeRawAfterTranscode(context, body.videoId);
1999
+ if (disposal === "deleted") addRouteResult(context, { originalDeleted: true });
2000
+ if (disposal === "retained") addRouteResult(context, { originalRetained: true });
2001
+ const ticket = context.req.header(JOB_TICKET_HEADER);
2002
+ const location = await verifyJobLocation(context.env.DELIVERY_SIGN_SECRET, ticket, body.videoId);
2003
+ await markTranscodeCompleted(context.env, body.videoId, location);
1160
2004
  await context.env.MEDIA_KV.put(
1161
2005
  `${KvPrefix.TRANSCODE_META}:${body.videoId}`,
1162
2006
  JSON.stringify({ duration: body.duration }),
@@ -1358,7 +2202,7 @@ async function transcodedDuration(kv, videoId) {
1358
2202
  const meta = await kv.get(`${KvPrefix.TRANSCODE_META}:${videoId}`, "json");
1359
2203
  return meta?.duration ?? void 0;
1360
2204
  }
1361
- var captions = new Hono5();
2205
+ var captions = new Hono6();
1362
2206
  captions.post("/", async (context) => {
1363
2207
  const body = await context.req.json();
1364
2208
  if (!body.key || !body.locales?.length) {
@@ -1382,7 +2226,7 @@ captions.post("/", async (context) => {
1382
2226
  }
1383
2227
  const filename = body.key.split("/").pop() ?? "audio.mp4";
1384
2228
  const webhookData = await resolveCaptionsWebhook(context, videoId, body.callbackUrl);
1385
- const rawEnc = decodeEncryptionMeta2(file.customMetadata?.enc);
2229
+ const rawEnc = decodeEncryptionMeta5(file.customMetadata?.enc);
1386
2230
  let audioBody = file.body;
1387
2231
  if (rawEnc) {
1388
2232
  const loaded = await loadStorageKey(context.env.KEYS_BUCKET, fullKey, rawEnc.keyId);
@@ -1392,12 +2236,15 @@ captions.post("/", async (context) => {
1392
2236
  audioBody = file.body.pipeThrough(createDecryptStream2(loaded.key, rawEnc.iv));
1393
2237
  }
1394
2238
  const visibility = visibilityFromKey(body.key) ?? "private";
1395
- const captionKey = (locale) => storageKey(context, `${visibility}/${captionPath(videoId, locale)}`);
2239
+ const captionVirtualKey = (locale) => `${visibility}/${captionPath(videoId, locale)}`;
1396
2240
  const resourceOverride = parseSecurityHeader(file.customMetadata?.security);
1397
2241
  const captionPolicy = resolveSecurity(resourceOverride, scope.config?.security);
1398
- const encryptCaptions = captionPolicy.encrypt && visibility === "private" && Boolean(context.env.KEYS_BUCKET);
2242
+ const encryptCaptions = Boolean(captionPolicy.encrypt) && visibility === "private";
2243
+ if (isKeysBucketMissing(encryptCaptions, context.env)) return keysBucketMissing(context);
2244
+ const writes = await prepareCaptionWrites(context, body.locales, captionVirtualKey, file);
2245
+ if ("blocked" in writes) return writes.blocked;
1399
2246
  const putCaption = async (locale, vtt) => {
1400
- const objectKey = captionKey(locale);
2247
+ const objectKey = writes.storageKeyOf(locale);
1401
2248
  const metadata = {};
1402
2249
  if (resourceOverride) metadata.security = JSON.stringify(resourceOverride);
1403
2250
  let bodyToStore = vtt;
@@ -1411,7 +2258,7 @@ captions.post("/", async (context) => {
1411
2258
  bodyToStore = result.body;
1412
2259
  metadata.enc = result.enc;
1413
2260
  }
1414
- await context.env.MEDIA_BUCKET.put(objectKey, bodyToStore, {
2261
+ await writes.put(locale, bodyToStore, {
1415
2262
  httpMetadata: { contentType: "text/vtt" },
1416
2263
  ...Object.keys(metadata).length > 0 ? { customMetadata: metadata } : {}
1417
2264
  });
@@ -1466,18 +2313,19 @@ captions.post("/", async (context) => {
1466
2313
  });
1467
2314
  } catch (err) {
1468
2315
  logCaptionsError(`job failed for ${videoId}`, err);
1469
- const failure3 = toCaptionsFailure(err);
2316
+ const failure4 = toCaptionsFailure(err);
1470
2317
  await forwardToClientWebhook(
1471
2318
  {
1472
2319
  event: "captions.failed",
1473
2320
  videoId,
1474
- error: failure3.error,
1475
- reason: failure3.reason,
1476
- retriable: failure3.retriable
2321
+ error: failure4.error,
2322
+ reason: failure4.reason,
2323
+ retriable: failure4.retriable
1477
2324
  },
1478
2325
  webhookData
1479
2326
  );
1480
- return context.json({ error: failure3.error, code: failure3.code }, failure3.status);
2327
+ const conflict = captionConflictResponse(context, err);
2328
+ return conflict ?? context.json({ error: failure4.error, code: failure4.code }, failure4.status);
1481
2329
  }
1482
2330
  });
1483
2331
  captions.get("/:transcriptId", async (context) => {
@@ -1498,16 +2346,15 @@ captions.get("/:transcriptId", async (context) => {
1498
2346
  // src/routes/media.ts
1499
2347
  import {
1500
2348
  computeFingerprint,
1501
- decodeEncryptionMeta as decodeEncryptionMeta4,
2349
+ decodeEncryptionMeta as decodeEncryptionMeta7,
1502
2350
  GENERATED_PRIVATE_CACHE_CONTROL,
1503
2351
  GENERATED_PUBLIC_CACHE_CONTROL,
1504
2352
  resolveSecurity as resolveSecurity2,
1505
2353
  SESSION_HEADER,
1506
2354
  toPrivateCacheControl,
1507
- videoBasePath,
1508
2355
  visibilityFromKey as visibilityFromKey2
1509
2356
  } from "@devultur/core";
1510
- import { Hono as Hono6 } from "hono";
2357
+ import { Hono as Hono7 } from "hono";
1511
2358
 
1512
2359
  // src/image/cloudflare.ts
1513
2360
  var IMAGE_RESIZING_VIA = "image-resizing";
@@ -1714,7 +2561,7 @@ function hasAvifSequenceBrand(bytes) {
1714
2561
  }
1715
2562
 
1716
2563
  // src/image/storage.ts
1717
- import { decodeEncryptionMeta as decodeEncryptionMeta3, decryptChunk as decryptChunk2 } from "@devultur/core";
2564
+ import { decodeEncryptionMeta as decodeEncryptionMeta6, decryptChunk as decryptChunk2 } from "@devultur/core";
1718
2565
  var HEADER_PROBE_BYTES = 256 * 1024;
1719
2566
  var passThrough = async (bytes) => bytes;
1720
2567
  var DerivedKind = {
@@ -1722,7 +2569,7 @@ var DerivedKind = {
1722
2569
  MEZZANINE: "mezzanine"
1723
2570
  };
1724
2571
  async function loadDecryptor(env, objectKey, customMetadata) {
1725
- const enc = decodeEncryptionMeta3(customMetadata?.enc);
2572
+ const enc = decodeEncryptionMeta6(customMetadata?.enc);
1726
2573
  if (!enc) return passThrough;
1727
2574
  const loaded = await loadStorageKey(env.KEYS_BUCKET, objectKey, enc.keyId);
1728
2575
  if (!loaded) return null;
@@ -1743,7 +2590,7 @@ async function storeDerived(env, storageKey2, body, contentType, original, kind)
1743
2590
  const customMetadata = { derivedFrom: kind };
1744
2591
  if (security) customMetadata.security = security;
1745
2592
  let storedBody = body;
1746
- if (decodeEncryptionMeta3(original.customMetadata?.enc)) {
2593
+ if (decodeEncryptionMeta6(original.customMetadata?.enc)) {
1747
2594
  if (!env.KEYS_BUCKET) throw new Error("Encrypted original without KEYS_BUCKET");
1748
2595
  const encrypted = await encryptForStorage(env.KEYS_BUCKET, storageKey2, body, security ? { security } : void 0);
1749
2596
  storedBody = encrypted.body;
@@ -1912,6 +2759,8 @@ import { edgeTtlSeconds, isAssetKey, sha256Hex as sha256Hex2 } from "@devultur/c
1912
2759
  var MAX_EDGE_TTL_SECONDS = 7 * 24 * 60 * 60;
1913
2760
  var MAX_EDGE_CACHE_FILL_BYTES = 100 * 1024 * 1024;
1914
2761
  var ORIGINAL_CACHE_CONTROL_HEADER = "x-devultur-cache-control";
2762
+ var EDGE_EXPIRES_HEADER = "x-devultur-edge-expires";
2763
+ var OWNER_HEADER_PREFIX = "x-devultur-lock";
1915
2764
  var CACHE_TAG_HEADER = "Cache-Tag";
1916
2765
  var TAG_HASH_LENGTH = 40;
1917
2766
  function publicEdgeCache() {
@@ -1935,35 +2784,112 @@ async function edgeCacheTags(storagePrefix2, virtualKey) {
1935
2784
  const objectTag = await edgeCacheTag(storagePrefix2, virtualKey);
1936
2785
  return prefixTag ? `${objectTag},${prefixTag}` : objectTag;
1937
2786
  }
2787
+ function isEdgeCopyExpired(hit, now = Date.now()) {
2788
+ const expiresAt = Number(hit.headers.get(EDGE_EXPIRES_HEADER));
2789
+ return Number.isFinite(expiresAt) && expiresAt > 0 && expiresAt <= now;
2790
+ }
2791
+ async function matchEdgeCopy(cache, request, options) {
2792
+ const hit = await cache.match(request);
2793
+ if (!hit || isEdgeCopyExpired(hit)) return null;
2794
+ return responseFromEdgeHit(hit, options);
2795
+ }
1938
2796
  function responseFromEdgeHit(hit, options) {
1939
2797
  const headers = new Headers(hit.headers);
1940
2798
  if (options.isFormatNegotiated) headers.set("Vary", "Accept");
1941
2799
  const original = headers.get(ORIGINAL_CACHE_CONTROL_HEADER);
1942
2800
  if (original) headers.set("Cache-Control", original);
1943
2801
  headers.delete(ORIGINAL_CACHE_CONTROL_HEADER);
2802
+ headers.delete(EDGE_EXPIRES_HEADER);
1944
2803
  headers.delete(CACHE_TAG_HEADER);
1945
2804
  return new Response(hit.body, { status: hit.status, headers });
1946
2805
  }
1947
2806
  function edgeTtlFor(cacheControl) {
1948
2807
  return edgeTtlSeconds(cacheControl, MAX_EDGE_TTL_SECONDS);
1949
2808
  }
1950
- async function putEdgeCopy(target, body, headers) {
2809
+ function edgeCopyTtl(target, now = Date.now()) {
1951
2810
  const ttl = edgeTtlFor(target.cacheControl);
2811
+ if (target.expiresAt === void 0) return ttl;
2812
+ return Math.max(0, Math.min(ttl, Math.floor((target.expiresAt - now) / 1e3)));
2813
+ }
2814
+ var carriesOwnerHeaders = (headers) => [...headers.keys()].some((name) => name.toLowerCase().startsWith(OWNER_HEADER_PREFIX));
2815
+ async function putEdgeCopy(target, body, headers) {
2816
+ const ttl = edgeCopyTtl(target);
2817
+ if (ttl <= 0 || carriesOwnerHeaders(headers)) {
2818
+ await body?.cancel();
2819
+ return;
2820
+ }
1952
2821
  const copyHeaders = new Headers(headers);
1953
2822
  copyHeaders.delete("Content-Range");
1954
2823
  copyHeaders.delete("Vary");
1955
2824
  copyHeaders.set(ORIGINAL_CACHE_CONTROL_HEADER, target.cacheControl);
1956
2825
  copyHeaders.set("Cache-Control", `public, max-age=${ttl}`);
2826
+ if (target.expiresAt !== void 0) copyHeaders.set(EDGE_EXPIRES_HEADER, String(target.expiresAt));
1957
2827
  copyHeaders.set(CACHE_TAG_HEADER, await edgeCacheTags(target.storagePrefix, target.virtualKey));
1958
2828
  await target.cache.put(target.cacheKey, new Response(body, { status: 200, headers: copyHeaders }));
1959
2829
  }
1960
2830
  async function fillEdgeCache(target, bucket, fullKey, headers) {
2831
+ if (edgeCopyTtl(target) <= 0 || carriesOwnerHeaders(headers)) return;
1961
2832
  const full = await bucket.get(fullKey);
1962
2833
  if (!full) return;
1963
2834
  const fullHeaders = new Headers(headers);
1964
2835
  fullHeaders.set("Content-Length", String(full.size));
1965
2836
  await putEdgeCopy(target, full.body, fullHeaders);
1966
2837
  }
2838
+ function cacheDelivered(waitUntil, target, delivered) {
2839
+ if (edgeCopyTtl(target) <= 0) return;
2840
+ if (delivered.kind === "plain-range" && delivered.size <= MAX_EDGE_CACHE_FILL_BYTES) {
2841
+ waitUntil(fillEdgeCache(target, delivered.bucket, delivered.servedKey, delivered.response.headers));
2842
+ }
2843
+ if (delivered.kind === "plain-full") {
2844
+ waitUntil(putEdgeCopy(target, delivered.response.clone().body, delivered.headers));
2845
+ }
2846
+ }
2847
+
2848
+ // src/routes/media-delete.ts
2849
+ import { videoBasePath } from "@devultur/core";
2850
+ async function handleVideoDelete(context) {
2851
+ const videoId = context.req.param("videoId") ?? "";
2852
+ const visibilityParam = context.req.query("visibility");
2853
+ const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
2854
+ const folders = visibilities.map((visibility) => `${visibility}/${videoBasePath(videoId)}`);
2855
+ const blocked = await assertWritable(context, folders, "delete");
2856
+ if (blocked) return blocked;
2857
+ let deletedCount = 0;
2858
+ let deletedKeyCount = 0;
2859
+ for (const folder of folders) {
2860
+ const deleted = await guardedDeletePrefix(context, `${folder}/`);
2861
+ if ("blocked" in deleted) return deleted.blocked;
2862
+ deletedCount += deleted.deletedCount;
2863
+ if (deleted.deletedCount > 0) {
2864
+ await forgetTranscodeCompleted(context.env.MEDIA_BUCKET, getRequestState(context).storagePrefix, folder);
2865
+ }
2866
+ if (!folder.startsWith("private/")) continue;
2867
+ const deletedKeys = await deleteFolderKeys(context, folder);
2868
+ if ("blocked" in deletedKeys) return deletedKeys.blocked;
2869
+ deletedKeyCount += deletedKeys.deletedCount;
2870
+ }
2871
+ return context.json({ videoId, deletedCount, deletedKeyCount });
2872
+ }
2873
+ async function handleKeyDelete(context) {
2874
+ const key = context.req.param("key") ?? "";
2875
+ const fullKey = storageKey(context, key);
2876
+ if (isVariantKey(key)) {
2877
+ return context.json({ error: "Not found" }, 404);
2878
+ }
2879
+ if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
2880
+ const exists = await context.env.MEDIA_BUCKET.head(fullKey);
2881
+ if (!exists) {
2882
+ return context.json({ error: "Not found" }, 404);
2883
+ }
2884
+ const blocked = await assertWritable(context, [key], "delete");
2885
+ if (blocked) return blocked;
2886
+ await context.env.MEDIA_BUCKET.delete(fullKey);
2887
+ const variants = await guardedDeletePrefix(context, variantPrefix(key));
2888
+ if ("blocked" in variants) return variants.blocked;
2889
+ const keys = await deleteKeysFor(context, key, exists);
2890
+ if ("blocked" in keys) return keys.blocked;
2891
+ return new Response(null, { status: 204 });
2892
+ }
1967
2893
 
1968
2894
  // src/routes/storyboard-vtt.ts
1969
2895
  import { createDecryptStream as createDecryptStream3 } from "@devultur/core";
@@ -1993,7 +2919,7 @@ function downloadDisposition(storedDisposition, key, filename) {
1993
2919
  if (storedDisposition?.startsWith("inline")) return `attachment${storedDisposition.slice("inline".length)}`;
1994
2920
  return contentDispositionAttachment(key.split("/").pop() || "download");
1995
2921
  }
1996
- var media = new Hono6();
2922
+ var media = new Hono7();
1997
2923
  media.get("/:key{.+}", async (context) => {
1998
2924
  const key = context.req.param("key");
1999
2925
  const fullKey = storageKey(context, key);
@@ -2033,24 +2959,28 @@ media.get("/:key{.+}", async (context) => {
2033
2959
  const accept = context.req.header("Accept");
2034
2960
  const edgeCacheKey = workerParams ? `${requestUrl.origin}/__variant/${workerVariantKey(fullKey, workerParams, accept)}` : requestUrl.toString();
2035
2961
  const isUncachedTransform = hasTransformQuery(requestUrl) && !workerParams;
2036
- const edgeCache = isPrivate || isOriginRequest || isUncachedTransform ? null : publicEdgeCache();
2962
+ const isEdgeCached = !isPrivate && !isOriginRequest && !isUncachedTransform && !isOwnerAccess;
2963
+ const edgeCache = isEdgeCached ? publicEdgeCache() : null;
2037
2964
  if (edgeCache) {
2038
2965
  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 });
2966
+ const hit = await matchEdgeCopy(edgeCache, cacheRequest, { isFormatNegotiated });
2967
+ if (hit) return hit;
2041
2968
  }
2042
2969
  let object = await context.env.MEDIA_BUCKET.get(fullKey);
2043
2970
  let servedKey = fullKey;
2044
2971
  if (!object) {
2045
2972
  return context.json({ error: "Not found" }, 404);
2046
2973
  }
2047
- if (isObjectExpired(object.customMetadata)) return expiredResponse();
2974
+ const location = { root: getRequestState(context).storagePrefix, key };
2975
+ const expired = await expiredRefusal(context.env.MEDIA_BUCKET, location, object, isOwnerAccess);
2976
+ if (expired) return expired;
2977
+ const expiresAt = objectExpiresAt(object.customMetadata);
2048
2978
  if (tokenPayload && await bindingMismatch(context, tokenPayload, object.customMetadata)) {
2049
2979
  return context.json({ error: "Session mismatch", code: "session_mismatch" }, 401);
2050
2980
  }
2051
2981
  let contentType = object.httpMetadata?.contentType ?? "application/octet-stream";
2052
2982
  const isPlaylist = key.endsWith(".m3u8");
2053
- const cacheControl = servedCacheControl(object.httpMetadata?.cacheControl, isPrivate);
2983
+ const cacheControl = servedCacheControl(object.httpMetadata?.cacheControl, isPrivate || isOwnerAccess);
2054
2984
  const downloadParam = requestUrl.searchParams.get("download");
2055
2985
  const isDownload = downloadParam !== null;
2056
2986
  if (isPlaylist && token2) {
@@ -2096,7 +3026,7 @@ media.get("/:key{.+}", async (context) => {
2096
3026
  });
2097
3027
  }
2098
3028
  if (token2 && isStoryboardVttKey(key) && object.body) {
2099
- const enc = decodeEncryptionMeta4(object.customMetadata?.enc);
3029
+ const enc = decodeEncryptionMeta7(object.customMetadata?.enc);
2100
3030
  const loaded = enc ? await loadStorageKey(context.env.KEYS_BUCKET, fullKey, enc.keyId) : null;
2101
3031
  if (enc && !loaded) return context.json({ error: "Decryption key unavailable" }, 500);
2102
3032
  const decryption = enc && loaded ? { key: loaded.key, iv: enc.iv } : null;
@@ -2111,6 +3041,7 @@ media.get("/:key{.+}", async (context) => {
2111
3041
  } else if (object.httpMetadata?.contentDisposition) {
2112
3042
  headers.set("Content-Disposition", object.httpMetadata.contentDisposition);
2113
3043
  }
3044
+ if (isOwnerAccess) for (const [name, value] of await lockHeaders(context, key, object)) headers.set(name, value);
2114
3045
  const { response, kind } = await streamObject({
2115
3046
  bucket: context.env.MEDIA_BUCKET,
2116
3047
  keysBucket: context.env.KEYS_BUCKET,
@@ -2119,56 +3050,25 @@ media.get("/:key{.+}", async (context) => {
2119
3050
  headers,
2120
3051
  range: context.req.header("Range")
2121
3052
  });
2122
- if (!edgeCache || edgeTtlFor(cacheControl) <= 0) return response;
3053
+ if (!edgeCache) return response;
2123
3054
  const target = {
2124
3055
  cache: edgeCache,
2125
3056
  cacheKey: edgeCacheKey,
2126
3057
  storagePrefix: getRequestState(context).storagePrefix,
2127
3058
  virtualKey: key,
2128
- cacheControl
3059
+ cacheControl,
3060
+ expiresAt
2129
3061
  };
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
- }
3062
+ const delivered = { bucket: context.env.MEDIA_BUCKET, servedKey, size: object.size, kind, response, headers };
3063
+ cacheDelivered((task) => context.executionCtx.waitUntil(task), target, delivered);
2136
3064
  return response;
2137
3065
  });
2138
3066
  function servedCacheControl(stored, isPrivate) {
2139
3067
  if (!stored) return isPrivate ? GENERATED_PRIVATE_CACHE_CONTROL : GENERATED_PUBLIC_CACHE_CONTROL;
2140
3068
  return isPrivate ? toPrivateCacheControl(stored) : stored;
2141
3069
  }
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
- });
3070
+ media.delete("/videos/:videoId", handleVideoDelete);
3071
+ media.delete("/:key{.+}", handleKeyDelete);
2172
3072
  function parseSecurityMeta(raw) {
2173
3073
  if (!raw) return void 0;
2174
3074
  try {
@@ -2217,9 +3117,9 @@ async function serveEncryptionKey(context, key, tokenPayload) {
2217
3117
 
2218
3118
  // src/routes/token.ts
2219
3119
  import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as TokenScope3 } from "@devultur/core";
2220
- import { Hono as Hono7 } from "hono";
3120
+ import { Hono as Hono8 } from "hono";
2221
3121
  var DEFAULT_VIEWER_TOKEN_TTL_SECONDS = 7200;
2222
- var token = new Hono7();
3122
+ var token = new Hono8();
2223
3123
  token.post("/", async (context) => {
2224
3124
  const body = await context.req.json();
2225
3125
  if (!body.userId) {
@@ -2281,7 +3181,7 @@ import {
2281
3181
  resolveSecurity as resolveSecurity3,
2282
3182
  resolveTranscodePreset,
2283
3183
  TokenScope as TokenScope4,
2284
- TranscodeStatus as TranscodeStatus2,
3184
+ TranscodeStatus as TranscodeStatus3,
2285
3185
  videoBasePath as videoBasePath2,
2286
3186
  videoHlsPath,
2287
3187
  videoMp4Dir,
@@ -2290,7 +3190,7 @@ import {
2290
3190
  videoThumbnailPath,
2291
3191
  visibilityFromKey as visibilityFromKey3
2292
3192
  } from "@devultur/core";
2293
- import { Hono as Hono8 } from "hono";
3193
+ import { Hono as Hono9 } from "hono";
2294
3194
 
2295
3195
  // src/transcode/plan.ts
2296
3196
  import { MAIN_VARIANT } from "@devultur/core";
@@ -3229,7 +4129,7 @@ function parseSecurityMeta2(raw) {
3229
4129
  }
3230
4130
  var SERVICE_TOKEN_TTL_SECONDS = 3600;
3231
4131
  var isVideoId = (videoId, key) => Boolean(videoId) && videoId !== key;
3232
- var transcode = new Hono8();
4132
+ var transcode = new Hono9();
3233
4133
  transcode.post("/", async (context) => {
3234
4134
  const body = await context.req.json();
3235
4135
  if (!body.key) {
@@ -3250,6 +4150,9 @@ transcode.post("/", async (context) => {
3250
4150
  if (!isVideoId(videoId, body.key)) {
3251
4151
  return context.json({ error: "Invalid key format" }, 400);
3252
4152
  }
4153
+ const video = { id: videoId, folder: `${visibility}/${videoBasePath2(videoId)}`, rawKey: body.key };
4154
+ const retained = await assertRetranscodable(context, video, object);
4155
+ if (retained) return retained;
3253
4156
  const scope = getRequestState(context);
3254
4157
  const transcodeConfig = scope.config?.transcode;
3255
4158
  const resolved = resolveTranscodePreset(body.preset, transcodeConfig?.presets);
@@ -3269,7 +4172,8 @@ transcode.post("/", async (context) => {
3269
4172
  const resourceSecurityHeader = rawOverride ? JSON.stringify(rawOverride) : "";
3270
4173
  const keyMetadata = rawOverride ? { security: resourceSecurityHeader } : void 0;
3271
4174
  const policy = resolveSecurity3(rawOverride, scope.config?.security);
3272
- const shouldEncrypt = policy.encrypt && visibility === "private" && Boolean(context.env.KEYS_BUCKET);
4175
+ const shouldEncrypt = Boolean(policy.encrypt) && visibility === "private";
4176
+ if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
3273
4177
  let encryptKeyB64 = "";
3274
4178
  let encryptKeyId = "";
3275
4179
  if (shouldEncrypt && context.env.KEYS_BUCKET) {
@@ -3325,7 +4229,8 @@ transcode.post("/", async (context) => {
3325
4229
  });
3326
4230
  const { ticket: jobTicket } = await signJobTicket(context.env.DELIVERY_SIGN_SECRET, {
3327
4231
  videoId,
3328
- ...scope.context ? { context: scope.context } : {}
4232
+ ...scope.context ? { context: scope.context } : {},
4233
+ location: { root: scope.storagePrefix, folder: video.folder }
3329
4234
  });
3330
4235
  const runner = createRunner(context.env);
3331
4236
  if (!runner) {
@@ -3386,7 +4291,7 @@ transcode.post("/", async (context) => {
3386
4291
  outputPrefix,
3387
4292
  thumbnailKey,
3388
4293
  preset,
3389
- status: TranscodeStatus2.QUEUED
4294
+ status: TranscodeStatus3.QUEUED
3390
4295
  });
3391
4296
  });
3392
4297
  transcode.get("/:jobId", async (context) => {
@@ -3399,12 +4304,12 @@ transcode.get("/:jobId", async (context) => {
3399
4304
  kv.get(`${KvPrefix.TRANSCODE_META}:${jobMeta.videoId}`, "json")
3400
4305
  ]);
3401
4306
  const ownRecord = record?.jobRef === jobId ? record : null;
3402
- const status = ownRecord?.status ?? (durationMeta ? TranscodeStatus2.COMPLETED : TranscodeStatus2.QUEUED);
3403
- if (status === TranscodeStatus2.FAILED) {
4307
+ const status = ownRecord?.status ?? (durationMeta ? TranscodeStatus3.COMPLETED : TranscodeStatus3.QUEUED);
4308
+ if (status === TranscodeStatus3.FAILED) {
3404
4309
  const { error, reason, retriable } = ownRecord ?? {};
3405
4310
  return context.json({ jobId, status, error, reason, retriable });
3406
4311
  }
3407
- if (status !== TranscodeStatus2.COMPLETED) return context.json({ jobId, status });
4312
+ if (status !== TranscodeStatus3.COMPLETED) return context.json({ jobId, status });
3408
4313
  return context.json({
3409
4314
  jobId,
3410
4315
  status,
@@ -3415,29 +4320,42 @@ transcode.get("/:jobId", async (context) => {
3415
4320
  });
3416
4321
 
3417
4322
  // 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";
4323
+ import { MediaType as MediaType2, resolveSecurity as resolveSecurity5, resolveVisibility, UploadRequestKind as UploadRequestKind2 } from "@devultur/core";
4324
+ import { Hono as Hono10 } from "hono";
3426
4325
 
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";
4326
+ // src/object-lock/upload-lock.ts
4327
+ import { canChange as canChange3, resolveLock, validateLock as validateLock6 } from "@devultur/core";
4328
+ var failure3 = (status, code, error) => ({ status, code, error });
4329
+ function requestedLock(lock, hold) {
4330
+ if (hold !== void 0 && typeof hold !== "boolean") return failure3(400, "invalid_lock", "hold must be a boolean");
4331
+ if (lock === void 0 && hold === void 0) return void 0;
4332
+ const validation = validateLock6(lock ?? {});
4333
+ if (!validation.ok) return failure3(400, "invalid_lock", validation.errors.join("; "));
4334
+ return { ...validation.lock, ...hold !== void 0 ? { hold } : {} };
4335
+ }
4336
+ function satisfiesMinimum(minimum, lock, now) {
4337
+ if (minimum.hold && !lock.hold) return false;
4338
+ const change = canChange3(minimum, lock, { privileged: false, now, timing: { uploadedAt: now } });
4339
+ return change.ok && !change.bypass;
4340
+ }
4341
+ function resolveUploadLock(input) {
4342
+ const requested = requestedLock(input.lock, input.hold);
4343
+ if (requested && "code" in requested) return requested;
4344
+ const minimum = input.grant?.lock;
4345
+ const resolution = resolveLock(requested ?? minimum, input.appDefault);
4346
+ if (!resolution.ok) return failure3(400, "invalid_lock", resolution.errors.join("; "));
4347
+ const lock = resolution.lock;
4348
+ if (minimum && !satisfiesMinimum(minimum, lock, input.now)) {
4349
+ return failure3(403, "lock_below_minimum", "The upload token requires a stricter lock than the request asks for");
4350
+ }
4351
+ return lock.retention || lock.hold ? lock : void 0;
4352
+ }
4353
+
4354
+ // src/object-lock/upload-write.ts
4355
+ import { decodeEncryptionMeta as decodeEncryptionMeta8, resolveLock as resolveLock2 } from "@devultur/core";
4356
+
4357
+ // src/object-lock/job-outputs.ts
4358
+ import { isBearerOf, TranscodeStatus as TranscodeStatus4 } from "@devultur/core";
3441
4359
 
3442
4360
  // src/routes/derived-upload.ts
3443
4361
  var DERIVED_ASSET_KEY = /^(public|private)\/video\/[A-Za-z0-9-]+\/(hls|mp4|thumbnails|storyboard)\/[^/]+$/;
@@ -3451,18 +4369,109 @@ var DERIVED_CONTENT_TYPES = /* @__PURE__ */ new Set([
3451
4369
  function isDerivedAssetKey(key) {
3452
4370
  return DERIVED_ASSET_KEY.test(key);
3453
4371
  }
4372
+ var RAW_LIST_LIMIT = 100;
4373
+ async function inheritedExpiresAt(context, key) {
4374
+ if (!isDerivedAssetKey(key)) return void 0;
4375
+ const videoFolder = key.split("/").slice(0, 3).join("/");
4376
+ const options = {
4377
+ prefix: `${getRequestState(context).storagePrefix}${videoFolder}/raw/`,
4378
+ limit: RAW_LIST_LIMIT,
4379
+ include: ["customMetadata"]
4380
+ };
4381
+ const listing = await context.env.MEDIA_BUCKET.list(options);
4382
+ const expiries = listing.objects.map((raw) => objectExpiresAt(raw.customMetadata)).filter((expiresAt) => expiresAt !== void 0);
4383
+ return expiries.length > 0 ? Math.min(...expiries) : void 0;
4384
+ }
3454
4385
  async function derivedAssetPending(context, key) {
3455
4386
  if (!isDerivedAssetKey(key) || !isAuthenticated(context)) return null;
3456
4387
  const body = await context.req.json().catch(() => null);
3457
4388
  const declared = typeof body?.contentType === "string" ? body.contentType : "";
3458
4389
  const security = parseSecurityHeader(context.req.header(RESOURCE_SECURITY_HEADER));
4390
+ const expiresAt = await inheritedExpiresAt(context, key);
3459
4391
  return {
3460
4392
  contentType: DERIVED_CONTENT_TYPES.has(declared) ? declared : "application/octet-stream",
3461
4393
  storageKey: storageKey(context, key),
3462
- ...security ? { security } : {}
4394
+ ...security ? { security } : {},
4395
+ ...expiresAt ? { expiresAt } : {}
3463
4396
  };
3464
4397
  }
3465
4398
 
4399
+ // src/object-lock/job-outputs.ts
4400
+ var VIDEO_ID = /^(?:public|private)\/video\/([^/]+)\//;
4401
+ var IN_PROGRESS = /* @__PURE__ */ new Set([TranscodeStatus4.QUEUED, TranscodeStatus4.RUNNING]);
4402
+ async function isInProgressJobOutput(context, key) {
4403
+ if (!isDerivedAssetKey(key)) return false;
4404
+ if (!isBearerOf(context.req.header("Authorization"), getRequestState(context).runnerCredential)) return false;
4405
+ const videoId = VIDEO_ID.exec(key)?.[1];
4406
+ if (!videoId) return false;
4407
+ const status = await readStatus(context.env.MEDIA_KV, videoId);
4408
+ return status !== null && IN_PROGRESS.has(status.status);
4409
+ }
4410
+
4411
+ // src/object-lock/upload-write.ts
4412
+ async function prepareUploadWrite(context, key, pending, fullKey) {
4413
+ const isLockExempt = await isInProgressJobOutput(context, key);
4414
+ const check = await checkWrite(context, key, fullKey, { isLockExempt });
4415
+ if ("blocked" in check) return check;
4416
+ const isCreateOnly = Boolean(pending?.isAsset && !pending.overwrite);
4417
+ if (isCreateOnly && check.exists) return { blocked: alreadyExists(context, key) };
4418
+ return check;
4419
+ }
4420
+ async function assetUploadConflict(context, key, fullKey, shouldOverwrite) {
4421
+ const check = await checkWrite(context, key, fullKey);
4422
+ if ("blocked" in check) return check.blocked;
4423
+ return check.exists && !shouldOverwrite ? alreadyExists(context, key) : null;
4424
+ }
4425
+ async function failedWrite(context, key, pending, enc) {
4426
+ const meta = decodeEncryptionMeta8(enc);
4427
+ const fullKey = pending?.storageKey ?? storageKey(context, key);
4428
+ if (context.env.KEYS_BUCKET && meta) await context.env.KEYS_BUCKET.delete(`${fullKey}/${meta.keyId}`);
4429
+ return pending?.isAsset && !pending.overwrite ? alreadyExists(context, key) : writeConflict(context, key);
4430
+ }
4431
+ function uploadLockOf(context, key, pending) {
4432
+ if (pending) return pending.lock;
4433
+ if (lockSubjectOf(key)?.kind !== "original") return void 0;
4434
+ const resolution = resolveLock2(void 0, getRequestState(context).config?.lock);
4435
+ if (!resolution.ok) {
4436
+ console.error("config.lock is invalid:", resolution.errors.join("; "));
4437
+ return { hold: true };
4438
+ }
4439
+ const { lock } = resolution;
4440
+ return lock.retention || lock.hold ? lock : void 0;
4441
+ }
4442
+ async function afterUploadWrite(context, key, pending, write, writtenEnc) {
4443
+ const fullKey = pending?.storageKey ?? storageKey(context, key);
4444
+ const root = fullKey.endsWith(key) ? fullKey.slice(0, -key.length) : getRequestState(context).storagePrefix;
4445
+ if (pending) await discardStaleVariants(context.env.MEDIA_BUCKET, pending);
4446
+ if (write.exists) await deleteReplacedKey(context, fullKey, write.replacedEnc, writtenEnc);
4447
+ const lock = uploadLockOf(context, key, pending);
4448
+ if (!lock) return;
4449
+ await recordLockAudit(context, {
4450
+ action: "set",
4451
+ keys: [key],
4452
+ before: null,
4453
+ after: lock,
4454
+ outcome: "applied",
4455
+ actor: pending ? pending.context ?? null : getRequestState(context).context ?? null,
4456
+ root
4457
+ });
4458
+ }
4459
+
4460
+ // src/routes/upload-multipart.ts
4461
+ import {
4462
+ AES_BLOCK_BYTES as AES_BLOCK_BYTES2,
4463
+ classifyUploadRequest,
4464
+ counterForBlock,
4465
+ decodeEncryptionMeta as decodeEncryptionMeta9,
4466
+ encryptBytes,
4467
+ MULTIPART_PART_SIZE_BYTES,
4468
+ MULTIPART_QUERY,
4469
+ multipartPartCount,
4470
+ multipartPartRange,
4471
+ resolveSecurity as resolveSecurity4,
4472
+ UploadRequestKind
4473
+ } from "@devultur/core";
4474
+
3466
4475
  // src/routes/upload-pending.ts
3467
4476
  var UPLOAD_PENDING_QUERY = "u";
3468
4477
  var PENDING_ID_BYTES = 16;
@@ -3620,15 +4629,19 @@ async function handleMultipartCreate(context, key, target) {
3620
4629
  if (pending.multipart) {
3621
4630
  return context.json({ uploadId: pending.multipart.uploadId, partSize: pending.multipart.partSize, partCount });
3622
4631
  }
3623
- if (await isTakenAsset(context, pending)) return alreadyExists(context, key);
4632
+ const policy = resolveSecurity4(pending.security, getRequestState(context).config?.security);
4633
+ const shouldEncrypt = shouldEncryptAtRest(policy, key);
4634
+ if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
4635
+ const write = await prepareUploadWrite(context, pending.key ?? key, pending, pending.storageKey);
4636
+ if ("blocked" in write) return write.blocked;
3624
4637
  const customMetadata = {
3625
4638
  ...allowedUploadMetadata(pending.uploadMetadata),
3626
- ...integrityMetadata(declaredSha256, false, pending.expiresAt)
4639
+ ...integrityMetadata(declaredSha256, false, pending.expiresAt),
4640
+ ...lockMetadata(pending.lock)
3627
4641
  };
3628
4642
  if (pending.security) customMetadata.security = JSON.stringify(pending.security);
3629
- const policy = resolveSecurity4(pending.security, getRequestState(context).config?.security);
3630
4643
  let enc;
3631
- if (context.env.KEYS_BUCKET && shouldEncryptAtRest(policy, key, true)) {
4644
+ if (shouldEncrypt && context.env.KEYS_BUCKET) {
3632
4645
  ({ enc } = await createObjectEncryption(context.env.KEYS_BUCKET, pending.storageKey, { ...customMetadata }));
3633
4646
  customMetadata.enc = enc;
3634
4647
  }
@@ -3661,7 +4674,7 @@ async function handleMultipartCreate(context, key, target) {
3661
4674
  }
3662
4675
  async function encryptPart(context, pending, multipart, partNumber, body) {
3663
4676
  if (!multipart.enc) return body;
3664
- const meta = decodeEncryptionMeta5(multipart.enc);
4677
+ const meta = decodeEncryptionMeta9(multipart.enc);
3665
4678
  const material = meta ? await loadStorageKey(context.env.KEYS_BUCKET, pending.storageKey, meta.keyId) : null;
3666
4679
  if (!meta || !material) return null;
3667
4680
  const startBlock = (partNumber - 1) * multipart.partSize / AES_BLOCK_BYTES2;
@@ -3698,18 +4711,20 @@ async function handleMultipartComplete(context, key) {
3698
4711
  const { pending, multipart, kvKey } = active;
3699
4712
  const parts = normalizeCompletedParts(multipart, body?.parts);
3700
4713
  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);
4714
+ const write = await prepareUploadWrite(context, pending.key ?? key, pending, pending.storageKey);
4715
+ if ("blocked" in write) return write.blocked;
3702
4716
  const multipartUpload = context.env.MEDIA_BUCKET.resumeMultipartUpload(pending.storageKey, multipart.uploadId);
4717
+ if (!await hasEncryptionKey(context.env.KEYS_BUCKET, pending.storageKey, multipart.enc)) {
4718
+ await multipartUpload.abort();
4719
+ await context.env.MEDIA_KV.delete(kvKey);
4720
+ return encryptionKeyMissing(context, key);
4721
+ }
3703
4722
  const object = await multipartUpload.complete(parts);
3704
4723
  await context.env.MEDIA_KV.delete(kvKey);
3705
- await discardStaleVariants(context.env.MEDIA_BUCKET, pending);
4724
+ await afterUploadWrite(context, key, pending, write, multipart.enc);
3706
4725
  const integrity = multipart.sha256 ? { sha256: multipart.sha256, sha256Verified: false } : {};
3707
4726
  return context.json({ key, size: object.size, ...integrity });
3708
4727
  }
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
4728
  async function checkPartSha256(context, multipart, body) {
3714
4729
  const partSha256 = headerSha256(context);
3715
4730
  if (partSha256 === null) {
@@ -3754,7 +4769,7 @@ async function handleMultipartAbort(context) {
3754
4769
  }
3755
4770
 
3756
4771
  // src/routes/upload.ts
3757
- var upload = new Hono9();
4772
+ var upload = new Hono10();
3758
4773
  upload.post("/", async (context) => {
3759
4774
  const body = await context.req.json();
3760
4775
  const isAssetRequest = body.path !== void 0;
@@ -3798,6 +4813,14 @@ upload.post("/", async (context) => {
3798
4813
  if (isUploadTargetError(sha256)) return targetFailure(context, sha256);
3799
4814
  const cacheControl = resolveObjectCacheControl(body.cacheControl, target, scope.config?.cacheRules);
3800
4815
  if (isUploadTargetError(cacheControl)) return targetFailure(context, cacheControl);
4816
+ const lock = resolveUploadLock({
4817
+ lock: body.lock,
4818
+ hold: body.hold,
4819
+ grant,
4820
+ appDefault: scope.config?.lock,
4821
+ now: Date.now()
4822
+ });
4823
+ if (isUploadTargetError(lock)) return targetFailure(context, lock);
3801
4824
  const maxSizeBytes = effectiveMaxBytes(uploadRule?.maxSizeBytes, grant);
3802
4825
  const declaredSize = Number(body.size);
3803
4826
  if (maxSizeBytes != null && Number.isFinite(declaredSize) && declaredSize > maxSizeBytes) {
@@ -3805,15 +4828,17 @@ upload.post("/", async (context) => {
3805
4828
  }
3806
4829
  const virtualKey = target.key;
3807
4830
  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);
4831
+ const resolvedSecurity = resolveSecurity5(body.security, scope.config?.security);
4832
+ if (isKeysBucketMissing(shouldEncryptAtRest(resolvedSecurity, virtualKey), context.env)) {
4833
+ return keysBucketMissing(context);
3811
4834
  }
4835
+ const shouldOverwrite = body.overwrite === true;
4836
+ const conflict = target.isAsset ? await assetUploadConflict(context, virtualKey, fullKey, shouldOverwrite) : null;
4837
+ if (conflict) return conflict;
3812
4838
  if (grant) {
3813
4839
  const exhausted = await claimTokenFileSlot(context.env.MEDIA_KV, grant);
3814
4840
  if (exhausted) return targetFailure(context, exhausted);
3815
4841
  }
3816
- const resolvedSecurity = resolveSecurity5(body.security, scope.config?.security);
3817
4842
  const pending = {
3818
4843
  contentType: body.contentType,
3819
4844
  storageKey: fullKey,
@@ -3828,7 +4853,8 @@ upload.post("/", async (context) => {
3828
4853
  ...expiresAt ? { expiresAt } : {},
3829
4854
  ...target.isAsset ? { isAsset: true, overwrite: shouldOverwrite } : {},
3830
4855
  ...cacheControl ? { cacheControl } : {},
3831
- ...scope.config?.uploadMetadata ? { uploadMetadata: scope.config.uploadMetadata } : {}
4856
+ ...scope.config?.uploadMetadata ? { uploadMetadata: scope.config.uploadMetadata } : {},
4857
+ ...lock ? { lock } : {}
3832
4858
  };
3833
4859
  const pendingId = newUploadPendingId();
3834
4860
  await context.env.MEDIA_KV.put(uploadPendingKvKey(pendingId), JSON.stringify(pending), {
@@ -3883,25 +4909,30 @@ upload.put("/:key{.+}", async (context) => {
3883
4909
  }
3884
4910
  if ("mismatch" in digest) return sha256Mismatch(context, digest.mismatch.expected, digest.mismatch.actual);
3885
4911
  const policy = resolveSecurity5(resourceSecurity, getRequestState(context).config?.security);
3886
- const shouldEncrypt = shouldEncryptAtRest(policy, key, Boolean(context.env.KEYS_BUCKET));
4912
+ const shouldEncrypt = shouldEncryptAtRest(policy, key);
4913
+ if (isKeysBucketMissing(shouldEncrypt, context.env)) return keysBucketMissing(context);
3887
4914
  const customMetadata = {
3888
4915
  ...allowedUploadMetadata(pending?.uploadMetadata ?? getRequestState(context).config?.uploadMetadata),
3889
- ...integrityMetadata(digest.sha256, true, pending?.expiresAt)
4916
+ // A transcode output (no pending) expires with its video's raw.
4917
+ ...integrityMetadata(digest.sha256, true, pending?.expiresAt ?? await inheritedExpiresAt(context, key)),
4918
+ ...lockMetadata(uploadLockOf(context, key, pending))
3890
4919
  };
3891
4920
  if (resourceSecurity) customMetadata.security = JSON.stringify(resourceSecurity);
4921
+ const write = await prepareUploadWrite(context, pending?.key ?? key, pending, finalStorageKey);
4922
+ if ("blocked" in write) return write.blocked;
3892
4923
  let storedBody = body;
3893
4924
  if (shouldEncrypt && context.env.KEYS_BUCKET) {
3894
4925
  const result = await encryptForStorage(context.env.KEYS_BUCKET, finalStorageKey, body, customMetadata);
3895
4926
  storedBody = result.body;
3896
4927
  customMetadata.enc = result.enc;
3897
4928
  }
3898
- const isCreateOnly = Boolean(pending?.isAsset && !pending.overwrite);
4929
+ if (!await hasEncryptionKey(context.env.KEYS_BUCKET, finalStorageKey, customMetadata.enc)) {
4930
+ return encryptionKeyMissing(context, key);
4931
+ }
3899
4932
  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.
4933
+ onlyIf: write.onlyIf,
4934
+ // Standard, except a video raw the config keeps ≥30 days: Infrequent Access (30-day minimum, and a retrieval
4935
+ // charge on every read, the transcode's included; see `RAW_STORAGE_CLASS`).
3905
4936
  storageClass,
3906
4937
  httpMetadata: {
3907
4938
  contentType,
@@ -3910,23 +4941,325 @@ upload.put("/:key{.+}", async (context) => {
3910
4941
  },
3911
4942
  ...Object.keys(customMetadata).length > 0 ? { customMetadata } : {}
3912
4943
  });
3913
- if (isCreateOnly && !stored) {
3914
- await discardObjectKey(context.env.KEYS_BUCKET, finalStorageKey, customMetadata.enc);
3915
- return alreadyExists(context, key);
3916
- }
4944
+ if (stored === null) return failedWrite(context, key, pending, customMetadata.enc);
3917
4945
  if (resolved) await context.env.MEDIA_KV.delete(resolved.kvKey);
3918
- await discardStaleVariants(context.env.MEDIA_BUCKET, pending);
4946
+ await afterUploadWrite(context, key, pending, write, customMetadata.enc);
3919
4947
  return context.json({ key, size: body.byteLength, sha256: digest.sha256, sha256Verified: true });
3920
4948
  });
3921
- async function discardObjectKey(keysBucket, objectStorageKey, enc) {
3922
- const meta = decodeEncryptionMeta6(enc);
3923
- if (keysBucket && meta) await keysBucket.delete(`${objectStorageKey}/${meta.keyId}`);
4949
+
4950
+ // src/object-lock/lock-status.ts
4951
+ var VISIBILITY_SEGMENTS = /* @__PURE__ */ new Set(["public", "private"]);
4952
+ var RESERVED_SEGMENT = /__lock(?:-audit)?$/;
4953
+ function placeOfStorageKey(storageKey2, root) {
4954
+ const segments = storageKey2.split("/");
4955
+ if (segments.some((segment) => RESERVED_SEGMENT.test(segment))) return { kind: "reserved" };
4956
+ if (root !== void 0) {
4957
+ const key = storageKey2.slice(root.length);
4958
+ const isWorkerKey = storageKey2.startsWith(root) && VISIBILITY_SEGMENTS.has(key.split("/")[0] ?? "");
4959
+ return isWorkerKey ? { kind: "object", root, key } : { kind: "unrecognized" };
4960
+ }
4961
+ const first = segments.findIndex((segment) => VISIBILITY_SEGMENTS.has(segment));
4962
+ if (first < 0) return { kind: "unrecognized" };
4963
+ const inferredRoot = first === 0 ? "" : `${segments.slice(0, first).join("/")}/`;
4964
+ return { kind: "object", root: inferredRoot, key: storageKey2.slice(inferredRoot.length) };
4965
+ }
4966
+ var UNRECOGNIZED_LOCK = { locked: true, hold: false, unrecognized: true };
4967
+ var VARIANTS_SEGMENT = "/__variants/";
4968
+ function retentionSubjectOf(key) {
4969
+ const isVariant = isVariantKey(key);
4970
+ const original = isVariant ? key.slice(0, key.indexOf(VARIANTS_SEGMENT)) : key;
4971
+ return { subject: lockSubjectOf(original) ?? { kind: "original", key: original }, isVariant };
4972
+ }
4973
+ function toStoredObjectLock(locked, place) {
4974
+ if (!locked) return { locked: false, hold: false };
4975
+ const { lock, isUnreadable } = locked.state;
4976
+ const until = untilOf(locked.state);
4977
+ return {
4978
+ locked: true,
4979
+ hold: lock.hold === true,
4980
+ ...until ? { until } : {},
4981
+ ...lock.retention ? { mode: lock.retention.mode } : {},
4982
+ ...isUnreadable ? { unreadable: true } : {},
4983
+ ...locked.key === place.key ? {} : { lockedBy: `${place.root}${locked.key}` }
4984
+ };
4985
+ }
4986
+ var LOCK_CACHE_MAX_USES = 50;
4987
+ var LOCK_CACHE_MAX_AGE_MS = 5e3;
4988
+ function lockResolver(mediaBucket, now) {
4989
+ const cache = /* @__PURE__ */ new Map();
4990
+ const resolveSubject = (root, subject) => {
4991
+ const id = `${root}\0${subject.kind === "original" ? subject.key : subject.rawPrefix}`;
4992
+ const cached = cache.get(id);
4993
+ const isFresh = cached && cached.uses < LOCK_CACHE_MAX_USES && Date.now() - cached.readAt < LOCK_CACHE_MAX_AGE_MS;
4994
+ if (cached && isFresh) {
4995
+ cached.uses += 1;
4996
+ return cached.pending;
4997
+ }
4998
+ const pending = findLockedOriginal(mediaBucket, root, subject, now);
4999
+ pending.catch(() => cache.delete(id));
5000
+ cache.set(id, { pending, readAt: Date.now(), uses: 1 });
5001
+ return pending;
5002
+ };
5003
+ return async (place, object) => {
5004
+ const { subject, isVariant } = retentionSubjectOf(place.key);
5005
+ if (subject.kind === "original" && subject.key === place.key) {
5006
+ return lockedObject(mediaBucket, place.root, place.key, object, now);
5007
+ }
5008
+ const locked = await resolveSubject(place.root, subject);
5009
+ if (isVariant && subject.kind === "original" && locked && !protectsDerived(locked.state)) return null;
5010
+ return locked;
5011
+ };
5012
+ }
5013
+ async function readObjectLock(options, storageKey2) {
5014
+ const place = placeOfStorageKey(storageKey2, options.root);
5015
+ if (place.kind === "reserved") return null;
5016
+ const object = await options.mediaBucket.head(storageKey2);
5017
+ if (!object) return null;
5018
+ if (place.kind === "unrecognized") return UNRECOGNIZED_LOCK;
5019
+ const locked = await lockResolver(options.mediaBucket, options.now ?? /* @__PURE__ */ new Date())(place, object);
5020
+ return toStoredObjectLock(locked, place);
5021
+ }
5022
+
5023
+ // src/object-lock/prefix-walk.ts
5024
+ var DEFAULT_MAX_SUBREQUESTS = 40;
5025
+ var DEFAULT_BATCH_SIZE = 100;
5026
+ var MAX_BATCH_SIZE = 1e3;
5027
+ var BudgetExhausted = class extends Error {
5028
+ constructor() {
5029
+ super("subrequest budget exhausted");
5030
+ }
5031
+ };
5032
+ function subrequestBudget(max) {
5033
+ if (!Number.isInteger(max) || max < 1) throw new RangeError("maxSubrequests must be a positive integer");
5034
+ let used = 0;
5035
+ return {
5036
+ get used() {
5037
+ return used;
5038
+ },
5039
+ canAfford: (count) => used + count <= max,
5040
+ track: (bucket) => new Proxy(bucket, {
5041
+ get(target, property) {
5042
+ const value = Reflect.get(target, property);
5043
+ if (typeof value !== "function") return value;
5044
+ return (...args) => {
5045
+ if (used >= max) throw new BudgetExhausted();
5046
+ used += 1;
5047
+ return value.apply(target, args);
5048
+ };
5049
+ }
5050
+ })
5051
+ };
5052
+ }
5053
+ function encodeCursor(prefix, cursor) {
5054
+ return btoa(JSON.stringify({ prefix, ...cursor }));
5055
+ }
5056
+ var VIDEO_FOLDER_SHAPE = /^(?:public|private)\/video\/[^/]+$/;
5057
+ function invalidCursor(reason) {
5058
+ throw new RangeError(`cursor isn't one this function returned for this prefix: ${reason}`);
5059
+ }
5060
+ function parseCursor(cursor) {
5061
+ try {
5062
+ const parsed = JSON.parse(atob(cursor));
5063
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
5064
+ return parsed;
5065
+ } catch {
5066
+ }
5067
+ return invalidCursor("not a cursor");
5068
+ }
5069
+ var isOptionalString = (value) => value === void 0 || typeof value === "string";
5070
+ function validFolder(value, options) {
5071
+ if (value === void 0) return void 0;
5072
+ const { root, folder } = typeof value === "object" && value !== null ? value : {};
5073
+ if (typeof root !== "string" || typeof folder !== "string" || !VIDEO_FOLDER_SHAPE.test(folder)) {
5074
+ return invalidCursor("folder");
5075
+ }
5076
+ const folderPrefix = `${root}${folder}/`;
5077
+ const place = placeOfStorageKey(`${folderPrefix}x`, options.root);
5078
+ const isPlaced = place.kind === "object" && place.root === root;
5079
+ if (!isPlaced || !folderPrefix.startsWith(options.prefix)) return invalidCursor("folder outside the prefix");
5080
+ return { root, folder };
5081
+ }
5082
+ function decodeCursor(cursor, options) {
5083
+ if (cursor === void 0) return {};
5084
+ const { prefix, after, listCursor, folder } = parseCursor(cursor);
5085
+ if (prefix !== options.prefix) return invalidCursor("another prefix");
5086
+ if (!isOptionalString(after) || !isOptionalString(listCursor)) return invalidCursor("position");
5087
+ if (typeof after === "string" && !after.startsWith(options.prefix))
5088
+ return invalidCursor("position outside the prefix");
5089
+ const pendingFolder = validFolder(folder, options);
5090
+ return {
5091
+ ...typeof after === "string" ? { after } : {},
5092
+ ...typeof listCursor === "string" ? { listCursor } : {},
5093
+ ...pendingFolder ? { folder: pendingFolder } : {}
5094
+ };
5095
+ }
5096
+ async function walkPrefix(options, mediaBucket, start, visit) {
5097
+ const limit = Math.min(Math.max(1, Math.floor(options.batchSize ?? DEFAULT_BATCH_SIZE)), MAX_BATCH_SIZE);
5098
+ let position = { ...start };
5099
+ let hasProgressed = false;
5100
+ const stopped = () => ({ position, isDone: false, hasProgressed });
5101
+ for (; ; ) {
5102
+ let listing;
5103
+ try {
5104
+ listing = await mediaBucket.list({ prefix: options.prefix, limit, ...listFrom(position) });
5105
+ } catch (error) {
5106
+ if (error instanceof BudgetExhausted) return stopped();
5107
+ throw error;
5108
+ }
5109
+ const stray = listing.objects.find((listed) => !listed.key.startsWith(options.prefix));
5110
+ if (stray) throw new RangeError(`listing returned ${stray.key}, outside the prefix ${options.prefix}`);
5111
+ for (const listed of listing.objects) {
5112
+ try {
5113
+ await visit({ listed, place: placeOfStorageKey(listed.key, options.root) });
5114
+ } catch (error) {
5115
+ if (error instanceof BudgetExhausted) return stopped();
5116
+ throw error;
5117
+ }
5118
+ position = { after: listed.key };
5119
+ hasProgressed = true;
5120
+ }
5121
+ if (!listing.truncated) return { position, isDone: true, hasProgressed };
5122
+ if (listing.objects.length > 0) continue;
5123
+ position = { ...position, listCursor: listing.cursor };
5124
+ hasProgressed = true;
5125
+ }
5126
+ }
5127
+ function listFrom(position) {
5128
+ if (position.listCursor) return { cursor: position.listCursor };
5129
+ return position.after ? { startAfter: position.after } : {};
5130
+ }
5131
+ function assertProgress(hasProgressed) {
5132
+ if (hasProgressed) return;
5133
+ throw new RangeError("maxSubrequests is too small to finish the next object: raise it");
5134
+ }
5135
+
5136
+ // src/object-lock/prefix-delete.ts
5137
+ var VIDEO_FOLDER = /^((?:public|private)\/video\/[^/]+)\//;
5138
+ var isPending = (folder) => folder?.hasDeleted === true && !folder.hasRetained;
5139
+ async function deletePrefixRespectingLocks(options) {
5140
+ const startedAt = /* @__PURE__ */ new Date();
5141
+ const budget = subrequestBudget(options.maxSubrequests ?? DEFAULT_MAX_SUBREQUESTS);
5142
+ const mediaBucket = budget.track(options.mediaBucket);
5143
+ const keysBucket = options.keysBucket ? budget.track(options.keysBucket) : void 0;
5144
+ const resolveLock3 = lockResolver(mediaBucket, startedAt);
5145
+ const start = decodeCursor(options.cursor, options);
5146
+ const result = {
5147
+ deletedCount: 0,
5148
+ deletedBytes: 0,
5149
+ deletedKeyCount: 0,
5150
+ retainedCount: 0,
5151
+ retainedBytes: 0,
5152
+ retained: [],
5153
+ skippedCount: 0,
5154
+ subrequests: 0
5155
+ };
5156
+ let openFolder = start.folder ? { ...start.folder, hasDeleted: true, hasRetained: false } : null;
5157
+ let hasClosed = false;
5158
+ const closeFolder = async () => {
5159
+ if (isPending(openFolder)) {
5160
+ const { root, folder } = openFolder;
5161
+ const folderPrefix = `${root}${folder}/`;
5162
+ const remaining = await mediaBucket.list({ prefix: folderPrefix, limit: 1 });
5163
+ if (remaining.objects.length === 0) {
5164
+ if (keysBucket) {
5165
+ const keys = await keysWrittenBy(keysBucket, folderPrefix, startedAt);
5166
+ result.deletedKeyCount += await deleteKeys(keysBucket, keys);
5167
+ }
5168
+ await forgetTranscodeCompleted(mediaBucket, root, folder);
5169
+ }
5170
+ hasClosed = true;
5171
+ }
5172
+ openFolder = null;
5173
+ };
5174
+ const enterFolder = async (place) => {
5175
+ const videoFolder = VIDEO_FOLDER.exec(place.key)?.[1];
5176
+ const folder = videoFolder && `${place.root}${videoFolder}/`.startsWith(options.prefix) ? videoFolder : void 0;
5177
+ if (openFolder && openFolder.root === place.root && openFolder.folder === folder) return openFolder;
5178
+ await closeFolder();
5179
+ openFolder = folder ? { root: place.root, folder, hasDeleted: false, hasRetained: false } : null;
5180
+ return openFolder;
5181
+ };
5182
+ const retain = (key, size, lock) => {
5183
+ result.retained.push({ key, size, ...lock });
5184
+ result.retainedCount += 1;
5185
+ result.retainedBytes += size;
5186
+ };
5187
+ const visit = async ({ listed, place }) => {
5188
+ if (place.kind === "reserved") {
5189
+ result.skippedCount += 1;
5190
+ return;
5191
+ }
5192
+ if (place.kind === "unrecognized") return retain(listed.key, listed.size, UNRECOGNIZED_LOCK);
5193
+ const folder = await enterFolder(place);
5194
+ const object = await mediaBucket.head(listed.key);
5195
+ if (!object) return;
5196
+ const locked = await resolveLock3(place, object);
5197
+ if (locked) {
5198
+ if (folder) folder.hasRetained = true;
5199
+ return retain(listed.key, object.size, toStoredObjectLock(locked, place));
5200
+ }
5201
+ const keys = keysBucket ? await objectKeysToDelete(keysBucket, listed.key, object) : [];
5202
+ if (!budget.canAfford(1 + deleteCallsFor(keys))) throw new BudgetExhausted();
5203
+ await mediaBucket.delete(listed.key);
5204
+ result.deletedCount += 1;
5205
+ result.deletedBytes += object.size;
5206
+ if (folder) folder.hasDeleted = true;
5207
+ if (keysBucket) result.deletedKeyCount += await deleteKeys(keysBucket, keys);
5208
+ };
5209
+ const walk = await walkPrefix(options, mediaBucket, start, visit);
5210
+ if (walk.isDone) {
5211
+ try {
5212
+ await closeFolder();
5213
+ } catch (error) {
5214
+ if (!(error instanceof BudgetExhausted)) throw error;
5215
+ }
5216
+ }
5217
+ const pendingFolder = isPending(openFolder) ? { root: openFolder.root, folder: openFolder.folder } : void 0;
5218
+ const isFinished = walk.isDone && !pendingFolder;
5219
+ assertProgress(walk.hasProgressed || hasClosed || isFinished);
5220
+ const cursor = isFinished ? void 0 : encodeCursor(options.prefix, { ...walk.position, ...pendingFolder ? { folder: pendingFolder } : {} });
5221
+ return { ...result, ...cursor ? { cursor } : {}, subrequests: budget.used };
5222
+ }
5223
+
5224
+ // src/object-lock/retained-summary.ts
5225
+ async function retainedSummary(options) {
5226
+ const budget = subrequestBudget(options.maxSubrequests ?? DEFAULT_MAX_SUBREQUESTS);
5227
+ const mediaBucket = budget.track(options.mediaBucket);
5228
+ const resolveLock3 = lockResolver(mediaBucket, options.now ?? /* @__PURE__ */ new Date());
5229
+ const summary = {
5230
+ scannedCount: 0,
5231
+ retainedCount: 0,
5232
+ retainedBytes: 0,
5233
+ hasHold: false,
5234
+ subrequests: 0
5235
+ };
5236
+ const visit = async ({ listed, place }) => {
5237
+ if (place.kind === "reserved") return;
5238
+ let lock = UNRECOGNIZED_LOCK;
5239
+ let size = listed.size;
5240
+ if (place.kind === "object") {
5241
+ const object = await mediaBucket.head(listed.key);
5242
+ if (!object) return;
5243
+ lock = toStoredObjectLock(await resolveLock3(place, object), place);
5244
+ size = object.size;
5245
+ }
5246
+ summary.scannedCount += 1;
5247
+ if (!lock.locked) return;
5248
+ summary.retainedCount += 1;
5249
+ summary.retainedBytes += size;
5250
+ if (lock.hold) summary.hasHold = true;
5251
+ if (lock.until && (!summary.latestUntil || lock.until > summary.latestUntil)) summary.latestUntil = lock.until;
5252
+ };
5253
+ const walk = await walkPrefix(options, mediaBucket, decodeCursor(options.cursor, options), visit);
5254
+ assertProgress(walk.hasProgressed || walk.isDone);
5255
+ const cursor = walk.isDone ? void 0 : encodeCursor(options.prefix, walk.position);
5256
+ return { ...summary, ...cursor ? { cursor } : {}, subrequests: budget.used };
3924
5257
  }
3925
5258
 
3926
5259
  // src/index.ts
3927
5260
  function createWorker(options) {
3928
5261
  const { middleware, routes, hooks } = composePlugins(options?.plugins ?? defaultPlugins());
3929
- const app = new Hono10();
5262
+ const app = new Hono11();
3930
5263
  app.use("*", logger());
3931
5264
  app.use(
3932
5265
  "*",
@@ -3941,6 +5274,7 @@ function createWorker(options) {
3941
5274
  context.set(PLUGIN_HOOKS_VARIABLE, hooks);
3942
5275
  await next();
3943
5276
  });
5277
+ app.use("/v1/*", rejectReservedLockPaths);
3944
5278
  for (const handler of middleware) app.use("*", handler);
3945
5279
  app.get("/health", (c) => c.json({ status: "ok" }));
3946
5280
  app.post("/v1/metrics/playback", (c) => c.body(null, 204));
@@ -3951,6 +5285,7 @@ function createWorker(options) {
3951
5285
  app.route("/v1/transcode", transcode);
3952
5286
  app.route("/v1/captions", captions);
3953
5287
  app.route("/v1/token", token);
5288
+ app.route("/v1/lock", lockRoute);
3954
5289
  for (const route of routes) app.route(route.path, route.app);
3955
5290
  app.notFound((c) => c.json({ error: "Not found" }, 404));
3956
5291
  app.onError((err, c) => {
@@ -3961,10 +5296,20 @@ function createWorker(options) {
3961
5296
  }
3962
5297
  export {
3963
5298
  DownloadLink,
5299
+ ENCRYPTION_KEY_MISSING_CODE,
5300
+ HOLD_PAGE_SIZE,
5301
+ KEYS_BUCKET_MISSING_CODE,
5302
+ LOCK_AUDIT_PREFIX,
5303
+ LOCK_HOLD_HEADER,
5304
+ LOCK_METADATA_KEY,
5305
+ LOCK_SIDECAR_PREFIX,
5306
+ LOCK_UNTIL_HEADER,
3964
5307
  MAX_ASSET_LIST_LIMIT,
5308
+ MAX_EXPIRED_LOOKUPS,
3965
5309
  MAX_UPLOAD_TOKEN_TTL_SECONDS,
3966
5310
  MaxResolution,
3967
5311
  ObjectMeta,
5312
+ RESERVED_LOCK_PREFIXES,
3968
5313
  TOKEN_REJECTED_CODE,
3969
5314
  UPLOAD_TOKEN_TYPE,
3970
5315
  WorkerAuthErrorCode,
@@ -3973,6 +5318,7 @@ export {
3973
5318
  credentialPolicy,
3974
5319
  defaultPlugins,
3975
5320
  deletePrefix,
5321
+ deletePrefixRespectingLocks,
3976
5322
  downloadLinks,
3977
5323
  edgeCacheTag,
3978
5324
  getPendingUpload,
@@ -3991,10 +5337,12 @@ export {
3991
5337
  parseEncodeMetrics,
3992
5338
  parseTransformParams,
3993
5339
  readImageOriginScope,
5340
+ readObjectLock,
3994
5341
  readStoryboardKey,
3995
5342
  readTokenScopes,
3996
5343
  readTranscodeOutputKeys,
3997
5344
  readTranscodeOutputs,
5345
+ retainedSummary,
3998
5346
  servedCacheControl,
3999
5347
  staticConfig,
4000
5348
  storageKey,