@devultur/self-host 1.4.3 → 1.5.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/README.md +2 -0
- package/docs/guides/object-lock.md +517 -0
- package/docs/guides/plugins.md +205 -0
- package/docs/guides/self-host.md +523 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -71,6 +71,8 @@ middleware of your own.
|
|
|
71
71
|
- Retention and holds (compliance and governance locks, holds, audit, caveats):
|
|
72
72
|
[github.com/devultur/devultur/blob/main/docs/guides/object-lock.md](https://github.com/devultur/devultur/blob/main/docs/guides/object-lock.md).
|
|
73
73
|
|
|
74
|
+
The three guides ship with the package, for the installed version, in `node_modules/@devultur/self-host/docs/guides/`.
|
|
75
|
+
|
|
74
76
|
## License
|
|
75
77
|
|
|
76
78
|
[AGPL-3.0-only](https://github.com/devultur/devultur/blob/main/packages/self-host/LICENSE)
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# Retention and holds
|
|
2
|
+
|
|
3
|
+
Keep a file from being deleted or overwritten until a date, or until you say so (WORM storage). The media Worker
|
|
4
|
+
enforces it on every route that deletes or replaces stored bytes.
|
|
5
|
+
|
|
6
|
+
- [The model](#the-model)
|
|
7
|
+
- [Compliance and governance](#compliance-and-governance)
|
|
8
|
+
- [What's enforced, and where](#whats-enforced-and-where)
|
|
9
|
+
- [Derived files](#derived-files)
|
|
10
|
+
- [Transcoding a locked video](#transcoding-a-locked-video)
|
|
11
|
+
- [Locking at upload](#locking-at-upload)
|
|
12
|
+
- [Retention vs expiry](#retention-vs-expiry)
|
|
13
|
+
- [Changing a lock](#changing-a-lock)
|
|
14
|
+
- [Holds](#holds)
|
|
15
|
+
- [Audit](#audit)
|
|
16
|
+
- [Deleting a prefix from your own code](#deleting-a-prefix-from-your-own-code)
|
|
17
|
+
- [Caveats](#caveats)
|
|
18
|
+
- [Recipes](#recipes)
|
|
19
|
+
|
|
20
|
+
## The model
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
type ObjectLock = {
|
|
24
|
+
retention?: {
|
|
25
|
+
mode: "governance" | "compliance";
|
|
26
|
+
until?: string; // a fixed date (ISO 8601 with a time zone)
|
|
27
|
+
period?: number; // or seconds from upload, or from the hold's release
|
|
28
|
+
derivatives?: "inherit" | "original-only"; // default "inherit"
|
|
29
|
+
};
|
|
30
|
+
hold?: boolean; // retained indefinitely while true
|
|
31
|
+
};
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **Retention** keeps the file until `until`, or for `period` seconds (at most 100 years). Set one of the two: passing
|
|
35
|
+
both is a `400`. A `period` counts from the upload, or from the moment a hold is released.
|
|
36
|
+
- **Hold** keeps the file indefinitely, whatever its retention says. Releasing it starts the retention's `period`.
|
|
37
|
+
If there's no retention, the file is free again.
|
|
38
|
+
|
|
39
|
+
If you know S3 Object Lock or Google Cloud Storage, it's the same model:
|
|
40
|
+
|
|
41
|
+
| Here | S3 Object Lock | Google Cloud Storage |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| `retention.mode: "compliance"` | Compliance mode | Object retention, `Locked` mode |
|
|
44
|
+
| `retention.mode: "governance"` | Governance mode (`s3:BypassGovernanceRetention`) | Object retention, `Unlocked` mode |
|
|
45
|
+
| `retention.until` | Retain until date | Retention expiration time |
|
|
46
|
+
| `hold` | Legal hold | Temporary hold |
|
|
47
|
+
| `hold` + `retention.period` | (no equivalent) | Event-based hold + retention period |
|
|
48
|
+
|
|
49
|
+
**Resolution order.** It works like `visibility` and the [security flags](https://github.com/devultur/devultur/blob/main/docs/guides/security.md): the resource's own lock,
|
|
50
|
+
then the Worker's default (`config.lock`), then no lock. `hold` resolves on its own. `retention` resolves as a whole:
|
|
51
|
+
its mode and its date come from the same level, so `until` and `period` never mix. `derivatives` falls back to the
|
|
52
|
+
default's, then to `inherit`. A resource can replace the default retention but can't drop it.
|
|
53
|
+
|
|
54
|
+
A past `until` is valid. It just means the file isn't locked any more, so a default with a fixed date keeps working
|
|
55
|
+
after that date passes.
|
|
56
|
+
|
|
57
|
+
## Compliance and governance
|
|
58
|
+
|
|
59
|
+
- **`compliance`**: nobody can delete or overwrite the file before its date, and the lock can only get stricter. Its
|
|
60
|
+
date can move later, it can't switch to `governance`, and it can't move from `inherit` to `original-only`. Not even
|
|
61
|
+
the Worker secret can shorten it.
|
|
62
|
+
- **`governance`**: the same, except that a privileged call can shorten or remove it. Privileged means the request
|
|
63
|
+
asks for `bypassGovernance: true` **and** an [`authorizeLockChange`](plugins.md#hooks) plugin approves it. Without
|
|
64
|
+
such a plugin, nothing can shorten a governance retention (`403 lock_privilege_required`).
|
|
65
|
+
|
|
66
|
+
Any lock can be made stricter without privilege: set a later date, a longer period, `inherit`, or a hold. An expired
|
|
67
|
+
retention can be replaced freely.
|
|
68
|
+
|
|
69
|
+
While a hold is set, a `period` can't be swapped for a fixed `until`. The period only starts at the release, so any
|
|
70
|
+
date could end before it: compliance refuses it, and governance needs the privilege. Release the hold first, or
|
|
71
|
+
extend the period.
|
|
72
|
+
|
|
73
|
+
## What's enforced, and where
|
|
74
|
+
|
|
75
|
+
The Worker checks the lock before it deletes or replaces anything:
|
|
76
|
+
|
|
77
|
+
| Path | A locked file |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `DELETE /v1/media/<key>` | `409 retention_active`. Its image variants and encryption keys stay too. |
|
|
80
|
+
| `DELETE /v1/media/videos/<id>` | `409 retention_active` when any of the video's originals is locked. Nothing is deleted. |
|
|
81
|
+
| Deleting a file's encryption keys | Refused the same way, since deleting them destroys the file (crypto-shredding) |
|
|
82
|
+
| `PUT /v1/upload/<key>`, multipart create and complete | `409 retention_active` when they would replace a locked file |
|
|
83
|
+
| `POST /v1/upload` with `overwrite: true` | `409 retention_active` before any bytes are sent |
|
|
84
|
+
| `POST /v1/captions` | `409 retention_active` for a locale that already exists on a locked video. A new locale is allowed. |
|
|
85
|
+
| `POST /v1/transcode` | `409 retention_active` for a re-transcode (see [Transcoding a locked video](#transcoding-a-locked-video)) |
|
|
86
|
+
| `transcode.complete` with `deleteOriginalOnSuccess` | The original is kept (see [below](#transcoding-a-locked-video)) |
|
|
87
|
+
|
|
88
|
+
A write to a key that doesn't exist yet is a create, not an overwrite, so it's always allowed.
|
|
89
|
+
|
|
90
|
+
Every write is conditional on the object the check saw. If the file changes between the check and the write (for
|
|
91
|
+
example, a lock lands meanwhile), R2 refuses the write and the Worker answers `409 write_conflict`. The Worker doesn't
|
|
92
|
+
retry, because the new object may be locked: retry the request and you get the current answer.
|
|
93
|
+
|
|
94
|
+
A refused delete or overwrite answers:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"error": "private/file/f1/deed.pdf is retained and can't be deleted",
|
|
99
|
+
"code": "retention_active",
|
|
100
|
+
"key": "private/file/f1/deed.pdf",
|
|
101
|
+
"mode": "compliance",
|
|
102
|
+
"until": "2031-01-01T00:00:00.000Z",
|
|
103
|
+
"hold": false
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`key` names the locked original. For a derived file (a caption, an HLS segment), that's the video's original. `mode`
|
|
108
|
+
is left out for a hold with no retention, and `until` is left out while a hold keeps a `period` from starting.
|
|
109
|
+
|
|
110
|
+
All the lock error codes:
|
|
111
|
+
|
|
112
|
+
| Status | `code` | When |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| `409` | `retention_active` | A delete or overwrite of a locked file |
|
|
115
|
+
| `409` | `write_conflict` | The file changed between the check and the write: retry |
|
|
116
|
+
| `409` | `compliance_extend_only` | A change that would weaken a compliance retention |
|
|
117
|
+
| `409` | `lock_conflict` | Another lock change landed first: retry |
|
|
118
|
+
| `409` | `lock_record_unreadable` | A change to a file whose lock record can't be read (see [Caveats](#caveats)) |
|
|
119
|
+
| `403` | `lock_privilege_required` | Shortening or removing a governance retention without an approved bypass |
|
|
120
|
+
| `403` | `lock_below_minimum` | An upload asks for a looser lock than its upload token requires |
|
|
121
|
+
| `400` | `invalid_lock`, `invalid_hold` | A malformed lock or hold request |
|
|
122
|
+
| `400` | `reserved_key` | A key under `__lock/` or `__lock-audit/`, which the Worker keeps for itself |
|
|
123
|
+
|
|
124
|
+
Owner reads (the Worker secret on `GET /v1/media/<key>`) also get `x-devultur-lock-until` and, while a hold is set,
|
|
125
|
+
`x-devultur-lock-hold: true`. An owner read never goes through the edge cache: it isn't answered from a public copy
|
|
126
|
+
and doesn't store one. Its `Cache-Control` is the private form of the file's own, so no shared cache keeps the lock
|
|
127
|
+
headers either.
|
|
128
|
+
|
|
129
|
+
## Derived files
|
|
130
|
+
|
|
131
|
+
A video's transcode outputs and captions live under its folder (`{visibility}/video/{id}/…`).
|
|
132
|
+
`retention.derivatives` decides whether they're retained with the original:
|
|
133
|
+
|
|
134
|
+
- **`inherit`** (the default): transcode outputs and captions can't be deleted or overwritten while the original is
|
|
135
|
+
locked. New outputs and new caption locales can still be added.
|
|
136
|
+
- **`original-only`**: only the uploaded file is retained. Its outputs and captions can be deleted or regenerated
|
|
137
|
+
freely. Deleting the whole video is still refused, since the original is in it.
|
|
138
|
+
|
|
139
|
+
Image variants and mezzanines are regenerable caches. They're never retained.
|
|
140
|
+
|
|
141
|
+
Derived files also expire with their original. An HLS playlist or segment, an MP4, a thumbnail, poster or storyboard
|
|
142
|
+
written by the transcode runner gets the earliest `expiresAt` of the video's raws, and a caption gets the one of the
|
|
143
|
+
file it was made from. Past it, they answer `410` like the original. The value is copied when the output is written
|
|
144
|
+
(the raw is deleted by `deleteOriginalOnSuccess` only after the transcode completes), so changing a raw's expiry
|
|
145
|
+
later doesn't reach outputs already written. The HLS encryption key (`.key`) has no expiry of its own: it's useless
|
|
146
|
+
without the segments.
|
|
147
|
+
|
|
148
|
+
## Transcoding a locked video
|
|
149
|
+
|
|
150
|
+
- **The first transcode** of a locked video is allowed, since it only creates outputs.
|
|
151
|
+
- **A transcode that never completed** (failed, or the runner died) can be retried, and the new job may overwrite
|
|
152
|
+
the partial outputs.
|
|
153
|
+
- **Once a transcode has completed**, re-transcoding is refused with `409 retention_active`, because it would
|
|
154
|
+
overwrite retained outputs. The Worker records the completion durably, under
|
|
155
|
+
`__lock/{visibility}/video/{id}/transcode-completed`.
|
|
156
|
+
- **While a job is in progress** (`queued` or `running`), the runner may rewrite its own outputs (HLS, MP4,
|
|
157
|
+
thumbnails, storyboard), for example to retry an upload. From `transcode.complete` on, they're protected like any
|
|
158
|
+
derived file.
|
|
159
|
+
|
|
160
|
+
**Deleting the original after transcode.** With `transcode.deleteOriginalOnSuccess`, the Worker deletes the original
|
|
161
|
+
once a transcode completes. A locked original wins: it's kept, the skip is audited (`original_retained`), and the
|
|
162
|
+
webhook still succeeds. `getRouteResult(context).originalRetained` tells a plugin it happened. The original keeps
|
|
163
|
+
taking storage until its lock ends, and in Infrequent Access (`originalStorageClass`) it also keeps that class's
|
|
164
|
+
30-day minimum and per-read retrieval charge. Once the lock ends, delete it yourself: `deleteOriginalOnSuccess`
|
|
165
|
+
doesn't come back for it.
|
|
166
|
+
|
|
167
|
+
## Locking at upload
|
|
168
|
+
|
|
169
|
+
An upload can carry its lock, so the file never exists unprotected. The lock is written with the bytes, and nothing
|
|
170
|
+
can strip it later (the Worker drops a `lock` key from `config.uploadMetadata`).
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
const client = createWorkerClient(secret, workerUrl);
|
|
174
|
+
|
|
175
|
+
await client.createUploadUrl({
|
|
176
|
+
filename: "deed.pdf",
|
|
177
|
+
contentType: "application/pdf",
|
|
178
|
+
lock: { retention: { mode: "compliance", period: 7 * 365 * 24 * 3600 } },
|
|
179
|
+
});
|
|
180
|
+
await client.createUploadUrl({ filename: "evidence.mp4", contentType: "video/mp4", hold: true });
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`hold: true` is shorthand for `lock: { hold: true }`. `uploadFile` and `uploads.uploadAsset` take the same options.
|
|
184
|
+
A Worker-wide default goes in `config.lock`:
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
export default createWorker({
|
|
188
|
+
plugins: defaultPlugins({ lock: { retention: { mode: "governance", period: 30 * 24 * 3600 } } }),
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Upload tokens** can pin a minimum lock: `uploads.createToken({ prefix, lock })`. An upload with that token gets at
|
|
193
|
+
least that lock. It can ask for a stricter one, never a looser one (`403 lock_below_minimum`).
|
|
194
|
+
|
|
195
|
+
**Expiry.** A lock may outlive the file's `expiresAt` (`expiresIn`, the token's or the config's TTL): see
|
|
196
|
+
[Retention vs expiry](#retention-vs-expiry).
|
|
197
|
+
|
|
198
|
+
**Uploads without a pending upload.** A `PUT /v1/upload/<key>` with the Worker secret and no `POST /v1/upload` before
|
|
199
|
+
it gets `config.lock` too, unless the key is a video's derived output (those follow their original through
|
|
200
|
+
`derivatives`).
|
|
201
|
+
|
|
202
|
+
Locks need the media Worker. In direct-storage mode, `createMediaRouter` throws when an upload asks for a lock,
|
|
203
|
+
because a presigned PUT to the bucket can't enforce one.
|
|
204
|
+
|
|
205
|
+
## Retention vs expiry
|
|
206
|
+
|
|
207
|
+
Retention and expiry are separate, as in S3 Object Lock. **Retention preserves** the object: it can't be deleted or
|
|
208
|
+
overwritten. **Expiry ends access** to it: past `expiresAt`, nobody but the owner reads it, and that covers a video's
|
|
209
|
+
transcode outputs and captions (see [Derived files](#derived-files)). A lock doesn't extend access, and an expiry
|
|
210
|
+
doesn't shorten a lock. A file can be shared for 30 days and kept for 10 years.
|
|
211
|
+
|
|
212
|
+
| State | Viewers, public requests, download links | The owner (Worker secret) | The object |
|
|
213
|
+
| --- | --- | --- | --- |
|
|
214
|
+
| Before `expiresAt` | Normal access | Normal access | Kept (and protected while locked) |
|
|
215
|
+
| After `expiresAt`, still retained | `410` | Can read it, to export or audit | Not deleted: delete and overwrite answer `409 retention_active` |
|
|
216
|
+
| After `expiresAt`, free | `410` | `410` | Stays in the bucket until your expiry cleanup deletes it |
|
|
217
|
+
|
|
218
|
+
- **Download links are access.** A link never outlives the file's `expiresAt`, even for a retained file. Creating one
|
|
219
|
+
for an expired file answers `410`, and redeeming one after the file expired does too.
|
|
220
|
+
- **Edge copies.** A public file's edge copy never outlives its `expiresAt`: its TTL is capped there, and a copy found
|
|
221
|
+
past it is ignored, so the request reaches the Worker and gets `410`.
|
|
222
|
+
- **Listings.** `GET /v1/assets` (an owner route) includes an expired file that's still retained, marked
|
|
223
|
+
`expired: true` with `retainedUntil` (its retention's end) or `hold: true`. An expired free file isn't listed.
|
|
224
|
+
Telling them apart reads each expired file's lock record (one subrequest each), so a page reads at most 32
|
|
225
|
+
(`MAX_EXPIRED_LOOKUPS`): past that, it ends early with a `cursor` that resumes right after its last entry. A page
|
|
226
|
+
can hold fewer entries than `limit` while more remain; keep following the `cursor` until it's `null`.
|
|
227
|
+
- **The expiry cleanup is yours to run.** Nothing in `@devultur/self-host` deletes expired files: the Worker only
|
|
228
|
+
refuses access to them. Run your own job (a cron Worker, a script) that deletes them through the Worker
|
|
229
|
+
(`DELETE /v1/media/<key>`, or `/v1/media/videos/<id>` for a video), so each delete goes through the lock check: a
|
|
230
|
+
retained file answers `409 retention_active` and is kept until its lock ends, then the job's next run deletes it.
|
|
231
|
+
Don't use an R2 lifecycle rule for it: it doesn't go through the Worker and deletes retained files too (see
|
|
232
|
+
[Caveats](#caveats)).
|
|
233
|
+
|
|
234
|
+
## Changing a lock
|
|
235
|
+
|
|
236
|
+
`GET /v1/lock/<key>` and `PUT /v1/lock/<key>` are control routes. The default auth accepts only the Worker secret.
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
await client.lock.get(key);
|
|
240
|
+
// { key, mode, until, period, hold, derivatives, source: "upload" | "sidecar" | "none", revision }
|
|
241
|
+
|
|
242
|
+
await client.lock.set(key, { until: "2032-01-01T00:00:00Z" }); // extend
|
|
243
|
+
await client.lock.set(key, { mode: "governance", period: 90 * 24 * 3600 }); // set on an unlocked file
|
|
244
|
+
await client.lock.set(key, { until: "2026-12-31T00:00:00Z", bypassGovernance: true }); // shorten (governance)
|
|
245
|
+
await client.lock.set(key, { remove: true, bypassGovernance: true }); // remove (governance)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The fields merge over the current retention, and `until` and `period` replace each other. `PUT` changes the retention
|
|
249
|
+
only. Holds have [their own route](#holds).
|
|
250
|
+
|
|
251
|
+
R2 can't edit an object's metadata in place, so a change goes to a small record at `{storagePrefix}__lock/<key>`. The
|
|
252
|
+
record is bound to the object version it was written for (R2's per-write `version`, never the etag, which repeats when
|
|
253
|
+
the same bytes are uploaded again). Deleting or overwriting the file leaves the record in place, inert: it never
|
|
254
|
+
applies to another version, so a file uploaded again at the same key starts from its own upload lock, and a lock
|
|
255
|
+
change made on the new file right after it landed can't be lost to a cleanup of the old record. A record of a deleted
|
|
256
|
+
file is a few hundred bytes and stays as evidence of its last lock. To reclaim them, delete only records whose file no
|
|
257
|
+
longer exists: deleting the record of an existing file drops every change made to its lock. Two concurrent changes
|
|
258
|
+
can't both land: the second gets `409 lock_conflict`.
|
|
259
|
+
|
|
260
|
+
## Holds
|
|
261
|
+
|
|
262
|
+
`POST /v1/lock/hold` is the one way to set or release a hold, including on a single file:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
await client.lock.hold([key]);
|
|
266
|
+
await client.lock.release([key]);
|
|
267
|
+
|
|
268
|
+
// Every page of a prefix, or a long list of keys
|
|
269
|
+
for await (const page of client.lock.holdAll({ prefix: "private/asset/case-17/" })) {
|
|
270
|
+
console.log(page.updated, page.skipped);
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
- A request names up to 50 keys (`HOLD_PAGE_SIZE`), or a prefix. A prefix is walked 50 objects per request, with a
|
|
275
|
+
`cursor`. Each object costs about four subrequests, so a page stays well under the Workers limit. `holdAll` and
|
|
276
|
+
`releaseAll` follow the cursor and split long key lists for you.
|
|
277
|
+
- The answer is `{ updated: string[], skipped: { key, reason }[], cursor? }`. The `reason` is `not_found`,
|
|
278
|
+
`unchanged` (already in that state), `lock_conflict` (changed concurrently: retry it) or `lock_record_unreadable`
|
|
279
|
+
(a release of a file whose lock record can't be read, see [Caveats](#caveats)).
|
|
280
|
+
- A hold never touches the retention, so it can only add protection. A hold on a compliance file is allowed, and its
|
|
281
|
+
date stays.
|
|
282
|
+
- **Releasing:** with a `retention.period`, the period starts now. With a fixed `until`, that date still applies.
|
|
283
|
+
With no retention, the file is free.
|
|
284
|
+
- Image variants under a prefix are skipped, since they're caches. The Worker's own records (`__lock/`,
|
|
285
|
+
`__lock-audit/`) are never listed or changed, whatever the prefix.
|
|
286
|
+
|
|
287
|
+
## Audit
|
|
288
|
+
|
|
289
|
+
Every lock event gets one record. That covers every change (including holds and releases), the lock set at upload,
|
|
290
|
+
every rejected delete or overwrite, and every original kept after a transcode.
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
interface LockAuditRecord {
|
|
294
|
+
at: string; // ISO time
|
|
295
|
+
action: "set" | "extend" | "shorten" | "remove" | "hold" | "release"
|
|
296
|
+
| "delete_rejected" | "overwrite_rejected" | "original_retained";
|
|
297
|
+
keys: string[];
|
|
298
|
+
before: ObjectLock | null;
|
|
299
|
+
after: ObjectLock | null;
|
|
300
|
+
actor: PluginContext | null; // the request's plugin context; for an upload, the uploader's
|
|
301
|
+
outcome: "applied" | "rejected" | "skipped";
|
|
302
|
+
bypass?: true; // the change used the governance privilege
|
|
303
|
+
reason?: string; // why it was rejected
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Each record goes to three places:
|
|
308
|
+
|
|
309
|
+
- a structured log line (`{"event":"lock.audit", …}`);
|
|
310
|
+
- one create-only object at `{storagePrefix}__lock-audit/{yyyy-mm-dd}/{ts}-{uuid}.json` in the media bucket;
|
|
311
|
+
- every plugin's [`onLockAudit`](plugins.md#hooks) hook, to store it elsewhere.
|
|
312
|
+
|
|
313
|
+
The object and the hooks run after the response. A failure there is logged, never surfaced. The audit isn't a general
|
|
314
|
+
access log: reads aren't recorded.
|
|
315
|
+
|
|
316
|
+
## Deleting a prefix from your own code
|
|
317
|
+
|
|
318
|
+
Code that runs in your Worker and deletes whole prefixes straight from the R2 binding (an app's teardown, a user's
|
|
319
|
+
data export cleanup, a sweeper for expired files) bypasses the routes' checks. `@devultur/self-host` exports the
|
|
320
|
+
guard's lock resolution for it, so it can skip retained files:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
import { deletePrefixRespectingLocks, readObjectLock, retainedSummary } from "@devultur/self-host";
|
|
324
|
+
|
|
325
|
+
// The lock in force on one stored object (its full R2 key), or null when there's no such object.
|
|
326
|
+
const lock = await readObjectLock({ mediaBucket: env.MEDIA_BUCKET }, "apps/a1/private/file/f1/deed.pdf");
|
|
327
|
+
// { locked: true, hold: false, until: "2031-01-01T00:00:00.000Z", mode: "compliance" }
|
|
328
|
+
|
|
329
|
+
// Delete what's free under a storage prefix, keep what's retained. Call again with `cursor` until it's gone.
|
|
330
|
+
let cursor: string | undefined;
|
|
331
|
+
do {
|
|
332
|
+
const result = await deletePrefixRespectingLocks({
|
|
333
|
+
mediaBucket: env.MEDIA_BUCKET,
|
|
334
|
+
keysBucket: env.KEYS_BUCKET,
|
|
335
|
+
prefix: "apps/a1/",
|
|
336
|
+
cursor,
|
|
337
|
+
});
|
|
338
|
+
// result: { deletedCount, deletedBytes, deletedKeyCount, retainedCount, retainedBytes, retained, skippedCount,
|
|
339
|
+
// cursor?, subrequests }
|
|
340
|
+
cursor = result.cursor;
|
|
341
|
+
} while (cursor);
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- **Keys are storage keys**: the full R2 keys, your storage prefix included. Nothing needs a request. The storage
|
|
345
|
+
prefix each key lives under (where its lock record is) is read from the key: everything before its first `public`
|
|
346
|
+
or `private` segment. Pass `root` when you walk a single storage prefix that has a segment named `public` or
|
|
347
|
+
`private`. One prefix can span several storage prefixes, each read on its own.
|
|
348
|
+
- **Keys it can't place are kept.** A key with no `public` or `private` segment (or none right after the `root` you
|
|
349
|
+
passed) isn't laid out like the Worker's, so its lock record can't be found. It's never deleted: it comes back in
|
|
350
|
+
`retained` with `unrecognized: true`. A key with a segment ending in `__lock` or `__lock-audit` (a lock record or
|
|
351
|
+
audit object, even under a storage prefix with no trailing `/`) is never touched, and is counted in `skippedCount`.
|
|
352
|
+
- **Same resolution as the routes.** The upload's lock, or the lock record bound to the stored version (a record left
|
|
353
|
+
by a replaced version is ignored). An unreadable record counts as held (`unreadable: true`). Each object's own lock
|
|
354
|
+
is checked on the version read (HEAD) right before its delete. A video's files inherit from its raws, and an image's
|
|
355
|
+
variants from the image, unless the lock is `original-only`. That inherited lock is read once and reused for at
|
|
356
|
+
most 50 objects or 5 seconds. `lockedBy` names the original whose lock it is.
|
|
357
|
+
- **What goes.** A free object goes the way `DELETE /v1/media/<key>` deletes it: with its encryption keys in
|
|
358
|
+
`KEYS_BUCKET` (except those of an upload in flight over it) and its variants'. A video folder left empty also loses
|
|
359
|
+
the rest of its keys written before the call started (the HLS key) and its transcode-completed marker, as with
|
|
360
|
+
`DELETE /v1/media/videos/<id>`, when the whole folder is under the prefix. `deletedBytes` adds up the sizes of the
|
|
361
|
+
versions deleted.
|
|
362
|
+
- **Real time only.** The delete takes no `now`: locks are judged by the real clock, so no option can make a retention
|
|
363
|
+
read as expired. `readObjectLock` and `retainedSummary` take a `now`, to ask what's retained at another time.
|
|
364
|
+
- **Cursors are bound to their prefix.** A cursor works only with the `prefix` it was returned for. One that's
|
|
365
|
+
malformed, from another prefix, or pointing outside it throws a `RangeError` before any bucket call.
|
|
366
|
+
- **What stays.** Retained files, with their variants, keys and lock records. The lock records of deleted files stay
|
|
367
|
+
too, inert, as with the routes. Nothing under `__lock/` or `__lock-audit/` is touched: once a storage prefix has
|
|
368
|
+
nothing retained left, delete those yourself if you're tearing it down.
|
|
369
|
+
- **Bounded.** A Worker invocation has a subrequest limit. Every bucket call is counted, and a call never makes more
|
|
370
|
+
than `maxSubrequests`. It reads everything an object needs before deleting any of it, and only deletes if the budget
|
|
371
|
+
left covers it, so it never stops halfway through an object. It returns a `cursor` to go on from (a video folder it
|
|
372
|
+
emptied is finished by the next call). The default, 40, fits under the Workers Free plan's 50 subrequests per
|
|
373
|
+
request and leaves room for your own calls. On a paid plan, raise it (to a few hundred, say) so each call gets
|
|
374
|
+
further. A bound too small to finish even one object throws a `RangeError`. `batchSize` (default 100, at most 1000)
|
|
375
|
+
sets how many objects it lists at a time. Run a large prefix over several invocations (a queue, or a cron that
|
|
376
|
+
keeps the cursor).
|
|
377
|
+
- **Not audited.** The call is your code, not a request, so no audit record or hook runs: log `retained` if you need a
|
|
378
|
+
trail. R2 deletes aren't conditional: a lock set between an object's check and its delete isn't seen. That gap is
|
|
379
|
+
milliseconds for the object's own lock, and up to 5 seconds for an inherited one (see [Caveats](#caveats)).
|
|
380
|
+
|
|
381
|
+
`retainedSummary({ mediaBucket, prefix, cursor?, now? })` answers "can this be deleted?" without deleting anything:
|
|
382
|
+
`{ scannedCount, retainedCount, retainedBytes, latestUntil?, hasHold, cursor?, subrequests }`, with the same bounds.
|
|
383
|
+
Add up the counts of each call. With `hasHold`, something stays until it's released, not until `latestUntil`.
|
|
384
|
+
|
|
385
|
+
## Caveats
|
|
386
|
+
|
|
387
|
+
- **The check/delete window.** R2 deletes aren't conditional, so a lock set in the milliseconds between the Worker's
|
|
388
|
+
check and its delete isn't seen. Writes are conditional, and protected.
|
|
389
|
+
- **Only the Worker enforces locks.** R2 lifecycle rules, the R2 dashboard, `wrangler r2`, and any S3 or binding
|
|
390
|
+
access that doesn't go through the Worker can delete a locked file. Treat locks as compliance only as long as nobody
|
|
391
|
+
else can write to the bucket. This isn't a certified WORM store.
|
|
392
|
+
- **R2 bucket locks** can be an optional second layer: an R2 bucket lock rule refuses deletes at the storage level.
|
|
393
|
+
Anyone who can edit the bucket's configuration can remove the rule, though, so it raises the bar rather than
|
|
394
|
+
closing it. [Self-host](self-host.md#r2-bucket-lock-defense-in-depth) has the safe prefixes and what
|
|
395
|
+
changes once a rule is on.
|
|
396
|
+
- **The runner credential.** While a transcode job is in progress, a request with the runner's credential may rewrite
|
|
397
|
+
that job's outputs on a locked video. With `workerSecretAuth()`, that credential is `WORKER_AUTH_SECRET`, which your
|
|
398
|
+
backend holds too. To limit the exemption to the runner, give the runner a narrower credential from your own auth
|
|
399
|
+
plugin (`updateRequestState(context, { runnerCredential })`) and accept it only for uploads of transcode outputs.
|
|
400
|
+
- **Direct storage** (`createMediaRouter` with your own bucket and no Worker) refuses locks: nothing would enforce
|
|
401
|
+
them.
|
|
402
|
+
- **`deletePrefix`**, exported by `@devultur/self-host`, deletes everything under a prefix and bypasses locks. Use it
|
|
403
|
+
only for regenerable data. The routes delete through the guarded helpers; your own code can use
|
|
404
|
+
[`deletePrefixRespectingLocks`](#deleting-a-prefix-from-your-own-code).
|
|
405
|
+
- **Listings show retention.** An expired, retained entry of `GET /v1/assets` carries `retainedUntil` and `hold`:
|
|
406
|
+
whoever can list a prefix learns what's retained in it. With `workerSecretAuth()`, listing takes the Worker secret,
|
|
407
|
+
the same credential as `GET /v1/lock`. An auth plugin of your own that lets a narrower credential list (a sync
|
|
408
|
+
script's, say) gives it that view too. If that credential shouldn't see what's retained, keep it off
|
|
409
|
+
`GET /v1/assets`.
|
|
410
|
+
- **An unreadable lock record** (the record at `__lock/<key>`, or the upload's `lock` metadata, isn't valid JSON or
|
|
411
|
+
isn't a valid lock) fails closed: the file reads as held, for whatever version is stored at the key. Since nobody can
|
|
412
|
+
tell what it really said (it might be a compliance retention), the API never weakens it: a release is skipped with
|
|
413
|
+
`lock_record_unreadable`, `PUT /v1/lock/<key>` answers `409 lock_record_unreadable`, and deletes and overwrites
|
|
414
|
+
answer `409 retention_active`. Recovery is an operator's job, outside the Worker: find the record's last good
|
|
415
|
+
content in the audit (the `after` of the key's last `applied` record under `__lock-audit/`), write it back to
|
|
416
|
+
`__lock/<key>` as `{ revision, objectVersion, lock, updatedAt }` with the current object's R2 `version` as
|
|
417
|
+
`objectVersion` (for bad upload metadata, `lock` is the lock it was uploaded with), and check it with
|
|
418
|
+
`GET /v1/lock/<key>`.
|
|
419
|
+
|
|
420
|
+
## Recipes
|
|
421
|
+
|
|
422
|
+
### Keep a record until N years after an event
|
|
423
|
+
|
|
424
|
+
The end date depends on an event that hasn't happened yet (a contract ends, a case closes). Upload with a hold and a
|
|
425
|
+
`period`, and release the hold when the event happens: the period starts then.
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
const SEVEN_YEARS = 7 * 365 * 24 * 3600;
|
|
429
|
+
|
|
430
|
+
// At upload: retained indefinitely until the event
|
|
431
|
+
const upload = await client.createUploadUrl({
|
|
432
|
+
filename: "record.pdf",
|
|
433
|
+
contentType: "application/pdf",
|
|
434
|
+
lock: { retention: { mode: "compliance", period: SEVEN_YEARS }, hold: true },
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// When the event happens: the 7 years start now
|
|
438
|
+
await client.lock.release([upload.key]);
|
|
439
|
+
|
|
440
|
+
// Or every file of the record at once
|
|
441
|
+
for await (const page of client.lock.releaseAll({ prefix: "private/asset/record-42/" })) {
|
|
442
|
+
if (page.skipped.length > 0) console.warn(page.skipped);
|
|
443
|
+
}
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Share for 30 days, keep for 10 years
|
|
447
|
+
|
|
448
|
+
Access ends after 30 days, and the file stays (undeletable) for 10 years:
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
await client.createUploadUrl({
|
|
452
|
+
filename: "statement.pdf",
|
|
453
|
+
contentType: "application/pdf",
|
|
454
|
+
expiresIn: 30 * 24 * 3600,
|
|
455
|
+
lock: { retention: { mode: "compliance", period: 10 * 365 * 24 * 3600 } },
|
|
456
|
+
});
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
After 30 days, viewers and download links get `410`. Your backend can still read the file with the Worker secret,
|
|
460
|
+
and `GET /v1/assets` lists it with `expired: true` and `retainedUntil`. Until the 10 years pass, your
|
|
461
|
+
[expiry cleanup](#retention-vs-expiry) gets `409 retention_active` for it; its first run after that deletes it.
|
|
462
|
+
|
|
463
|
+
### Compliance retention for N years from upload
|
|
464
|
+
|
|
465
|
+
```typescript
|
|
466
|
+
export default createWorker({
|
|
467
|
+
plugins: defaultPlugins({
|
|
468
|
+
lock: { retention: { mode: "compliance", period: 10 * 365 * 24 * 3600 } },
|
|
469
|
+
}),
|
|
470
|
+
});
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Every upload gets the lock, unless it asks for a stricter one. To scope it to some uploads, set the lock per upload
|
|
474
|
+
instead, or hand out [upload tokens](#locking-at-upload) with a minimum `lock`.
|
|
475
|
+
|
|
476
|
+
### Convex
|
|
477
|
+
|
|
478
|
+
The [Convex component](https://github.com/devultur/devultur/blob/main/packages/convex/README.md#object-lock) exposes the same operations to your own
|
|
479
|
+
functions. A Worker refusal arrives as a `ConvexError` whose `data.code` is the code above:
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
import { Devultur, devulturErrorOf } from "@devultur/convex";
|
|
483
|
+
import { v } from "convex/values";
|
|
484
|
+
import { components } from "./_generated/api";
|
|
485
|
+
import { action } from "./_generated/server";
|
|
486
|
+
|
|
487
|
+
const devultur = new Devultur(components.devultur);
|
|
488
|
+
|
|
489
|
+
export const extendRetention = action({
|
|
490
|
+
args: { key: v.string(), until: v.string() },
|
|
491
|
+
handler: async (ctx, { key, until }) => {
|
|
492
|
+
try {
|
|
493
|
+
return await devultur.setLock(ctx, key, { until });
|
|
494
|
+
} catch (error) {
|
|
495
|
+
const refusal = devulturErrorOf(error);
|
|
496
|
+
if (refusal?.code === "compliance_extend_only") return { ok: false, reason: "can only move later" } as const;
|
|
497
|
+
if (refusal?.code === "lock_conflict") return devultur.setLock(ctx, key, { until }); // changed meanwhile
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
export const deleteFile = action({
|
|
504
|
+
args: { key: v.string() },
|
|
505
|
+
handler: async (ctx, { key }) => {
|
|
506
|
+
try {
|
|
507
|
+
await ctx.runAction(components.devultur.public.deleteMediaAction, { key });
|
|
508
|
+
} catch (error) {
|
|
509
|
+
const refusal = devulturErrorOf(error);
|
|
510
|
+
// { key, mode, until, hold } of the locked original
|
|
511
|
+
if (refusal?.code === "retention_active") return { deleted: false, lock: refusal.details } as const;
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
return { deleted: true } as const;
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
```
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# Plugins
|
|
2
|
+
|
|
3
|
+
The media Worker is built from plugins. `createWorker()` is `createWorker({ plugins: defaultPlugins() })`: the
|
|
4
|
+
Worker secret auth, the rules of every request, the credential policy and download links are plugins that use the
|
|
5
|
+
same API as yours. Add plugins to extend the Worker, or replace a default one.
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { createWorker, defaultPlugins } from "@devultur/self-host";
|
|
9
|
+
import { usageLog } from "./plugins/usage-log";
|
|
10
|
+
|
|
11
|
+
export { DownloadLink } from "@devultur/self-host";
|
|
12
|
+
|
|
13
|
+
export default createWorker({
|
|
14
|
+
plugins: [...defaultPlugins({ transcode: { maxResolution: "1080p" } }), usageLog()],
|
|
15
|
+
});
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Default plugins
|
|
19
|
+
|
|
20
|
+
| Plugin | What it does |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `staticConfig(config)` | Applies the [rules](self-host.md#rules) to every request. Put it first: later plugins narrow it. |
|
|
23
|
+
| `workerSecretAuth()` | Control routes require `Authorization: Bearer <WORKER_AUTH_SECRET>`; the transcode runner uploads with the same secret. |
|
|
24
|
+
| `credentialPolicy(options?)` | Decides before a credential is issued. Without options it issues what is asked; `maxTtlSeconds: { viewer: 3600 }` caps a kind. |
|
|
25
|
+
| `downloadLinks()` | `POST /v1/token/download` and `GET /v1/download/<id>` (needs the `DOWNLOAD_LINKS` Durable Object). Without it both answer `404`. |
|
|
26
|
+
|
|
27
|
+
`createWorker()` refuses to start when no plugin authenticates requests, so dropping `workerSecretAuth()` without
|
|
28
|
+
an auth plugin of yours fails at deploy, not in production.
|
|
29
|
+
|
|
30
|
+
## Writing a plugin
|
|
31
|
+
|
|
32
|
+
A plugin is an object:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
interface WorkerPlugin {
|
|
36
|
+
name: string;
|
|
37
|
+
middleware?: {
|
|
38
|
+
beforeAuth?: MiddlewareHandler[];
|
|
39
|
+
auth?: MiddlewareHandler[];
|
|
40
|
+
afterAuth?: MiddlewareHandler[];
|
|
41
|
+
};
|
|
42
|
+
routes?: Array<{ path: string; app: Hono }>;
|
|
43
|
+
issueCredential?: (context: Context, request: CredentialRequest) => CredentialDecision | Promise<CredentialDecision>;
|
|
44
|
+
beforeLinkRedeem?: (context: Context, link: DownloadLinkInfo) => Response | null | Promise<Response | null>;
|
|
45
|
+
authorizeLockChange?: (context: Context, request: LockChangeRequest) => LockChangeDecision | Promise<LockChangeDecision>;
|
|
46
|
+
onLockAudit?: (context: Context, record: LockAuditRecord) => void | Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Middleware are [Hono](https://hono.dev) middleware. Every request goes through all plugins' `beforeAuth`, then
|
|
51
|
+
their `auth`, then their `afterAuth`, each phase in plugin order, and then the route:
|
|
52
|
+
|
|
53
|
+
- **`beforeAuth`**: work that doesn't need to know the caller, like rate limiting or an endpoint the plugin answers
|
|
54
|
+
itself.
|
|
55
|
+
- **`auth`**: who is calling and what the request may touch. At least one plugin needs it.
|
|
56
|
+
- **`afterAuth`**: authorization and per-request rules, and anything that reads `context.res` after `await next()`
|
|
57
|
+
(logs, metrics, cache purges).
|
|
58
|
+
|
|
59
|
+
`routes` are mounted after the Worker's own routes, behind every plugin's middleware.
|
|
60
|
+
|
|
61
|
+
## Request state
|
|
62
|
+
|
|
63
|
+
A plugin tells the routes about the current request with `updateRequestState(context, patch)`, and reads it with
|
|
64
|
+
`getRequestState(context)`. Top-level fields replace; `config`, `access` and `context` merge field by field.
|
|
65
|
+
|
|
66
|
+
| Field | What it does |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `storagePrefix` | Prefix of every storage key the request touches (`""` = the bucket root). Keeps each of your apps in its own folder. |
|
|
69
|
+
| `config` | The [rules](self-host.md#rules), field by field over `staticConfig` |
|
|
70
|
+
| `access.authenticated` | The credential was verified. Without it, an upload URL only acts on the upload an authenticated `POST /v1/upload` created. |
|
|
71
|
+
| `access.trustedRead` | Serve private media without a viewer token. Only set it for a credential sent in `Authorization`, never for a `?token=` query. |
|
|
72
|
+
| `access.uploadGrant` | The credential is an upload token: `POST /v1/upload` stays inside its prefix |
|
|
73
|
+
| `runnerCredential` | Bearer the transcode runner uploads its outputs with (`workerSecretAuth()` sets the Worker secret) |
|
|
74
|
+
| `webhookCredential` | Bearer sent to a job's `callbackUrl`. Unset: `runnerCredential`; `""`: none. |
|
|
75
|
+
| `context` | Plugin context: see below |
|
|
76
|
+
|
|
77
|
+
### Plugin context
|
|
78
|
+
|
|
79
|
+
`context` is any JSON a plugin attaches to the request. The Worker stores it with the uploads and download links the
|
|
80
|
+
request creates and in the transcode job, and hands it back when they're used: the upload PUT, a link redemption and
|
|
81
|
+
the runner's callbacks carry no credential, so it's how a plugin knows who started them. The Worker never reads it.
|
|
82
|
+
|
|
83
|
+
Type your plugin's keys by augmenting the interface:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
declare module "@devultur/self-host" {
|
|
87
|
+
interface PluginContext {
|
|
88
|
+
appId?: string;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
updateRequestState(context, { context: { appId: "web" } });
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Hooks
|
|
96
|
+
|
|
97
|
+
**`issueCredential`** runs before the Worker issues a viewer token, an upload token, a download link or the runner's
|
|
98
|
+
service token. It gets the `kind`, the `scopes`, the `path` it is bound to and the requested `expiresAt` (epoch ms).
|
|
99
|
+
Return `{ allow: false, response }` to refuse, or `{ allow: true, expiresAt?, claims? }` to issue it: `expiresAt`
|
|
100
|
+
caps the expiry, and `claims` are added to a token. The hooks of every plugin run in order: the first refusal wins,
|
|
101
|
+
the earliest `expiresAt` applies, and the claims of all of them are added.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
const viewerTokensOnlyRead: WorkerPlugin = {
|
|
105
|
+
name: "viewer-tokens-only-read",
|
|
106
|
+
issueCredential: (context, { kind, scopes }) => {
|
|
107
|
+
if (kind === "viewer" && scopes.some((scope) => scope !== "media:read")) {
|
|
108
|
+
return { allow: false, response: context.json({ error: "Viewer tokens only read" }, 403) };
|
|
109
|
+
}
|
|
110
|
+
return { allow: true };
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The Worker sets the claims that bound a token itself, and a plugin can't override them: `aud` is the storage
|
|
116
|
+
prefix it was issued under (without its trailing slash) and `scope` its space-separated scopes. The delivery gate
|
|
117
|
+
also enforces `res`, a path prefix its keys must fall under, when a plugin adds it. A token outside what its claims
|
|
118
|
+
allow gets `403 { "code": "token_rejected" }`; which claim failed is only logged.
|
|
119
|
+
|
|
120
|
+
**`beforeLinkRedeem`** runs before `GET /v1/download/<id>` spends a use of a link. Return a `Response` to refuse it
|
|
121
|
+
(the use isn't spent), or `null` to let it go on. It gets the link's `storagePrefix`, `key` and plugin `context`.
|
|
122
|
+
|
|
123
|
+
**`authorizeLockChange`** runs before the Worker changes a lock ([Retention and holds](object-lock.md)):
|
|
124
|
+
`PUT /v1/lock/<key>` once per change, and `POST /v1/lock/hold` once per page. It gets:
|
|
125
|
+
|
|
126
|
+
- `action`: `set`, `extend`, `shorten`, `remove`, `hold` or `release`;
|
|
127
|
+
- `keys`, and the `prefix` of a bulk hold;
|
|
128
|
+
- `before` and `after`, the locks (`null` / `{ hold }` for a hold page).
|
|
129
|
+
|
|
130
|
+
Return `{ allow: false, response }` to refuse, or `{ allow: true }`. The first refusal wins. The hook also decides
|
|
131
|
+
the governance privilege: shortening or removing a governance retention takes `bypassGovernance: true` in the request
|
|
132
|
+
**and** at least one `authorizeLockChange` hook, with every hook allowing it. With no such hook, nothing can shorten a
|
|
133
|
+
governance retention.
|
|
134
|
+
|
|
135
|
+
This one lets only a records officer, who sends a second credential, shorten or remove a governance retention:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
import { isBearerOf } from "@devultur/core";
|
|
139
|
+
import type { WorkerPlugin } from "@devultur/self-host";
|
|
140
|
+
|
|
141
|
+
const recordsOfficer: WorkerPlugin = {
|
|
142
|
+
name: "records-officer",
|
|
143
|
+
authorizeLockChange: (context, { action }) => {
|
|
144
|
+
if (action !== "shorten" && action !== "remove") return { allow: true };
|
|
145
|
+
// Bound with `wrangler secret put RECORDS_OFFICER_SECRET`; the request still carries the Worker secret.
|
|
146
|
+
if (isBearerOf(context.req.header("X-Records-Officer"), context.env.RECORDS_OFFICER_SECRET)) {
|
|
147
|
+
return { allow: true };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
allow: false,
|
|
151
|
+
response: context.json({ error: "Only a records officer can shorten a retention", code: "officer_required" }, 403),
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**`onLockAudit`** gets every [audit record](object-lock.md#audit) after the response (`waitUntil`): lock changes, the
|
|
158
|
+
lock set at upload, refused deletes and overwrites, and originals kept after a transcode. Use it to store the records
|
|
159
|
+
somewhere other than the default `__lock-audit/` objects, for example a database or a log pipeline. A failure is
|
|
160
|
+
logged and never reaches the caller.
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
const auditToD1: WorkerPlugin = {
|
|
164
|
+
name: "audit-to-d1",
|
|
165
|
+
onLockAudit: async (context, record) => {
|
|
166
|
+
await context.env.AUDIT_DB.prepare("INSERT INTO lock_audit (at, action, keys, record) VALUES (?, ?, ?, ?)")
|
|
167
|
+
.bind(record.at, record.action, record.keys.join(" "), JSON.stringify(record))
|
|
168
|
+
.run();
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## After the route
|
|
174
|
+
|
|
175
|
+
- `getRouteResult(context)` tells a middleware that runs after `await next()` what the route did: `redeemedLink`
|
|
176
|
+
(the download link just redeemed), `originalDeleted` (`transcode.complete` deleted the original),
|
|
177
|
+
`originalRetained` (it kept a locked original instead) and
|
|
178
|
+
`transcodeJob` (the job a runner callback belongs to, with its plugin `context`).
|
|
179
|
+
- `getPendingUpload(context)`, before the route, returns the upload an upload URL points to: its key, storage key,
|
|
180
|
+
content type and plugin `context`. `cancelPendingUpload(context, uploadUrl)` makes an upload URL stop working, for
|
|
181
|
+
a plugin that rejects an upload after `POST /v1/upload` created it.
|
|
182
|
+
- `parseEncodeMetrics(body)` reads the encode measurement of a runner's `transcode.complete`
|
|
183
|
+
(see [Measuring encode cost](self-host.md#measuring-encode-cost)).
|
|
184
|
+
|
|
185
|
+
## Deleting from a plugin
|
|
186
|
+
|
|
187
|
+
A plugin route (or a scheduled handler next to the Worker) that deletes from `MEDIA_BUCKET` directly skips the
|
|
188
|
+
routes' lock checks. Delete a prefix with `deletePrefixRespectingLocks` instead, and read one object's lock with
|
|
189
|
+
`readObjectLock`: they keep retained files, their keys and their lock records, and stay within a subrequest bound
|
|
190
|
+
(see [Deleting a prefix from your own code](object-lock.md#deleting-a-prefix-from-your-own-code)). The storage
|
|
191
|
+
prefix of the request is `getRequestState(context).storagePrefix`.
|
|
192
|
+
|
|
193
|
+
## Example plugins
|
|
194
|
+
|
|
195
|
+
Three small plugins in [`examples/plugins`](https://github.com/devultur/devultur/blob/main/examples/plugins/src), typechecked and tested with the packages:
|
|
196
|
+
|
|
197
|
+
- **[`jwt-auth`](https://github.com/devultur/devultur/blob/main/examples/plugins/src/jwt-auth.ts)** authenticates with JWTs from your identity provider
|
|
198
|
+
(its JWKS, or a shared HS256 `JWT_SECRET`) instead of the Worker secret, and can map a claim to a storage
|
|
199
|
+
prefix. The transcode runner keeps using the Worker secret, so a job outlives any user token.
|
|
200
|
+
- **[`usage-log`](https://github.com/devultur/devultur/blob/main/examples/plugins/src/usage-log.ts)** records the bytes and requests of media delivery and
|
|
201
|
+
download links, counted as they stream, into the log or a D1 table.
|
|
202
|
+
- **[`rate-limit`](https://github.com/devultur/devultur/blob/main/examples/plugins/src/rate-limit.ts)** answers `429` to a client over its limit, with
|
|
203
|
+
Cloudflare's Rate Limiting binding, before any token is verified.
|
|
204
|
+
|
|
205
|
+
[`worker.ts`](https://github.com/devultur/devultur/blob/main/examples/plugins/src/worker.ts) puts them together.
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# Self-host (bring your own R2)
|
|
2
|
+
|
|
3
|
+
Run the Devultur media Worker and SDKs against your own Cloudflare account and R2 bucket.
|
|
4
|
+
|
|
5
|
+
There are two ways to use Devultur on your own infrastructure:
|
|
6
|
+
|
|
7
|
+
- **Media Worker** (recommended): deploy `@devultur/self-host` on your Cloudflare account. It handles uploads,
|
|
8
|
+
delivery, transcode, captions and image transforms, and your backend talks to it with `@devultur/server`.
|
|
9
|
+
- **Direct R2**: point `@devultur/server` at your R2 bucket with S3 credentials and sign delivery yourself, with
|
|
10
|
+
no Worker in between.
|
|
11
|
+
|
|
12
|
+
## Direct R2
|
|
13
|
+
|
|
14
|
+
The server SDK presigns uploads straight to your bucket and signs delivery URLs:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { createMediaRouter, video, image, file } from "@devultur/server";
|
|
18
|
+
|
|
19
|
+
export const media = createMediaRouter({
|
|
20
|
+
storage: {
|
|
21
|
+
provider: "r2",
|
|
22
|
+
bucket: "my-media",
|
|
23
|
+
accountId: process.env.CF_ACCOUNT_ID!,
|
|
24
|
+
accessKeyId: process.env.R2_ACCESS_KEY!,
|
|
25
|
+
secretAccessKey: process.env.R2_SECRET_KEY!,
|
|
26
|
+
},
|
|
27
|
+
delivery: {
|
|
28
|
+
signSecret: process.env.DELIVERY_SIGN_SECRET!,
|
|
29
|
+
},
|
|
30
|
+
video: video({ maxSize: "2GB" }),
|
|
31
|
+
image: image({ maxSize: "10MB" }),
|
|
32
|
+
file: file({ maxSize: "500MB" }),
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> [!NOTE]
|
|
37
|
+
> The Worker honors the `preset` you pass, so transcode resolution is yours to control. Cap it with
|
|
38
|
+
> `config.transcode.maxResolution` (see below).
|
|
39
|
+
|
|
40
|
+
## The media Worker
|
|
41
|
+
|
|
42
|
+
To run uploads, delivery, transcode and captions on your own Cloudflare account, deploy the
|
|
43
|
+
media Worker from `@devultur/self-host`:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { createWorker } from "@devultur/self-host";
|
|
47
|
+
export { DownloadLink } from "@devultur/self-host";
|
|
48
|
+
|
|
49
|
+
export default createWorker();
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The Worker needs an R2 bucket bound as `MEDIA_BUCKET`, a second R2 bucket as `KEYS_BUCKET` (encryption keys), a KV
|
|
53
|
+
namespace as `MEDIA_KV` (pending uploads, transcode job state), the `DownloadLink` Durable Object (single-use download
|
|
54
|
+
links) and three secrets. The steps below set them up in order.
|
|
55
|
+
|
|
56
|
+
### Step by step
|
|
57
|
+
|
|
58
|
+
#### 1. Pick the account
|
|
59
|
+
|
|
60
|
+
If your login can see more than one Cloudflare account, non-interactive Wrangler commands fail with "More than one
|
|
61
|
+
account available but unable to select one in non-interactive mode". Pin the account before anything else, either in
|
|
62
|
+
`wrangler.toml`:
|
|
63
|
+
|
|
64
|
+
```toml
|
|
65
|
+
account_id = "<your-account-id>"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
or in the environment, for CI and scripts:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export CLOUDFLARE_ACCOUNT_ID=<your-account-id>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To check auth from a script, run `wrangler r2 bucket list`: it returns, and it proves both the login and R2 access.
|
|
75
|
+
Don't use `wrangler whoami` for that check: some versions print the account and then hang.
|
|
76
|
+
|
|
77
|
+
#### 2. Create the buckets
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
wrangler r2 bucket create media
|
|
81
|
+
wrangler r2 bucket create media-keys
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`KEYS_BUCKET` holds the key of every file encrypted at rest ([L4 encryption](https://github.com/devultur/devultur/blob/main/docs/guides/security.md#l4--encryption)). With
|
|
85
|
+
`security.encrypt` on, the Worker fails closed without it: a private upload, transcode or captions request answers
|
|
86
|
+
`503 keys_bucket_missing` and nothing is stored, instead of storing the file unencrypted. Keep it a separate
|
|
87
|
+
bucket, never public, and [back it up](#backing-up-keys_bucket): losing a key loses its file for good.
|
|
88
|
+
|
|
89
|
+
#### 3. Create the KV namespace
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
wrangler kv namespace create media-state
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
It prints a config snippet with the new namespace's `id`. Paste that id into the `MEDIA_KV` binding of
|
|
96
|
+
`wrangler.toml`. Running `create` again doesn't give you the id of a namespace that already exists: find it with
|
|
97
|
+
`wrangler kv namespace list`, which prints every namespace of the account with its `title` and `id`.
|
|
98
|
+
|
|
99
|
+
#### 4. Write `wrangler.toml`
|
|
100
|
+
|
|
101
|
+
Bind both buckets, the KV namespace and the `DownloadLink` Durable Object:
|
|
102
|
+
|
|
103
|
+
```toml
|
|
104
|
+
name = "media"
|
|
105
|
+
main = "src/index.ts"
|
|
106
|
+
compatibility_date = "2026-10-01"
|
|
107
|
+
account_id = "<your-account-id>"
|
|
108
|
+
|
|
109
|
+
[[r2_buckets]]
|
|
110
|
+
binding = "MEDIA_BUCKET"
|
|
111
|
+
bucket_name = "media"
|
|
112
|
+
|
|
113
|
+
# Per-object key material for encryption at rest. Required when `security.encrypt` is on.
|
|
114
|
+
[[r2_buckets]]
|
|
115
|
+
binding = "KEYS_BUCKET"
|
|
116
|
+
bucket_name = "media-keys"
|
|
117
|
+
|
|
118
|
+
[[kv_namespaces]]
|
|
119
|
+
binding = "MEDIA_KV"
|
|
120
|
+
id = "<kv-namespace-id>"
|
|
121
|
+
|
|
122
|
+
[[durable_objects.bindings]]
|
|
123
|
+
name = "DOWNLOAD_LINKS"
|
|
124
|
+
class_name = "DownloadLink"
|
|
125
|
+
|
|
126
|
+
[[migrations]]
|
|
127
|
+
tag = "v1"
|
|
128
|
+
new_sqlite_classes = ["DownloadLink"]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Without the `DOWNLOAD_LINKS` binding, `POST /v1/token/download` answers `501`. For staging and production in one
|
|
132
|
+
file, see [Per-environment config](#per-environment-config).
|
|
133
|
+
|
|
134
|
+
#### 5. Set the secrets
|
|
135
|
+
|
|
136
|
+
The Worker requires all three secrets, even if you never transcode a video. Without them it fails closed: it doesn't
|
|
137
|
+
fall back to open access.
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
wrangler secret put DELIVERY_SIGN_SECRET # openssl rand -base64 32
|
|
141
|
+
wrangler secret put WORKER_AUTH_SECRET # openssl rand -base64 32
|
|
142
|
+
wrangler secret put TRANSCODE_WEBHOOK_SECRET # openssl rand -base64 32
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- **`DELIVERY_SIGN_SECRET`** signs viewer tokens, download links and transcode job tickets. Without it,
|
|
146
|
+
issuing or verifying them fails with `500`.
|
|
147
|
+
- **`WORKER_AUTH_SECRET`** is your backend's credential. Every control route requires
|
|
148
|
+
`Authorization: Bearer <WORKER_AUTH_SECRET>`. Without the secret, those routes answer
|
|
149
|
+
`503 worker_auth_secret_missing`. With a missing or wrong bearer, they answer `401`. The transcode
|
|
150
|
+
runner also uploads its renditions with this secret, and a job's `callbackUrl` receives it as its bearer (the
|
|
151
|
+
Convex component checks it against `DEVULTUR_SECRET`).
|
|
152
|
+
- **`TRANSCODE_WEBHOOK_SECRET`** signs the transcode callbacks (see [Transcode runners](#transcode-runners)).
|
|
153
|
+
Without it, the Worker answers `503` to every callback, so no transcode ever completes: the first video upload
|
|
154
|
+
would stay in progress for good. Set it even if you don't plan to transcode.
|
|
155
|
+
|
|
156
|
+
For captions, also set `GROQ_API_KEY` (transcription) and `MISTRAL_API_KEY` (translation) as secrets. Secrets are
|
|
157
|
+
per environment: with [environments](#per-environment-config), add `--env <name>` to each `wrangler secret put`.
|
|
158
|
+
|
|
159
|
+
#### 6. Deploy
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
wrangler deploy # with environments: wrangler deploy --env production
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
#### 7. Verify
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
curl https://media.example.com/health
|
|
169
|
+
curl https://media.example.com/v1/assets -H "Authorization: Bearer $WORKER_AUTH_SECRET"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`/health` is open. `GET /v1/assets` takes the Worker secret: it answers `401` with a wrong bearer, and
|
|
173
|
+
`503 worker_auth_secret_missing` if the secret didn't reach this environment. Then upload one file from your backend
|
|
174
|
+
and fetch it back.
|
|
175
|
+
|
|
176
|
+
### Per-environment config
|
|
177
|
+
|
|
178
|
+
Under `[env.<name>]`, Wrangler doesn't inherit `r2_buckets`, `kv_namespaces` or `durable_objects` from the top
|
|
179
|
+
level: repeat every binding in every environment, `KEYS_BUCKET` included. `migrations` stays top-level. Give each
|
|
180
|
+
environment its own buckets, KV namespace and secrets:
|
|
181
|
+
|
|
182
|
+
```toml
|
|
183
|
+
name = "media"
|
|
184
|
+
main = "src/index.ts"
|
|
185
|
+
compatibility_date = "2026-10-01"
|
|
186
|
+
account_id = "<your-account-id>"
|
|
187
|
+
|
|
188
|
+
[[migrations]]
|
|
189
|
+
tag = "v1"
|
|
190
|
+
new_sqlite_classes = ["DownloadLink"]
|
|
191
|
+
|
|
192
|
+
# Staging
|
|
193
|
+
[env.staging]
|
|
194
|
+
name = "media-staging"
|
|
195
|
+
|
|
196
|
+
[[env.staging.r2_buckets]]
|
|
197
|
+
binding = "MEDIA_BUCKET"
|
|
198
|
+
bucket_name = "media-staging"
|
|
199
|
+
|
|
200
|
+
[[env.staging.r2_buckets]]
|
|
201
|
+
binding = "KEYS_BUCKET"
|
|
202
|
+
bucket_name = "media-keys-staging"
|
|
203
|
+
|
|
204
|
+
[[env.staging.kv_namespaces]]
|
|
205
|
+
binding = "MEDIA_KV"
|
|
206
|
+
id = "<staging-kv-namespace-id>"
|
|
207
|
+
|
|
208
|
+
[[env.staging.durable_objects.bindings]]
|
|
209
|
+
name = "DOWNLOAD_LINKS"
|
|
210
|
+
class_name = "DownloadLink"
|
|
211
|
+
|
|
212
|
+
# Production
|
|
213
|
+
[env.production]
|
|
214
|
+
name = "media-production"
|
|
215
|
+
|
|
216
|
+
[[env.production.r2_buckets]]
|
|
217
|
+
binding = "MEDIA_BUCKET"
|
|
218
|
+
bucket_name = "media-production"
|
|
219
|
+
|
|
220
|
+
[[env.production.r2_buckets]]
|
|
221
|
+
binding = "KEYS_BUCKET"
|
|
222
|
+
bucket_name = "media-keys-production"
|
|
223
|
+
|
|
224
|
+
[[env.production.kv_namespaces]]
|
|
225
|
+
binding = "MEDIA_KV"
|
|
226
|
+
id = "<production-kv-namespace-id>"
|
|
227
|
+
|
|
228
|
+
[[env.production.durable_objects.bindings]]
|
|
229
|
+
name = "DOWNLOAD_LINKS"
|
|
230
|
+
class_name = "DownloadLink"
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`vars` aren't inherited either: repeat `[env.<name>.vars]` (for example `TRANSCODE_RUNNER`) in each one. Then set
|
|
234
|
+
the secrets and deploy per environment:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
wrangler secret put WORKER_AUTH_SECRET --env staging
|
|
238
|
+
wrangler deploy --env staging
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
[Wrangler environments](https://developers.cloudflare.com/workers/wrangler/environments/) lists what is and isn't
|
|
242
|
+
inherited.
|
|
243
|
+
|
|
244
|
+
### Backing up `KEYS_BUCKET`
|
|
245
|
+
|
|
246
|
+
R2 has no built-in backup, and losing a key loses its file for good: the encrypted bytes in `MEDIA_BUCKET` can't be
|
|
247
|
+
read without it. Copy `KEYS_BUCKET` to a bucket of its own, ideally in another account or with another provider:
|
|
248
|
+
|
|
249
|
+
- **A cron Worker** bound to `KEYS_BUCKET` and the backup bucket, which lists the keys bucket and copies each object
|
|
250
|
+
the backup doesn't have yet. A key is written once and never changes, so copying the new ones is enough. Copy each
|
|
251
|
+
object's `customMetadata` along with its body.
|
|
252
|
+
- **rclone** with [R2's S3 API](https://developers.cloudflare.com/r2/examples/rclone/), from a machine or a CI job on
|
|
253
|
+
a schedule.
|
|
254
|
+
|
|
255
|
+
Each key lives at `{storageKey}/{keyId}`: the full storage key of its file plus a random id. To restore, copy the
|
|
256
|
+
objects back to the same keys, with their metadata. The Worker finds a file's key by that path, so a key restored
|
|
257
|
+
under another path is as good as lost. Give the backup the same access rules as the keys bucket: whoever holds both
|
|
258
|
+
the keys and the media can read your encrypted files.
|
|
259
|
+
|
|
260
|
+
### R2 bucket lock (defense in depth)
|
|
261
|
+
|
|
262
|
+
[Retention and holds](object-lock.md) are enforced by the Worker. An
|
|
263
|
+
[R2 bucket lock rule](https://developers.cloudflare.com/r2/buckets/bucket-locks/) adds a storage-level layer: R2
|
|
264
|
+
itself refuses overwrites and deletes of matching objects, also from the dashboard, `wrangler r2` or the S3 API.
|
|
265
|
+
Lock the same prefix in both buckets, so a file and its key are kept together:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
# {root} is the Worker's storage prefix ("" if you don't set one)
|
|
269
|
+
wrangler r2 bucket lock add media records-retention "{root}private/asset/records/" --retention-days 3650
|
|
270
|
+
wrangler r2 bucket lock add media-keys records-retention "{root}private/asset/records/" --retention-days 3650
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
An asset is stored at `{root}{visibility}/asset/{path}` in `MEDIA_BUCKET`, and its key at
|
|
274
|
+
`{root}{visibility}/asset/{path}/{keyId}` in `KEYS_BUCKET`, so one prefix covers both. Use `--retention-days`,
|
|
275
|
+
`--retention-date` or `--retention-indefinite` to match the longest retention you set through the Worker.
|
|
276
|
+
|
|
277
|
+
> [!WARNING]
|
|
278
|
+
> Never lock `__lock/` or `__lock-audit/`, or a prefix that contains them, such as `""` or `{root}` alone. The Worker
|
|
279
|
+
> rewrites those records on every lock change, and a bucket lock on them makes lock changes and holds fail.
|
|
280
|
+
|
|
281
|
+
What changes once the rule is on:
|
|
282
|
+
|
|
283
|
+
- Nothing under the prefix can be deleted or replaced until its R2 retention ends, whatever the Worker's own lock
|
|
284
|
+
says. That includes image variants (`{key}/__variants/…`) and the cleanup of `maxFileLifetimeSeconds`.
|
|
285
|
+
- **Racing duplicate uploads.** When two create-only uploads race for the same asset path, the loser deletes the
|
|
286
|
+
key it made for its failed write. Under the lock R2 refuses that delete, so the loser gets a `500` instead of
|
|
287
|
+
`409 already_exists`. Retry it: the retry sees the existing file and answers `409 already_exists`.
|
|
288
|
+
- **Don't trust `wrangler r2 object delete` to test the rule.** On a locked object it prints "Delete complete." and
|
|
289
|
+
exits `0`, but the object stays. Check for the object afterwards instead:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
wrangler r2 object delete "media/private/asset/records/test.pdf" --remote
|
|
293
|
+
wrangler r2 object get "media/private/asset/records/test.pdf" --remote --file check.pdf # still there
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Anyone who can edit the bucket's configuration can still remove the rule, so it raises the bar rather than closing
|
|
297
|
+
it.
|
|
298
|
+
|
|
299
|
+
### Calling the Worker
|
|
300
|
+
|
|
301
|
+
Your backend calls the Worker with the server SDK. Pass the Worker URL as `baseUrl` and the
|
|
302
|
+
secret as `secret`. Never ship the secret to the browser.
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
import { createMediaRouter } from "@devultur/server";
|
|
306
|
+
|
|
307
|
+
export const media = createMediaRouter({
|
|
308
|
+
baseUrl: "https://media.example.com",
|
|
309
|
+
secret: process.env.WORKER_AUTH_SECRET!,
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
| Route | Access |
|
|
314
|
+
| --- | --- |
|
|
315
|
+
| `POST /v1/upload` | Worker secret, or an upload token from `POST /v1/token/upload` (only inside its prefix) |
|
|
316
|
+
| `PUT`, `POST`, `DELETE /v1/upload/<key>?u=<id>` | The URL that `POST /v1/upload` returned is the credential: its random `u` id names the upload, and a missing or wrong id gets `404`. Without that URL, only the Worker secret can write. |
|
|
317
|
+
| `DELETE /v1/media/…`, `GET /v1/assets` | Worker secret |
|
|
318
|
+
| `/v1/transcode`, `/v1/captions` | Worker secret |
|
|
319
|
+
| `POST /v1/token`, `/v1/token/upload`, `/v1/token/download` | Worker secret |
|
|
320
|
+
| `GET`/`PUT /v1/lock/<key>`, `POST /v1/lock/hold` | Worker secret ([Retention and holds](object-lock.md)) |
|
|
321
|
+
| `GET`/`HEAD /v1/media/…` | Public media is open. Private media needs a viewer token from `POST /v1/token`, or the Worker secret. |
|
|
322
|
+
| `GET /v1/download/<id>` | The link id is the credential (single use) |
|
|
323
|
+
| `POST /v1/webhooks/transcode` | `TRANSCODE_WEBHOOK_SECRET` |
|
|
324
|
+
| `GET /health`, `POST /v1/metrics/playback` | Open |
|
|
325
|
+
|
|
326
|
+
The browser never talks to a control route. It uploads to the URL your backend got from
|
|
327
|
+
`POST /v1/upload`, and plays private media with a viewer token your backend issued.
|
|
328
|
+
|
|
329
|
+
> [!WARNING]
|
|
330
|
+
> The Worker secret check is the `workerSecretAuth()` plugin. To authenticate with your own credentials, replace it
|
|
331
|
+
> with an auth plugin of yours (see [Plugins](plugins.md)): `createWorker()` refuses to start without one.
|
|
332
|
+
|
|
333
|
+
## Rules
|
|
334
|
+
|
|
335
|
+
`createWorker()` runs the [default plugins](plugins.md#default-plugins). Pass the rules every request follows to
|
|
336
|
+
`defaultPlugins(config)`:
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
import { createWorker, defaultPlugins } from "@devultur/self-host";
|
|
340
|
+
|
|
341
|
+
export default createWorker({
|
|
342
|
+
plugins: defaultPlugins({
|
|
343
|
+
security: { sessionBinding: true, ipBinding: false, encrypt: true }, // encrypt needs KEYS_BUCKET
|
|
344
|
+
uploadRules: { image: { maxSizeBytes: 10_000_000, accept: ["image/*"] } },
|
|
345
|
+
captionsLocales: ["en", "es"],
|
|
346
|
+
transcode: { maxResolution: "1080p", deleteOriginalOnSuccess: true },
|
|
347
|
+
}),
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
| `config` field | What it does |
|
|
352
|
+
| --- | --- |
|
|
353
|
+
| `security` | Default security policy: session and IP binding, encryption at rest |
|
|
354
|
+
| `uploadRules` | Per media type (`video`, `image`, `file`): `maxSizeBytes`, `accept` and default `visibility` |
|
|
355
|
+
| `captionsLocales` | Locales `/v1/captions` accepts (unset: any) |
|
|
356
|
+
| `cacheRules` | `Cache-Control` per prefix of the `asset/` namespace |
|
|
357
|
+
| `originalStorageClass` | R2 storage class of an uploaded video original (`Standard` or `InfrequentAccess`) |
|
|
358
|
+
| `maxFileLifetimeSeconds` | Longest lifetime of an uploaded file, in seconds: the default TTL and a ceiling (a shorter `expiresIn` still wins). For a demo or scratch Worker whose uploads clean themselves up. A [locked](object-lock.md#retention-vs-expiry) file still expires (viewers get `410`) but isn't deleted until its lock ends. |
|
|
359
|
+
| `uploadMetadata` | String map added to the R2 `customMetadata` of every uploaded object (for example which app uploaded it). Keys the Worker owns (`security`, `enc`, `expiresAt`, `derivedFrom`, `lock`, `sha256*`) are dropped. |
|
|
360
|
+
| `lock` | Default lock of an uploaded file: retention and hold (see [Retention and holds](object-lock.md)). An upload or its upload token overrides it. |
|
|
361
|
+
| `contentSniffing` | What an upload whose bytes contradict its declared type gets: `scriptable` (default: `415 content_type_mismatch` only for markup such as HTML or SVG under an inert type), `reject` (every mismatch), `octet-stream` or `off`. Every mismatch is logged as `[content-sniff] {…}`. See [Content verification](https://github.com/devultur/devultur/blob/main/docs/guides/security.md#content-verification). |
|
|
362
|
+
| `transcode.presets` | Named presets and the default one, used when a request names no preset |
|
|
363
|
+
| `transcode.maxResolution` | Highest rung of the HLS ladder (`720p`, `1080p`, `4k`) |
|
|
364
|
+
| `transcode.deleteOriginalOnSuccess` | Delete the original once a transcode completes. It's never deleted after a failure, nor while it's [locked](object-lock.md#transcoding-a-locked-video). |
|
|
365
|
+
| `transcode.outputBaseUrl` | Base of the output URLs in your webhook. Unset: the origin of the transcode request. |
|
|
366
|
+
|
|
367
|
+
A plugin can narrow these rules per request, for example to give each of your apps its own storage prefix,
|
|
368
|
+
upload rules and credentials. [Plugins](plugins.md) covers the request state, the hooks before a credential is
|
|
369
|
+
issued or a download link is redeemed, and three example plugins: JWT auth, a usage log and rate limiting.
|
|
370
|
+
|
|
371
|
+
## Transcode runners
|
|
372
|
+
|
|
373
|
+
The self-host media Worker doesn't run FFmpeg itself. It generates a shell script for each
|
|
374
|
+
video and hands it to a **runner**. The runner starts that script inside the transcode image
|
|
375
|
+
(`infra/transcode/Dockerfile`, Alpine + FFmpeg + curl). The script downloads the raw video from
|
|
376
|
+
your Worker, encodes the HLS ladder, uploads the renditions back, and reports progress through
|
|
377
|
+
signed webhooks to `/v1/webhooks/transcode`. Every runner uses the same script, and job status
|
|
378
|
+
always comes from those webhooks, never from the provider.
|
|
379
|
+
|
|
380
|
+
Pick the runner with the `TRANSCODE_RUNNER` var:
|
|
381
|
+
|
|
382
|
+
| Runner | `TRANSCODE_RUNNER` | Best for | Needs |
|
|
383
|
+
| --- | --- | --- | --- |
|
|
384
|
+
| Cloudflare Containers | `cloudflare` (default when unset) | **Recommended.** Same deploy and account as the Worker | Workers Paid plan, Docker where you run `wrangler deploy` |
|
|
385
|
+
| Fly Machines | `fly` | An existing Fly setup | A Fly app, an API token, a pushed image |
|
|
386
|
+
| HTTP | `http` | A VPS or your own queue | A host running the reference runner behind HTTPS |
|
|
387
|
+
|
|
388
|
+
Set `TRANSCODE_WEBHOOK_SECRET` ([step 5](#5-set-the-secrets)) whatever runner you pick. The
|
|
389
|
+
script sends it on every callback, and the Worker rejects callbacks that don't carry it. If the
|
|
390
|
+
secret is unset, the Worker answers `503` to every callback.
|
|
391
|
+
|
|
392
|
+
### Cloudflare Containers (recommended)
|
|
393
|
+
|
|
394
|
+
Each video gets its own Durable Object, and that Durable Object starts one container with
|
|
395
|
+
the transcode image. Wrangler builds and pushes the image on `wrangler deploy`.
|
|
396
|
+
|
|
397
|
+
1. Re-export the container class from your Worker entry:
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
import { createWorker } from "@devultur/self-host";
|
|
401
|
+
export { TranscodeContainer } from "@devultur/self-host/transcode-container";
|
|
402
|
+
|
|
403
|
+
export default createWorker();
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
2. Add the container, the binding and the var to `wrangler.toml` (full snippet in
|
|
407
|
+
`infra/transcode/wrangler.containers.example.toml`):
|
|
408
|
+
|
|
409
|
+
```toml
|
|
410
|
+
[vars]
|
|
411
|
+
TRANSCODE_RUNNER = "cloudflare"
|
|
412
|
+
# CF_TRANSCODE_INSTANCE = "standard-4" # default: 4 vCPU, 12 GiB, 20 GB disk
|
|
413
|
+
|
|
414
|
+
[[containers]]
|
|
415
|
+
class_name = "TranscodeContainer"
|
|
416
|
+
scheduling_policy = "durable_object"
|
|
417
|
+
|
|
418
|
+
[containers.images.transcode]
|
|
419
|
+
dockerfile = "../../infra/transcode/Dockerfile"
|
|
420
|
+
|
|
421
|
+
[[durable_objects.bindings]]
|
|
422
|
+
name = "TRANSCODE_CONTAINER"
|
|
423
|
+
class_name = "TranscodeContainer"
|
|
424
|
+
|
|
425
|
+
[exports.TranscodeContainer]
|
|
426
|
+
type = "durable-object"
|
|
427
|
+
storage = "sqlite"
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
3. Run `wrangler deploy` with a recent Wrangler (4.150 accepts `scheduling_policy = "durable_object"`, `images` and `exports`; 4.103 rejects them).
|
|
431
|
+
|
|
432
|
+
> [!WARNING]
|
|
433
|
+
> The instance disk has to hold the raw upload plus every rendition. On `standard-4` that's
|
|
434
|
+
> 20 GB, so a very long 4K source may not fit. If a container dies without running the
|
|
435
|
+
> script's failure trap (out of memory, host restart), its Durable Object reports
|
|
436
|
+
> `transcode.failed` for it.
|
|
437
|
+
|
|
438
|
+
### Fly Machines
|
|
439
|
+
|
|
440
|
+
Each job runs on a fresh Fly Machine that destroys itself when the job ends. Creating a
|
|
441
|
+
machine is retried on 429/5xx with exponential backoff, because the Machines API allows 1
|
|
442
|
+
create per second.
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
fly apps create my-transcode
|
|
446
|
+
cd infra/transcode && fly deploy --ha=false # note the image tag it prints
|
|
447
|
+
wrangler secret put FLY_API_TOKEN
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
| Var | Default | |
|
|
451
|
+
| --- | --- | --- |
|
|
452
|
+
| `FLY_APP_NAME` | — | Fly app that runs the machines |
|
|
453
|
+
| `FLY_TRANSCODE_IMAGE` | — | `registry.fly.io/<app>:<tag>` |
|
|
454
|
+
| `FLY_API_TOKEN` | — | Secret |
|
|
455
|
+
| `FLY_TRANSCODE_CPU_KIND` | `performance` | Avoid `shared`: FFmpeg gets throttled to its 6.25% CPU baseline |
|
|
456
|
+
| `FLY_TRANSCODE_CPUS` | `4` | |
|
|
457
|
+
| `FLY_TRANSCODE_MEMORY_MB` | `8192` | |
|
|
458
|
+
| `FLY_REGION` | — | Region of the machines (`iad`, `fra`, …). Unset: Fly picks one. |
|
|
459
|
+
|
|
460
|
+
### HTTP (VPS or your own queue)
|
|
461
|
+
|
|
462
|
+
The Worker POSTs each job to `TRANSCODE_RUNNER_URL` with `Authorization: Bearer <TRANSCODE_RUNNER_SECRET>`:
|
|
463
|
+
|
|
464
|
+
```json
|
|
465
|
+
{
|
|
466
|
+
"version": 1,
|
|
467
|
+
"videoId": "abc123",
|
|
468
|
+
"command": ["sh", "-c", "echo \"$TRANSCODE_SCRIPT_B64\" | base64 -d | sh"],
|
|
469
|
+
"env": { "TRANSCODE_SCRIPT_B64": "…", "WORKER_URL": "…", "CALLBACK_URL": "…" }
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Your runner runs `command` with `env` in the transcode image and answers `2xx`, optionally
|
|
474
|
+
with `{ "jobId": "…" }`. The Worker retries 429/5xx responses with backoff. The reference
|
|
475
|
+
runner `infra/transcode/http-runner/server.ts` (Node 22.18+, no dependencies) queues jobs
|
|
476
|
+
and runs each one with `docker run`:
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
docker build -t devultur-ffmpeg infra/transcode
|
|
480
|
+
RUNNER_SECRET=$(openssl rand -base64 32) MAX_CONCURRENT_JOBS=2 \
|
|
481
|
+
node infra/transcode/http-runner/server.ts
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
Then set `TRANSCODE_RUNNER = "http"` and `TRANSCODE_RUNNER_URL = "https://runner.example.com/jobs"`
|
|
485
|
+
on the Worker, plus `wrangler secret put TRANSCODE_RUNNER_SECRET`. Optionally set
|
|
486
|
+
`TRANSCODE_RUNNER_CPUS` to the cores per job, which feeds the encode measurement.
|
|
487
|
+
|
|
488
|
+
> [!CAUTION]
|
|
489
|
+
> The job `env` carries the runner's upload credential and the callback secret. Only expose the
|
|
490
|
+
> runner over HTTPS, for example behind Caddy or a Cloudflare Tunnel.
|
|
491
|
+
|
|
492
|
+
### Measuring encode cost
|
|
493
|
+
|
|
494
|
+
Every `transcode.complete` the runner sends carries an `encode` measurement, for you as the operator: `seconds`
|
|
495
|
+
(FFmpeg wall-clock time), `cpus`, `runner` and `topRung` (the highest rendition), plus the per-title `complexity`,
|
|
496
|
+
`probeSeconds` and `params`. The Worker logs it as `[transcode-metrics] {…"vcpuSecondsPerVideoMinute":…}` (vCPU
|
|
497
|
+
seconds per minute of video, to size and compare runners), and a plugin can read it with `parseEncodeMetrics`.
|
|
498
|
+
Your app's webhook doesn't get it.
|
|
499
|
+
|
|
500
|
+
For presets, templates and inline specs, each entry of `renditions` in the webhook carries the output's real
|
|
501
|
+
`width` and `height` in pixels (an HLS ladder by its top rung), measured on the encoded file.
|
|
502
|
+
|
|
503
|
+
## Image transforms
|
|
504
|
+
|
|
505
|
+
Image transforms (`?w=`, `?h=`, `?fit=`, `?format=`, `?q=`) run inside the media Worker with WebAssembly
|
|
506
|
+
codecs. Each unique transform is computed once and stored in your bucket next to the original
|
|
507
|
+
(`{key}/__variants/…`), so later requests are served like any other object, with `Range` support and
|
|
508
|
+
the edge cache.
|
|
509
|
+
|
|
510
|
+
- **Large originals.** The Worker decodes images up to 4 megapixels (`IMAGE_TRANSFORMS_MAX_MEGAPIXELS`).
|
|
511
|
+
For a larger original, the first transform asks Cloudflare Images for one downscaled master and stores
|
|
512
|
+
it next to the original. Every later variant is built from that master in the Worker.
|
|
513
|
+
- **Cloudflare Images fallback.** GIFs, animated images, sizes that need more pixels than the master
|
|
514
|
+
has, and any codec failure go to Cloudflare Images for that request. Image Transformations must be
|
|
515
|
+
enabled on the zone that serves the Worker.
|
|
516
|
+
- **`fit=fill`** stretches to the exact `w`×`h`, ignoring the aspect ratio.
|
|
517
|
+
- **`format=auto`** is negotiated from the `Accept` header: AVIF for outputs up to about 1 megapixel, then
|
|
518
|
+
WebP, then JPEG (PNG for PNG originals). Responses carry `Vary: Accept`.
|
|
519
|
+
- **Private and encrypted images.** Variants of a private image stay private. If the original is
|
|
520
|
+
encrypted at rest, its variants are encrypted too, each with its own key.
|
|
521
|
+
- **Deleting** an original also deletes its variants and their keys.
|
|
522
|
+
|
|
523
|
+
Set `IMAGE_TRANSFORMS = "cloudflare"` to send every transform to Cloudflare Images instead.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devultur/self-host",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Self-hosted media Worker for Cloudflare: R2 uploads, edge-cached delivery, image transforms, HLS transcode and captions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
}
|
|
38
38
|
},
|
|
39
39
|
"files": [
|
|
40
|
-
"dist"
|
|
40
|
+
"dist",
|
|
41
|
+
"docs"
|
|
41
42
|
],
|
|
42
43
|
"dependencies": {
|
|
43
44
|
"@jsquash/avif": "2.1.1",
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
"@jsquash/resize": "2.1.1",
|
|
47
48
|
"@jsquash/webp": "1.5.0",
|
|
48
49
|
"hono": "^4.7.0",
|
|
49
|
-
"@devultur/core": "1.
|
|
50
|
+
"@devultur/core": "1.5.1"
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
53
|
"@cf-wasm/photon": "0.4.0",
|
|
@@ -60,7 +61,7 @@
|
|
|
60
61
|
"access": "public"
|
|
61
62
|
},
|
|
62
63
|
"scripts": {
|
|
63
|
-
"build": "tsup src/index.ts src/transcode/container.ts --format esm --dts --external cloudflare:workers --external '*.wasm' --clean",
|
|
64
|
+
"build": "tsup src/index.ts src/transcode/container.ts --format esm --dts --external cloudflare:workers --external '*.wasm' --clean && node ../../scripts/copy-guides.mts",
|
|
64
65
|
"typecheck": "tsc --noEmit"
|
|
65
66
|
}
|
|
66
67
|
}
|