@devultur/self-host 1.5.1 → 1.6.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
@@ -23,6 +23,7 @@ import {
23
23
  hashKey,
24
24
  isAuthenticated,
25
25
  isBearerAuthorized,
26
+ isConstantTimeEqual,
26
27
  isContentTypeAllowed,
27
28
  isServiceToken,
28
29
  isTranscodeWebhookAuthorized,
@@ -41,7 +42,7 @@ import {
41
42
  verifyJobLocation,
42
43
  verifyJobTicket,
43
44
  verifyViewerToken
44
- } from "./chunk-PQO3X57I.js";
45
+ } from "./chunk-BPHMKSQ7.js";
45
46
  import {
46
47
  ImageTransformsMode,
47
48
  WORKER_DECODABLE_TYPES,
@@ -62,13 +63,17 @@ import {
62
63
 
63
64
  // src/index.ts
64
65
  import {
66
+ ENCRYPTED_HEADER as ENCRYPTED_HEADER2,
65
67
  EXPIRES_AT_HEADER as EXPIRES_AT_HEADER2,
68
+ GRANT_EXPIRES_AT_HEADER as GRANT_EXPIRES_AT_HEADER2,
69
+ GRANT_KEY_HEADER as GRANT_KEY_HEADER2,
70
+ GRANT_METADATA_HEADER as GRANT_METADATA_HEADER2,
66
71
  REPR_DIGEST_HEADER as REPR_DIGEST_HEADER2,
67
72
  SESSION_HEADER as SESSION_HEADER2,
68
73
  SHA256_HEADER as SHA256_HEADER3,
69
74
  SHA256_VERIFIED_HEADER as SHA256_VERIFIED_HEADER2
70
75
  } from "@devultur/core";
71
- import { Hono as Hono11 } from "hono";
76
+ import { Hono as Hono13 } from "hono";
72
77
  import { cors } from "hono/cors";
73
78
  import { logger } from "hono/logger";
74
79
 
@@ -77,7 +82,13 @@ import { isVideoRawKey } from "@devultur/core";
77
82
 
78
83
  // src/plugin-hooks.ts
79
84
  var PLUGIN_HOOKS_VARIABLE = "pluginHooks";
80
- var NO_HOOKS = { issueCredential: [], beforeLinkRedeem: [], authorizeLockChange: [], onLockAudit: [] };
85
+ var NO_HOOKS = {
86
+ issueCredential: [],
87
+ beforeLinkRedeem: [],
88
+ authorizeGrant: [],
89
+ authorizeLockChange: [],
90
+ onLockAudit: []
91
+ };
81
92
  function getPluginHooks(context) {
82
93
  return { ...NO_HOOKS, ...context.get(PLUGIN_HOOKS_VARIABLE) };
83
94
  }
@@ -100,6 +111,13 @@ async function gateLinkRedemption(context, link) {
100
111
  }
101
112
  return null;
102
113
  }
114
+ async function gateGrantAccess(context, request) {
115
+ for (const hook of getPluginHooks(context).authorizeGrant) {
116
+ const refusal2 = await hook(context, request);
117
+ if (refusal2) return refusal2;
118
+ }
119
+ return null;
120
+ }
103
121
  async function decideLockChange(context, request) {
104
122
  const hooks = getPluginHooks(context).authorizeLockChange;
105
123
  for (const hook of hooks) {
@@ -700,6 +718,7 @@ function composePlugins(plugins) {
700
718
  hooks: {
701
719
  issueCredential: plugins.flatMap((plugin) => plugin.issueCredential ? [plugin.issueCredential] : []),
702
720
  beforeLinkRedeem: plugins.flatMap((plugin) => plugin.beforeLinkRedeem ? [plugin.beforeLinkRedeem] : []),
721
+ authorizeGrant: plugins.flatMap((plugin) => plugin.authorizeGrant ? [plugin.authorizeGrant] : []),
703
722
  authorizeLockChange: plugins.flatMap(
704
723
  (plugin) => plugin.authorizeLockChange ? [plugin.authorizeLockChange] : []
705
724
  ),
@@ -721,7 +740,7 @@ function credentialPolicy(options = {}) {
721
740
  }
722
741
 
723
742
  // src/plugins/download-links.ts
724
- import { Hono as Hono3 } from "hono";
743
+ import { Hono as Hono5 } from "hono";
725
744
 
726
745
  // src/routes/download.ts
727
746
  import { contentDispositionAttachment as contentDispositionAttachment2, relativeMediaPath, TokenScope } from "@devultur/core";
@@ -792,6 +811,35 @@ var DownloadLink = class {
792
811
  }
793
812
  };
794
813
 
814
+ // src/grant-credential.ts
815
+ import { DOWNLOAD_GRANT_ID_PREFIX, DOWNLOAD_GRANT_PREFIX } from "@devultur/core";
816
+ var OBJECT_ID = "[0-9a-f]{64}";
817
+ var GRANT_ID = "[A-Za-z0-9_-]{22}";
818
+ var SECRET = "[A-Za-z0-9_-]{43}";
819
+ var SECRET_PATTERN = new RegExp(`^${DOWNLOAD_GRANT_PREFIX}(${OBJECT_ID})\\.(${GRANT_ID})\\.(${SECRET})$`);
820
+ var PUBLIC_ID_PATTERN = new RegExp(`^${DOWNLOAD_GRANT_ID_PREFIX}(${OBJECT_ID})\\.(${GRANT_ID})$`);
821
+ function randomBase64Url(bytes) {
822
+ const random = crypto.getRandomValues(new Uint8Array(bytes));
823
+ return btoa(String.fromCharCode(...random)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
824
+ }
825
+ var newGrantId = () => randomBase64Url(16);
826
+ var newGrantSecret = () => randomBase64Url(32);
827
+ function formatGrantSecret({ objectId, grantId, secret }) {
828
+ return `${DOWNLOAD_GRANT_PREFIX}${objectId}.${grantId}.${secret}`;
829
+ }
830
+ function formatGrantPublicId({ objectId, grantId }) {
831
+ return `${DOWNLOAD_GRANT_ID_PREFIX}${objectId}.${grantId}`;
832
+ }
833
+ function parseGrantSecret(value) {
834
+ const match = SECRET_PATTERN.exec(value);
835
+ return match ? { objectId: match[1], grantId: match[2], secret: match[3] } : null;
836
+ }
837
+ function parseGrantPublicId(value) {
838
+ const match = PUBLIC_ID_PATTERN.exec(value);
839
+ return match ? { objectId: match[1], grantId: match[2] } : null;
840
+ }
841
+ var hashGrantSecret = (secret) => hashKey(secret);
842
+
795
843
  // src/route-result.ts
796
844
  var ROUTE_RESULT_VARIABLE = "routeResult";
797
845
  function getRouteResult(context) {
@@ -988,8 +1036,8 @@ function allowedUploadMetadata(metadata) {
988
1036
  if (!metadata) return {};
989
1037
  return Object.fromEntries(Object.entries(metadata).filter(([key]) => !isWorkerMetadataKey(key)));
990
1038
  }
991
- function targetFailure(context, failure4) {
992
- return context.json({ error: failure4.error, code: failure4.code }, failure4.status);
1039
+ function targetFailure(context, failure5) {
1040
+ return context.json({ error: failure5.error, code: failure5.code }, failure5.status);
993
1041
  }
994
1042
  function alreadyExists(context, key) {
995
1043
  return context.json(
@@ -1162,10 +1210,7 @@ var DEFAULT_LINK_TTL_SECONDS = 15 * 60;
1162
1210
  var MAX_LINK_TTL_SECONDS = 7 * 24 * 60 * 60;
1163
1211
  var MAX_LINK_USES = 100;
1164
1212
  var LINK_ID_PATTERN = /^[A-Za-z0-9_-]{22}$/;
1165
- function newLinkId() {
1166
- const bytes = crypto.getRandomValues(new Uint8Array(16));
1167
- return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1168
- }
1213
+ var newLinkId = () => randomBase64Url(16);
1169
1214
  function linkStub(namespace, linkId) {
1170
1215
  return namespace.get(namespace.idFromName(linkId));
1171
1216
  }
@@ -1205,9 +1250,8 @@ async function handleDownloadLink(context) {
1205
1250
  });
1206
1251
  if (!decision.allow) return decision.response;
1207
1252
  const expiresAt = cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs);
1208
- const linkId = newLinkId();
1209
1253
  const scope = getRequestState(context);
1210
- const record = {
1254
+ const created = await storeLink(context, namespace, {
1211
1255
  storageKey: fullKey,
1212
1256
  key: parsed.key,
1213
1257
  expiresAt,
@@ -1215,14 +1259,18 @@ async function handleDownloadLink(context) {
1215
1259
  ...parsed.filename ? { filename: parsed.filename } : {},
1216
1260
  storagePrefix: scope.storagePrefix,
1217
1261
  ...scope.context ? { context: scope.context } : {}
1218
- };
1262
+ });
1263
+ if (!created) return context.json({ error: "Could not create the download link" }, 500);
1264
+ return context.json({ ...created, expiresAt, maxUses: parsed.maxUses });
1265
+ }
1266
+ async function storeLink(context, namespace, record) {
1267
+ const linkId = newLinkId();
1219
1268
  const created = await linkStub(namespace, linkId).fetch(`https://download-link${DownloadLinkPath.CREATE}`, {
1220
1269
  method: "POST",
1221
1270
  body: JSON.stringify(record)
1222
1271
  });
1223
- if (!created.ok) return context.json({ error: "Could not create the download link" }, 500);
1224
- const origin = new URL(context.req.url).origin;
1225
- return context.json({ url: `${origin}/v1/download/${linkId}`, expiresAt, maxUses: parsed.maxUses });
1272
+ if (!created.ok) return null;
1273
+ return { id: linkId, url: `${new URL(context.req.url).origin}/v1/download/${linkId}` };
1226
1274
  }
1227
1275
  function attachmentDisposition(record, storedDisposition) {
1228
1276
  if (record.filename) return contentDispositionAttachment2(record.filename);
@@ -1274,14 +1322,700 @@ download.get("/:linkId", async (context) => {
1274
1322
  return response;
1275
1323
  });
1276
1324
 
1325
+ // src/routes/grant-bearer.ts
1326
+ import {
1327
+ DownloadGrantErrorCode as DownloadGrantErrorCode2,
1328
+ DownloadGrantLimits,
1329
+ ENCRYPTED_HEADER,
1330
+ GRANT_EXPIRES_AT_HEADER,
1331
+ GRANT_KEY_HEADER,
1332
+ GRANT_METADATA_HEADER,
1333
+ TokenScope as TokenScope2
1334
+ } from "@devultur/core";
1335
+ import { Hono as Hono3 } from "hono";
1336
+
1337
+ // src/download-grants.ts
1338
+ import { DownloadGrantErrorCode } from "@devultur/core";
1339
+ var DownloadGrantPath = {
1340
+ /** Stores a new grant. Body: the record without `linksIssued`/`revokedAt`. */
1341
+ CREATE: "/create",
1342
+ /** The live grant for `{ id, secretHash }`. */
1343
+ AUTHORIZE: "/authorize",
1344
+ /** The grant for `{ id, secretHash }`, live or not: who it belongs to, before deciding anything. */
1345
+ IDENTIFY: "/identify",
1346
+ /** Spends one link of a live grant. Body: `{ id, secretHash, expiresIn, maxUses }`. */
1347
+ ISSUE: "/issue",
1348
+ /** Gives back a link spent by `ISSUE` whose creation failed. Body: `{ id }`. */
1349
+ REFUND: "/refund",
1350
+ /** Revokes `{ id }` (the owner) or `{ id, secretHash }` (the bearer). Idempotent. */
1351
+ REVOKE: "/revoke",
1352
+ /** One grant by `{ id }`, live or not, for the owner. */
1353
+ GET: "/get",
1354
+ /** Every grant of the file, for the owner. */
1355
+ LIST: "/list",
1356
+ /** Revokes every grant of the file: it's gone. They stay listed until they expire. */
1357
+ REVOKE_ALL: "/revoke-all"
1358
+ };
1359
+ var GRANT_KEY_PREFIX = "grant:";
1360
+ var grantKey = (id) => `${GRANT_KEY_PREFIX}${id}`;
1361
+ var failure = (status, code, error) => Response.json({ error, code }, { status });
1362
+ var invalidGrant = () => failure(401, DownloadGrantErrorCode.INVALID, "Invalid download grant");
1363
+ var grantGone = () => failure(410, DownloadGrantErrorCode.GONE, "This download grant is no longer valid");
1364
+ var notFound2 = () => failure(404, DownloadGrantErrorCode.NOT_FOUND, "Download grant not found");
1365
+ function grantInfoOf(record) {
1366
+ const { secretHash: _secretHash, ...info } = record;
1367
+ return info;
1368
+ }
1369
+ function isGrantDead(record, now = Date.now()) {
1370
+ return record.revokedAt !== null || record.expiresAt <= now;
1371
+ }
1372
+ function fitsLinkCaps(caps, expiresIn, maxUses) {
1373
+ return expiresIn <= caps.maxExpiresIn && maxUses <= caps.maxUses;
1374
+ }
1375
+ var DownloadGrants = class {
1376
+ state;
1377
+ storage;
1378
+ constructor(state) {
1379
+ this.state = state;
1380
+ this.storage = state.storage;
1381
+ }
1382
+ async fetch(request) {
1383
+ if (request.method !== "POST") return new Response(null, { status: 405 });
1384
+ const handler = this.handlerFor(new URL(request.url).pathname);
1385
+ if (!handler) return new Response(null, { status: 404 });
1386
+ const body = await request.json().catch(() => ({}));
1387
+ return this.state.blockConcurrencyWhile(() => handler(body));
1388
+ }
1389
+ handlerFor(pathname) {
1390
+ switch (pathname) {
1391
+ case DownloadGrantPath.CREATE:
1392
+ return (body) => this.create(body);
1393
+ case DownloadGrantPath.AUTHORIZE:
1394
+ return (body) => this.authorize(body);
1395
+ case DownloadGrantPath.IDENTIFY:
1396
+ return (body) => this.identify(body);
1397
+ case DownloadGrantPath.ISSUE:
1398
+ return (body) => this.issue(body);
1399
+ case DownloadGrantPath.REFUND:
1400
+ return (body) => this.refund(body);
1401
+ case DownloadGrantPath.REVOKE:
1402
+ return (body) => this.revoke(body);
1403
+ case DownloadGrantPath.GET:
1404
+ return (body) => this.getOne(body);
1405
+ case DownloadGrantPath.LIST:
1406
+ return () => this.list();
1407
+ case DownloadGrantPath.REVOKE_ALL:
1408
+ return () => this.revokeAll();
1409
+ default:
1410
+ return null;
1411
+ }
1412
+ }
1413
+ async create(record) {
1414
+ if (await this.storage.get(grantKey(record.id))) {
1415
+ return failure(409, "grant_exists", "Grant id already in use");
1416
+ }
1417
+ await this.storage.put(grantKey(record.id), { ...record, linksIssued: 0, revokedAt: null });
1418
+ await this.scheduleAlarm();
1419
+ return Response.json({ ok: true }, { status: 201 });
1420
+ }
1421
+ async recordOf(id) {
1422
+ return typeof id === "string" ? this.storage.get(grantKey(id)) : void 0;
1423
+ }
1424
+ /** The record whose secret hash matches, live or not, or 401. */
1425
+ async matchingRecord(body) {
1426
+ const record = await this.recordOf(body.id);
1427
+ const secretHash = typeof body.secretHash === "string" ? body.secretHash : "";
1428
+ if (!record || !isConstantTimeEqual(secretHash, record.secretHash)) return invalidGrant();
1429
+ return record;
1430
+ }
1431
+ /** The live record whose secret hash matches, or the response explaining why not. */
1432
+ async liveRecord(body) {
1433
+ const record = await this.matchingRecord(body);
1434
+ if (record instanceof Response) return record;
1435
+ if (isGrantDead(record)) return grantGone();
1436
+ return record;
1437
+ }
1438
+ async identify(body) {
1439
+ const record = await this.matchingRecord(body);
1440
+ return record instanceof Response ? record : Response.json(grantInfoOf(record));
1441
+ }
1442
+ async authorize(body) {
1443
+ const record = await this.liveRecord(body);
1444
+ return record instanceof Response ? record : Response.json(grantInfoOf(record));
1445
+ }
1446
+ /** Spends one link: caps first (a refused link spends nothing), then the total. */
1447
+ async issue(body) {
1448
+ const record = await this.liveRecord(body);
1449
+ if (record instanceof Response) return record;
1450
+ if (!fitsLinkCaps(record.link, Number(body.expiresIn), Number(body.maxUses))) {
1451
+ return Response.json(
1452
+ {
1453
+ error: "The link exceeds the grant's caps",
1454
+ code: DownloadGrantErrorCode.LINK_EXCEEDS_GRANT,
1455
+ link: record.link
1456
+ },
1457
+ { status: 400 }
1458
+ );
1459
+ }
1460
+ if (record.linksIssued >= record.maxLinks) {
1461
+ return failure(410, DownloadGrantErrorCode.EXHAUSTED, "This download grant has issued all its links");
1462
+ }
1463
+ const spent = { ...record, linksIssued: record.linksIssued + 1 };
1464
+ await this.storage.put(grantKey(record.id), spent);
1465
+ return Response.json(grantInfoOf(spent));
1466
+ }
1467
+ async refund(body) {
1468
+ const record = await this.recordOf(body.id);
1469
+ if (!record) return notFound2();
1470
+ await this.storage.put(grantKey(record.id), { ...record, linksIssued: Math.max(0, record.linksIssued - 1) });
1471
+ return new Response(null, { status: 204 });
1472
+ }
1473
+ /** Without `secretHash` the route already authorized the owner; with it, only the grant's own secret revokes it. */
1474
+ async revoke(body) {
1475
+ const record = await this.recordOf(body.id);
1476
+ if (!record) return body.secretHash === void 0 ? notFound2() : invalidGrant();
1477
+ const isBearer = body.secretHash !== void 0;
1478
+ if (isBearer && !isConstantTimeEqual(String(body.secretHash), record.secretHash)) return invalidGrant();
1479
+ if (record.revokedAt !== null) return Response.json(grantInfoOf(record));
1480
+ const revoked = { ...record, revokedAt: Date.now() };
1481
+ await this.storage.put(grantKey(record.id), revoked);
1482
+ return Response.json(grantInfoOf(revoked));
1483
+ }
1484
+ async getOne(body) {
1485
+ const record = await this.recordOf(body.id);
1486
+ return record ? Response.json(grantInfoOf(record)) : notFound2();
1487
+ }
1488
+ async records() {
1489
+ const stored = await this.storage.list({ prefix: GRANT_KEY_PREFIX });
1490
+ return [...stored.values()];
1491
+ }
1492
+ async list() {
1493
+ const records = await this.records();
1494
+ return Response.json(records.sort((first, second) => first.createdAt - second.createdAt).map(grantInfoOf));
1495
+ }
1496
+ async revokeAll() {
1497
+ const now = Date.now();
1498
+ for (const record of await this.records()) {
1499
+ if (record.revokedAt === null) await this.storage.put(grantKey(record.id), { ...record, revokedAt: now });
1500
+ }
1501
+ return new Response(null, { status: 204 });
1502
+ }
1503
+ /** Alarm at the earliest expiry among the stored grants; none left: no alarm. */
1504
+ async scheduleAlarm() {
1505
+ const expiries = (await this.records()).map((record) => record.expiresAt);
1506
+ if (expiries.length === 0) {
1507
+ await this.storage.deleteAlarm();
1508
+ return;
1509
+ }
1510
+ await this.storage.setAlarm(Math.min(...expiries));
1511
+ }
1512
+ /** Drops the grants past their expiry (revoked ones stay listed until then) and schedules the next alarm. */
1513
+ async alarm() {
1514
+ const now = Date.now();
1515
+ for (const record of await this.records()) {
1516
+ if (record.expiresAt <= now) await this.storage.delete(grantKey(record.id));
1517
+ }
1518
+ await this.scheduleAlarm();
1519
+ }
1520
+ };
1521
+
1522
+ // src/routes/grant-shared.ts
1523
+ function grantNamespaces(context) {
1524
+ const { DOWNLOAD_GRANTS: grants, DOWNLOAD_LINKS: links } = context.env;
1525
+ return grants && links ? { grants, links } : null;
1526
+ }
1527
+ function notConfigured2(context) {
1528
+ return context.json({ error: "Download grants are not configured", code: "not_configured" }, 501);
1529
+ }
1530
+ function fileGrantsObject(namespace, storageKey2) {
1531
+ const id = namespace.idFromName(storageKey2);
1532
+ return { objectId: id.toString(), stub: namespace.get(id) };
1533
+ }
1534
+ function grantsObjectById(namespace, objectId) {
1535
+ try {
1536
+ return namespace.get(namespace.idFromString(objectId));
1537
+ } catch {
1538
+ return null;
1539
+ }
1540
+ }
1541
+ function callGrants(stub, path, body = {}) {
1542
+ return stub.fetch(`https://download-grants${path}`, { method: "POST", body: JSON.stringify(body) });
1543
+ }
1544
+ var relayGrantFailure = (response) => new Response(response.body, { status: response.status, headers: response.headers });
1545
+ function grantListEntry(objectId, grant) {
1546
+ return {
1547
+ id: formatGrantPublicId({ objectId, grantId: grant.id }),
1548
+ key: grant.key,
1549
+ expiresAt: grant.expiresAt,
1550
+ link: grant.link,
1551
+ maxLinks: grant.maxLinks,
1552
+ canDelete: grant.canDelete,
1553
+ ...grant.metadata ? { metadata: grant.metadata } : {},
1554
+ linksIssued: grant.linksIssued,
1555
+ createdAt: grant.createdAt,
1556
+ revokedAt: grant.revokedAt
1557
+ };
1558
+ }
1559
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1560
+ function parseMetadata(value, maxBytes) {
1561
+ if (value === void 0) return void 0;
1562
+ if (!isPlainObject(value)) return null;
1563
+ if (!Object.values(value).every((entry) => typeof entry === "string")) return null;
1564
+ const size = new TextEncoder().encode(JSON.stringify(value)).byteLength;
1565
+ return size <= maxBytes ? value : null;
1566
+ }
1567
+ function parseCount(value, fallback2, max) {
1568
+ const count = value ?? fallback2;
1569
+ return typeof count === "number" && Number.isSafeInteger(count) && count > 0 && count <= max ? count : null;
1570
+ }
1571
+
1572
+ // src/routes/media-delete.ts
1573
+ import { videoBasePath } from "@devultur/core";
1574
+
1575
+ // src/transcode/raw-retention.ts
1576
+ import { isVideoRawKey as isVideoRawKey2 } from "@devultur/core";
1577
+ var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
1578
+ async function readRawDisposal(kv, videoId) {
1579
+ return kv.get(disposalKey(videoId), "json");
1580
+ }
1581
+ async function rememberRawDisposal(kv, videoId, disposal) {
1582
+ await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
1583
+ }
1584
+ async function disposeRawAfterTranscode(context, videoId) {
1585
+ const { env } = context;
1586
+ const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
1587
+ if (!disposal?.deleteOnComplete || !disposal.storageKey) return "kept";
1588
+ const raw = await env.MEDIA_BUCKET.head(disposal.storageKey);
1589
+ const location = splitVideoStorageKey(disposal.storageKey);
1590
+ const lock = raw && location ? await effectiveLock(env.MEDIA_BUCKET, location.root, location.key, raw) : null;
1591
+ if (lock?.state && isStateLocked(lock.state, /* @__PURE__ */ new Date())) {
1592
+ await recordLockAudit(context, {
1593
+ action: "original_retained",
1594
+ keys: [location?.key ?? disposal.storageKey],
1595
+ before: lock.state.lock,
1596
+ after: null,
1597
+ outcome: "skipped",
1598
+ root: location?.root ?? ""
1599
+ });
1600
+ return "retained";
1601
+ }
1602
+ await env.MEDIA_BUCKET.delete(disposal.storageKey);
1603
+ if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
1604
+ return "deleted";
1605
+ }
1606
+ var ORIGINAL_NOT_RETAINED = {
1607
+ 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.",
1608
+ code: "original_not_retained"
1609
+ };
1610
+ var PRIOR_OUTPUTS_LIST_LIMIT = 100;
1611
+ async function hasPriorTranscodeOutputs(bucket, videoFolder) {
1612
+ const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
1613
+ return listing.objects.some((object) => !isVideoRawKey2(object.key));
1614
+ }
1615
+
1616
+ // src/object-lock/transcode-marker.ts
1617
+ var MARKER_NAME = "transcode-completed";
1618
+ var markerKey = (root, videoFolder) => sidecarKey(root, `${videoFolder}/${MARKER_NAME}`);
1619
+ async function markTranscodeCompleted(env, videoId, ticketLocation) {
1620
+ const location = ticketLocation ?? await rememberedLocation(env, videoId);
1621
+ if (!location) return;
1622
+ await env.MEDIA_BUCKET.put(markerKey(location.root, location.folder), (/* @__PURE__ */ new Date()).toISOString(), {
1623
+ httpMetadata: { contentType: "text/plain" }
1624
+ });
1625
+ }
1626
+ async function rememberedLocation(env, videoId) {
1627
+ const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
1628
+ const raw = disposal?.storageKey ? splitVideoStorageKey(disposal.storageKey) : null;
1629
+ const folder = raw?.key.split("/").slice(0, 3).join("/");
1630
+ return raw && folder ? { root: raw.root, folder } : null;
1631
+ }
1632
+ async function hasCompletedTranscode(bucket, root, videoFolder) {
1633
+ return await bucket.head(markerKey(root, videoFolder)) !== null;
1634
+ }
1635
+ async function forgetTranscodeCompleted(bucket, root, videoFolder) {
1636
+ await bucket.delete(markerKey(root, videoFolder));
1637
+ }
1638
+
1639
+ // src/routes/media-delete.ts
1640
+ async function handleVideoDelete(context) {
1641
+ const videoId = context.req.param("videoId") ?? "";
1642
+ const visibilityParam = context.req.query("visibility");
1643
+ const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
1644
+ const folders = visibilities.map((visibility) => `${visibility}/${videoBasePath(videoId)}`);
1645
+ const blocked = await assertWritable(context, folders, "delete");
1646
+ if (blocked) return blocked;
1647
+ let deletedCount = 0;
1648
+ let deletedKeyCount = 0;
1649
+ for (const folder of folders) {
1650
+ const deleted = await guardedDeletePrefix(context, `${folder}/`);
1651
+ if ("blocked" in deleted) return deleted.blocked;
1652
+ deletedCount += deleted.deletedCount;
1653
+ if (deleted.deletedCount > 0) {
1654
+ await forgetTranscodeCompleted(context.env.MEDIA_BUCKET, getRequestState(context).storagePrefix, folder);
1655
+ }
1656
+ if (!folder.startsWith("private/")) continue;
1657
+ const deletedKeys = await deleteFolderKeys(context, folder);
1658
+ if ("blocked" in deletedKeys) return deletedKeys.blocked;
1659
+ deletedKeyCount += deletedKeys.deletedCount;
1660
+ }
1661
+ return context.json({ videoId, deletedCount, deletedKeyCount });
1662
+ }
1663
+ function handleKeyDelete(context) {
1664
+ return deleteMediaKey(context, context.req.param("key") ?? "");
1665
+ }
1666
+ async function deleteMediaKey(context, key) {
1667
+ const fullKey = storageKey(context, key);
1668
+ if (isVariantKey(key)) {
1669
+ return context.json({ error: "Not found" }, 404);
1670
+ }
1671
+ if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
1672
+ const exists = await context.env.MEDIA_BUCKET.head(fullKey);
1673
+ if (!exists) {
1674
+ return context.json({ error: "Not found" }, 404);
1675
+ }
1676
+ const blocked = await assertWritable(context, [key], "delete");
1677
+ if (blocked) return blocked;
1678
+ await context.env.MEDIA_BUCKET.delete(fullKey);
1679
+ const variants = await guardedDeletePrefix(context, variantPrefix(key));
1680
+ if ("blocked" in variants) return variants.blocked;
1681
+ const keys = await deleteKeysFor(context, key, exists);
1682
+ if ("blocked" in keys) return keys.blocked;
1683
+ return new Response(null, { status: 204 });
1684
+ }
1685
+
1686
+ // src/routes/grant-bearer.ts
1687
+ var grantError = (status, code, error) => Response.json({ error, code }, { status });
1688
+ var invalidGrant2 = () => grantError(401, DownloadGrantErrorCode2.INVALID, "Invalid download grant");
1689
+ var grantGone2 = () => grantError(410, DownloadGrantErrorCode2.GONE, "This download grant is no longer valid");
1690
+ async function readBearer(context, grants) {
1691
+ const bearer = context.req.header("Authorization")?.match(/^Bearer\s+(\S+)$/i)?.[1];
1692
+ if (!bearer && context.req.query("token") !== void 0) {
1693
+ return grantError(401, DownloadGrantErrorCode2.IN_QUERY, "Send the grant in Authorization: Bearer, never in a URL");
1694
+ }
1695
+ const parsed = bearer ? parseGrantSecret(bearer) : null;
1696
+ const stub = parsed ? grantsObjectById(grants, parsed.objectId) : null;
1697
+ if (!parsed || !stub) return invalidGrant2();
1698
+ return {
1699
+ stub,
1700
+ parsed,
1701
+ secretHash: await hashGrantSecret(parsed.secret),
1702
+ publicId: formatGrantPublicId(parsed)
1703
+ };
1704
+ }
1705
+ async function identifyDownloadGrant(anyContext) {
1706
+ const context = anyContext;
1707
+ const namespaces = grantNamespaces(context);
1708
+ if (!namespaces) return notConfigured2(context);
1709
+ const bearer = await readBearer(context, namespaces.grants);
1710
+ if (bearer instanceof Response) return bearer;
1711
+ const identified = await callGrants(bearer.stub, DownloadGrantPath.IDENTIFY, {
1712
+ id: bearer.parsed.grantId,
1713
+ secretHash: bearer.secretHash
1714
+ });
1715
+ if (!identified.ok) return relayGrantFailure(identified);
1716
+ return { publicId: bearer.publicId, grant: await identified.json() };
1717
+ }
1718
+ async function grantedObject(context, bearer, grant) {
1719
+ const object = await context.env.MEDIA_BUCKET.head(grant.storageKey);
1720
+ if (!object) {
1721
+ await callGrants(bearer.stub, DownloadGrantPath.REVOKE_ALL);
1722
+ return grantGone2();
1723
+ }
1724
+ if (object.version !== grant.version || isObjectExpired(object.customMetadata)) return grantGone2();
1725
+ return object;
1726
+ }
1727
+ async function authorizeGrant(context, action) {
1728
+ const namespaces = grantNamespaces(context);
1729
+ if (!namespaces) return notConfigured2(context);
1730
+ const bearer = await readBearer(context, namespaces.grants);
1731
+ if (bearer instanceof Response) return bearer;
1732
+ const authorized = await callGrants(bearer.stub, DownloadGrantPath.AUTHORIZE, {
1733
+ id: bearer.parsed.grantId,
1734
+ secretHash: bearer.secretHash
1735
+ });
1736
+ if (!authorized.ok) return relayGrantFailure(authorized);
1737
+ const grant = await authorized.json();
1738
+ if (grant.storagePrefix !== getRequestState(context).storagePrefix) return invalidGrant2();
1739
+ const refusal2 = await gateGrantAccess(context, { action, grant });
1740
+ if (refusal2) return refusal2;
1741
+ const object = await grantedObject(context, bearer, grant);
1742
+ if (object instanceof Response) return object;
1743
+ return { ...bearer, grant, object };
1744
+ }
1745
+ function headHeaders({ grant, object }) {
1746
+ const headers = new Headers({
1747
+ "Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream",
1748
+ "Content-Length": String(object.size),
1749
+ ETag: object.httpEtag,
1750
+ "Cache-Control": "private, no-store",
1751
+ [ENCRYPTED_HEADER]: object.customMetadata?.enc ? "true" : "false",
1752
+ [GRANT_EXPIRES_AT_HEADER]: String(grant.expiresAt),
1753
+ [GRANT_KEY_HEADER]: grant.key
1754
+ });
1755
+ applyIntegrityHeaders(headers, object.customMetadata);
1756
+ applyImageSizeHeaders(headers, object.customMetadata);
1757
+ if (grant.metadata) headers.set(GRANT_METADATA_HEADER, encodeURIComponent(JSON.stringify(grant.metadata)));
1758
+ return headers;
1759
+ }
1760
+ async function handleHead(context) {
1761
+ if (context.req.method !== "HEAD") return new Response(null, { status: 405, headers: { Allow: "HEAD" } });
1762
+ const authorized = await authorizeGrant(context, "head");
1763
+ if (authorized instanceof Response) return authorized;
1764
+ return new Response(null, { status: 200, headers: headHeaders(authorized) });
1765
+ }
1766
+ function parseGrantLinkRequest(body, grant) {
1767
+ const defaultExpiresIn = Math.min(DownloadGrantLimits.DEFAULT_LINK_EXPIRES_IN, grant.link.maxExpiresIn);
1768
+ const expiresIn = parseCount(body.expiresIn, defaultExpiresIn, MAX_LINK_TTL_SECONDS);
1769
+ if (expiresIn === null)
1770
+ return { error: "expiresIn must be a positive number of seconds", code: "invalid_expires_in" };
1771
+ const maxUses = parseCount(body.maxUses, 1, Number.MAX_SAFE_INTEGER);
1772
+ if (maxUses === null) return { error: "maxUses must be a positive integer", code: "invalid_max_uses" };
1773
+ const metadata = parseMetadata(body.metadata, DownloadGrantLimits.MAX_METADATA_BYTES);
1774
+ if (metadata === null)
1775
+ return { error: "metadata must map strings to strings, 1 KB at most", code: "invalid_metadata" };
1776
+ const filename = typeof body.filename === "string" && body.filename ? body.filename : void 0;
1777
+ const parsed = {
1778
+ expiresIn,
1779
+ maxUses,
1780
+ ...filename ? { filename } : {},
1781
+ ...metadata ? { metadata } : {}
1782
+ };
1783
+ return parsed;
1784
+ }
1785
+ async function handleIssueLink(context) {
1786
+ const namespaces = grantNamespaces(context);
1787
+ if (!namespaces) return notConfigured2(context);
1788
+ const authorized = await authorizeGrant(context, "issue-link");
1789
+ if (authorized instanceof Response) return authorized;
1790
+ const { grant, object, stub } = authorized;
1791
+ const parsed = parseGrantLinkRequest(await context.req.json().catch(() => null) ?? {}, grant);
1792
+ if ("error" in parsed) return context.json(parsed, 400);
1793
+ const requestedExpiresAt = Math.min(
1794
+ Date.now() + parsed.expiresIn * 1e3,
1795
+ objectExpiresAt(object.customMetadata) ?? Number.POSITIVE_INFINITY,
1796
+ grant.expiresAt
1797
+ );
1798
+ const decision = await decideCredential(context, {
1799
+ kind: "download-link",
1800
+ scopes: [TokenScope2.MEDIA_READ],
1801
+ path: grant.key,
1802
+ expiresAt: requestedExpiresAt
1803
+ });
1804
+ if (!decision.allow) return decision.response;
1805
+ const credential = { id: authorized.parsed.grantId, secretHash: authorized.secretHash };
1806
+ const spent = await callGrants(stub, DownloadGrantPath.ISSUE, { ...credential, ...parsed });
1807
+ if (!spent.ok) return relayGrantFailure(spent);
1808
+ const expiresAt = cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs);
1809
+ const created = await storeLink(context, namespaces.links, {
1810
+ storageKey: grant.storageKey,
1811
+ key: grant.key,
1812
+ expiresAt,
1813
+ maxUses: parsed.maxUses,
1814
+ ...parsed.filename ? { filename: parsed.filename } : {},
1815
+ storagePrefix: grant.storagePrefix,
1816
+ ...grant.context ? { context: grant.context } : {},
1817
+ grantId: authorized.publicId,
1818
+ ...parsed.metadata ? { metadata: parsed.metadata } : {}
1819
+ });
1820
+ if (!created) {
1821
+ await callGrants(stub, DownloadGrantPath.REFUND, { id: credential.id });
1822
+ return context.json({ error: "Could not create the download link" }, 500);
1823
+ }
1824
+ return context.json({ ...created, expiresAt, maxUses: parsed.maxUses });
1825
+ }
1826
+ async function handleDeleteFile(context) {
1827
+ const authorized = await authorizeGrant(context, "delete-file");
1828
+ if (authorized instanceof Response) return authorized;
1829
+ const { grant, stub, publicId } = authorized;
1830
+ if (!grant.canDelete) {
1831
+ return grantError(403, DownloadGrantErrorCode2.CANNOT_DELETE, "This download grant can't delete its file");
1832
+ }
1833
+ const deleted = await deleteMediaKey(context, grant.key);
1834
+ if (deleted.status !== 204) return deleted;
1835
+ await callGrants(stub, DownloadGrantPath.REVOKE_ALL);
1836
+ addRouteResult(context, {
1837
+ grantDeletedKey: grant.key,
1838
+ revokedGrant: { publicId, grant: { ...grant, revokedAt: Date.now() }, by: "bearer" }
1839
+ });
1840
+ return deleted;
1841
+ }
1842
+ async function handleRevoke(context) {
1843
+ const namespaces = grantNamespaces(context);
1844
+ if (!namespaces) return notConfigured2(context);
1845
+ const bearer = await readBearer(context, namespaces.grants);
1846
+ if (bearer instanceof Response) return bearer;
1847
+ const revoked = await callGrants(bearer.stub, DownloadGrantPath.REVOKE, {
1848
+ id: bearer.parsed.grantId,
1849
+ secretHash: bearer.secretHash
1850
+ });
1851
+ if (!revoked.ok) return relayGrantFailure(revoked);
1852
+ const grant = await revoked.json();
1853
+ if (grant.storagePrefix === getRequestState(context).storagePrefix) {
1854
+ addRouteResult(context, { revokedGrant: { publicId: bearer.publicId, grant, by: "bearer" } });
1855
+ }
1856
+ return new Response(null, { status: 204 });
1857
+ }
1858
+ var grantBearer = new Hono3().get("/file", handleHead).post("/links", handleIssueLink).delete("/file", handleDeleteFile).delete("/", handleRevoke);
1859
+
1860
+ // src/routes/grant-owner.ts
1861
+ import { DownloadGrantLimits as DownloadGrantLimits2, relativeMediaPath as relativeMediaPath2, TokenScope as TokenScope3 } from "@devultur/core";
1862
+ import { Hono as Hono4 } from "hono";
1863
+ var GRANT_DELETE_SCOPE = "media:delete";
1864
+ var invalid = (code, error) => ({ error, code });
1865
+ function parseLinkCaps(link) {
1866
+ const maxExpiresIn = parseCount(
1867
+ link?.maxExpiresIn,
1868
+ DownloadGrantLimits2.DEFAULT_LINK_EXPIRES_IN,
1869
+ DownloadGrantLimits2.MAX_LINK_EXPIRES_IN
1870
+ );
1871
+ if (maxExpiresIn === null) {
1872
+ return invalid("invalid_link_caps", `link.maxExpiresIn must be 1..${DownloadGrantLimits2.MAX_LINK_EXPIRES_IN}`);
1873
+ }
1874
+ const maxUses = parseCount(
1875
+ link?.maxUses,
1876
+ DownloadGrantLimits2.DEFAULT_LINK_MAX_USES,
1877
+ DownloadGrantLimits2.MAX_LINK_MAX_USES
1878
+ );
1879
+ if (maxUses === null) {
1880
+ return invalid("invalid_link_caps", `link.maxUses must be 1..${DownloadGrantLimits2.MAX_LINK_MAX_USES}`);
1881
+ }
1882
+ return { maxExpiresIn, maxUses };
1883
+ }
1884
+ function parseGrantRequest(body) {
1885
+ const key = typeof body.key === "string" ? body.key : "";
1886
+ if (relativeMediaPath2(key) === null) return invalid("invalid_key", "key must be a visibility-prefixed media key");
1887
+ const expiresIn = body.expiresIn === void 0 ? void 0 : parseCount(body.expiresIn, 0, Number.MAX_SAFE_INTEGER);
1888
+ if (expiresIn === null) return invalid("invalid_expires_in", "expiresIn must be a positive number of seconds");
1889
+ if (body.link !== void 0 && (typeof body.link !== "object" || body.link === null)) {
1890
+ return invalid("invalid_link_caps", "link must be an object");
1891
+ }
1892
+ const link = parseLinkCaps(body.link);
1893
+ if ("error" in link) return link;
1894
+ const maxLinks = parseCount(body.maxLinks, DownloadGrantLimits2.DEFAULT_MAX_LINKS, DownloadGrantLimits2.MAX_MAX_LINKS);
1895
+ if (maxLinks === null)
1896
+ return invalid("invalid_max_links", `maxLinks must be 1..${DownloadGrantLimits2.MAX_MAX_LINKS}`);
1897
+ if (body.canDelete !== void 0 && typeof body.canDelete !== "boolean") {
1898
+ return invalid("invalid_can_delete", "canDelete must be a boolean");
1899
+ }
1900
+ const metadata = parseMetadata(body.metadata, DownloadGrantLimits2.MAX_METADATA_BYTES);
1901
+ if (metadata === null) {
1902
+ return invalid(
1903
+ "invalid_metadata",
1904
+ `metadata must map strings to strings, ${DownloadGrantLimits2.MAX_METADATA_BYTES} bytes at most`
1905
+ );
1906
+ }
1907
+ return {
1908
+ key,
1909
+ ...expiresIn ? { expiresIn } : {},
1910
+ link,
1911
+ maxLinks,
1912
+ canDelete: body.canDelete === true,
1913
+ ...metadata ? { metadata } : {}
1914
+ };
1915
+ }
1916
+ function grantExpiry(expiresIn, fileExpiresAt, now) {
1917
+ const cap = fileExpiresAt ?? now + DownloadGrantLimits2.MAX_LIFETIME_SECONDS * 1e3;
1918
+ return expiresIn === void 0 ? cap : Math.min(now + expiresIn * 1e3, cap);
1919
+ }
1920
+ async function handleCreateGrant(context) {
1921
+ const namespaces = grantNamespaces(context);
1922
+ if (!namespaces) return notConfigured2(context);
1923
+ const parsed = parseGrantRequest(await context.req.json().catch(() => null) ?? {});
1924
+ if ("error" in parsed) return context.json(parsed, 400);
1925
+ const fullKey = storageKey(context, parsed.key);
1926
+ const object = await context.env.MEDIA_BUCKET.head(fullKey);
1927
+ if (!object) return context.json({ error: "Not found" }, 404);
1928
+ if (isObjectExpired(object.customMetadata)) return expiredResponse();
1929
+ const requestedExpiresAt = grantExpiry(parsed.expiresIn, objectExpiresAt(object.customMetadata), Date.now());
1930
+ const decision = await decideCredential(context, {
1931
+ kind: "download-grant",
1932
+ scopes: parsed.canDelete ? [TokenScope3.MEDIA_READ, GRANT_DELETE_SCOPE] : [TokenScope3.MEDIA_READ],
1933
+ path: parsed.key,
1934
+ expiresAt: requestedExpiresAt
1935
+ });
1936
+ if (!decision.allow) return decision.response;
1937
+ const { objectId, stub } = fileGrantsObject(namespaces.grants, fullKey);
1938
+ const grantId = newGrantId();
1939
+ const secret = newGrantSecret();
1940
+ const scope = getRequestState(context);
1941
+ const record = {
1942
+ id: grantId,
1943
+ secretHash: await hashGrantSecret(secret),
1944
+ storageKey: fullKey,
1945
+ key: parsed.key,
1946
+ version: object.version,
1947
+ storagePrefix: scope.storagePrefix,
1948
+ expiresAt: cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs),
1949
+ link: parsed.link,
1950
+ maxLinks: parsed.maxLinks,
1951
+ canDelete: parsed.canDelete,
1952
+ ...parsed.metadata ? { metadata: parsed.metadata } : {},
1953
+ ...scope.context ? { context: scope.context } : {},
1954
+ createdAt: Date.now()
1955
+ };
1956
+ const created = await callGrants(stub, DownloadGrantPath.CREATE, record);
1957
+ if (!created.ok) return context.json({ error: "Could not create the download grant" }, 500);
1958
+ const publicId = formatGrantPublicId({ objectId, grantId });
1959
+ const { secretHash: _secretHash, ...info } = record;
1960
+ addRouteResult(context, { issuedGrant: { publicId, grant: { ...info, linksIssued: 0, revokedAt: null } } });
1961
+ const grant = {
1962
+ grant: formatGrantSecret({ objectId, grantId, secret }),
1963
+ id: publicId,
1964
+ key: record.key,
1965
+ expiresAt: record.expiresAt,
1966
+ link: record.link,
1967
+ maxLinks: record.maxLinks,
1968
+ canDelete: record.canDelete,
1969
+ ...record.metadata ? { metadata: record.metadata } : {}
1970
+ };
1971
+ return context.json(grant);
1972
+ }
1973
+ async function handleListGrants(context) {
1974
+ const namespaces = grantNamespaces(context);
1975
+ if (!namespaces) return notConfigured2(context);
1976
+ const key = context.req.query("key") ?? "";
1977
+ if (relativeMediaPath2(key) === null) {
1978
+ return context.json({ error: "key must be a visibility-prefixed media key", code: "invalid_key" }, 400);
1979
+ }
1980
+ const { objectId, stub } = fileGrantsObject(namespaces.grants, storageKey(context, key));
1981
+ const listed = await callGrants(stub, DownloadGrantPath.LIST);
1982
+ if (!listed.ok) return context.json({ error: "Could not list the download grants" }, 500);
1983
+ const grants = await listed.json();
1984
+ return context.json(grants.map((grant) => grantListEntry(objectId, grant)));
1985
+ }
1986
+ var grantNotFound = (context) => context.json({ error: "Download grant not found", code: "grant_not_found" }, 404);
1987
+ async function handleRevokeGrant(context) {
1988
+ const namespaces = grantNamespaces(context);
1989
+ if (!namespaces) return notConfigured2(context);
1990
+ const publicId = context.req.param("id") ?? "";
1991
+ const location = parseGrantPublicId(publicId);
1992
+ const stub = location ? grantsObjectById(namespaces.grants, location.objectId) : null;
1993
+ if (!location || !stub) return grantNotFound(context);
1994
+ const found = await callGrants(stub, DownloadGrantPath.GET, { id: location.grantId });
1995
+ if (!found.ok) return grantNotFound(context);
1996
+ const grant = await found.json();
1997
+ if (grant.storagePrefix !== getRequestState(context).storagePrefix) return grantNotFound(context);
1998
+ const refusal2 = await gateGrantAccess(context, { action: "owner-revoke", grant });
1999
+ if (refusal2) return refusal2;
2000
+ const revoked = await callGrants(stub, DownloadGrantPath.REVOKE, { id: location.grantId });
2001
+ if (!revoked.ok) return context.json({ error: "Could not revoke the download grant" }, 500);
2002
+ addRouteResult(context, {
2003
+ revokedGrant: { publicId, grant: await revoked.json(), by: "owner" }
2004
+ });
2005
+ return new Response(null, { status: 204 });
2006
+ }
2007
+ var grantOwner = new Hono4().post("/", handleCreateGrant).get("/", handleListGrants).delete("/:id", handleRevokeGrant);
2008
+
1277
2009
  // src/plugins/download-links.ts
1278
2010
  function downloadLinks() {
1279
- const issue = new Hono3().post("/", handleDownloadLink);
2011
+ const issue = new Hono5().post("/", handleDownloadLink);
1280
2012
  return {
1281
2013
  name: "download-links",
1282
2014
  routes: [
1283
2015
  { path: "/v1/token/download", app: issue },
1284
- { path: "/v1/download", app: download }
2016
+ { path: "/v1/download", app: download },
2017
+ { path: "/v1/token/grant", app: grantOwner },
2018
+ { path: "/v1/grant", app: grantBearer }
1285
2019
  ]
1286
2020
  };
1287
2021
  }
@@ -1336,7 +2070,7 @@ import {
1336
2070
  } from "@devultur/core";
1337
2071
 
1338
2072
  // src/routes/viewer-token-claims.ts
1339
- import { coversScope, isWithinPathPrefix, relativeMediaPath as relativeMediaPath2, TokenScope as TokenScope2 } from "@devultur/core";
2073
+ import { coversScope, isWithinPathPrefix, relativeMediaPath as relativeMediaPath3, TokenScope as TokenScope4 } from "@devultur/core";
1340
2074
  var TOKEN_REJECTED_CODE = "token_rejected";
1341
2075
  function isOtherAudience(payload, storagePrefix2) {
1342
2076
  if (payload.aud === void 0) return false;
@@ -1345,12 +2079,12 @@ function isOtherAudience(payload, storagePrefix2) {
1345
2079
  function isOutsideResource(payload, key) {
1346
2080
  if (payload.res === void 0) return false;
1347
2081
  if (typeof payload.res !== "string") return true;
1348
- const relativePath = relativeMediaPath2(key);
2082
+ const relativePath = relativeMediaPath3(key);
1349
2083
  return relativePath === null || !isWithinPathPrefix(relativePath, payload.res);
1350
2084
  }
1351
2085
  function lacksReadScope(payload) {
1352
2086
  const scopes = readTokenScopes(payload);
1353
- return scopes !== void 0 && !coversScope(scopes, TokenScope2.MEDIA_READ);
2087
+ return scopes !== void 0 && !coversScope(scopes, TokenScope4.MEDIA_READ);
1354
2088
  }
1355
2089
  function rejectionReason(payload, target) {
1356
2090
  if (isOtherAudience(payload, target.storagePrefix)) return "audience";
@@ -1368,7 +2102,7 @@ function viewerTokenClaimRejection(payload, target) {
1368
2102
  var MAX_FILE_TTL_SECONDS = 365 * 24 * 60 * 60;
1369
2103
  var MAX_FILES_COUNTER_TTL_SECONDS = 24 * 60 * 60;
1370
2104
  var MAX_FILES_PREFIX = "upload-token-files";
1371
- var failure = (status, code, error) => ({
2105
+ var failure2 = (status, code, error) => ({
1372
2106
  status,
1373
2107
  code,
1374
2108
  error
@@ -1384,12 +2118,12 @@ function generatedKindPath(mediaType, id, filename) {
1384
2118
  }
1385
2119
  function tokenRejected(reason, path) {
1386
2120
  console.warn(`[upload] token rejected (${reason}) for path ${String(path)}`);
1387
- return failure(403, TOKEN_REJECTED_CODE, "Token rejected");
2121
+ return failure2(403, TOKEN_REJECTED_CODE, "Token rejected");
1388
2122
  }
1389
2123
  function assetTarget(rawPath, visibility, grant) {
1390
2124
  const assetPath = normalizeAssetPath(rawPath);
1391
2125
  if (!assetPath) {
1392
- return failure(400, "invalid_path", "path must be a relative path of [A-Za-z0-9._-] segments, without `..`");
2126
+ return failure2(400, "invalid_path", "path must be a relative path of [A-Za-z0-9._-] segments, without `..`");
1393
2127
  }
1394
2128
  const relativePath = assetRelativePath(assetPath);
1395
2129
  if (grant && !isWithinPathPrefix2(relativePath, grant.pathPrefix)) return tokenRejected("resource", relativePath);
@@ -1398,7 +2132,7 @@ function assetTarget(rawPath, visibility, grant) {
1398
2132
  }
1399
2133
  function resolveUploadTarget(body, mediaType, visibility, grant) {
1400
2134
  if (grant && body.path === void 0) {
1401
- return failure(400, "path_required", "an upload token can only create files at an explicit `path`");
2135
+ return failure2(400, "path_required", "an upload token can only create files at an explicit `path`");
1402
2136
  }
1403
2137
  if (grant && body.visibility && body.visibility !== grant.visibility) return tokenRejected("visibility", body.path);
1404
2138
  if (body.path !== void 0) return assetTarget(body.path, grant?.visibility ?? visibility, grant);
@@ -1409,7 +2143,7 @@ function resolveExpiresAt(requested, grant, now, maxTtlSeconds) {
1409
2143
  const hasRequest = requested !== void 0 && requested !== null;
1410
2144
  const isValid = typeof requested === "number" && Number.isInteger(requested) && requested > 0;
1411
2145
  if (hasRequest && (!isValid || requested > MAX_FILE_TTL_SECONDS)) {
1412
- return failure(400, "invalid_expires_in", "expiresIn must be a positive integer number of seconds (max 1 year)");
2146
+ return failure2(400, "invalid_expires_in", "expiresIn must be a positive integer number of seconds (max 1 year)");
1413
2147
  }
1414
2148
  const candidates = [hasRequest ? requested : void 0, grant?.fileExpiresIn, maxTtlSeconds];
1415
2149
  const ttls = candidates.filter((ttl) => typeof ttl === "number" && ttl > 0);
@@ -1417,7 +2151,7 @@ function resolveExpiresAt(requested, grant, now, maxTtlSeconds) {
1417
2151
  }
1418
2152
  function resolveDeclaredSha256(raw) {
1419
2153
  if (raw === void 0 || raw === null) return void 0;
1420
- return normalizeSha2562(raw) ?? failure(400, "invalid_sha256", "sha256 must be a 64-character hex string");
2154
+ return normalizeSha2562(raw) ?? failure2(400, "invalid_sha256", "sha256 must be a 64-character hex string");
1421
2155
  }
1422
2156
  function effectiveMaxBytes(ruleMax, grant) {
1423
2157
  const limits = [ruleMax, grant?.maxBytes].filter((limit) => typeof limit === "number");
@@ -1428,7 +2162,7 @@ async function claimTokenFileSlot(kv, grant) {
1428
2162
  const counterKey = `${MAX_FILES_PREFIX}:${grant.tokenId}`;
1429
2163
  const used = Number(await kv.get(counterKey) ?? "0");
1430
2164
  if (used >= grant.maxFiles) {
1431
- return failure(403, "max_files_reached", `the upload token already created ${grant.maxFiles} files`);
2165
+ return failure2(403, "max_files_reached", `the upload token already created ${grant.maxFiles} files`);
1432
2166
  }
1433
2167
  await kv.put(counterKey, String(used + 1), { expirationTtl: MAX_FILES_COUNTER_TTL_SECONDS });
1434
2168
  return null;
@@ -1436,7 +2170,7 @@ async function claimTokenFileSlot(kv, grant) {
1436
2170
  function resolveObjectCacheControl(requested, target, rules) {
1437
2171
  if (!target.isAsset || !target.assetPath) return void 0;
1438
2172
  if (requested !== void 0 && requested !== null) {
1439
- return resolveCacheControl(requested) ?? failure(400, "invalid_cache_control", "cacheControl must be immutable, revalidate, no-cache or a valid header");
2173
+ return resolveCacheControl(requested) ?? failure2(400, "invalid_cache_control", "cacheControl must be immutable, revalidate, no-cache or a valid header");
1440
2174
  }
1441
2175
  return matchCacheRule(rules, target.assetPath)?.cacheControl ?? DEFAULT_ASSET_CACHE_CONTROL;
1442
2176
  }
@@ -1449,10 +2183,10 @@ function inferMediaType(contentType) {
1449
2183
  function resolveUploadMediaType(declared, contentType, isAssetRequest) {
1450
2184
  const inferredType = inferMediaType(contentType);
1451
2185
  if (declared && inferredType && declared !== inferredType) {
1452
- return failure(400, "media_type_mismatch", `mediaType "${declared}" does not match content type ${contentType}`);
2186
+ return failure2(400, "media_type_mismatch", `mediaType "${declared}" does not match content type ${contentType}`);
1453
2187
  }
1454
2188
  const mediaType = declared ?? inferredType ?? (isAssetRequest ? MediaType.FILE : null);
1455
- return mediaType ?? failure(400, "unsupported_content_type", `Unsupported content type: ${contentType}`);
2189
+ return mediaType ?? failure2(400, "unsupported_content_type", `Unsupported content type: ${contentType}`);
1456
2190
  }
1457
2191
 
1458
2192
  // src/routes/token-upload.ts
@@ -1560,6 +2294,7 @@ var WorkerAuthErrorCode = {
1560
2294
  var MEDIA_PREFIX = "/v1/media/";
1561
2295
  var DOWNLOAD_PREFIX = "/v1/download/";
1562
2296
  var WEBHOOKS_PREFIX = "/v1/webhooks/";
2297
+ var GRANT_PATH = "/v1/grant";
1563
2298
  var UPLOAD_CREATE_PATH = "/v1/upload";
1564
2299
  var UPLOAD_URL_PREFIX = "/v1/upload/";
1565
2300
  var isReadMethod = (method) => method === "GET" || method === "HEAD";
@@ -1568,6 +2303,7 @@ function isOpenRoute(method, path) {
1568
2303
  if (path === "/health") return isReadMethod(method);
1569
2304
  if (path === "/v1/metrics/playback") return method === "POST";
1570
2305
  if (path.startsWith(MEDIA_PREFIX) || path.startsWith(DOWNLOAD_PREFIX)) return isReadMethod(method);
2306
+ if (path === GRANT_PATH || path.startsWith(`${GRANT_PATH}/`)) return true;
1571
2307
  return path.startsWith(WEBHOOKS_PREFIX) && method === "POST";
1572
2308
  }
1573
2309
  function isUploadUrl(path) {
@@ -1631,7 +2367,7 @@ function defaultPlugins(config = {}) {
1631
2367
 
1632
2368
  // src/routes/assets.ts
1633
2369
  import { ASSET_NAMESPACE, normalizeAssetPrefix as normalizeAssetPrefix2 } from "@devultur/core";
1634
- import { Hono as Hono4 } from "hono";
2370
+ import { Hono as Hono6 } from "hono";
1635
2371
 
1636
2372
  // src/object-lock/delivery.ts
1637
2373
  import { effectiveUntil as effectiveUntil3 } from "@devultur/core";
@@ -1738,7 +2474,7 @@ async function listedEntries(bucket, listing, storageRoot, visibility) {
1738
2474
  }
1739
2475
  return page;
1740
2476
  }
1741
- var assets = new Hono4();
2477
+ var assets = new Hono6();
1742
2478
  assets.get("/", async (context) => {
1743
2479
  const prefix = parseListPrefix(context.req.query("prefix"));
1744
2480
  if (prefix === null) {
@@ -1774,7 +2510,7 @@ import {
1774
2510
  resolveSecurity,
1775
2511
  visibilityFromKey
1776
2512
  } from "@devultur/core";
1777
- import { Hono as Hono6 } from "hono";
2513
+ import { Hono as Hono8 } from "hono";
1778
2514
 
1779
2515
  // src/keys-bucket.ts
1780
2516
  import { decodeEncryptionMeta as decodeEncryptionMeta3 } from "@devultur/core";
@@ -1852,70 +2588,6 @@ async function applyStatusEvent(kv, event) {
1852
2588
  await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
1853
2589
  }
1854
2590
 
1855
- // src/transcode/raw-retention.ts
1856
- import { isVideoRawKey as isVideoRawKey2 } from "@devultur/core";
1857
- var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
1858
- async function readRawDisposal(kv, videoId) {
1859
- return kv.get(disposalKey(videoId), "json");
1860
- }
1861
- async function rememberRawDisposal(kv, videoId, disposal) {
1862
- await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
1863
- }
1864
- async function disposeRawAfterTranscode(context, videoId) {
1865
- const { env } = context;
1866
- const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
1867
- if (!disposal?.deleteOnComplete || !disposal.storageKey) return "kept";
1868
- const raw = await env.MEDIA_BUCKET.head(disposal.storageKey);
1869
- const location = splitVideoStorageKey(disposal.storageKey);
1870
- const lock = raw && location ? await effectiveLock(env.MEDIA_BUCKET, location.root, location.key, raw) : null;
1871
- if (lock?.state && isStateLocked(lock.state, /* @__PURE__ */ new Date())) {
1872
- await recordLockAudit(context, {
1873
- action: "original_retained",
1874
- keys: [location?.key ?? disposal.storageKey],
1875
- before: lock.state.lock,
1876
- after: null,
1877
- outcome: "skipped",
1878
- root: location?.root ?? ""
1879
- });
1880
- return "retained";
1881
- }
1882
- await env.MEDIA_BUCKET.delete(disposal.storageKey);
1883
- if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
1884
- return "deleted";
1885
- }
1886
- var ORIGINAL_NOT_RETAINED = {
1887
- 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.",
1888
- code: "original_not_retained"
1889
- };
1890
- var PRIOR_OUTPUTS_LIST_LIMIT = 100;
1891
- async function hasPriorTranscodeOutputs(bucket, videoFolder) {
1892
- const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
1893
- return listing.objects.some((object) => !isVideoRawKey2(object.key));
1894
- }
1895
-
1896
- // src/object-lock/transcode-marker.ts
1897
- var MARKER_NAME = "transcode-completed";
1898
- var markerKey = (root, videoFolder) => sidecarKey(root, `${videoFolder}/${MARKER_NAME}`);
1899
- async function markTranscodeCompleted(env, videoId, ticketLocation) {
1900
- const location = ticketLocation ?? await rememberedLocation(env, videoId);
1901
- if (!location) return;
1902
- await env.MEDIA_BUCKET.put(markerKey(location.root, location.folder), (/* @__PURE__ */ new Date()).toISOString(), {
1903
- httpMetadata: { contentType: "text/plain" }
1904
- });
1905
- }
1906
- async function rememberedLocation(env, videoId) {
1907
- const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
1908
- const raw = disposal?.storageKey ? splitVideoStorageKey(disposal.storageKey) : null;
1909
- const folder = raw?.key.split("/").slice(0, 3).join("/");
1910
- return raw && folder ? { root: raw.root, folder } : null;
1911
- }
1912
- async function hasCompletedTranscode(bucket, root, videoFolder) {
1913
- return await bucket.head(markerKey(root, videoFolder)) !== null;
1914
- }
1915
- async function forgetTranscodeCompleted(bucket, root, videoFolder) {
1916
- await bucket.delete(markerKey(root, videoFolder));
1917
- }
1918
-
1919
2591
  // src/object-lock/overwrite.ts
1920
2592
  function sameObject(object) {
1921
2593
  return {
@@ -2059,7 +2731,7 @@ function logCaptionsError(context, error) {
2059
2731
  }
2060
2732
 
2061
2733
  // src/routes/webhooks.ts
2062
- import { Hono as Hono5 } from "hono";
2734
+ import { Hono as Hono7 } from "hono";
2063
2735
 
2064
2736
  // src/transcode/metrics.ts
2065
2737
  var OPERATOR_EVENT_FIELDS = ["encode", "posterKey", "posterFormats"];
@@ -2178,7 +2850,7 @@ async function loadOutputBaseUrl(kv, videoId, fallbackUrl) {
2178
2850
  }
2179
2851
 
2180
2852
  // src/routes/webhooks.ts
2181
- var webhooks = new Hono5();
2853
+ var webhooks = new Hono7();
2182
2854
  webhooks.post("/transcode", async (context) => {
2183
2855
  const webhookSecret = context.env.TRANSCODE_WEBHOOK_SECRET;
2184
2856
  if (!webhookSecret) {
@@ -2400,7 +3072,7 @@ async function transcodedDuration(kv, videoId) {
2400
3072
  const meta = await kv.get(`${KvPrefix.TRANSCODE_META}:${videoId}`, "json");
2401
3073
  return meta?.duration ?? void 0;
2402
3074
  }
2403
- var captions = new Hono6();
3075
+ var captions = new Hono8();
2404
3076
  captions.post("/", async (context) => {
2405
3077
  const body = await context.req.json();
2406
3078
  if (!body.key || !body.locales?.length) {
@@ -2511,19 +3183,19 @@ captions.post("/", async (context) => {
2511
3183
  });
2512
3184
  } catch (err) {
2513
3185
  logCaptionsError(`job failed for ${videoId}`, err);
2514
- const failure4 = toCaptionsFailure(err);
3186
+ const failure5 = toCaptionsFailure(err);
2515
3187
  await forwardToClientWebhook(
2516
3188
  {
2517
3189
  event: "captions.failed",
2518
3190
  videoId,
2519
- error: failure4.error,
2520
- reason: failure4.reason,
2521
- retriable: failure4.retriable
3191
+ error: failure5.error,
3192
+ reason: failure5.reason,
3193
+ retriable: failure5.retriable
2522
3194
  },
2523
3195
  webhookData
2524
3196
  );
2525
3197
  const conflict = captionConflictResponse(context, err);
2526
- return conflict ?? context.json({ error: failure4.error, code: failure4.code }, failure4.status);
3198
+ return conflict ?? context.json({ error: failure5.error, code: failure5.code }, failure5.status);
2527
3199
  }
2528
3200
  });
2529
3201
  captions.get("/:transcriptId", async (context) => {
@@ -2552,7 +3224,7 @@ import {
2552
3224
  toPrivateCacheControl,
2553
3225
  visibilityFromKey as visibilityFromKey2
2554
3226
  } from "@devultur/core";
2555
- import { Hono as Hono7 } from "hono";
3227
+ import { Hono as Hono9 } from "hono";
2556
3228
 
2557
3229
  // src/image/cloudflare.ts
2558
3230
  var IMAGE_RESIZING_VIA = "image-resizing";
@@ -2964,52 +3636,6 @@ function cacheDelivered(waitUntil, target, delivered) {
2964
3636
  }
2965
3637
  }
2966
3638
 
2967
- // src/routes/media-delete.ts
2968
- import { videoBasePath } from "@devultur/core";
2969
- async function handleVideoDelete(context) {
2970
- const videoId = context.req.param("videoId") ?? "";
2971
- const visibilityParam = context.req.query("visibility");
2972
- const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
2973
- const folders = visibilities.map((visibility) => `${visibility}/${videoBasePath(videoId)}`);
2974
- const blocked = await assertWritable(context, folders, "delete");
2975
- if (blocked) return blocked;
2976
- let deletedCount = 0;
2977
- let deletedKeyCount = 0;
2978
- for (const folder of folders) {
2979
- const deleted = await guardedDeletePrefix(context, `${folder}/`);
2980
- if ("blocked" in deleted) return deleted.blocked;
2981
- deletedCount += deleted.deletedCount;
2982
- if (deleted.deletedCount > 0) {
2983
- await forgetTranscodeCompleted(context.env.MEDIA_BUCKET, getRequestState(context).storagePrefix, folder);
2984
- }
2985
- if (!folder.startsWith("private/")) continue;
2986
- const deletedKeys = await deleteFolderKeys(context, folder);
2987
- if ("blocked" in deletedKeys) return deletedKeys.blocked;
2988
- deletedKeyCount += deletedKeys.deletedCount;
2989
- }
2990
- return context.json({ videoId, deletedCount, deletedKeyCount });
2991
- }
2992
- async function handleKeyDelete(context) {
2993
- const key = context.req.param("key") ?? "";
2994
- const fullKey = storageKey(context, key);
2995
- if (isVariantKey(key)) {
2996
- return context.json({ error: "Not found" }, 404);
2997
- }
2998
- if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
2999
- const exists = await context.env.MEDIA_BUCKET.head(fullKey);
3000
- if (!exists) {
3001
- return context.json({ error: "Not found" }, 404);
3002
- }
3003
- const blocked = await assertWritable(context, [key], "delete");
3004
- if (blocked) return blocked;
3005
- await context.env.MEDIA_BUCKET.delete(fullKey);
3006
- const variants = await guardedDeletePrefix(context, variantPrefix(key));
3007
- if ("blocked" in variants) return variants.blocked;
3008
- const keys = await deleteKeysFor(context, key, exists);
3009
- if ("blocked" in keys) return keys.blocked;
3010
- return new Response(null, { status: 204 });
3011
- }
3012
-
3013
3639
  // src/routes/storyboard-vtt.ts
3014
3640
  import { createDecryptStream as createDecryptStream3 } from "@devultur/core";
3015
3641
  var STORYBOARD_VTT = /(?:^|\/)video\/[^/]+\/storyboard\/[^/]+\.vtt$/;
@@ -3038,7 +3664,7 @@ function downloadDisposition(storedDisposition, key, filename) {
3038
3664
  if (storedDisposition?.startsWith("inline")) return `attachment${storedDisposition.slice("inline".length)}`;
3039
3665
  return contentDispositionAttachment(key.split("/").pop() || "download");
3040
3666
  }
3041
- var media = new Hono7();
3667
+ var media = new Hono9();
3042
3668
  media.use("*", contentSafety);
3043
3669
  media.get("/:key{.+}", async (context) => {
3044
3670
  const key = context.req.param("key");
@@ -3237,10 +3863,10 @@ async function serveEncryptionKey(context, key, tokenPayload) {
3237
3863
  }
3238
3864
 
3239
3865
  // src/routes/token.ts
3240
- import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as TokenScope3 } from "@devultur/core";
3241
- import { Hono as Hono8 } from "hono";
3866
+ import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as TokenScope5 } from "@devultur/core";
3867
+ import { Hono as Hono10 } from "hono";
3242
3868
  var DEFAULT_VIEWER_TOKEN_TTL_SECONDS = 7200;
3243
- var token = new Hono8();
3869
+ var token = new Hono10();
3244
3870
  token.post("/", async (context) => {
3245
3871
  const body = await context.req.json();
3246
3872
  if (!body.userId) {
@@ -3253,7 +3879,7 @@ token.post("/", async (context) => {
3253
3879
  );
3254
3880
  }
3255
3881
  const clientIp = context.req.header("CF-Connecting-IP") ?? context.req.header("X-Forwarded-For") ?? "unknown";
3256
- const requestedScopes = body.scopes ?? [TokenScope3.MEDIA_READ];
3882
+ const requestedScopes = body.scopes ?? [TokenScope5.MEDIA_READ];
3257
3883
  if (!Array.isArray(requestedScopes) || hasInvalidScope(requestedScopes)) {
3258
3884
  return context.json({ error: "scopes must be non-empty strings without spaces", code: "invalid_scopes" }, 400);
3259
3885
  }
@@ -3301,7 +3927,7 @@ import {
3301
3927
  generateId as generateId2,
3302
3928
  resolveSecurity as resolveSecurity3,
3303
3929
  resolveTranscodePreset,
3304
- TokenScope as TokenScope4,
3930
+ TokenScope as TokenScope6,
3305
3931
  TranscodeStatus as TranscodeStatus3,
3306
3932
  videoBasePath as videoBasePath2,
3307
3933
  videoHlsPath,
@@ -3311,7 +3937,7 @@ import {
3311
3937
  videoThumbnailPath,
3312
3938
  visibilityFromKey as visibilityFromKey3
3313
3939
  } from "@devultur/core";
3314
- import { Hono as Hono9 } from "hono";
3940
+ import { Hono as Hono11 } from "hono";
3315
3941
 
3316
3942
  // src/transcode/plan.ts
3317
3943
  import { MAIN_VARIANT } from "@devultur/core";
@@ -3586,7 +4212,7 @@ function postMachine(request, retry) {
3586
4212
  retry
3587
4213
  );
3588
4214
  }
3589
- async function failure2(response, error) {
4215
+ async function failure3(response, error) {
3590
4216
  const detail = await response.text();
3591
4217
  const status = response.status === 429 || response.status >= 500 ? 503 : 500;
3592
4218
  console.error(`[transcode] fly: ${error} (status ${response.status}):`, detail);
@@ -3598,7 +4224,7 @@ async function createFlyMachine(request, retry) {
3598
4224
  const errorBody = await response.text();
3599
4225
  const isSuspended = response.status === 412 || errorBody.includes("suspended");
3600
4226
  if (!isSuspended) {
3601
- return failure2(new Response(errorBody, { status: response.status }), "Failed to create transcode job");
4227
+ return failure3(new Response(errorBody, { status: response.status }), "Failed to create transcode job");
3602
4228
  }
3603
4229
  if (!await resumeFlyApp(request.appName, request.apiToken)) {
3604
4230
  console.error("[transcode] fly: failed to resume suspended app:", errorBody);
@@ -3612,7 +4238,7 @@ async function createFlyMachine(request, retry) {
3612
4238
  }
3613
4239
  const retried = await postMachine(request, retry);
3614
4240
  if (retried.ok) return { ok: true, machine: await retried.json() };
3615
- return failure2(retried, "Failed to create transcode job after resume");
4241
+ return failure3(retried, "Failed to create transcode job after resume");
3616
4242
  }
3617
4243
  function createFlyRunner(env, retry) {
3618
4244
  const guest = flyGuestFromEnv(env);
@@ -4250,7 +4876,7 @@ function parseSecurityMeta2(raw) {
4250
4876
  }
4251
4877
  var SERVICE_TOKEN_TTL_SECONDS = 3600;
4252
4878
  var isVideoId = (videoId, key) => Boolean(videoId) && videoId !== key;
4253
- var transcode = new Hono9();
4879
+ var transcode = new Hono11();
4254
4880
  transcode.post("/", async (context) => {
4255
4881
  const body = await context.req.json();
4256
4882
  if (!body.key) {
@@ -4310,7 +4936,7 @@ transcode.post("/", async (context) => {
4310
4936
  const requestedExp = nowSeconds + SERVICE_TOKEN_TTL_SECONDS;
4311
4937
  const decision = await decideCredential(context, {
4312
4938
  kind: "service",
4313
- scopes: [TokenScope4.MEDIA_READ],
4939
+ scopes: [TokenScope6.MEDIA_READ],
4314
4940
  path: body.key,
4315
4941
  expiresAt: requestedExp * 1e3
4316
4942
  });
@@ -4320,7 +4946,7 @@ transcode.post("/", async (context) => {
4320
4946
  {
4321
4947
  sub: `${SERVICE_SUBJECT_PREFIX}${videoId}`,
4322
4948
  typ: SERVICE_TOKEN_TYPE,
4323
- ...scopeClaim([TokenScope4.MEDIA_READ]),
4949
+ ...scopeClaim([TokenScope6.MEDIA_READ]),
4324
4950
  ...audienceClaim(scope.storagePrefix),
4325
4951
  bind: { sessionBinding: false, ipBinding: false },
4326
4952
  iat: nowSeconds,
@@ -4442,16 +5068,16 @@ transcode.get("/:jobId", async (context) => {
4442
5068
 
4443
5069
  // src/routes/upload.ts
4444
5070
  import { MediaType as MediaType2, resolveSecurity as resolveSecurity5, resolveVisibility, UploadRequestKind as UploadRequestKind2 } from "@devultur/core";
4445
- import { Hono as Hono10 } from "hono";
5071
+ import { Hono as Hono12 } from "hono";
4446
5072
 
4447
5073
  // src/object-lock/upload-lock.ts
4448
5074
  import { canChange as canChange3, resolveLock, validateLock as validateLock6 } from "@devultur/core";
4449
- var failure3 = (status, code, error) => ({ status, code, error });
5075
+ var failure4 = (status, code, error) => ({ status, code, error });
4450
5076
  function requestedLock(lock, hold) {
4451
- if (hold !== void 0 && typeof hold !== "boolean") return failure3(400, "invalid_lock", "hold must be a boolean");
5077
+ if (hold !== void 0 && typeof hold !== "boolean") return failure4(400, "invalid_lock", "hold must be a boolean");
4452
5078
  if (lock === void 0 && hold === void 0) return void 0;
4453
5079
  const validation = validateLock6(lock ?? {});
4454
- if (!validation.ok) return failure3(400, "invalid_lock", validation.errors.join("; "));
5080
+ if (!validation.ok) return failure4(400, "invalid_lock", validation.errors.join("; "));
4455
5081
  return { ...validation.lock, ...hold !== void 0 ? { hold } : {} };
4456
5082
  }
4457
5083
  function satisfiesMinimum(minimum, lock, now) {
@@ -4464,10 +5090,10 @@ function resolveUploadLock(input) {
4464
5090
  if (requested && "code" in requested) return requested;
4465
5091
  const minimum = input.grant?.lock;
4466
5092
  const resolution = resolveLock(requested ?? minimum, input.appDefault);
4467
- if (!resolution.ok) return failure3(400, "invalid_lock", resolution.errors.join("; "));
5093
+ if (!resolution.ok) return failure4(400, "invalid_lock", resolution.errors.join("; "));
4468
5094
  const lock = resolution.lock;
4469
5095
  if (minimum && !satisfiesMinimum(minimum, lock, input.now)) {
4470
- return failure3(403, "lock_below_minimum", "The upload token requires a stricter lock than the request asks for");
5096
+ return failure4(403, "lock_below_minimum", "The upload token requires a stricter lock than the request asks for");
4471
5097
  }
4472
5098
  return lock.retention || lock.hold ? lock : void 0;
4473
5099
  }
@@ -5175,7 +5801,7 @@ async function handleMultipartAbort(context) {
5175
5801
  }
5176
5802
 
5177
5803
  // src/routes/upload.ts
5178
- var upload = new Hono10();
5804
+ var upload = new Hono12();
5179
5805
  upload.post("/", async (context) => {
5180
5806
  const body = await context.req.json();
5181
5807
  const isAssetRequest = body.path !== void 0;
@@ -5658,7 +6284,7 @@ async function retainedSummary(options) {
5658
6284
  // src/index.ts
5659
6285
  function createWorker(options) {
5660
6286
  const { middleware, routes, hooks } = composePlugins(options?.plugins ?? defaultPlugins());
5661
- const app = new Hono11();
6287
+ const app = new Hono13();
5662
6288
  app.use("*", logger());
5663
6289
  app.use(
5664
6290
  "*",
@@ -5666,7 +6292,17 @@ function createWorker(options) {
5666
6292
  origin: "*",
5667
6293
  allowMethods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
5668
6294
  allowHeaders: ["Content-Type", "Authorization", "Range", SESSION_HEADER2, SHA256_HEADER3],
5669
- exposeHeaders: [SHA256_HEADER3, SHA256_VERIFIED_HEADER2, REPR_DIGEST_HEADER2, EXPIRES_AT_HEADER2, "Content-Range"]
6295
+ exposeHeaders: [
6296
+ SHA256_HEADER3,
6297
+ SHA256_VERIFIED_HEADER2,
6298
+ REPR_DIGEST_HEADER2,
6299
+ EXPIRES_AT_HEADER2,
6300
+ ENCRYPTED_HEADER2,
6301
+ GRANT_EXPIRES_AT_HEADER2,
6302
+ GRANT_KEY_HEADER2,
6303
+ GRANT_METADATA_HEADER2,
6304
+ "Content-Range"
6305
+ ]
5670
6306
  })
5671
6307
  );
5672
6308
  app.use("*", async (context, next) => {
@@ -5694,6 +6330,7 @@ function createWorker(options) {
5694
6330
  return app;
5695
6331
  }
5696
6332
  export {
6333
+ DownloadGrants,
5697
6334
  DownloadLink,
5698
6335
  ENCRYPTION_KEY_MISSING_CODE,
5699
6336
  HOLD_PAGE_SIZE,
@@ -5724,6 +6361,7 @@ export {
5724
6361
  getRequestState,
5725
6362
  getRouteResult,
5726
6363
  hashKey,
6364
+ identifyDownloadGrant,
5727
6365
  imageOriginAuthorization,
5728
6366
  isAuthenticated,
5729
6367
  isImageOriginRequest,