@devultur/self-host 1.5.2 → 1.6.1

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,19 +740,13 @@ 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";
728
747
  import { Hono as Hono2 } from "hono";
729
748
 
730
749
  // src/download-link.ts
731
- var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
732
- function recordContext(record) {
733
- if (record.context) return record.context;
734
- const legacy = record.callerContext;
735
- return isRecord(legacy) ? legacy : void 0;
736
- }
737
750
  var DownloadLinkPath = {
738
751
  CREATE: "/create",
739
752
  REDEEM: "/redeem",
@@ -792,6 +805,35 @@ var DownloadLink = class {
792
805
  }
793
806
  };
794
807
 
808
+ // src/grant-credential.ts
809
+ import { DOWNLOAD_GRANT_ID_PREFIX, DOWNLOAD_GRANT_PREFIX } from "@devultur/core";
810
+ var OBJECT_ID = "[0-9a-f]{64}";
811
+ var GRANT_ID = "[A-Za-z0-9_-]{22}";
812
+ var SECRET = "[A-Za-z0-9_-]{43}";
813
+ var SECRET_PATTERN = new RegExp(`^${DOWNLOAD_GRANT_PREFIX}(${OBJECT_ID})\\.(${GRANT_ID})\\.(${SECRET})$`);
814
+ var PUBLIC_ID_PATTERN = new RegExp(`^${DOWNLOAD_GRANT_ID_PREFIX}(${OBJECT_ID})\\.(${GRANT_ID})$`);
815
+ function randomBase64Url(bytes) {
816
+ const random = crypto.getRandomValues(new Uint8Array(bytes));
817
+ return btoa(String.fromCharCode(...random)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
818
+ }
819
+ var newGrantId = () => randomBase64Url(16);
820
+ var newGrantSecret = () => randomBase64Url(32);
821
+ function formatGrantSecret({ objectId, grantId, secret }) {
822
+ return `${DOWNLOAD_GRANT_PREFIX}${objectId}.${grantId}.${secret}`;
823
+ }
824
+ function formatGrantPublicId({ objectId, grantId }) {
825
+ return `${DOWNLOAD_GRANT_ID_PREFIX}${objectId}.${grantId}`;
826
+ }
827
+ function parseGrantSecret(value) {
828
+ const match = SECRET_PATTERN.exec(value);
829
+ return match ? { objectId: match[1], grantId: match[2], secret: match[3] } : null;
830
+ }
831
+ function parseGrantPublicId(value) {
832
+ const match = PUBLIC_ID_PATTERN.exec(value);
833
+ return match ? { objectId: match[1], grantId: match[2] } : null;
834
+ }
835
+ var hashGrantSecret = (secret) => hashKey(secret);
836
+
795
837
  // src/route-result.ts
796
838
  var ROUTE_RESULT_VARIABLE = "routeResult";
797
839
  function getRouteResult(context) {
@@ -988,8 +1030,8 @@ function allowedUploadMetadata(metadata) {
988
1030
  if (!metadata) return {};
989
1031
  return Object.fromEntries(Object.entries(metadata).filter(([key]) => !isWorkerMetadataKey(key)));
990
1032
  }
991
- function targetFailure(context, failure4) {
992
- return context.json({ error: failure4.error, code: failure4.code }, failure4.status);
1033
+ function targetFailure(context, failure5) {
1034
+ return context.json({ error: failure5.error, code: failure5.code }, failure5.status);
993
1035
  }
994
1036
  function alreadyExists(context, key) {
995
1037
  return context.json(
@@ -1162,10 +1204,7 @@ var DEFAULT_LINK_TTL_SECONDS = 15 * 60;
1162
1204
  var MAX_LINK_TTL_SECONDS = 7 * 24 * 60 * 60;
1163
1205
  var MAX_LINK_USES = 100;
1164
1206
  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
- }
1207
+ var newLinkId = () => randomBase64Url(16);
1169
1208
  function linkStub(namespace, linkId) {
1170
1209
  return namespace.get(namespace.idFromName(linkId));
1171
1210
  }
@@ -1205,9 +1244,8 @@ async function handleDownloadLink(context) {
1205
1244
  });
1206
1245
  if (!decision.allow) return decision.response;
1207
1246
  const expiresAt = cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs);
1208
- const linkId = newLinkId();
1209
1247
  const scope = getRequestState(context);
1210
- const record = {
1248
+ const created = await storeLink(context, namespace, {
1211
1249
  storageKey: fullKey,
1212
1250
  key: parsed.key,
1213
1251
  expiresAt,
@@ -1215,14 +1253,18 @@ async function handleDownloadLink(context) {
1215
1253
  ...parsed.filename ? { filename: parsed.filename } : {},
1216
1254
  storagePrefix: scope.storagePrefix,
1217
1255
  ...scope.context ? { context: scope.context } : {}
1218
- };
1256
+ });
1257
+ if (!created) return context.json({ error: "Could not create the download link" }, 500);
1258
+ return context.json({ ...created, expiresAt, maxUses: parsed.maxUses });
1259
+ }
1260
+ async function storeLink(context, namespace, record) {
1261
+ const linkId = newLinkId();
1219
1262
  const created = await linkStub(namespace, linkId).fetch(`https://download-link${DownloadLinkPath.CREATE}`, {
1220
1263
  method: "POST",
1221
1264
  body: JSON.stringify(record)
1222
1265
  });
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 });
1266
+ if (!created.ok) return null;
1267
+ return { id: linkId, url: `${new URL(context.req.url).origin}/v1/download/${linkId}` };
1226
1268
  }
1227
1269
  function attachmentDisposition(record, storedDisposition) {
1228
1270
  if (record.filename) return contentDispositionAttachment2(record.filename);
@@ -1232,8 +1274,11 @@ function attachmentDisposition(record, storedDisposition) {
1232
1274
  var callLink = (stub, path) => stub.fetch(`https://download-link${path}`, { method: "POST" });
1233
1275
  var relayLinkFailure = (response) => new Response(response.body, { status: response.status, headers: response.headers });
1234
1276
  function linkInfoOf(record) {
1235
- const context = recordContext(record);
1236
- return { storagePrefix: record.storagePrefix, key: record.key, ...context ? { context } : {} };
1277
+ return {
1278
+ storagePrefix: record.storagePrefix,
1279
+ key: record.key,
1280
+ ...record.context ? { context: record.context } : {}
1281
+ };
1237
1282
  }
1238
1283
  async function gateRedemption(context, stub) {
1239
1284
  if (getPluginHooks(context).beforeLinkRedeem.length === 0) return null;
@@ -1255,7 +1300,7 @@ download.get("/:linkId", async (context) => {
1255
1300
  if (!redeemed.ok) return relayLinkFailure(redeemed);
1256
1301
  const record = await redeemed.json();
1257
1302
  const object = await context.env.MEDIA_BUCKET.get(record.storageKey);
1258
- if (!object) return context.json({ error: "Not found" }, 404);
1303
+ if (!object) return context.json({ error: "This link's file was deleted", code: "file_gone" }, 410);
1259
1304
  if (isObjectExpired(object.customMetadata)) return expiredResponse();
1260
1305
  addRouteResult(context, { redeemedLink: linkInfoOf(record) });
1261
1306
  const headers = new Headers({
@@ -1274,14 +1319,700 @@ download.get("/:linkId", async (context) => {
1274
1319
  return response;
1275
1320
  });
1276
1321
 
1322
+ // src/routes/grant-bearer.ts
1323
+ import {
1324
+ DownloadGrantErrorCode as DownloadGrantErrorCode2,
1325
+ DownloadGrantLimits,
1326
+ ENCRYPTED_HEADER,
1327
+ GRANT_EXPIRES_AT_HEADER,
1328
+ GRANT_KEY_HEADER,
1329
+ GRANT_METADATA_HEADER,
1330
+ TokenScope as TokenScope2
1331
+ } from "@devultur/core";
1332
+ import { Hono as Hono3 } from "hono";
1333
+
1334
+ // src/download-grants.ts
1335
+ import { DownloadGrantErrorCode } from "@devultur/core";
1336
+ var DownloadGrantPath = {
1337
+ /** Stores a new grant. Body: the record without `linksIssued`/`revokedAt`. */
1338
+ CREATE: "/create",
1339
+ /** The live grant for `{ id, secretHash }`. */
1340
+ AUTHORIZE: "/authorize",
1341
+ /** The grant for `{ id, secretHash }`, live or not: who it belongs to, before deciding anything. */
1342
+ IDENTIFY: "/identify",
1343
+ /** Spends one link of a live grant. Body: `{ id, secretHash, expiresIn, maxUses }`. */
1344
+ ISSUE: "/issue",
1345
+ /** Gives back a link spent by `ISSUE` whose creation failed. Body: `{ id }`. */
1346
+ REFUND: "/refund",
1347
+ /** Revokes `{ id }` (the owner) or `{ id, secretHash }` (the bearer). Idempotent. */
1348
+ REVOKE: "/revoke",
1349
+ /** One grant by `{ id }`, live or not, for the owner. */
1350
+ GET: "/get",
1351
+ /** Every grant of the file, for the owner. */
1352
+ LIST: "/list",
1353
+ /** Revokes every grant of the file: it's gone. They stay listed until they expire. */
1354
+ REVOKE_ALL: "/revoke-all"
1355
+ };
1356
+ var GRANT_KEY_PREFIX = "grant:";
1357
+ var grantKey = (id) => `${GRANT_KEY_PREFIX}${id}`;
1358
+ var failure = (status, code, error) => Response.json({ error, code }, { status });
1359
+ var invalidGrant = () => failure(401, DownloadGrantErrorCode.INVALID, "Invalid download grant");
1360
+ var grantGone = () => failure(410, DownloadGrantErrorCode.GONE, "This download grant is no longer valid");
1361
+ var notFound2 = () => failure(404, DownloadGrantErrorCode.NOT_FOUND, "Download grant not found");
1362
+ function grantInfoOf(record) {
1363
+ const { secretHash: _secretHash, ...info } = record;
1364
+ return info;
1365
+ }
1366
+ function isGrantDead(record, now = Date.now()) {
1367
+ return record.revokedAt !== null || record.expiresAt <= now;
1368
+ }
1369
+ function fitsLinkCaps(caps, expiresIn, maxUses) {
1370
+ return expiresIn <= caps.maxExpiresIn && maxUses <= caps.maxUses;
1371
+ }
1372
+ var DownloadGrants = class {
1373
+ state;
1374
+ storage;
1375
+ constructor(state) {
1376
+ this.state = state;
1377
+ this.storage = state.storage;
1378
+ }
1379
+ async fetch(request) {
1380
+ if (request.method !== "POST") return new Response(null, { status: 405 });
1381
+ const handler = this.handlerFor(new URL(request.url).pathname);
1382
+ if (!handler) return new Response(null, { status: 404 });
1383
+ const body = await request.json().catch(() => ({}));
1384
+ return this.state.blockConcurrencyWhile(() => handler(body));
1385
+ }
1386
+ handlerFor(pathname) {
1387
+ switch (pathname) {
1388
+ case DownloadGrantPath.CREATE:
1389
+ return (body) => this.create(body);
1390
+ case DownloadGrantPath.AUTHORIZE:
1391
+ return (body) => this.authorize(body);
1392
+ case DownloadGrantPath.IDENTIFY:
1393
+ return (body) => this.identify(body);
1394
+ case DownloadGrantPath.ISSUE:
1395
+ return (body) => this.issue(body);
1396
+ case DownloadGrantPath.REFUND:
1397
+ return (body) => this.refund(body);
1398
+ case DownloadGrantPath.REVOKE:
1399
+ return (body) => this.revoke(body);
1400
+ case DownloadGrantPath.GET:
1401
+ return (body) => this.getOne(body);
1402
+ case DownloadGrantPath.LIST:
1403
+ return () => this.list();
1404
+ case DownloadGrantPath.REVOKE_ALL:
1405
+ return () => this.revokeAll();
1406
+ default:
1407
+ return null;
1408
+ }
1409
+ }
1410
+ async create(record) {
1411
+ if (await this.storage.get(grantKey(record.id))) {
1412
+ return failure(409, "grant_exists", "Grant id already in use");
1413
+ }
1414
+ await this.storage.put(grantKey(record.id), { ...record, linksIssued: 0, revokedAt: null });
1415
+ await this.scheduleAlarm();
1416
+ return Response.json({ ok: true }, { status: 201 });
1417
+ }
1418
+ async recordOf(id) {
1419
+ return typeof id === "string" ? this.storage.get(grantKey(id)) : void 0;
1420
+ }
1421
+ /** The record whose secret hash matches, live or not, or 401. */
1422
+ async matchingRecord(body) {
1423
+ const record = await this.recordOf(body.id);
1424
+ const secretHash = typeof body.secretHash === "string" ? body.secretHash : "";
1425
+ if (!record || !isConstantTimeEqual(secretHash, record.secretHash)) return invalidGrant();
1426
+ return record;
1427
+ }
1428
+ /** The live record whose secret hash matches, or the response explaining why not. */
1429
+ async liveRecord(body) {
1430
+ const record = await this.matchingRecord(body);
1431
+ if (record instanceof Response) return record;
1432
+ if (isGrantDead(record)) return grantGone();
1433
+ return record;
1434
+ }
1435
+ async identify(body) {
1436
+ const record = await this.matchingRecord(body);
1437
+ return record instanceof Response ? record : Response.json(grantInfoOf(record));
1438
+ }
1439
+ async authorize(body) {
1440
+ const record = await this.liveRecord(body);
1441
+ return record instanceof Response ? record : Response.json(grantInfoOf(record));
1442
+ }
1443
+ /** Spends one link: caps first (a refused link spends nothing), then the total. */
1444
+ async issue(body) {
1445
+ const record = await this.liveRecord(body);
1446
+ if (record instanceof Response) return record;
1447
+ if (!fitsLinkCaps(record.link, Number(body.expiresIn), Number(body.maxUses))) {
1448
+ return Response.json(
1449
+ {
1450
+ error: "The link exceeds the grant's caps",
1451
+ code: DownloadGrantErrorCode.LINK_EXCEEDS_GRANT,
1452
+ link: record.link
1453
+ },
1454
+ { status: 400 }
1455
+ );
1456
+ }
1457
+ if (record.linksIssued >= record.maxLinks) {
1458
+ return failure(410, DownloadGrantErrorCode.EXHAUSTED, "This download grant has issued all its links");
1459
+ }
1460
+ const spent = { ...record, linksIssued: record.linksIssued + 1 };
1461
+ await this.storage.put(grantKey(record.id), spent);
1462
+ return Response.json(grantInfoOf(spent));
1463
+ }
1464
+ async refund(body) {
1465
+ const record = await this.recordOf(body.id);
1466
+ if (!record) return notFound2();
1467
+ await this.storage.put(grantKey(record.id), { ...record, linksIssued: Math.max(0, record.linksIssued - 1) });
1468
+ return new Response(null, { status: 204 });
1469
+ }
1470
+ /** Without `secretHash` the route already authorized the owner; with it, only the grant's own secret revokes it. */
1471
+ async revoke(body) {
1472
+ const record = await this.recordOf(body.id);
1473
+ if (!record) return body.secretHash === void 0 ? notFound2() : invalidGrant();
1474
+ const isBearer = body.secretHash !== void 0;
1475
+ if (isBearer && !isConstantTimeEqual(String(body.secretHash), record.secretHash)) return invalidGrant();
1476
+ if (record.revokedAt !== null) return Response.json(grantInfoOf(record));
1477
+ const revoked = { ...record, revokedAt: Date.now() };
1478
+ await this.storage.put(grantKey(record.id), revoked);
1479
+ return Response.json(grantInfoOf(revoked));
1480
+ }
1481
+ async getOne(body) {
1482
+ const record = await this.recordOf(body.id);
1483
+ return record ? Response.json(grantInfoOf(record)) : notFound2();
1484
+ }
1485
+ async records() {
1486
+ const stored = await this.storage.list({ prefix: GRANT_KEY_PREFIX });
1487
+ return [...stored.values()];
1488
+ }
1489
+ async list() {
1490
+ const records = await this.records();
1491
+ return Response.json(records.sort((first, second) => first.createdAt - second.createdAt).map(grantInfoOf));
1492
+ }
1493
+ async revokeAll() {
1494
+ const now = Date.now();
1495
+ for (const record of await this.records()) {
1496
+ if (record.revokedAt === null) await this.storage.put(grantKey(record.id), { ...record, revokedAt: now });
1497
+ }
1498
+ return new Response(null, { status: 204 });
1499
+ }
1500
+ /** Alarm at the earliest expiry among the stored grants; none left: no alarm. */
1501
+ async scheduleAlarm() {
1502
+ const expiries = (await this.records()).map((record) => record.expiresAt);
1503
+ if (expiries.length === 0) {
1504
+ await this.storage.deleteAlarm();
1505
+ return;
1506
+ }
1507
+ await this.storage.setAlarm(Math.min(...expiries));
1508
+ }
1509
+ /** Drops the grants past their expiry (revoked ones stay listed until then) and schedules the next alarm. */
1510
+ async alarm() {
1511
+ const now = Date.now();
1512
+ for (const record of await this.records()) {
1513
+ if (record.expiresAt <= now) await this.storage.delete(grantKey(record.id));
1514
+ }
1515
+ await this.scheduleAlarm();
1516
+ }
1517
+ };
1518
+
1519
+ // src/routes/grant-shared.ts
1520
+ function grantNamespaces(context) {
1521
+ const { DOWNLOAD_GRANTS: grants, DOWNLOAD_LINKS: links } = context.env;
1522
+ return grants && links ? { grants, links } : null;
1523
+ }
1524
+ function notConfigured2(context) {
1525
+ return context.json({ error: "Download grants are not configured", code: "not_configured" }, 501);
1526
+ }
1527
+ function fileGrantsObject(namespace, storageKey2) {
1528
+ const id = namespace.idFromName(storageKey2);
1529
+ return { objectId: id.toString(), stub: namespace.get(id) };
1530
+ }
1531
+ function grantsObjectById(namespace, objectId) {
1532
+ try {
1533
+ return namespace.get(namespace.idFromString(objectId));
1534
+ } catch {
1535
+ return null;
1536
+ }
1537
+ }
1538
+ function callGrants(stub, path, body = {}) {
1539
+ return stub.fetch(`https://download-grants${path}`, { method: "POST", body: JSON.stringify(body) });
1540
+ }
1541
+ var relayGrantFailure = (response) => new Response(response.body, { status: response.status, headers: response.headers });
1542
+ function grantListEntry(objectId, grant) {
1543
+ return {
1544
+ id: formatGrantPublicId({ objectId, grantId: grant.id }),
1545
+ key: grant.key,
1546
+ expiresAt: grant.expiresAt,
1547
+ link: grant.link,
1548
+ maxLinks: grant.maxLinks,
1549
+ canDelete: grant.canDelete,
1550
+ ...grant.metadata ? { metadata: grant.metadata } : {},
1551
+ linksIssued: grant.linksIssued,
1552
+ createdAt: grant.createdAt,
1553
+ revokedAt: grant.revokedAt
1554
+ };
1555
+ }
1556
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1557
+ function parseMetadata(value, maxBytes) {
1558
+ if (value === void 0) return void 0;
1559
+ if (!isPlainObject(value)) return null;
1560
+ if (!Object.values(value).every((entry) => typeof entry === "string")) return null;
1561
+ const size = new TextEncoder().encode(JSON.stringify(value)).byteLength;
1562
+ return size <= maxBytes ? value : null;
1563
+ }
1564
+ function parseCount(value, fallback2, max) {
1565
+ const count = value ?? fallback2;
1566
+ return typeof count === "number" && Number.isSafeInteger(count) && count > 0 && count <= max ? count : null;
1567
+ }
1568
+
1569
+ // src/routes/media-delete.ts
1570
+ import { videoBasePath } from "@devultur/core";
1571
+
1572
+ // src/transcode/raw-retention.ts
1573
+ import { isVideoRawKey as isVideoRawKey2 } from "@devultur/core";
1574
+ var disposalKey = (videoId) => `${KvPrefix.TRANSCODE_RAW}:${videoId}`;
1575
+ async function readRawDisposal(kv, videoId) {
1576
+ return kv.get(disposalKey(videoId), "json");
1577
+ }
1578
+ async function rememberRawDisposal(kv, videoId, disposal) {
1579
+ await kv.put(disposalKey(videoId), JSON.stringify(disposal), { expirationTtl: KV_JOB_TTL_SECONDS });
1580
+ }
1581
+ async function disposeRawAfterTranscode(context, videoId) {
1582
+ const { env } = context;
1583
+ const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
1584
+ if (!disposal?.deleteOnComplete || !disposal.storageKey) return "kept";
1585
+ const raw = await env.MEDIA_BUCKET.head(disposal.storageKey);
1586
+ const location = splitVideoStorageKey(disposal.storageKey);
1587
+ const lock = raw && location ? await effectiveLock(env.MEDIA_BUCKET, location.root, location.key, raw) : null;
1588
+ if (lock?.state && isStateLocked(lock.state, /* @__PURE__ */ new Date())) {
1589
+ await recordLockAudit(context, {
1590
+ action: "original_retained",
1591
+ keys: [location?.key ?? disposal.storageKey],
1592
+ before: lock.state.lock,
1593
+ after: null,
1594
+ outcome: "skipped",
1595
+ root: location?.root ?? ""
1596
+ });
1597
+ return "retained";
1598
+ }
1599
+ await env.MEDIA_BUCKET.delete(disposal.storageKey);
1600
+ if (env.KEYS_BUCKET) await deletePrefix(env.KEYS_BUCKET, `${disposal.storageKey}/`);
1601
+ return "deleted";
1602
+ }
1603
+ var ORIGINAL_NOT_RETAINED = {
1604
+ 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.",
1605
+ code: "original_not_retained"
1606
+ };
1607
+ var PRIOR_OUTPUTS_LIST_LIMIT = 100;
1608
+ async function hasPriorTranscodeOutputs(bucket, videoFolder) {
1609
+ const listing = await bucket.list({ prefix: videoFolder, limit: PRIOR_OUTPUTS_LIST_LIMIT });
1610
+ return listing.objects.some((object) => !isVideoRawKey2(object.key));
1611
+ }
1612
+
1613
+ // src/object-lock/transcode-marker.ts
1614
+ var MARKER_NAME = "transcode-completed";
1615
+ var markerKey = (root, videoFolder) => sidecarKey(root, `${videoFolder}/${MARKER_NAME}`);
1616
+ async function markTranscodeCompleted(env, videoId, ticketLocation) {
1617
+ const location = ticketLocation ?? await rememberedLocation(env, videoId);
1618
+ if (!location) return;
1619
+ await env.MEDIA_BUCKET.put(markerKey(location.root, location.folder), (/* @__PURE__ */ new Date()).toISOString(), {
1620
+ httpMetadata: { contentType: "text/plain" }
1621
+ });
1622
+ }
1623
+ async function rememberedLocation(env, videoId) {
1624
+ const disposal = await readRawDisposal(env.MEDIA_KV, videoId);
1625
+ const raw = disposal?.storageKey ? splitVideoStorageKey(disposal.storageKey) : null;
1626
+ const folder = raw?.key.split("/").slice(0, 3).join("/");
1627
+ return raw && folder ? { root: raw.root, folder } : null;
1628
+ }
1629
+ async function hasCompletedTranscode(bucket, root, videoFolder) {
1630
+ return await bucket.head(markerKey(root, videoFolder)) !== null;
1631
+ }
1632
+ async function forgetTranscodeCompleted(bucket, root, videoFolder) {
1633
+ await bucket.delete(markerKey(root, videoFolder));
1634
+ }
1635
+
1636
+ // src/routes/media-delete.ts
1637
+ async function handleVideoDelete(context) {
1638
+ const videoId = context.req.param("videoId") ?? "";
1639
+ const visibilityParam = context.req.query("visibility");
1640
+ const visibilities = visibilityParam === "public" || visibilityParam === "private" ? [visibilityParam] : ["public", "private"];
1641
+ const folders = visibilities.map((visibility) => `${visibility}/${videoBasePath(videoId)}`);
1642
+ const blocked = await assertWritable(context, folders, "delete");
1643
+ if (blocked) return blocked;
1644
+ let deletedCount = 0;
1645
+ let deletedKeyCount = 0;
1646
+ for (const folder of folders) {
1647
+ const deleted = await guardedDeletePrefix(context, `${folder}/`);
1648
+ if ("blocked" in deleted) return deleted.blocked;
1649
+ deletedCount += deleted.deletedCount;
1650
+ if (deleted.deletedCount > 0) {
1651
+ await forgetTranscodeCompleted(context.env.MEDIA_BUCKET, getRequestState(context).storagePrefix, folder);
1652
+ }
1653
+ if (!folder.startsWith("private/")) continue;
1654
+ const deletedKeys = await deleteFolderKeys(context, folder);
1655
+ if ("blocked" in deletedKeys) return deletedKeys.blocked;
1656
+ deletedKeyCount += deletedKeys.deletedCount;
1657
+ }
1658
+ return context.json({ videoId, deletedCount, deletedKeyCount });
1659
+ }
1660
+ function handleKeyDelete(context) {
1661
+ return deleteMediaKey(context, context.req.param("key") ?? "");
1662
+ }
1663
+ async function deleteMediaKey(context, key) {
1664
+ const fullKey = storageKey(context, key);
1665
+ if (isVariantKey(key)) {
1666
+ return context.json({ error: "Not found" }, 404);
1667
+ }
1668
+ if (isReservedLockKey(key)) return reservedKeyResponse(context, key);
1669
+ const exists = await context.env.MEDIA_BUCKET.head(fullKey);
1670
+ if (!exists) {
1671
+ return context.json({ error: "Not found" }, 404);
1672
+ }
1673
+ const blocked = await assertWritable(context, [key], "delete");
1674
+ if (blocked) return blocked;
1675
+ await context.env.MEDIA_BUCKET.delete(fullKey);
1676
+ const variants = await guardedDeletePrefix(context, variantPrefix(key));
1677
+ if ("blocked" in variants) return variants.blocked;
1678
+ const keys = await deleteKeysFor(context, key, exists);
1679
+ if ("blocked" in keys) return keys.blocked;
1680
+ return new Response(null, { status: 204 });
1681
+ }
1682
+
1683
+ // src/routes/grant-bearer.ts
1684
+ var grantError = (status, code, error) => Response.json({ error, code }, { status });
1685
+ var invalidGrant2 = () => grantError(401, DownloadGrantErrorCode2.INVALID, "Invalid download grant");
1686
+ var grantGone2 = () => grantError(410, DownloadGrantErrorCode2.GONE, "This download grant is no longer valid");
1687
+ async function readBearer(context, grants) {
1688
+ const bearer = context.req.header("Authorization")?.match(/^Bearer\s+(\S+)$/i)?.[1];
1689
+ if (!bearer && context.req.query("token") !== void 0) {
1690
+ return grantError(401, DownloadGrantErrorCode2.IN_QUERY, "Send the grant in Authorization: Bearer, never in a URL");
1691
+ }
1692
+ const parsed = bearer ? parseGrantSecret(bearer) : null;
1693
+ const stub = parsed ? grantsObjectById(grants, parsed.objectId) : null;
1694
+ if (!parsed || !stub) return invalidGrant2();
1695
+ return {
1696
+ stub,
1697
+ parsed,
1698
+ secretHash: await hashGrantSecret(parsed.secret),
1699
+ publicId: formatGrantPublicId(parsed)
1700
+ };
1701
+ }
1702
+ async function identifyDownloadGrant(anyContext) {
1703
+ const context = anyContext;
1704
+ const namespaces = grantNamespaces(context);
1705
+ if (!namespaces) return notConfigured2(context);
1706
+ const bearer = await readBearer(context, namespaces.grants);
1707
+ if (bearer instanceof Response) return bearer;
1708
+ const identified = await callGrants(bearer.stub, DownloadGrantPath.IDENTIFY, {
1709
+ id: bearer.parsed.grantId,
1710
+ secretHash: bearer.secretHash
1711
+ });
1712
+ if (!identified.ok) return relayGrantFailure(identified);
1713
+ return { publicId: bearer.publicId, grant: await identified.json() };
1714
+ }
1715
+ async function grantedObject(context, bearer, grant) {
1716
+ const object = await context.env.MEDIA_BUCKET.head(grant.storageKey);
1717
+ if (!object) {
1718
+ await callGrants(bearer.stub, DownloadGrantPath.REVOKE_ALL);
1719
+ return grantGone2();
1720
+ }
1721
+ if (object.version !== grant.version || isObjectExpired(object.customMetadata)) return grantGone2();
1722
+ return object;
1723
+ }
1724
+ async function authorizeGrant(context, action) {
1725
+ const namespaces = grantNamespaces(context);
1726
+ if (!namespaces) return notConfigured2(context);
1727
+ const bearer = await readBearer(context, namespaces.grants);
1728
+ if (bearer instanceof Response) return bearer;
1729
+ const authorized = await callGrants(bearer.stub, DownloadGrantPath.AUTHORIZE, {
1730
+ id: bearer.parsed.grantId,
1731
+ secretHash: bearer.secretHash
1732
+ });
1733
+ if (!authorized.ok) return relayGrantFailure(authorized);
1734
+ const grant = await authorized.json();
1735
+ if (grant.storagePrefix !== getRequestState(context).storagePrefix) return invalidGrant2();
1736
+ const refusal2 = await gateGrantAccess(context, { action, grant });
1737
+ if (refusal2) return refusal2;
1738
+ const object = await grantedObject(context, bearer, grant);
1739
+ if (object instanceof Response) return object;
1740
+ return { ...bearer, grant, object };
1741
+ }
1742
+ function headHeaders({ grant, object }) {
1743
+ const headers = new Headers({
1744
+ "Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream",
1745
+ "Content-Length": String(object.size),
1746
+ ETag: object.httpEtag,
1747
+ "Cache-Control": "private, no-store",
1748
+ [ENCRYPTED_HEADER]: object.customMetadata?.enc ? "true" : "false",
1749
+ [GRANT_EXPIRES_AT_HEADER]: String(grant.expiresAt),
1750
+ [GRANT_KEY_HEADER]: grant.key
1751
+ });
1752
+ applyIntegrityHeaders(headers, object.customMetadata);
1753
+ applyImageSizeHeaders(headers, object.customMetadata);
1754
+ if (grant.metadata) headers.set(GRANT_METADATA_HEADER, encodeURIComponent(JSON.stringify(grant.metadata)));
1755
+ return headers;
1756
+ }
1757
+ async function handleHead(context) {
1758
+ if (context.req.method !== "HEAD") return new Response(null, { status: 405, headers: { Allow: "HEAD" } });
1759
+ const authorized = await authorizeGrant(context, "head");
1760
+ if (authorized instanceof Response) return authorized;
1761
+ return new Response(null, { status: 200, headers: headHeaders(authorized) });
1762
+ }
1763
+ function parseGrantLinkRequest(body, grant) {
1764
+ const defaultExpiresIn = Math.min(DownloadGrantLimits.DEFAULT_LINK_EXPIRES_IN, grant.link.maxExpiresIn);
1765
+ const expiresIn = parseCount(body.expiresIn, defaultExpiresIn, MAX_LINK_TTL_SECONDS);
1766
+ if (expiresIn === null)
1767
+ return { error: "expiresIn must be a positive number of seconds", code: "invalid_expires_in" };
1768
+ const maxUses = parseCount(body.maxUses, 1, Number.MAX_SAFE_INTEGER);
1769
+ if (maxUses === null) return { error: "maxUses must be a positive integer", code: "invalid_max_uses" };
1770
+ const metadata = parseMetadata(body.metadata, DownloadGrantLimits.MAX_METADATA_BYTES);
1771
+ if (metadata === null)
1772
+ return { error: "metadata must map strings to strings, 1 KB at most", code: "invalid_metadata" };
1773
+ const filename = typeof body.filename === "string" && body.filename ? body.filename : void 0;
1774
+ const parsed = {
1775
+ expiresIn,
1776
+ maxUses,
1777
+ ...filename ? { filename } : {},
1778
+ ...metadata ? { metadata } : {}
1779
+ };
1780
+ return parsed;
1781
+ }
1782
+ async function handleIssueLink(context) {
1783
+ const namespaces = grantNamespaces(context);
1784
+ if (!namespaces) return notConfigured2(context);
1785
+ const authorized = await authorizeGrant(context, "issue-link");
1786
+ if (authorized instanceof Response) return authorized;
1787
+ const { grant, object, stub } = authorized;
1788
+ const parsed = parseGrantLinkRequest(await context.req.json().catch(() => null) ?? {}, grant);
1789
+ if ("error" in parsed) return context.json(parsed, 400);
1790
+ const requestedExpiresAt = Math.min(
1791
+ Date.now() + parsed.expiresIn * 1e3,
1792
+ objectExpiresAt(object.customMetadata) ?? Number.POSITIVE_INFINITY,
1793
+ grant.expiresAt
1794
+ );
1795
+ const decision = await decideCredential(context, {
1796
+ kind: "download-link",
1797
+ scopes: [TokenScope2.MEDIA_READ],
1798
+ path: grant.key,
1799
+ expiresAt: requestedExpiresAt
1800
+ });
1801
+ if (!decision.allow) return decision.response;
1802
+ const credential = { id: authorized.parsed.grantId, secretHash: authorized.secretHash };
1803
+ const spent = await callGrants(stub, DownloadGrantPath.ISSUE, { ...credential, ...parsed });
1804
+ if (!spent.ok) return relayGrantFailure(spent);
1805
+ const expiresAt = cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs);
1806
+ const created = await storeLink(context, namespaces.links, {
1807
+ storageKey: grant.storageKey,
1808
+ key: grant.key,
1809
+ expiresAt,
1810
+ maxUses: parsed.maxUses,
1811
+ ...parsed.filename ? { filename: parsed.filename } : {},
1812
+ storagePrefix: grant.storagePrefix,
1813
+ ...grant.context ? { context: grant.context } : {},
1814
+ grantId: authorized.publicId,
1815
+ ...parsed.metadata ? { metadata: parsed.metadata } : {}
1816
+ });
1817
+ if (!created) {
1818
+ await callGrants(stub, DownloadGrantPath.REFUND, { id: credential.id });
1819
+ return context.json({ error: "Could not create the download link" }, 500);
1820
+ }
1821
+ return context.json({ ...created, expiresAt, maxUses: parsed.maxUses });
1822
+ }
1823
+ async function handleDeleteFile(context) {
1824
+ const authorized = await authorizeGrant(context, "delete-file");
1825
+ if (authorized instanceof Response) return authorized;
1826
+ const { grant, stub, publicId } = authorized;
1827
+ if (!grant.canDelete) {
1828
+ return grantError(403, DownloadGrantErrorCode2.CANNOT_DELETE, "This download grant can't delete its file");
1829
+ }
1830
+ const deleted = await deleteMediaKey(context, grant.key);
1831
+ if (deleted.status !== 204) return deleted;
1832
+ await callGrants(stub, DownloadGrantPath.REVOKE_ALL);
1833
+ addRouteResult(context, {
1834
+ grantDeletedKey: grant.key,
1835
+ revokedGrant: { publicId, grant: { ...grant, revokedAt: Date.now() }, by: "bearer" }
1836
+ });
1837
+ return deleted;
1838
+ }
1839
+ async function handleRevoke(context) {
1840
+ const namespaces = grantNamespaces(context);
1841
+ if (!namespaces) return notConfigured2(context);
1842
+ const bearer = await readBearer(context, namespaces.grants);
1843
+ if (bearer instanceof Response) return bearer;
1844
+ const revoked = await callGrants(bearer.stub, DownloadGrantPath.REVOKE, {
1845
+ id: bearer.parsed.grantId,
1846
+ secretHash: bearer.secretHash
1847
+ });
1848
+ if (!revoked.ok) return relayGrantFailure(revoked);
1849
+ const grant = await revoked.json();
1850
+ if (grant.storagePrefix === getRequestState(context).storagePrefix) {
1851
+ addRouteResult(context, { revokedGrant: { publicId: bearer.publicId, grant, by: "bearer" } });
1852
+ }
1853
+ return new Response(null, { status: 204 });
1854
+ }
1855
+ var grantBearer = new Hono3().get("/file", handleHead).post("/links", handleIssueLink).delete("/file", handleDeleteFile).delete("/", handleRevoke);
1856
+
1857
+ // src/routes/grant-owner.ts
1858
+ import { DownloadGrantLimits as DownloadGrantLimits2, relativeMediaPath as relativeMediaPath2, TokenScope as TokenScope3 } from "@devultur/core";
1859
+ import { Hono as Hono4 } from "hono";
1860
+ var GRANT_DELETE_SCOPE = "media:delete";
1861
+ var invalid = (code, error) => ({ error, code });
1862
+ function parseLinkCaps(link) {
1863
+ const maxExpiresIn = parseCount(
1864
+ link?.maxExpiresIn,
1865
+ DownloadGrantLimits2.DEFAULT_LINK_EXPIRES_IN,
1866
+ DownloadGrantLimits2.MAX_LINK_EXPIRES_IN
1867
+ );
1868
+ if (maxExpiresIn === null) {
1869
+ return invalid("invalid_link_caps", `link.maxExpiresIn must be 1..${DownloadGrantLimits2.MAX_LINK_EXPIRES_IN}`);
1870
+ }
1871
+ const maxUses = parseCount(
1872
+ link?.maxUses,
1873
+ DownloadGrantLimits2.DEFAULT_LINK_MAX_USES,
1874
+ DownloadGrantLimits2.MAX_LINK_MAX_USES
1875
+ );
1876
+ if (maxUses === null) {
1877
+ return invalid("invalid_link_caps", `link.maxUses must be 1..${DownloadGrantLimits2.MAX_LINK_MAX_USES}`);
1878
+ }
1879
+ return { maxExpiresIn, maxUses };
1880
+ }
1881
+ function parseGrantRequest(body) {
1882
+ const key = typeof body.key === "string" ? body.key : "";
1883
+ if (relativeMediaPath2(key) === null) return invalid("invalid_key", "key must be a visibility-prefixed media key");
1884
+ const expiresIn = body.expiresIn === void 0 ? void 0 : parseCount(body.expiresIn, 0, Number.MAX_SAFE_INTEGER);
1885
+ if (expiresIn === null) return invalid("invalid_expires_in", "expiresIn must be a positive number of seconds");
1886
+ if (body.link !== void 0 && (typeof body.link !== "object" || body.link === null)) {
1887
+ return invalid("invalid_link_caps", "link must be an object");
1888
+ }
1889
+ const link = parseLinkCaps(body.link);
1890
+ if ("error" in link) return link;
1891
+ const maxLinks = parseCount(body.maxLinks, DownloadGrantLimits2.DEFAULT_MAX_LINKS, DownloadGrantLimits2.MAX_MAX_LINKS);
1892
+ if (maxLinks === null)
1893
+ return invalid("invalid_max_links", `maxLinks must be 1..${DownloadGrantLimits2.MAX_MAX_LINKS}`);
1894
+ if (body.canDelete !== void 0 && typeof body.canDelete !== "boolean") {
1895
+ return invalid("invalid_can_delete", "canDelete must be a boolean");
1896
+ }
1897
+ const metadata = parseMetadata(body.metadata, DownloadGrantLimits2.MAX_METADATA_BYTES);
1898
+ if (metadata === null) {
1899
+ return invalid(
1900
+ "invalid_metadata",
1901
+ `metadata must map strings to strings, ${DownloadGrantLimits2.MAX_METADATA_BYTES} bytes at most`
1902
+ );
1903
+ }
1904
+ return {
1905
+ key,
1906
+ ...expiresIn ? { expiresIn } : {},
1907
+ link,
1908
+ maxLinks,
1909
+ canDelete: body.canDelete === true,
1910
+ ...metadata ? { metadata } : {}
1911
+ };
1912
+ }
1913
+ function grantExpiry(expiresIn, fileExpiresAt, now) {
1914
+ const cap = fileExpiresAt ?? now + DownloadGrantLimits2.MAX_LIFETIME_SECONDS * 1e3;
1915
+ return expiresIn === void 0 ? cap : Math.min(now + expiresIn * 1e3, cap);
1916
+ }
1917
+ async function handleCreateGrant(context) {
1918
+ const namespaces = grantNamespaces(context);
1919
+ if (!namespaces) return notConfigured2(context);
1920
+ const parsed = parseGrantRequest(await context.req.json().catch(() => null) ?? {});
1921
+ if ("error" in parsed) return context.json(parsed, 400);
1922
+ const fullKey = storageKey(context, parsed.key);
1923
+ const object = await context.env.MEDIA_BUCKET.head(fullKey);
1924
+ if (!object) return context.json({ error: "Not found" }, 404);
1925
+ if (isObjectExpired(object.customMetadata)) return expiredResponse();
1926
+ const requestedExpiresAt = grantExpiry(parsed.expiresIn, objectExpiresAt(object.customMetadata), Date.now());
1927
+ const decision = await decideCredential(context, {
1928
+ kind: "download-grant",
1929
+ scopes: parsed.canDelete ? [TokenScope3.MEDIA_READ, GRANT_DELETE_SCOPE] : [TokenScope3.MEDIA_READ],
1930
+ path: parsed.key,
1931
+ expiresAt: requestedExpiresAt
1932
+ });
1933
+ if (!decision.allow) return decision.response;
1934
+ const { objectId, stub } = fileGrantsObject(namespaces.grants, fullKey);
1935
+ const grantId = newGrantId();
1936
+ const secret = newGrantSecret();
1937
+ const scope = getRequestState(context);
1938
+ const record = {
1939
+ id: grantId,
1940
+ secretHash: await hashGrantSecret(secret),
1941
+ storageKey: fullKey,
1942
+ key: parsed.key,
1943
+ version: object.version,
1944
+ storagePrefix: scope.storagePrefix,
1945
+ expiresAt: cappedExpiry(requestedExpiresAt, decision.expiresAt, (epochMs) => epochMs),
1946
+ link: parsed.link,
1947
+ maxLinks: parsed.maxLinks,
1948
+ canDelete: parsed.canDelete,
1949
+ ...parsed.metadata ? { metadata: parsed.metadata } : {},
1950
+ ...scope.context ? { context: scope.context } : {},
1951
+ createdAt: Date.now()
1952
+ };
1953
+ const created = await callGrants(stub, DownloadGrantPath.CREATE, record);
1954
+ if (!created.ok) return context.json({ error: "Could not create the download grant" }, 500);
1955
+ const publicId = formatGrantPublicId({ objectId, grantId });
1956
+ const { secretHash: _secretHash, ...info } = record;
1957
+ addRouteResult(context, { issuedGrant: { publicId, grant: { ...info, linksIssued: 0, revokedAt: null } } });
1958
+ const grant = {
1959
+ grant: formatGrantSecret({ objectId, grantId, secret }),
1960
+ id: publicId,
1961
+ key: record.key,
1962
+ expiresAt: record.expiresAt,
1963
+ link: record.link,
1964
+ maxLinks: record.maxLinks,
1965
+ canDelete: record.canDelete,
1966
+ ...record.metadata ? { metadata: record.metadata } : {}
1967
+ };
1968
+ return context.json(grant);
1969
+ }
1970
+ async function handleListGrants(context) {
1971
+ const namespaces = grantNamespaces(context);
1972
+ if (!namespaces) return notConfigured2(context);
1973
+ const key = context.req.query("key") ?? "";
1974
+ if (relativeMediaPath2(key) === null) {
1975
+ return context.json({ error: "key must be a visibility-prefixed media key", code: "invalid_key" }, 400);
1976
+ }
1977
+ const { objectId, stub } = fileGrantsObject(namespaces.grants, storageKey(context, key));
1978
+ const listed = await callGrants(stub, DownloadGrantPath.LIST);
1979
+ if (!listed.ok) return context.json({ error: "Could not list the download grants" }, 500);
1980
+ const grants = await listed.json();
1981
+ return context.json(grants.map((grant) => grantListEntry(objectId, grant)));
1982
+ }
1983
+ var grantNotFound = (context) => context.json({ error: "Download grant not found", code: "grant_not_found" }, 404);
1984
+ async function handleRevokeGrant(context) {
1985
+ const namespaces = grantNamespaces(context);
1986
+ if (!namespaces) return notConfigured2(context);
1987
+ const publicId = context.req.param("id") ?? "";
1988
+ const location = parseGrantPublicId(publicId);
1989
+ const stub = location ? grantsObjectById(namespaces.grants, location.objectId) : null;
1990
+ if (!location || !stub) return grantNotFound(context);
1991
+ const found = await callGrants(stub, DownloadGrantPath.GET, { id: location.grantId });
1992
+ if (!found.ok) return grantNotFound(context);
1993
+ const grant = await found.json();
1994
+ if (grant.storagePrefix !== getRequestState(context).storagePrefix) return grantNotFound(context);
1995
+ const refusal2 = await gateGrantAccess(context, { action: "owner-revoke", grant });
1996
+ if (refusal2) return refusal2;
1997
+ const revoked = await callGrants(stub, DownloadGrantPath.REVOKE, { id: location.grantId });
1998
+ if (!revoked.ok) return context.json({ error: "Could not revoke the download grant" }, 500);
1999
+ addRouteResult(context, {
2000
+ revokedGrant: { publicId, grant: await revoked.json(), by: "owner" }
2001
+ });
2002
+ return new Response(null, { status: 204 });
2003
+ }
2004
+ var grantOwner = new Hono4().post("/", handleCreateGrant).get("/", handleListGrants).delete("/:id", handleRevokeGrant);
2005
+
1277
2006
  // src/plugins/download-links.ts
1278
2007
  function downloadLinks() {
1279
- const issue = new Hono3().post("/", handleDownloadLink);
2008
+ const issue = new Hono5().post("/", handleDownloadLink);
1280
2009
  return {
1281
2010
  name: "download-links",
1282
2011
  routes: [
1283
2012
  { path: "/v1/token/download", app: issue },
1284
- { path: "/v1/download", app: download }
2013
+ { path: "/v1/download", app: download },
2014
+ { path: "/v1/token/grant", app: grantOwner },
2015
+ { path: "/v1/grant", app: grantBearer }
1285
2016
  ]
1286
2017
  };
1287
2018
  }
@@ -1336,7 +2067,7 @@ import {
1336
2067
  } from "@devultur/core";
1337
2068
 
1338
2069
  // src/routes/viewer-token-claims.ts
1339
- import { coversScope, isWithinPathPrefix, relativeMediaPath as relativeMediaPath2, TokenScope as TokenScope2 } from "@devultur/core";
2070
+ import { coversScope, isWithinPathPrefix, relativeMediaPath as relativeMediaPath3, TokenScope as TokenScope4 } from "@devultur/core";
1340
2071
  var TOKEN_REJECTED_CODE = "token_rejected";
1341
2072
  function isOtherAudience(payload, storagePrefix2) {
1342
2073
  if (payload.aud === void 0) return false;
@@ -1345,12 +2076,12 @@ function isOtherAudience(payload, storagePrefix2) {
1345
2076
  function isOutsideResource(payload, key) {
1346
2077
  if (payload.res === void 0) return false;
1347
2078
  if (typeof payload.res !== "string") return true;
1348
- const relativePath = relativeMediaPath2(key);
2079
+ const relativePath = relativeMediaPath3(key);
1349
2080
  return relativePath === null || !isWithinPathPrefix(relativePath, payload.res);
1350
2081
  }
1351
2082
  function lacksReadScope(payload) {
1352
2083
  const scopes = readTokenScopes(payload);
1353
- return scopes !== void 0 && !coversScope(scopes, TokenScope2.MEDIA_READ);
2084
+ return scopes !== void 0 && !coversScope(scopes, TokenScope4.MEDIA_READ);
1354
2085
  }
1355
2086
  function rejectionReason(payload, target) {
1356
2087
  if (isOtherAudience(payload, target.storagePrefix)) return "audience";
@@ -1368,7 +2099,7 @@ function viewerTokenClaimRejection(payload, target) {
1368
2099
  var MAX_FILE_TTL_SECONDS = 365 * 24 * 60 * 60;
1369
2100
  var MAX_FILES_COUNTER_TTL_SECONDS = 24 * 60 * 60;
1370
2101
  var MAX_FILES_PREFIX = "upload-token-files";
1371
- var failure = (status, code, error) => ({
2102
+ var failure2 = (status, code, error) => ({
1372
2103
  status,
1373
2104
  code,
1374
2105
  error
@@ -1384,12 +2115,12 @@ function generatedKindPath(mediaType, id, filename) {
1384
2115
  }
1385
2116
  function tokenRejected(reason, path) {
1386
2117
  console.warn(`[upload] token rejected (${reason}) for path ${String(path)}`);
1387
- return failure(403, TOKEN_REJECTED_CODE, "Token rejected");
2118
+ return failure2(403, TOKEN_REJECTED_CODE, "Token rejected");
1388
2119
  }
1389
2120
  function assetTarget(rawPath, visibility, grant) {
1390
2121
  const assetPath = normalizeAssetPath(rawPath);
1391
2122
  if (!assetPath) {
1392
- return failure(400, "invalid_path", "path must be a relative path of [A-Za-z0-9._-] segments, without `..`");
2123
+ return failure2(400, "invalid_path", "path must be a relative path of [A-Za-z0-9._-] segments, without `..`");
1393
2124
  }
1394
2125
  const relativePath = assetRelativePath(assetPath);
1395
2126
  if (grant && !isWithinPathPrefix2(relativePath, grant.pathPrefix)) return tokenRejected("resource", relativePath);
@@ -1398,7 +2129,7 @@ function assetTarget(rawPath, visibility, grant) {
1398
2129
  }
1399
2130
  function resolveUploadTarget(body, mediaType, visibility, grant) {
1400
2131
  if (grant && body.path === void 0) {
1401
- return failure(400, "path_required", "an upload token can only create files at an explicit `path`");
2132
+ return failure2(400, "path_required", "an upload token can only create files at an explicit `path`");
1402
2133
  }
1403
2134
  if (grant && body.visibility && body.visibility !== grant.visibility) return tokenRejected("visibility", body.path);
1404
2135
  if (body.path !== void 0) return assetTarget(body.path, grant?.visibility ?? visibility, grant);
@@ -1409,7 +2140,7 @@ function resolveExpiresAt(requested, grant, now, maxTtlSeconds) {
1409
2140
  const hasRequest = requested !== void 0 && requested !== null;
1410
2141
  const isValid = typeof requested === "number" && Number.isInteger(requested) && requested > 0;
1411
2142
  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)");
2143
+ return failure2(400, "invalid_expires_in", "expiresIn must be a positive integer number of seconds (max 1 year)");
1413
2144
  }
1414
2145
  const candidates = [hasRequest ? requested : void 0, grant?.fileExpiresIn, maxTtlSeconds];
1415
2146
  const ttls = candidates.filter((ttl) => typeof ttl === "number" && ttl > 0);
@@ -1417,7 +2148,7 @@ function resolveExpiresAt(requested, grant, now, maxTtlSeconds) {
1417
2148
  }
1418
2149
  function resolveDeclaredSha256(raw) {
1419
2150
  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");
2151
+ return normalizeSha2562(raw) ?? failure2(400, "invalid_sha256", "sha256 must be a 64-character hex string");
1421
2152
  }
1422
2153
  function effectiveMaxBytes(ruleMax, grant) {
1423
2154
  const limits = [ruleMax, grant?.maxBytes].filter((limit) => typeof limit === "number");
@@ -1428,7 +2159,7 @@ async function claimTokenFileSlot(kv, grant) {
1428
2159
  const counterKey = `${MAX_FILES_PREFIX}:${grant.tokenId}`;
1429
2160
  const used = Number(await kv.get(counterKey) ?? "0");
1430
2161
  if (used >= grant.maxFiles) {
1431
- return failure(403, "max_files_reached", `the upload token already created ${grant.maxFiles} files`);
2162
+ return failure2(403, "max_files_reached", `the upload token already created ${grant.maxFiles} files`);
1432
2163
  }
1433
2164
  await kv.put(counterKey, String(used + 1), { expirationTtl: MAX_FILES_COUNTER_TTL_SECONDS });
1434
2165
  return null;
@@ -1436,7 +2167,7 @@ async function claimTokenFileSlot(kv, grant) {
1436
2167
  function resolveObjectCacheControl(requested, target, rules) {
1437
2168
  if (!target.isAsset || !target.assetPath) return void 0;
1438
2169
  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");
2170
+ return resolveCacheControl(requested) ?? failure2(400, "invalid_cache_control", "cacheControl must be immutable, revalidate, no-cache or a valid header");
1440
2171
  }
1441
2172
  return matchCacheRule(rules, target.assetPath)?.cacheControl ?? DEFAULT_ASSET_CACHE_CONTROL;
1442
2173
  }
@@ -1449,10 +2180,10 @@ function inferMediaType(contentType) {
1449
2180
  function resolveUploadMediaType(declared, contentType, isAssetRequest) {
1450
2181
  const inferredType = inferMediaType(contentType);
1451
2182
  if (declared && inferredType && declared !== inferredType) {
1452
- return failure(400, "media_type_mismatch", `mediaType "${declared}" does not match content type ${contentType}`);
2183
+ return failure2(400, "media_type_mismatch", `mediaType "${declared}" does not match content type ${contentType}`);
1453
2184
  }
1454
2185
  const mediaType = declared ?? inferredType ?? (isAssetRequest ? MediaType.FILE : null);
1455
- return mediaType ?? failure(400, "unsupported_content_type", `Unsupported content type: ${contentType}`);
2186
+ return mediaType ?? failure2(400, "unsupported_content_type", `Unsupported content type: ${contentType}`);
1456
2187
  }
1457
2188
 
1458
2189
  // src/routes/token-upload.ts
@@ -1560,6 +2291,7 @@ var WorkerAuthErrorCode = {
1560
2291
  var MEDIA_PREFIX = "/v1/media/";
1561
2292
  var DOWNLOAD_PREFIX = "/v1/download/";
1562
2293
  var WEBHOOKS_PREFIX = "/v1/webhooks/";
2294
+ var GRANT_PATH = "/v1/grant";
1563
2295
  var UPLOAD_CREATE_PATH = "/v1/upload";
1564
2296
  var UPLOAD_URL_PREFIX = "/v1/upload/";
1565
2297
  var isReadMethod = (method) => method === "GET" || method === "HEAD";
@@ -1568,6 +2300,7 @@ function isOpenRoute(method, path) {
1568
2300
  if (path === "/health") return isReadMethod(method);
1569
2301
  if (path === "/v1/metrics/playback") return method === "POST";
1570
2302
  if (path.startsWith(MEDIA_PREFIX) || path.startsWith(DOWNLOAD_PREFIX)) return isReadMethod(method);
2303
+ if (path === GRANT_PATH || path.startsWith(`${GRANT_PATH}/`)) return true;
1571
2304
  return path.startsWith(WEBHOOKS_PREFIX) && method === "POST";
1572
2305
  }
1573
2306
  function isUploadUrl(path) {
@@ -1631,7 +2364,7 @@ function defaultPlugins(config = {}) {
1631
2364
 
1632
2365
  // src/routes/assets.ts
1633
2366
  import { ASSET_NAMESPACE, normalizeAssetPrefix as normalizeAssetPrefix2 } from "@devultur/core";
1634
- import { Hono as Hono4 } from "hono";
2367
+ import { Hono as Hono6 } from "hono";
1635
2368
 
1636
2369
  // src/object-lock/delivery.ts
1637
2370
  import { effectiveUntil as effectiveUntil3 } from "@devultur/core";
@@ -1738,7 +2471,7 @@ async function listedEntries(bucket, listing, storageRoot, visibility) {
1738
2471
  }
1739
2472
  return page;
1740
2473
  }
1741
- var assets = new Hono4();
2474
+ var assets = new Hono6();
1742
2475
  assets.get("/", async (context) => {
1743
2476
  const prefix = parseListPrefix(context.req.query("prefix"));
1744
2477
  if (prefix === null) {
@@ -1774,7 +2507,7 @@ import {
1774
2507
  resolveSecurity,
1775
2508
  visibilityFromKey
1776
2509
  } from "@devultur/core";
1777
- import { Hono as Hono6 } from "hono";
2510
+ import { Hono as Hono8 } from "hono";
1778
2511
 
1779
2512
  // src/keys-bucket.ts
1780
2513
  import { decodeEncryptionMeta as decodeEncryptionMeta3 } from "@devultur/core";
@@ -1852,70 +2585,6 @@ async function applyStatusEvent(kv, event) {
1852
2585
  await kv.put(statusKey(videoId), JSON.stringify(record), { expirationTtl: KV_JOB_TTL_SECONDS });
1853
2586
  }
1854
2587
 
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
2588
  // src/object-lock/overwrite.ts
1920
2589
  function sameObject(object) {
1921
2590
  return {
@@ -2059,7 +2728,7 @@ function logCaptionsError(context, error) {
2059
2728
  }
2060
2729
 
2061
2730
  // src/routes/webhooks.ts
2062
- import { Hono as Hono5 } from "hono";
2731
+ import { Hono as Hono7 } from "hono";
2063
2732
 
2064
2733
  // src/transcode/metrics.ts
2065
2734
  var OPERATOR_EVENT_FIELDS = ["encode", "posterKey", "posterFormats"];
@@ -2083,9 +2752,9 @@ function perTitleMetrics(encode) {
2083
2752
  ...encodeParams ? { encodeParams } : {}
2084
2753
  };
2085
2754
  }
2086
- var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2755
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2087
2756
  function parseEncodeMetrics(event) {
2088
- if (!isRecord2(event) || event.event !== "transcode.complete" || !isRecord2(event.encode)) return null;
2757
+ if (!isRecord(event) || event.event !== "transcode.complete" || !isRecord(event.encode)) return null;
2089
2758
  const { encode } = event;
2090
2759
  if (!isNonNegativeNumber(encode.seconds) || !isNonNegativeNumber(event.duration)) return null;
2091
2760
  return {
@@ -2178,7 +2847,7 @@ async function loadOutputBaseUrl(kv, videoId, fallbackUrl) {
2178
2847
  }
2179
2848
 
2180
2849
  // src/routes/webhooks.ts
2181
- var webhooks = new Hono5();
2850
+ var webhooks = new Hono7();
2182
2851
  webhooks.post("/transcode", async (context) => {
2183
2852
  const webhookSecret = context.env.TRANSCODE_WEBHOOK_SECRET;
2184
2853
  if (!webhookSecret) {
@@ -2400,7 +3069,7 @@ async function transcodedDuration(kv, videoId) {
2400
3069
  const meta = await kv.get(`${KvPrefix.TRANSCODE_META}:${videoId}`, "json");
2401
3070
  return meta?.duration ?? void 0;
2402
3071
  }
2403
- var captions = new Hono6();
3072
+ var captions = new Hono8();
2404
3073
  captions.post("/", async (context) => {
2405
3074
  const body = await context.req.json();
2406
3075
  if (!body.key || !body.locales?.length) {
@@ -2511,19 +3180,19 @@ captions.post("/", async (context) => {
2511
3180
  });
2512
3181
  } catch (err) {
2513
3182
  logCaptionsError(`job failed for ${videoId}`, err);
2514
- const failure4 = toCaptionsFailure(err);
3183
+ const failure5 = toCaptionsFailure(err);
2515
3184
  await forwardToClientWebhook(
2516
3185
  {
2517
3186
  event: "captions.failed",
2518
3187
  videoId,
2519
- error: failure4.error,
2520
- reason: failure4.reason,
2521
- retriable: failure4.retriable
3188
+ error: failure5.error,
3189
+ reason: failure5.reason,
3190
+ retriable: failure5.retriable
2522
3191
  },
2523
3192
  webhookData
2524
3193
  );
2525
3194
  const conflict = captionConflictResponse(context, err);
2526
- return conflict ?? context.json({ error: failure4.error, code: failure4.code }, failure4.status);
3195
+ return conflict ?? context.json({ error: failure5.error, code: failure5.code }, failure5.status);
2527
3196
  }
2528
3197
  });
2529
3198
  captions.get("/:transcriptId", async (context) => {
@@ -2552,7 +3221,7 @@ import {
2552
3221
  toPrivateCacheControl,
2553
3222
  visibilityFromKey as visibilityFromKey2
2554
3223
  } from "@devultur/core";
2555
- import { Hono as Hono7 } from "hono";
3224
+ import { Hono as Hono9 } from "hono";
2556
3225
 
2557
3226
  // src/image/cloudflare.ts
2558
3227
  var IMAGE_RESIZING_VIA = "image-resizing";
@@ -2964,52 +3633,6 @@ function cacheDelivered(waitUntil, target, delivered) {
2964
3633
  }
2965
3634
  }
2966
3635
 
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
3636
  // src/routes/storyboard-vtt.ts
3014
3637
  import { createDecryptStream as createDecryptStream3 } from "@devultur/core";
3015
3638
  var STORYBOARD_VTT = /(?:^|\/)video\/[^/]+\/storyboard\/[^/]+\.vtt$/;
@@ -3038,7 +3661,7 @@ function downloadDisposition(storedDisposition, key, filename) {
3038
3661
  if (storedDisposition?.startsWith("inline")) return `attachment${storedDisposition.slice("inline".length)}`;
3039
3662
  return contentDispositionAttachment(key.split("/").pop() || "download");
3040
3663
  }
3041
- var media = new Hono7();
3664
+ var media = new Hono9();
3042
3665
  media.use("*", contentSafety);
3043
3666
  media.get("/:key{.+}", async (context) => {
3044
3667
  const key = context.req.param("key");
@@ -3237,10 +3860,10 @@ async function serveEncryptionKey(context, key, tokenPayload) {
3237
3860
  }
3238
3861
 
3239
3862
  // src/routes/token.ts
3240
- import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as TokenScope3 } from "@devultur/core";
3241
- import { Hono as Hono8 } from "hono";
3863
+ import { computeFingerprint as computeFingerprint2, DEFAULT_SECURITY, TokenScope as TokenScope5 } from "@devultur/core";
3864
+ import { Hono as Hono10 } from "hono";
3242
3865
  var DEFAULT_VIEWER_TOKEN_TTL_SECONDS = 7200;
3243
- var token = new Hono8();
3866
+ var token = new Hono10();
3244
3867
  token.post("/", async (context) => {
3245
3868
  const body = await context.req.json();
3246
3869
  if (!body.userId) {
@@ -3253,7 +3876,7 @@ token.post("/", async (context) => {
3253
3876
  );
3254
3877
  }
3255
3878
  const clientIp = context.req.header("CF-Connecting-IP") ?? context.req.header("X-Forwarded-For") ?? "unknown";
3256
- const requestedScopes = body.scopes ?? [TokenScope3.MEDIA_READ];
3879
+ const requestedScopes = body.scopes ?? [TokenScope5.MEDIA_READ];
3257
3880
  if (!Array.isArray(requestedScopes) || hasInvalidScope(requestedScopes)) {
3258
3881
  return context.json({ error: "scopes must be non-empty strings without spaces", code: "invalid_scopes" }, 400);
3259
3882
  }
@@ -3301,7 +3924,7 @@ import {
3301
3924
  generateId as generateId2,
3302
3925
  resolveSecurity as resolveSecurity3,
3303
3926
  resolveTranscodePreset,
3304
- TokenScope as TokenScope4,
3927
+ TokenScope as TokenScope6,
3305
3928
  TranscodeStatus as TranscodeStatus3,
3306
3929
  videoBasePath as videoBasePath2,
3307
3930
  videoHlsPath,
@@ -3311,7 +3934,7 @@ import {
3311
3934
  videoThumbnailPath,
3312
3935
  visibilityFromKey as visibilityFromKey3
3313
3936
  } from "@devultur/core";
3314
- import { Hono as Hono9 } from "hono";
3937
+ import { Hono as Hono11 } from "hono";
3315
3938
 
3316
3939
  // src/transcode/plan.ts
3317
3940
  import { MAIN_VARIANT } from "@devultur/core";
@@ -3586,7 +4209,7 @@ function postMachine(request, retry) {
3586
4209
  retry
3587
4210
  );
3588
4211
  }
3589
- async function failure2(response, error) {
4212
+ async function failure3(response, error) {
3590
4213
  const detail = await response.text();
3591
4214
  const status = response.status === 429 || response.status >= 500 ? 503 : 500;
3592
4215
  console.error(`[transcode] fly: ${error} (status ${response.status}):`, detail);
@@ -3598,7 +4221,7 @@ async function createFlyMachine(request, retry) {
3598
4221
  const errorBody = await response.text();
3599
4222
  const isSuspended = response.status === 412 || errorBody.includes("suspended");
3600
4223
  if (!isSuspended) {
3601
- return failure2(new Response(errorBody, { status: response.status }), "Failed to create transcode job");
4224
+ return failure3(new Response(errorBody, { status: response.status }), "Failed to create transcode job");
3602
4225
  }
3603
4226
  if (!await resumeFlyApp(request.appName, request.apiToken)) {
3604
4227
  console.error("[transcode] fly: failed to resume suspended app:", errorBody);
@@ -3612,7 +4235,7 @@ async function createFlyMachine(request, retry) {
3612
4235
  }
3613
4236
  const retried = await postMachine(request, retry);
3614
4237
  if (retried.ok) return { ok: true, machine: await retried.json() };
3615
- return failure2(retried, "Failed to create transcode job after resume");
4238
+ return failure3(retried, "Failed to create transcode job after resume");
3616
4239
  }
3617
4240
  function createFlyRunner(env, retry) {
3618
4241
  const guest = flyGuestFromEnv(env);
@@ -4250,7 +4873,7 @@ function parseSecurityMeta2(raw) {
4250
4873
  }
4251
4874
  var SERVICE_TOKEN_TTL_SECONDS = 3600;
4252
4875
  var isVideoId = (videoId, key) => Boolean(videoId) && videoId !== key;
4253
- var transcode = new Hono9();
4876
+ var transcode = new Hono11();
4254
4877
  transcode.post("/", async (context) => {
4255
4878
  const body = await context.req.json();
4256
4879
  if (!body.key) {
@@ -4310,7 +4933,7 @@ transcode.post("/", async (context) => {
4310
4933
  const requestedExp = nowSeconds + SERVICE_TOKEN_TTL_SECONDS;
4311
4934
  const decision = await decideCredential(context, {
4312
4935
  kind: "service",
4313
- scopes: [TokenScope4.MEDIA_READ],
4936
+ scopes: [TokenScope6.MEDIA_READ],
4314
4937
  path: body.key,
4315
4938
  expiresAt: requestedExp * 1e3
4316
4939
  });
@@ -4320,7 +4943,7 @@ transcode.post("/", async (context) => {
4320
4943
  {
4321
4944
  sub: `${SERVICE_SUBJECT_PREFIX}${videoId}`,
4322
4945
  typ: SERVICE_TOKEN_TYPE,
4323
- ...scopeClaim([TokenScope4.MEDIA_READ]),
4946
+ ...scopeClaim([TokenScope6.MEDIA_READ]),
4324
4947
  ...audienceClaim(scope.storagePrefix),
4325
4948
  bind: { sessionBinding: false, ipBinding: false },
4326
4949
  iat: nowSeconds,
@@ -4442,16 +5065,16 @@ transcode.get("/:jobId", async (context) => {
4442
5065
 
4443
5066
  // src/routes/upload.ts
4444
5067
  import { MediaType as MediaType2, resolveSecurity as resolveSecurity5, resolveVisibility, UploadRequestKind as UploadRequestKind2 } from "@devultur/core";
4445
- import { Hono as Hono10 } from "hono";
5068
+ import { Hono as Hono12 } from "hono";
4446
5069
 
4447
5070
  // src/object-lock/upload-lock.ts
4448
5071
  import { canChange as canChange3, resolveLock, validateLock as validateLock6 } from "@devultur/core";
4449
- var failure3 = (status, code, error) => ({ status, code, error });
5072
+ var failure4 = (status, code, error) => ({ status, code, error });
4450
5073
  function requestedLock(lock, hold) {
4451
- if (hold !== void 0 && typeof hold !== "boolean") return failure3(400, "invalid_lock", "hold must be a boolean");
5074
+ if (hold !== void 0 && typeof hold !== "boolean") return failure4(400, "invalid_lock", "hold must be a boolean");
4452
5075
  if (lock === void 0 && hold === void 0) return void 0;
4453
5076
  const validation = validateLock6(lock ?? {});
4454
- if (!validation.ok) return failure3(400, "invalid_lock", validation.errors.join("; "));
5077
+ if (!validation.ok) return failure4(400, "invalid_lock", validation.errors.join("; "));
4455
5078
  return { ...validation.lock, ...hold !== void 0 ? { hold } : {} };
4456
5079
  }
4457
5080
  function satisfiesMinimum(minimum, lock, now) {
@@ -4464,10 +5087,10 @@ function resolveUploadLock(input) {
4464
5087
  if (requested && "code" in requested) return requested;
4465
5088
  const minimum = input.grant?.lock;
4466
5089
  const resolution = resolveLock(requested ?? minimum, input.appDefault);
4467
- if (!resolution.ok) return failure3(400, "invalid_lock", resolution.errors.join("; "));
5090
+ if (!resolution.ok) return failure4(400, "invalid_lock", resolution.errors.join("; "));
4468
5091
  const lock = resolution.lock;
4469
5092
  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");
5093
+ return failure4(403, "lock_below_minimum", "The upload token requires a stricter lock than the request asks for");
4471
5094
  }
4472
5095
  return lock.retention || lock.hold ? lock : void 0;
4473
5096
  }
@@ -4910,20 +5533,11 @@ function uploadPendingIdOf(uploadUrl) {
4910
5533
  if (id === null) return void 0;
4911
5534
  return PENDING_ID_PATTERN.test(id) ? id : null;
4912
5535
  }
4913
- var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4914
- function withLegacyFieldNames(pending) {
4915
- const { callerContext, objectMetadata, ...current } = pending;
4916
- return {
4917
- ...current,
4918
- ...current.context === void 0 && isRecord3(callerContext) ? { context: callerContext } : {},
4919
- ...current.uploadMetadata === void 0 && isRecord3(objectMetadata) ? { uploadMetadata: objectMetadata } : {}
4920
- };
4921
- }
4922
5536
  function parsePending(raw) {
4923
5537
  if (!raw) return null;
4924
5538
  try {
4925
5539
  const pending = JSON.parse(raw);
4926
- return pending?.storageKey ? withLegacyFieldNames(pending) : null;
5540
+ return pending?.storageKey ? pending : null;
4927
5541
  } catch {
4928
5542
  return null;
4929
5543
  }
@@ -5175,7 +5789,7 @@ async function handleMultipartAbort(context) {
5175
5789
  }
5176
5790
 
5177
5791
  // src/routes/upload.ts
5178
- var upload = new Hono10();
5792
+ var upload = new Hono12();
5179
5793
  upload.post("/", async (context) => {
5180
5794
  const body = await context.req.json();
5181
5795
  const isAssetRequest = body.path !== void 0;
@@ -5658,7 +6272,7 @@ async function retainedSummary(options) {
5658
6272
  // src/index.ts
5659
6273
  function createWorker(options) {
5660
6274
  const { middleware, routes, hooks } = composePlugins(options?.plugins ?? defaultPlugins());
5661
- const app = new Hono11();
6275
+ const app = new Hono13();
5662
6276
  app.use("*", logger());
5663
6277
  app.use(
5664
6278
  "*",
@@ -5666,7 +6280,17 @@ function createWorker(options) {
5666
6280
  origin: "*",
5667
6281
  allowMethods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
5668
6282
  allowHeaders: ["Content-Type", "Authorization", "Range", SESSION_HEADER2, SHA256_HEADER3],
5669
- exposeHeaders: [SHA256_HEADER3, SHA256_VERIFIED_HEADER2, REPR_DIGEST_HEADER2, EXPIRES_AT_HEADER2, "Content-Range"]
6283
+ exposeHeaders: [
6284
+ SHA256_HEADER3,
6285
+ SHA256_VERIFIED_HEADER2,
6286
+ REPR_DIGEST_HEADER2,
6287
+ EXPIRES_AT_HEADER2,
6288
+ ENCRYPTED_HEADER2,
6289
+ GRANT_EXPIRES_AT_HEADER2,
6290
+ GRANT_KEY_HEADER2,
6291
+ GRANT_METADATA_HEADER2,
6292
+ "Content-Range"
6293
+ ]
5670
6294
  })
5671
6295
  );
5672
6296
  app.use("*", async (context, next) => {
@@ -5694,6 +6318,7 @@ function createWorker(options) {
5694
6318
  return app;
5695
6319
  }
5696
6320
  export {
6321
+ DownloadGrants,
5697
6322
  DownloadLink,
5698
6323
  ENCRYPTION_KEY_MISSING_CODE,
5699
6324
  HOLD_PAGE_SIZE,
@@ -5724,6 +6349,7 @@ export {
5724
6349
  getRequestState,
5725
6350
  getRouteResult,
5726
6351
  hashKey,
6352
+ identifyDownloadGrant,
5727
6353
  imageOriginAuthorization,
5728
6354
  isAuthenticated,
5729
6355
  isImageOriginRequest,