@devultur/self-host 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,10 @@ compatibility_date = "2026-10-01"
30
30
  binding = "MEDIA_BUCKET"
31
31
  bucket_name = "my-media"
32
32
 
33
+ [[r2_buckets]]
34
+ binding = "KEYS_BUCKET" # per-object encryption keys: required when security.encrypt is on
35
+ bucket_name = "my-media-keys"
36
+
33
37
  [[kv_namespaces]]
34
38
  binding = "MEDIA_KV" # pending uploads and transcode job state
35
39
  id = "<your KV namespace id>"
@@ -44,13 +48,16 @@ new_sqlite_classes = ["DownloadLink"]
44
48
  ```
45
49
 
46
50
  ```bash
51
+ wrangler r2 bucket create my-media
52
+ wrangler r2 bucket create my-media-keys # keep it private and backed up
47
53
  wrangler secret put DELIVERY_SIGN_SECRET # signs viewer tokens and links
48
54
  wrangler secret put WORKER_AUTH_SECRET # your backend's bearer on control routes
49
55
  wrangler secret put TRANSCODE_WEBHOOK_SECRET # signs transcode callbacks
50
56
  wrangler deploy
51
57
  ```
52
58
 
53
- Without its secrets the Worker fails closed: control routes answer `503`. Call it from your backend with
59
+ Without its secrets the Worker fails closed: control routes answer `503`. With encryption at rest on and no
60
+ `KEYS_BUCKET`, private writes answer `503 keys_bucket_missing` instead of storing plaintext. Call it from your backend with
54
61
  [`@devultur/server`](https://www.npmjs.com/package/@devultur/server). The Worker is built from plugins:
55
62
  `createWorker({ plugins })` takes your rules, your own auth, hooks before a credential is issued, and routes and
56
63
  middleware of your own.
@@ -61,6 +68,8 @@ middleware of your own.
61
68
  [github.com/devultur/devultur/blob/main/docs/guides/self-host.md](https://github.com/devultur/devultur/blob/main/docs/guides/self-host.md).
62
69
  - Plugins (auth, request state, hooks, example plugins):
63
70
  [github.com/devultur/devultur/blob/main/docs/guides/plugins.md](https://github.com/devultur/devultur/blob/main/docs/guides/plugins.md).
71
+ - Retention and holds (compliance and governance locks, holds, audit, caveats):
72
+ [github.com/devultur/devultur/blob/main/docs/guides/object-lock.md](https://github.com/devultur/devultur/blob/main/docs/guides/object-lock.md).
64
73
 
65
74
  ## License
66
75
 
@@ -187,9 +187,9 @@ async function encryptForStorage(keysBucket, objectStorageKey, body, keyMetadata
187
187
  const { cryptoKey, iv, enc } = await createObjectEncryption(keysBucket, objectStorageKey, keyMetadata);
188
188
  return { body: await encryptBytes(body, cryptoKey, iv), enc };
189
189
  }
190
- function shouldEncryptAtRest(policy, key, hasKeysBucket) {
190
+ function shouldEncryptAtRest(policy, key) {
191
191
  const isHlsAsset = key.endsWith(".ts") || key.endsWith(".m3u8");
192
- return Boolean(policy.encrypt) && visibilityFromKey(key) === "private" && !isHlsAsset && hasKeysBucket;
192
+ return Boolean(policy.encrypt) && visibilityFromKey(key) === "private" && !isHlsAsset;
193
193
  }
194
194
  async function loadStorageKey(keysBucket, objectStorageKey, keyId) {
195
195
  if (!keysBucket) return null;
@@ -262,7 +262,8 @@ async function signJobTicket(secret, job, nowMs = Date.now()) {
262
262
  jti: runId,
263
263
  iat: issuedAt,
264
264
  exp: issuedAt + JOB_TICKET_TTL_SECONDS,
265
- ...job.context ? { context: job.context } : {}
265
+ ...job.context ? { context: job.context } : {},
266
+ ...job.location ? { loc: { root: job.location.root, folder: job.location.folder } } : {}
266
267
  },
267
268
  ticketKey(secret)
268
269
  );
@@ -279,6 +280,12 @@ async function verifyJobTicket(secret, ticket, videoId) {
279
280
  ...isRecord(claims.context) ? { context: claims.context } : {}
280
281
  };
281
282
  }
283
+ async function verifyJobLocation(secret, ticket, videoId) {
284
+ const claims = await verifyViewerToken(ticket, ticketKey(secret));
285
+ if (claims?.typ !== JOB_TICKET_TYPE || claims.sub !== videoId || !isRecord(claims.loc)) return null;
286
+ const { root, folder } = claims.loc;
287
+ return typeof root === "string" && typeof folder === "string" ? { root, folder } : null;
288
+ }
282
289
 
283
290
  // src/transcode/runner.ts
284
291
  var TranscodeRunnerName = {
@@ -446,6 +453,7 @@ export {
446
453
  JOB_TICKET_HEADER,
447
454
  signJobTicket,
448
455
  verifyJobTicket,
456
+ verifyJobLocation,
449
457
  TranscodeRunnerName,
450
458
  RUN_SCRIPT_COMMAND,
451
459
  TranscodeLaunchErrorCode,
package/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import * as hono_types from 'hono/types';
2
2
  import { Context, MiddlewareHandler, Hono } from 'hono';
3
- import { Visibility, SecurityPolicy, CacheRule, TranscodeSettings, TranscodeOutputKeys, TranscodeOutputUrls } from '@devultur/core';
3
+ import { Visibility, ObjectLock, SecurityPolicy, CacheRule, TranscodeSettings, TranscodeOutputKeys, TranscodeOutputUrls } from '@devultur/core';
4
4
  import { T as TranscodeContainerNamespace } from './cloudflare-kZ7J3LQ2.js';
5
5
 
6
- /** What an upload token allows (claims `res`, `visibility`, `max_bytes`, `max_files`, `file_ttl`). */
6
+ /** What an upload token allows (claims `res`, `visibility`, `max_bytes`, `max_files`, `file_ttl`, `lock`). */
7
7
  interface UploadGrant {
8
8
  /** Token's `jti`: best-effort counter for `maxFiles`. */
9
9
  tokenId: string;
@@ -14,6 +14,8 @@ interface UploadGrant {
14
14
  maxFiles?: number;
15
15
  /** TTL (seconds) of each file uploaded with the token. */
16
16
  fileExpiresIn?: number;
17
+ /** Lock every file uploaded with the token gets at least (a request can make it stricter, never looser). */
18
+ lock?: ObjectLock;
17
19
  }
18
20
 
19
21
  /** A download link as the Worker hands it to plugins. */
@@ -40,6 +42,8 @@ interface RouteResult {
40
42
  redeemedLink?: DownloadLinkInfo;
41
43
  /** `transcode.complete` deleted the video's original (`config.transcode.deleteOriginalOnSuccess`). */
42
44
  originalDeleted?: boolean;
45
+ /** `transcode.complete` was asked to delete the original but kept it: it's locked (retention or hold). */
46
+ originalRetained?: boolean;
43
47
  /** The transcode callback carried a valid job ticket: the job it belongs to. */
44
48
  transcodeJob?: TranscodeJob;
45
49
  }
@@ -154,9 +158,14 @@ interface WorkerConfig {
154
158
  /**
155
159
  * R2 custom metadata added to every object the request uploads (also on the later credential-less PUT), for example
156
160
  * which app or user uploaded it. Keys the Worker owns (integrity `sha256*`, `expiresAt`, `security`, `enc`,
157
- * `derivedFrom`) are dropped from it.
161
+ * `derivedFrom`, `lock`) are dropped from it.
158
162
  */
159
163
  uploadMetadata?: Record<string, string>;
164
+ /**
165
+ * Default lock of an uploaded file: retention (`governance` or `compliance`, until a date or for a period) and hold.
166
+ * The upload request (or its upload token) overrides it. Unset: files aren't locked.
167
+ */
168
+ lock?: ObjectLock;
160
169
  transcode?: TranscodeConfig;
161
170
  }
162
171
  /** What an auth plugin established about the request's credential. */
@@ -209,6 +218,26 @@ declare function isAuthenticated(context: AnyContext$2): boolean;
209
218
  */
210
219
  declare function updateRequestState(context: AnyContext$2, patch: RequestStatePatch): RequestState;
211
220
 
221
+ declare const LOCK_AUDIT_PREFIX = "__lock-audit/";
222
+ type LockAuditAction = "set" | "extend" | "shorten" | "remove" | "hold" | "release" | "delete_rejected" | "overwrite_rejected" | "original_retained";
223
+ interface LockAuditRecord {
224
+ /** ISO time of the event. */
225
+ at: string;
226
+ action: LockAuditAction;
227
+ /** Virtual keys the event is about. */
228
+ keys: string[];
229
+ before: ObjectLock | null;
230
+ after: ObjectLock | null;
231
+ /** The request's plugin context (who the auth layer said is calling), or null. */
232
+ actor: PluginContext | null;
233
+ /** `applied`: the change landed. `rejected`: the Worker refused it. `skipped`: a delete didn't run (retained raw). */
234
+ outcome: "applied" | "rejected" | "skipped";
235
+ /** The change used the governance privilege (`bypassGovernance` approved by `authorizeLockChange`). */
236
+ bypass?: boolean;
237
+ /** Why it was rejected (a response code). */
238
+ reason?: string;
239
+ }
240
+
212
241
  /** What is being issued: a viewer token, an upload token, a download link or the transcode runner's token. */
213
242
  type CredentialKind = "viewer" | "upload" | "download-link" | "service";
214
243
  interface CredentialRequest {
@@ -242,10 +271,39 @@ type AnyContext$1 = Context<any>;
242
271
  type IssueCredentialHook = (context: AnyContext$1, request: CredentialRequest) => CredentialDecision | Promise<CredentialDecision>;
243
272
  /** A response refuses the redemption (returned as is, and the use is NOT spent); `null` lets it go on. */
244
273
  type BeforeLinkRedeemHook = (context: AnyContext$1, link: DownloadLinkInfo) => Response | null | Promise<Response | null>;
274
+ /**
275
+ * What a lock change does: `set` a first (or expired) retention, `extend` it, `shorten` or `remove` it; `hold` or
276
+ * `release` a hold.
277
+ */
278
+ type LockChangeAction = "set" | "extend" | "shorten" | "remove" | "hold" | "release";
279
+ interface LockChangeRequest {
280
+ action: LockChangeAction;
281
+ /** The virtual keys it changes (for a bulk hold by prefix: this page of them). */
282
+ keys: string[];
283
+ /** A bulk hold by prefix: the prefix the request named. */
284
+ prefix?: string;
285
+ before: ObjectLock | null;
286
+ after: ObjectLock | null;
287
+ }
288
+ type LockChangeDecision = {
289
+ allow: false;
290
+ response: Response;
291
+ } | {
292
+ allow: true;
293
+ };
294
+ /**
295
+ * Before the Worker changes a lock. Refuse with a response, or allow. Approving a governance `shorten`/`remove` asked
296
+ * with `bypassGovernance` takes at least one of these hooks, and all of them must allow it (decision 4 of #3).
297
+ */
298
+ type AuthorizeLockChangeHook = (context: AnyContext$1, request: LockChangeRequest) => LockChangeDecision | Promise<LockChangeDecision>;
299
+ /** After a lock event (change, rejected delete or overwrite, retained original), to store the record elsewhere. */
300
+ type OnLockAuditHook = (context: AnyContext$1, record: LockAuditRecord) => void | Promise<void>;
245
301
  /** The hooks of every plugin, in plugin order. */
246
302
  interface PluginHooks {
247
303
  issueCredential: IssueCredentialHook[];
248
304
  beforeLinkRedeem: BeforeLinkRedeemHook[];
305
+ authorizeLockChange: AuthorizeLockChangeHook[];
306
+ onLockAudit: OnLockAuditHook[];
249
307
  }
250
308
 
251
309
  type AnyMiddleware = MiddlewareHandler<any>;
@@ -276,6 +334,10 @@ interface WorkerPlugin {
276
334
  issueCredential?: IssueCredentialHook;
277
335
  /** Before `GET /v1/download/{id}` spends a use of a link that still has one. */
278
336
  beforeLinkRedeem?: BeforeLinkRedeemHook;
337
+ /** Before `PUT /v1/lock` changes a lock: refuse it, or approve a governance bypass. */
338
+ authorizeLockChange?: AuthorizeLockChangeHook;
339
+ /** After every lock event, with its audit record. Runs after the response. */
340
+ onLockAudit?: OnLockAuditHook;
279
341
  }
280
342
  interface ComposedPlugins {
281
343
  middleware: AnyMiddleware[];
@@ -339,6 +401,11 @@ declare function isServiceToken(payload: Record<string, unknown> | null | undefi
339
401
  */
340
402
  declare function verifyViewerToken(token: string | undefined, secret: string): Promise<Record<string, unknown> | null>;
341
403
  declare function hashKey(key: string): Promise<string>;
404
+ /**
405
+ * Lists and batch-deletes every object under `prefix` (R2 deletes up to 1000 keys/call) and returns how many it
406
+ * deleted. Idempotent: empty prefix → 0. It bypasses object locks: routes delete through `guardedDeletePrefix` and
407
+ * `deleteKeysFor` (object-lock/guard.ts). Use this only for regenerable data (image variants) or after the guard ran.
408
+ */
342
409
  declare function deletePrefix(bucket: R2Bucket, prefix: string): Promise<number>;
343
410
 
344
411
  interface ImageTransformParams {
@@ -369,6 +436,45 @@ declare function readImageOriginScope(request: Request, secret: string | undefin
369
436
  /** Is this Cloudflare Images' signed subrequest? It's not delivery to the client: it isn't gated or counted. */
370
437
  declare function isImageOriginRequest(request: Request, secret: string | undefined): Promise<boolean>;
371
438
 
439
+ declare const KEYS_BUCKET_MISSING_CODE = "keys_bucket_missing";
440
+ declare const ENCRYPTION_KEY_MISSING_CODE = "encryption_key_missing";
441
+
442
+ /** Retention expiry (ISO) of a locked object. */
443
+ declare const LOCK_UNTIL_HEADER = "x-devultur-lock-until";
444
+ /** `true` while a hold is set. */
445
+ declare const LOCK_HOLD_HEADER = "x-devultur-lock-hold";
446
+
447
+ /** Prefixes the Worker keeps for lock sidecars and audit records: no route reads or writes a key under them. */
448
+ declare const RESERVED_LOCK_PREFIXES: readonly ["__lock/", "__lock-audit/"];
449
+
450
+ /** Objects changed per request: a list of keys may name at most this many; a prefix is walked this many at a time. */
451
+ declare const HOLD_PAGE_SIZE = 50;
452
+ interface HoldSkip {
453
+ key: string;
454
+ /**
455
+ * `not_found`, `unchanged` (already in that state), `lock_conflict` (changed concurrently: retry it) or
456
+ * `lock_record_unreadable` (a release of a lock record that can't be read: an operator must repair it).
457
+ */
458
+ reason: "not_found" | "unchanged" | "lock_conflict" | "lock_record_unreadable";
459
+ }
460
+
461
+ /** `customMetadata` key holding the lock set at upload (JSON `{ retention?, hold }`). Owned by the Worker. */
462
+ declare const LOCK_METADATA_KEY = "lock";
463
+
464
+ declare const LOCK_SIDECAR_PREFIX = "__lock/";
465
+ interface LockSidecar {
466
+ /** Increases by one on every change. */
467
+ revision: number;
468
+ /** The object version this lock applies to (see `objectVersionOf`). */
469
+ objectVersion: string;
470
+ lock: ObjectLock;
471
+ /** ISO time the hold was released (a `period` then counts from here). */
472
+ holdReleasedAt?: string;
473
+ /** ISO time of the last change. */
474
+ updatedAt: string;
475
+ }
476
+ type LockSource = "sidecar" | "upload" | "none";
477
+
372
478
  interface CredentialPolicyOptions {
373
479
  /** Longest lifetime, in seconds, per kind of credential. A longer request is shortened to it, not refused. */
374
480
  maxTtlSeconds?: Partial<Record<CredentialKind, number>>;
@@ -407,6 +513,11 @@ declare function workerSecretAuth(): WorkerPlugin;
407
513
  declare function defaultPlugins(config?: WorkerConfig): WorkerPlugin[];
408
514
 
409
515
  declare const MAX_ASSET_LIST_LIMIT = 1000;
516
+ /**
517
+ * Lock-record reads per page (one subrequest each, plus the listing's own): well under the Workers subrequest limit
518
+ * on any plan, whatever share of the page has expired.
519
+ */
520
+ declare const MAX_EXPIRED_LOOKUPS = 32;
410
521
  interface AssetListEntry {
411
522
  key: string;
412
523
  path: string;
@@ -417,17 +528,23 @@ interface AssetListEntry {
417
528
  uploaded: string;
418
529
  cacheControl: string | null;
419
530
  visibility: Visibility;
531
+ /** Past its `expiresAt` but still retained: listed (the owner can still read it), and not deleted. */
532
+ expired?: true;
533
+ /** The retention that keeps an expired entry (its effective expiry), when it has a date. */
534
+ retainedUntil?: string;
535
+ /** An expired entry kept by a hold. */
536
+ hold?: true;
420
537
  }
421
538
 
539
+ /** Has the object expired (TTL)? From then on we respond 410 even if the sweeper hasn't deleted it yet. Pure. */
540
+ declare function isObjectExpired(customMetadata: Record<string, string> | undefined, now?: number): boolean;
541
+
422
542
  /** The unit's tag: a hash (Cloudflare's tags are case-insensitive but paths aren't). */
423
543
  declare function edgeCacheTag(storagePrefix: string, virtualKey: string): Promise<string>;
424
544
 
425
545
  /** `Cache-Control` served: the one stored on the object (for private, no shared caches) or the one for a generated id. */
426
546
  declare function servedCacheControl(stored: string | undefined, isPrivate: boolean): string;
427
547
 
428
- /** Has the object expired (TTL)? From then on we respond 410 even if the sweeper hasn't deleted it yet. Pure. */
429
- declare function isObjectExpired(customMetadata: Record<string, string> | undefined, now?: number): boolean;
430
-
431
548
  declare const UPLOAD_TOKEN_TYPE = "upload";
432
549
  /** Short-lived: it's a JWT (an auth layer can also reject it if the credential that issued it is no longer valid). */
433
550
  declare const MAX_UPLOAD_TOKEN_TTL_SECONDS: number;
@@ -527,4 +644,4 @@ interface CreateWorkerOptions {
527
644
  }
528
645
  declare function createWorker(options?: CreateWorkerOptions): Hono<WorkerApp, hono_types.BlankSchema, "/">;
529
646
 
530
- export { type AllowedCredential, type AssetListEntry, type BeforeLinkRedeemHook, type ComposedPlugins, type CreateWorkerOptions, type CredentialDecision, type CredentialKind, type CredentialPolicyOptions, type CredentialRequest, DownloadLink, type DownloadLinkInfo, type DownloadLinkRecord, type DownloadLinkStorage, type EncodeMetrics, type ImageTransformParams, type IssueCredentialHook, type Json, MAX_ASSET_LIST_LIMIT, MAX_UPLOAD_TOKEN_TTL_SECONDS, MaxResolution, ObjectMeta, type PendingUploadInfo, type PluginContext, type PluginMiddleware, type PluginRoute, type RawStorageClass, type RequestAccess, type RequestState, type RequestStatePatch, type RouteResult, TOKEN_REJECTED_CODE, type TranscodeConfig, type TranscodeJob, UPLOAD_TOKEN_TYPE, type UploadGrant, type UploadRules, type UploadTypeRule, type WorkerApp, WorkerAuthErrorCode, type WorkerConfig, type WorkerEnv, type WorkerPlugin, type WorkerVariables, cancelPendingUpload, createWorker, credentialPolicy, defaultPlugins, deletePrefix, downloadLinks, edgeCacheTag, getPendingUpload, getRequestState, getRouteResult, hashKey, imageOriginAuthorization, isAuthenticated, isImageOriginRequest, isObjectExpired, isOpenRoute, isServiceToken, isUploadUrl, isVariantKey, logEncodeMetrics, parseEncodeMetrics, parseTransformParams, readImageOriginScope, readStoryboardKey, readTokenScopes, readTranscodeOutputKeys, readTranscodeOutputs, servedCacheControl, staticConfig, storageKey, updateRequestState, uploadGrantFromClaims, variantPrefix, vcpuSecondsPerVideoMinute, verifyViewerToken, withOutputUrls, workerSecretAuth };
647
+ export { type AllowedCredential, type AssetListEntry, type AuthorizeLockChangeHook, type BeforeLinkRedeemHook, type ComposedPlugins, type CreateWorkerOptions, type CredentialDecision, type CredentialKind, type CredentialPolicyOptions, type CredentialRequest, DownloadLink, type DownloadLinkInfo, type DownloadLinkRecord, type DownloadLinkStorage, ENCRYPTION_KEY_MISSING_CODE, type EncodeMetrics, HOLD_PAGE_SIZE, type HoldSkip, type ImageTransformParams, type IssueCredentialHook, type Json, KEYS_BUCKET_MISSING_CODE, LOCK_AUDIT_PREFIX, LOCK_HOLD_HEADER, LOCK_METADATA_KEY, LOCK_SIDECAR_PREFIX, LOCK_UNTIL_HEADER, type LockAuditAction, type LockAuditRecord, type LockChangeAction, type LockChangeDecision, type LockChangeRequest, type LockSidecar, type LockSource, MAX_ASSET_LIST_LIMIT, MAX_EXPIRED_LOOKUPS, MAX_UPLOAD_TOKEN_TTL_SECONDS, MaxResolution, ObjectMeta, type OnLockAuditHook, type PendingUploadInfo, type PluginContext, type PluginMiddleware, type PluginRoute, RESERVED_LOCK_PREFIXES, type RawStorageClass, type RequestAccess, type RequestState, type RequestStatePatch, type RouteResult, TOKEN_REJECTED_CODE, type TranscodeConfig, type TranscodeJob, UPLOAD_TOKEN_TYPE, type UploadGrant, type UploadRules, type UploadTypeRule, type WorkerApp, WorkerAuthErrorCode, type WorkerConfig, type WorkerEnv, type WorkerPlugin, type WorkerVariables, cancelPendingUpload, createWorker, credentialPolicy, defaultPlugins, deletePrefix, downloadLinks, edgeCacheTag, getPendingUpload, getRequestState, getRouteResult, hashKey, imageOriginAuthorization, isAuthenticated, isImageOriginRequest, isObjectExpired, isOpenRoute, isServiceToken, isUploadUrl, isVariantKey, logEncodeMetrics, parseEncodeMetrics, parseTransformParams, readImageOriginScope, readStoryboardKey, readTokenScopes, readTranscodeOutputKeys, readTranscodeOutputs, servedCacheControl, staticConfig, storageKey, updateRequestState, uploadGrantFromClaims, variantPrefix, vcpuSecondsPerVideoMinute, verifyViewerToken, withOutputUrls, workerSecretAuth };