@devultur/convex 1.5.0 → 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 +24 -3
- package/dist/chunk-BDEO67OT.js +110 -0
- package/dist/component/http.js +2 -2
- package/dist/component/library.d.ts +2 -2
- package/dist/component/library.js +3 -3
- package/dist/component/lock.js +3 -3
- package/dist/component/mediaClient.js +1 -1
- package/dist/component/metadata.js +2 -2
- package/dist/component/public.js +9 -9
- package/dist/component/uploads.js +6 -6
- package/dist/component/workerErrors.d.ts +1 -1
- package/dist/component/workerErrors.js +1 -1
- package/dist/hooks-uqpBd5UF.d.ts +171 -0
- package/dist/index.d.ts +53 -16
- package/dist/index.js +1 -0
- package/dist/react/hooks.d.ts +6 -0
- package/dist/react/hooks.js +23 -0
- package/dist/react.d.ts +123 -277
- package/dist/react.js +784 -829
- package/docs/guides/object-lock.md +517 -0
- package/docs/guides/plugins.md +205 -0
- package/docs/guides/self-host.md +523 -0
- package/docs/guides/video-player.md +105 -0
- package/package.json +10 -5
|
@@ -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.
|