@mieweb/pulsevault 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/LICENSE +15 -39
  3. package/OPERATIONS.md +223 -0
  4. package/PROTOCOL.md +312 -0
  5. package/README.md +532 -122
  6. package/augment.d.ts +1 -0
  7. package/augment.js +3 -0
  8. package/core.d.ts +1 -0
  9. package/core.js +7 -0
  10. package/dist/app.d.ts +90 -34
  11. package/dist/app.d.ts.map +1 -1
  12. package/dist/app.js +23 -33
  13. package/dist/app.js.map +1 -1
  14. package/dist/augment.d.ts +14 -7
  15. package/dist/augment.d.ts.map +1 -1
  16. package/dist/augment.js.map +1 -1
  17. package/dist/core.d.ts +108 -0
  18. package/dist/core.d.ts.map +1 -0
  19. package/dist/core.js +431 -0
  20. package/dist/core.js.map +1 -0
  21. package/dist/lib/authorize.d.ts +19 -0
  22. package/dist/lib/authorize.d.ts.map +1 -0
  23. package/dist/lib/authorize.js +2 -0
  24. package/dist/lib/authorize.js.map +1 -0
  25. package/dist/lib/capability-token.d.ts +80 -0
  26. package/dist/lib/capability-token.d.ts.map +1 -0
  27. package/dist/lib/capability-token.js +126 -0
  28. package/dist/lib/capability-token.js.map +1 -0
  29. package/dist/lib/checksum.d.ts +45 -0
  30. package/dist/lib/checksum.d.ts.map +1 -0
  31. package/dist/lib/checksum.js +102 -0
  32. package/dist/lib/checksum.js.map +1 -0
  33. package/dist/lib/deeplinks.d.ts +37 -21
  34. package/dist/lib/deeplinks.d.ts.map +1 -1
  35. package/dist/lib/deeplinks.js +54 -13
  36. package/dist/lib/deeplinks.js.map +1 -1
  37. package/dist/lib/magic.d.ts +30 -5
  38. package/dist/lib/magic.d.ts.map +1 -1
  39. package/dist/lib/magic.js +51 -14
  40. package/dist/lib/magic.js.map +1 -1
  41. package/dist/lib/options.d.ts +39 -0
  42. package/dist/lib/options.d.ts.map +1 -0
  43. package/dist/lib/options.js +103 -0
  44. package/dist/lib/options.js.map +1 -0
  45. package/dist/lib/pulsevaultTus.d.ts +52 -26
  46. package/dist/lib/pulsevaultTus.d.ts.map +1 -1
  47. package/dist/lib/pulsevaultTus.js +111 -43
  48. package/dist/lib/pulsevaultTus.js.map +1 -1
  49. package/dist/lib/request.d.ts +24 -0
  50. package/dist/lib/request.d.ts.map +1 -0
  51. package/dist/lib/request.js +16 -0
  52. package/dist/lib/request.js.map +1 -0
  53. package/dist/lib/uuid.d.ts.map +1 -1
  54. package/dist/lib/uuid.js +1 -1
  55. package/dist/lib/uuid.js.map +1 -1
  56. package/dist/routes/pulsevault.d.ts +18 -16
  57. package/dist/routes/pulsevault.d.ts.map +1 -1
  58. package/dist/routes/pulsevault.js +120 -251
  59. package/dist/routes/pulsevault.js.map +1 -1
  60. package/dist/storage/local.d.ts +31 -10
  61. package/dist/storage/local.d.ts.map +1 -1
  62. package/dist/storage/local.js +166 -82
  63. package/dist/storage/local.js.map +1 -1
  64. package/dist/storage/s3.d.ts +109 -0
  65. package/dist/storage/s3.d.ts.map +1 -0
  66. package/dist/storage/s3.js +432 -0
  67. package/dist/storage/s3.js.map +1 -0
  68. package/dist/storage/types.d.ts +69 -17
  69. package/dist/storage/types.d.ts.map +1 -1
  70. package/package.json +35 -7
package/README.md CHANGED
@@ -1,45 +1,37 @@
1
1
  # @mieweb/pulsevault
2
2
 
3
- Fastify plugin for resumable video uploads via the [TUS protocol](https://tus.io/), with filesystem-first local storage and deep link helpers for the [Pulse](https://github.com/mieweb/pulse) mobile app.
4
-
5
- ```text
6
- Pulse app Your Fastify app
7
- ───────── ────────────────
8
-
9
- ┌───────────┐ pair + ┌──────────────────────┐
10
- │ iOS / web │ ─── TUS ───► │ Pulsevault plugin │
11
- └───────────┘ │ POST /upload │
12
- │ PATCH /upload/:id │
13
- │ GET /:videoid │
14
- └──────────┬───────────┘
15
- │
16
- ▼
17
- ┌──────────────────────┐
18
- │ Your hooks │
19
- │ • authorize │
20
- │ • validatePayload │
21
- │ • onUploadComplete │
22
- └──┬─────────────────┬─┘
23
- │ │
24
- ▼ ▼
25
- ┌──────────────┐ ┌──────────────┐
26
- │ Storage │ │ Your systems │
27
- │ adapter │ │ DB · SSO · │
28
- │ local / S3 / │ │ audit logs │
29
- │ GCS / custom │ │ pipelines │
30
- └──────────────┘ └──────────────┘
31
- ```
32
-
33
- Self-hosted video capture for places that can't ship recordings to a vendor. Pulse records the walkthrough on the phone; Pulsevault receives it inside the Fastify app you already run, behind your auth, on your storage. Pair a device by QR code and upload over TUS so two-minute captures from the floor survive signal drops and device restarts.
34
-
35
- Hook in at `authorize`, `validatePayload`, or `onUploadComplete` to bolt on whatever your institution already runs — SSO, audit logs, transcoding queues, AI pipelines. The plugin mounts three routes; the rest stays yours.
3
+ Resumable video uploads via the [TUS protocol](https://tus.io/), with filesystem-first local storage and deep link helpers for the [Pulse](https://github.com/mieweb/pulse) mobile app. Ships as a Fastify plugin (`@mieweb/pulsevault`) and as a framework-agnostic core (`@mieweb/pulsevault/core`) for Express, Meteor, or plain `http.createServer` — see [Non-Fastify hosts](#non-fastify-hosts-express-meteor-plain-http).
4
+
5
+ See also: [`PROTOCOL.md`](PROTOCOL.md) (the wire contract, independent of this implementation — read this if you're building a Pulse-compatible server *without* this package) and [`OPERATIONS.md`](OPERATIONS.md) (scaling, secrets, retention, monitoring).
6
+
7
+ Self-hosted video capture for places that can't ship recordings to a vendor. Pulse records the walkthrough on the phone; Pulsevault receives it inside the backend you already run, behind your auth, on your storage. Pair a device by QR code and upload over TUS so two-minute captures from the floor survive signal drops and device restarts.
8
+
9
+ Hook in at `authorize`, `validatePayload`, or `onUploadComplete` to bolt on whatever your institution already runs — SSO, audit logs, transcoding queues, AI pipelines. The plugin mounts a handful of routes; the rest stays yours. **Every deployment of this plugin is fully independent** — there's no central Pulse-run service anywhere in this picture:
10
+
11
+ ```mermaid
12
+ graph LR
13
+ subgraph "Org A — fully independent"
14
+ A1["Org A's Fastify server"] -->|registers| A2["@mieweb/pulsevault"]
15
+ A1 -->|owns| A3["artifactId minting, token issuance,\nTTL policy, secret rotation, revocation"]
16
+ end
17
+ subgraph "Org B — fully independent, no pulsevault"
18
+ B1["Org B's own server"] -->|implements from spec only| B2["PROTOCOL.md"]
19
+ B1 -->|owns| B3["its own auth scheme entirely"]
20
+ end
21
+ P["Pulse mobile app\nmulti-tenant client"] -->|pairs + uploads via open contract| A1
22
+ P -->|pairs + uploads via open contract| B1
23
+ P -.->|no dependency on either| X[("No central Pulse service")]
24
+ ```
36
25
 
37
26
  The local storage adapter writes to a stable on-disk layout (see [Local storage](#local-storage)) so you can layer post-processing — transcription, thumbnails, AI analysis — directly against the files from an `onUploadComplete` hook.
38
27
 
39
28
  ## Requirements
40
29
 
41
- - Fastify `^5.x`
42
30
  - Node.js `>=18`
31
+ - Fastify `^5.x` — only if you use the default `@mieweb/pulsevault` (Fastify
32
+ plugin) entry point. The framework-agnostic `@mieweb/pulsevault/core` entry
33
+ point (Express, Meteor, or plain `http.createServer`) has no Fastify
34
+ dependency at all — see [Non-Fastify hosts](#non-fastify-hosts-express-meteor-plain-http).
43
35
 
44
36
  ## Installation
45
37
 
@@ -57,35 +49,178 @@ import pulseVault, { createLocalStorage } from "@mieweb/pulsevault";
57
49
  const app = Fastify();
58
50
 
59
51
  await app.register(pulseVault, {
60
- prefix: "",
52
+ prefix: "/pulsevault",
61
53
  storage: createLocalStorage({ workspaceDir: "./data" }),
62
54
  maxUploadSize: 5 * 1024 * 1024 * 1024, // 5 GiB
63
55
  });
64
56
 
65
- // Your server owns videoid creation — attach auth, DB records, quotas here.
57
+ // Your server owns artifactId creation — attach auth, DB records, quotas here.
66
58
  app.post("/reserve", async (_req, reply) => {
67
- return reply.send({ videoid: randomUUID() });
59
+ return reply.send({ artifactId: randomUUID() });
68
60
  });
69
61
 
70
62
  await app.listen({ port: 3030 });
71
63
  ```
72
64
 
65
+ This is the minimal setup with no authentication — fine for local development,
66
+ not for production. See [`authorize`](#authorize) and [Capability tokens](#capability-tokens)
67
+ for a secure-by-default option, and `OPERATIONS.md` for the full production checklist.
68
+
69
+ ## Non-Fastify hosts (Express, Meteor, plain `http`)
70
+
71
+ Everything above the Fastify-specific route/hook wiring — the authorize/
72
+ validatePayload/onUploadComplete orchestration, the TUS glue, the
73
+ capabilities payload, artifact GET/DELETE — lives in a framework-agnostic
74
+ core that the Fastify plugin itself is a thin adapter over. That core is
75
+ published as a separate entry point, `@mieweb/pulsevault/core`, with no
76
+ Fastify dependency, so a non-Fastify backend gets full protocol parity for
77
+ about the same amount of code as the Fastify quick-start above.
78
+
79
+ `createPulseVaultCore(...)` returns a connect-style `handler(req, res, next?)`
80
+ you can mount directly:
81
+
82
+ ```ts
83
+ // Express
84
+ import express from "express";
85
+ import { randomUUID } from "node:crypto";
86
+ import { createPulseVaultCore, createLocalStorage } from "@mieweb/pulsevault/core";
87
+
88
+ const app = express();
89
+ const pulseVault = createPulseVaultCore({
90
+ basePath: "/pulsevault",
91
+ // Express's app.use(prefix, ...) already strips the mount prefix from
92
+ // req.url before calling the middleware, so tell the core not to also
93
+ // match/strip it — basePath is still used for the tus Location header.
94
+ stripBasePath: false,
95
+ storage: createLocalStorage({ workspaceDir: "./data" }),
96
+ maxUploadSize: 5 * 1024 * 1024 * 1024, // 5 GiB
97
+ });
98
+ app.use("/pulsevault", (req, res, next) => pulseVault.handler(req, res, next).catch(next));
99
+
100
+ app.post("/reserve", express.json(), (_req, res) => {
101
+ res.json({ artifactId: randomUUID() });
102
+ });
103
+
104
+ app.listen(3030);
105
+ ```
106
+
107
+ ```ts
108
+ // Meteor (server-only module)
109
+ import { WebApp } from "meteor/webapp";
110
+ import { createPulseVaultCore, createLocalStorage } from "@mieweb/pulsevault/core";
111
+
112
+ const pulseVault = createPulseVaultCore({
113
+ basePath: "/pulsevault",
114
+ stripBasePath: false, // WebApp.connectHandlers strips the mount prefix too
115
+ storage: createLocalStorage({ workspaceDir: process.env.PULSEVAULT_DIR }),
116
+ maxUploadSize: 5 * 1024 * 1024 * 1024,
117
+ });
118
+ WebApp.connectHandlers.use("/pulsevault", (req, res, next) => {
119
+ pulseVault.handler(req, res, next).catch(next);
120
+ });
121
+ ```
122
+
123
+ **Meteor compatibility note.** `WebApp.connectHandlers.use(prefix, handler)`
124
+ is the right integration point (it's `connect`, the same shape as Express),
125
+ but Meteor's bundler (tested: Meteor 3.4, `modules@0.20.3`) doesn't resolve
126
+ `package.json` `"exports"` subpath maps — neither this package's own
127
+ `"./core"` entry, nor (transitively, until this release) `@tus/server`'s
128
+ own dependency on `srvx`, which shipped *only* subpath exports with no
129
+ legacy `main` fallback. Both are worked around: this package pins
130
+ `@tus/server`/`@tus/file-store` to `2.0.0` (the last release before the
131
+ `srvx` migration — see `CHANGELOG.md`) and ships plain root-level
132
+ `core.js`/`augment.js` fallback files alongside the `"exports"` map, so
133
+ resolvers that ignore `"exports"` (Meteor's included) still find them via
134
+ ordinary relative-path resolution. Verified against a real
135
+ `meteor create` app: `WebApp.connectHandlers.use("/pulsevault", ...)`
136
+ resolves cleanly and a full TUS create → PATCH → GET round-trip works.
137
+
138
+ For a bare `http.createServer` (or any host that hands `handler` the
139
+ request's full, unmodified URL instead of pre-stripping a mount prefix),
140
+ leave `stripBasePath` at its default (`true`):
141
+
142
+ ```ts
143
+ import http from "node:http";
144
+ import { createPulseVaultCore, createLocalStorage } from "@mieweb/pulsevault/core";
145
+
146
+ const pulseVault = createPulseVaultCore({
147
+ basePath: "/pulsevault",
148
+ storage: createLocalStorage({ workspaceDir: "./data" }),
149
+ maxUploadSize: 5 * 1024 * 1024 * 1024,
150
+ });
151
+ http.createServer((req, res) => {
152
+ pulseVault.handler(req, res).catch((err) => {
153
+ res.writeHead(500);
154
+ res.end(String(err));
155
+ });
156
+ }).listen(3030);
157
+ ```
158
+
159
+ `@mieweb/pulsevault/core` re-exports everything the Fastify entry point does
160
+ (`createLocalStorage`, `createS3Storage`, `createMp4Sniffer`,
161
+ `createChecksumValidator`, `createCapabilityAuthorize`, etc.), so a
162
+ non-Fastify integration only ever imports from this one path — every option
163
+ documented under [Plugin options](#plugin-options) below (`authorize`,
164
+ `validatePayload`, `onUploadComplete`, `allowedExtensions`, `cache`, and so
165
+ on) applies identically. `createPulseVaultCore` differs from the Fastify
166
+ plugin's options in a few small ways: it takes `basePath` where the plugin
167
+ takes `prefix`; it has no `decoratorName` (there's no Fastify decorator to
168
+ name — keep your own reference to `storage` instead); it adds `stripBasePath`
169
+ (see above — `false` for hosts that already strip their own mount prefix,
170
+ default `true` otherwise); and it adds an optional `logger` (`{ info(obj,
171
+ msg?), error(obj, msg?) }`, Pino-shaped) for the core's own internal
172
+ diagnostics — the Fastify plugin always uses `request.log` for these
173
+ automatically, but a non-Fastify host has no equivalent to infer one from,
174
+ so it falls back to `console` when omitted.
175
+
176
+ See [`examples/express-demo`](examples/express-demo) and
177
+ [`examples/meteor-demo`](examples/meteor-demo) for full runnable servers
178
+ (pairing page, QR codes, auth demo, local/S3 storage) — the Express and
179
+ Meteor counterparts to [`examples/rn-demo`](examples/rn-demo)'s Fastify
180
+ demo, both built on `@mieweb/pulsevault/core` instead of the plugin. Both
181
+ are verified against the real frameworks, not just the test suite —
182
+ `meteor-demo` in particular against a real `meteor create` app, since
183
+ Meteor's bundler needed the compatibility fixes described above.
184
+
185
+ ## How a pairing + upload session flows
186
+
187
+ ```mermaid
188
+ sequenceDiagram
189
+ participant Org as Your server
190
+ participant User
191
+ participant Pulse as Pulse app
192
+
193
+ Org->>Org: mint artifactId + issue a token
194
+ Org->>User: show QR / deep link
195
+ User->>Pulse: open link
196
+ Pulse->>Pulse: validate link, show pairing screen
197
+ User->>Pulse: confirm and choose draft
198
+ Pulse->>Org: POST upload, Bearer token, kind video, checksum
199
+ Org->>Pulse: 201 Location
200
+ Pulse->>Org: PATCH chunks, HEAD before resume
201
+ Org->>Org: validatePayload, markReady, onUploadComplete
202
+ Pulse->>Org: POST upload, kind captions, same session
203
+ Org->>Org: validatePayload, markReady, onUploadComplete
204
+ Org->>Pulse: ready for playback and captions fetch
205
+ ```
206
+
73
207
  ## Routes
74
208
 
75
- The plugin mounts the following routes under `prefix`:
209
+ The plugin mounts the following routes under `prefix` (`@mieweb/pulsevault/core` mounts the identical set under `basePath` — see [Non-Fastify hosts](#non-fastify-hosts-express-meteor-plain-http)):
76
210
 
77
- | Method | Path | Description |
78
- | ------------------------------ | ------------- | ------------------------------------------------ |
79
- | `POST` | `/upload` | Create a TUS upload session |
80
- | `PATCH` / `HEAD` / `DELETE` \* | `/upload/:id` | Upload chunks, probe offset, cancel upload (TUS) |
81
- | `GET` | `/:videoid` | Stream or redirect to the uploaded video |
82
- | `DELETE` | `/:videoid` | Delete a finalized upload (bytes + sidecar) |
211
+ | Method | Path | Description |
212
+ | --- | --- | --- |
213
+ | `GET` | `/pulsevault/capabilities` | Unauthenticated discovery: protocol version, `uploadUnit`, allowed extensions, limits |
214
+ | `POST` | `/pulsevault/upload` | Create a TUS upload session |
215
+ | `PATCH` / `HEAD` / `DELETE` \* | `/pulsevault/upload/:id` | Upload chunks, probe offset, cancel upload (TUS) |
216
+ | `GET` | `/pulsevault/artifacts/:artifactId` | Stream or redirect to the uploaded artifact (any kind) |
217
+ | `DELETE` | `/pulsevault/artifacts/:artifactId` | Delete a finalized upload (bytes + sidecar) |
83
218
 
84
- \* `DELETE /upload/:id` is TUS's own "cancel in-flight upload" — distinct from `DELETE /:videoid`, which removes a finalized video.
219
+ \* `DELETE /pulsevault/upload/:id` is TUS's own "cancel in-flight upload" — distinct from `DELETE /pulsevault/artifacts/:artifactId`, which removes a finalized artifact.
85
220
 
86
- > `POST /reserve` is **not** part of the plugin. Your server implements it so you control auth, ownership, and any business logic tied to video creation.
221
+ > `POST /reserve` is **not** part of the plugin. Your server implements it so you control auth, ownership, and any business logic tied to artifact creation.
87
222
 
88
- `GET /:videoid` only serves uploads whose adapter has been told to mark them ready. With the built-in local adapter, that means the final PATCH has landed _and_ `validatePayload` (if configured) accepted the bytes. In-progress uploads return 404.
223
+ `GET /pulsevault/artifacts/:artifactId` only serves uploads whose adapter has been told to mark them ready. With the built-in local adapter, that means the final PATCH has landed _and_ `validatePayload` (if configured) accepted the bytes. In-progress uploads return 404. The artifact's kind (`video`/`project`/`captions`) is resolved from storage, not the URL — there's one route for every kind.
89
224
 
90
225
  ## Plugin options
91
226
 
@@ -94,22 +229,31 @@ type PulseVaultPluginOptions = {
94
229
  storage: PulseVaultStorage;
95
230
  prefix: string;
96
231
  maxUploadSize: number;
97
- decoratorName?: string; // default: "pulseVault"
98
- allowedExtensions?: string[]; // default: [".mp4"]
232
+ uploadUnit?: "beat" | "merged"; // default: "beat" — see "Upload unit"
233
+ decoratorName?: string; // default: "pulseVault"
234
+ allowedExtensions?:
235
+ | string[] // legacy — treated as video-only
236
+ | { video?: string[]; project?: string[]; captions?: string[] }; // per-kind (recommended)
237
+ // defaults: { video: [".mp4"], project: [".pulse", ".zip"], captions: [".srt"] }
99
238
  cache?: PulseVaultCacheOptions;
100
239
  authorize?: PulseVaultAuthorize;
101
- validatePayload?: PulseVaultValidatePayload;
102
- onUploadComplete?: PulseVaultOnUploadComplete;
240
+ validatePayload?: PulseVaultValidatePayload; // runs for every kind; branch on ctx.kind
241
+ onUploadComplete?: PulseVaultOnUploadComplete; // runs for every kind; branch on ctx.kind
242
+ onArtifactEvent?: PulseVaultOnArtifactEvent; // low-frequency hook for metrics + audit logging
243
+ /** @deprecated use validatePayload + ctx.kind === "project" */
244
+ validateProjectPayload?: PulseVaultValidatePayload;
245
+ /** @deprecated use onUploadComplete + ctx.kind === "project" */
246
+ onProjectUploadComplete?: PulseVaultOnUploadComplete;
103
247
  };
104
248
  ```
105
249
 
106
250
  ### `storage`
107
251
 
108
- A `PulseVaultStorage` adapter. Use the built-in `createLocalStorage` for filesystem-backed deployments (the blessed default) or implement the interface for custom backends (S3, GCS, etc.).
252
+ A `PulseVaultStorage` adapter. Use the built-in `createLocalStorage` for filesystem-backed deployments (the blessed default), `createS3Storage` for Cloudflare R2 / AWS S3 (see [Object storage](#object-storage-cloudflare-r2--aws-s3)), or implement the interface for custom backends (GCS, database, etc.).
109
253
 
110
254
  ### `prefix`
111
255
 
112
- URL prefix for all plugin routes. Use `""` to mount at the root or `"/pulsevault"` to namespace. Must start with `/` (no trailing slash) or be `""`.
256
+ URL prefix for all plugin routes. Set to `"/pulsevault"` for the standard namespaced mount. Use `""` to mount at the root. Must start with `/` (no trailing slash) or be `""`.
113
257
 
114
258
  > Because the plugin uses `fastify-plugin` to escape encapsulation, Fastify's native `register(..., { prefix })` is a no-op — always pass `prefix` through this option.
115
259
 
@@ -117,6 +261,17 @@ URL prefix for all plugin routes. Use `""` to mount at the root or `"/pulsevault
117
261
 
118
262
  Maximum upload size in bytes. Use `Infinity` for no cap.
119
263
 
264
+ ### Upload unit
265
+
266
+ `uploadUnit: "beat" | "merged"` — purely advertised via `GET /pulsevault/capabilities`; **the plugin doesn't enforce either.** It tells the client which upload strategy this deployment expects:
267
+
268
+ - `"beat"` (default): the client uploads each clip individually (no merge/re-encode pass), plus a `kind=project` manifest artifact for ordering. Lower client-side cost, finer-grained resumability.
269
+ - `"merged"`: the client pre-merges a pulse's clips into one video before uploading. Simpler server-side mental model (one artifact per pulse), more client-side work.
270
+
271
+ A client reads this at pairing time, before doing any merge or upload work, and branches accordingly. See `PROTOCOL.md` §8 for the full contract.
272
+
273
+ Need both strategies live at once instead of one fixed value for the whole deployment? Pass `uploadUnit` to [`buildUploadLink`](#deep-link-helper) per session — it overrides `/capabilities` for that pairing only, with no server-side option to change.
274
+
120
275
  ### `decoratorName`
121
276
 
122
277
  Name of the Fastify decorator that exposes the storage adapter on the instance. Defaults to `"pulseVault"`. Override when registering the plugin more than once in the same process.
@@ -129,11 +284,24 @@ import "@mieweb/pulsevault/augment";
129
284
 
130
285
  ### `allowedExtensions`
131
286
 
132
- File extensions accepted in `Upload-Metadata.filename`. Must include the leading dot. Defaults to `[".mp4"]`.
287
+ File extensions accepted per artifact kind. Three accepted forms:
288
+
289
+ ```ts
290
+ // 1. Omit entirely — uses all three defaults:
291
+ // video: [".mp4"] project: [".pulse", ".zip"] captions: [".srt"]
292
+
293
+ // 2. Flat array (legacy) — video-only; project/captions keep their defaults:
294
+ allowedExtensions: [".mp4"]
295
+
296
+ // 3. Per-kind object — unset keys fall back to their defaults:
297
+ allowedExtensions: { video: [".mp4"], project: [".pulse"], captions: [".srt"] }
298
+ ```
299
+
300
+ All extensions must include the leading dot and are matched case-insensitively. The `kind` field in `Upload-Metadata` determines which list is checked.
133
301
 
134
302
  ### `cache`
135
303
 
136
- Cache-control options for the `GET /:videoid` route, forwarded to `@fastify/send`:
304
+ Cache-control options for the `GET /artifacts/:artifactId` route, forwarded to `@fastify/send`:
137
305
 
138
306
  ```ts
139
307
  type PulseVaultCacheOptions = {
@@ -149,12 +317,22 @@ Upload filenames are keyed by UUID, so `immutable: true` is safe when `maxAge` i
149
317
 
150
318
  Optional async hook called before TUS create/patch, before GET resolve, and before DELETE. Throw to reject — a `statusCode` or `status_code` number on the thrown error is used as the HTTP status (default `403`).
151
319
 
320
+ Phase mapping: `"create"` is the initial TUS `POST`; `"patch"` covers **every other request on the upload routes** — `PATCH` chunks, `HEAD` offset queries, **and the in-flight cancel `DELETE {prefix}/upload/<id>`**; `"resolve"` is `GET {prefix}/artifacts/<id>`; `"delete"` fires **only** for the finalized-artifact route `DELETE {prefix}/artifacts/<id>`. If you gate deletion on `phase === "delete"` alone, you are not gating upload cancellation — that arrives as `"patch"`.
321
+
152
322
  ```ts
153
323
  type PulseVaultAuthorize = (
154
- request: FastifyRequest,
324
+ // PulseVaultRequest only requires `.headers` — under the Fastify plugin
325
+ // this is always the real `FastifyRequest` object (cast if you need
326
+ // Fastify-specific fields); under @mieweb/pulsevault/core it's whatever
327
+ // the host framework hands the handler (Express's `req`, a raw
328
+ // `http.IncomingMessage`, etc.) — the same hook works under either.
329
+ request: PulseVaultRequest,
155
330
  ctx: {
156
331
  phase: "create" | "patch" | "resolve" | "delete";
157
- videoid: string;
332
+ artifactId: string;
333
+ kind: "video" | "project" | "captions"; // artifact kind; always present
334
+ token?: string; // only on "resolve" phase
335
+ relatedTo?: string; // the session-anchor artifact this one belongs to, if any
158
336
  },
159
337
  ) => void | Promise<void>;
160
338
  ```
@@ -162,38 +340,44 @@ type PulseVaultAuthorize = (
162
340
  ```ts
163
341
  await app.register(pulseVault, {
164
342
  // ...
165
- authorize: async (request, { phase, videoid }) => {
343
+ authorize: async (request, { phase, artifactId, kind }) => {
166
344
  const token = request.headers.authorization?.replace("Bearer ", "");
167
- if (!isValid(token, videoid)) {
345
+ if (!isValid(token, artifactId)) {
168
346
  throw Object.assign(new Error("Forbidden"), { statusCode: 403 });
169
347
  }
170
348
  },
171
349
  });
172
350
  ```
173
351
 
352
+ Don't want to write your own auth from scratch? See [Capability tokens](#capability-tokens) for a secure-by-default option.
353
+
174
354
  ### `validatePayload`
175
355
 
176
- Optional async hook that runs _after_ TUS writes the final byte but _before_ the upload is marked ready or `onUploadComplete` fires. Throw to reject — the plugin calls `storage.remove` to free the bytes and returns a 4xx (default 422) to the client. The sidecar never flips to `"ready"`, so the video is never served.
356
+ Optional async hook that runs _after_ TUS writes the final byte but _before_ the upload is marked ready or `onUploadComplete` fires — **for every artifact kind**, with `ctx.kind` telling you which. Throw to reject — the plugin calls `storage.remove` to free the bytes and returns a 4xx (default 422) to the client. The sidecar never flips to `"ready"`, so the artifact is never served.
177
357
 
178
358
  ```ts
179
359
  type PulseVaultValidatePayload = (
180
- request: FastifyRequest,
360
+ request: PulseVaultRequest, // see the note under `authorize` above
181
361
  ctx: {
182
- videoid: string;
362
+ artifactId: string;
183
363
  size: number;
184
364
  uploadId: string;
365
+ kind: "video" | "project" | "captions";
185
366
  /** Absolute path to finalized bytes for adapters that expose `getLocalPath`. */
186
367
  localPath: string | null;
368
+ /** Client-supplied `<algorithm>:<hex>` digest, if `Upload-Metadata.checksum` was sent. */
369
+ checksum?: string;
187
370
  },
188
371
  ) => void | Promise<void>;
189
372
  ```
190
373
 
191
- Use for magic-byte sniffing, virus scanning, size re-checks — anything that needs the final bytes. A ready-made helper ships with the package:
374
+ Use for magic-byte sniffing, checksum verification, virus scanning, size re-checks — anything that needs the final bytes. Ready-made helpers ship with the package:
192
375
 
193
376
  ```ts
194
377
  import pulseVault, {
195
378
  createLocalStorage,
196
379
  createMp4Sniffer,
380
+ createChecksumValidator,
197
381
  } from "@mieweb/pulsevault";
198
382
 
199
383
  const storage = createLocalStorage({ workspaceDir: "./data" });
@@ -201,32 +385,147 @@ const storage = createLocalStorage({ workspaceDir: "./data" });
201
385
  await app.register(pulseVault, {
202
386
  // ...
203
387
  storage,
204
- validatePayload: createMp4Sniffer(storage),
388
+ // Chain validators: checksum runs first, then the MP4 sniff.
389
+ validatePayload: createChecksumValidator(createMp4Sniffer(storage)),
205
390
  });
206
391
  ```
207
392
 
208
- `createMp4Sniffer` reads the first 12 bytes and verifies the ISOBMFF `ftyp` header (MP4, MOV, M4V, 3GP). Uploads that pass the extension check but contain non-video bytes are rejected with 422 and the disk is cleaned up.
393
+ `createMp4Sniffer` reads the first 12 bytes and verifies the ISOBMFF `ftyp` header (MP4, MOV, M4V, 3GP). Uploads that pass the extension check but contain non-video bytes are rejected with 422 and the disk is cleaned up. The lower-level `sniffMp4(path)` is also exported if you want to drive your own validator.
394
+
395
+ `createChecksumValidator`/`createS3ChecksumValidator` parse `ctx.checksum` with the also-exported `parseChecksumMetadata(raw)`, which returns `{ algorithm, digest }` or `null` for a missing/malformed value — use it directly if you're writing your own checksum check instead of the ship-ready validators.
209
396
 
210
- The lower-level `sniffMp4(path)` is also exported if you want to drive your own validator.
397
+ `createMp4Sniffer`/`createChecksumValidator` only make sense for `kind=video`/general payloads — they run unconditionally for every kind your hook receives, so branch on `ctx.kind` yourself if you only want them applied to one kind, or compose differently per kind:
398
+
399
+ ```ts
400
+ validatePayload: async (request, ctx) => {
401
+ if (ctx.kind === "video") return createMp4Sniffer(storage)(request, ctx);
402
+ // project/captions: no byte-level check, or your own.
403
+ },
404
+ ```
211
405
 
212
406
  ### `onUploadComplete`
213
407
 
214
- Optional async hook fired once the final byte is written, `validatePayload` has passed, and the sidecar has been marked ready. Use it to update a database row, enqueue a job, or write an audit log. Throwing returns a `500` to the client. The video is ready at this point — if you want all-or-nothing semantics, call `storage.remove` before throwing.
408
+ Optional async hook fired once the final byte is written, `validatePayload` has passed, and the sidecar has been marked ready — **for every artifact kind**, with `ctx.kind` telling you which. Use it to update a database row, enqueue a job, or write an audit log. Throwing returns a `500` to the client. The artifact is ready at this point — if you want all-or-nothing semantics, call `storage.remove` before throwing.
215
409
 
216
410
  ```ts
217
411
  type PulseVaultOnUploadComplete = (
218
- request: FastifyRequest,
219
- ctx: { videoid: string; size: number; uploadId: string },
412
+ request: PulseVaultRequest, // see the note under `authorize` above
413
+ ctx: { artifactId: string; kind: "video" | "project" | "captions"; size: number; uploadId: string },
220
414
  ) => void | Promise<void>;
221
415
  ```
222
416
 
417
+ ### `onArtifactEvent`
418
+
419
+ Optional low-frequency hook — fired on authorize rejection (`create`/`delete`/`resolve` phases only, never per-chunk `patch`), upload completion, and payload-validation rejection. One hook covers both ops metrics and a compliance audit trail instead of hand-wiring both from the lower-level hooks above:
420
+
421
+ ```ts
422
+ type PulseVaultArtifactEvent = {
423
+ phase: "authorize" | "complete" | "reject";
424
+ artifactId: string;
425
+ kind: "video" | "project" | "captions";
426
+ size?: number;
427
+ reason?: string; // present for "authorize" and "reject"
428
+ };
429
+
430
+ onArtifactEvent: (event) => {
431
+ app.log.info(event, "pulsevault artifact event"); // or push to a metrics/audit sink
432
+ },
433
+ ```
434
+
435
+ See `OPERATIONS.md` for a Prometheus-counter example and what to alert on.
436
+
437
+ ### `validateProjectPayload` / `onProjectUploadComplete` (deprecated)
438
+
439
+ Same lifecycle as `validatePayload`/`onUploadComplete`, but only fired for `kind=project` uploads. **Deprecated** — use the generic `validatePayload`/`onUploadComplete` with a `ctx.kind === "project"` branch instead. Still honored this release (passing either emits a one-time `DeprecationWarning` at registration); will be removed in a future major version.
440
+
223
441
  ### Upload-complete sequencing
224
442
 
225
- When the final PATCH lands, the plugin runs the following in order. Any step failing short-circuits the rest.
443
+ When the final PATCH lands the plugin runs the following steps in order, for every kind. Any step failing short-circuits the rest.
444
+
445
+ 1. **`validatePayload`** (optional, runs for every kind) — throws → `storage.remove(artifactId)`, HTTP 4xx (default 422).
446
+ 2. **`storage.markReady(artifactId)`** — flips the sidecar so `resolve()` will serve the bytes.
447
+ 3. **`onUploadComplete`** (optional, runs for every kind) — throws → HTTP 500; bytes remain ready unless the consumer removes them.
448
+
449
+ (`validateProjectPayload`/`onProjectUploadComplete`, if passed, run instead of the generic hooks specifically for `kind=project` — see the deprecation note above.)
450
+
451
+ ## Capabilities discovery
452
+
453
+ `GET {prefix}/capabilities` is unauthenticated (no secrets in the response) and lets a client detect protocol compatibility and this deployment's configuration before pairing:
454
+
455
+ ```json
456
+ {
457
+ "protocolVersion": 1,
458
+ "minSupportedVersion": 1,
459
+ "maxSupportedVersion": 1,
460
+ "uploadUnit": "beat",
461
+ "kinds": ["video", "project", "captions"],
462
+ "allowedExtensions": { "video": [".mp4"], "project": [".pulse", ".zip"], "captions": [".srt"] },
463
+ "maxUploadSize": 5368709120,
464
+ "checksum": { "algorithms": ["sha256", "sha1", "md5"] }
465
+ }
466
+ ```
467
+
468
+ `checksum.algorithms` always lists what `createChecksumValidator`/`createS3ChecksumValidator` are capable of verifying — it does not detect whether this deployment's `validatePayload` actually calls one of them. A client sending a `checksum` metadata value is only verified if the operator wired in one of these helpers (or an equivalent check of their own).
469
+
470
+ See `PROTOCOL.md` §2 for the full normative shape.
226
471
 
227
- 1. **`validatePayload`** (optional) — throws → `storage.remove(videoid)`, HTTP 4xx (default 422).
228
- 2. **`storage.markReady(videoid)`** — flips the sidecar so `resolve()` will serve the bytes.
229
- 3. **`onUploadComplete`** (optional) — throws → HTTP 500; bytes remain on disk and ready unless the consumer explicitly removes them.
472
+ ## Capability tokens
473
+
474
+ Don't want to write your own auth scheme? `issueCapabilityToken`/`verifyCapabilityToken`/`createCapabilityAuthorize` implement a stateless, HMAC-signed token — no server-side session table, so you can rotate secrets, change TTL policy, or revoke at the artifact level entirely on your own schedule.
475
+
476
+ ```ts
477
+ import pulseVault, {
478
+ createLocalStorage,
479
+ createCapabilityAuthorize,
480
+ issueCapabilityToken,
481
+ buildUploadLink,
482
+ } from "@mieweb/pulsevault";
483
+ import { randomUUID } from "node:crypto";
484
+
485
+ const keys = { "2026-06": process.env.PULSEVAULT_KEY_2026_06! }; // add the previous key during rotation
486
+ const issuer = "https://vault.example.org"; // identity claim — no path, just who issued the token
487
+ const prefix = "/pulsevault";
488
+
489
+ await app.register(pulseVault, {
490
+ prefix,
491
+ storage: createLocalStorage({ workspaceDir: "./data" }),
492
+ maxUploadSize: 5 * 1024 * 1024 * 1024,
493
+ authorize: createCapabilityAuthorize((kid) => keys[kid] ?? null, { issuer }),
494
+ });
495
+
496
+ app.post("/pair", async (_req, reply) => {
497
+ const artifactId = randomUUID();
498
+ const token = issueCapabilityToken(artifactId, keys["2026-06"], {
499
+ keyId: "2026-06",
500
+ issuer,
501
+ expirySeconds: 1800, // 30 minutes — long enough for one upload session
502
+ });
503
+ // `server` is the full base URL the client uploads to — origin *plus* the
504
+ // plugin's prefix — not just `issuer`'s bare origin.
505
+ return reply.send({ link: buildUploadLink({ server: `${issuer}${prefix}`, artifactId, token }) });
506
+ });
507
+ ```
508
+
509
+ A token authorizes either the artifact it names, or any artifact that declares that one as its `relatedTo` — so one token issued for a video also covers its captions and (under `uploadUnit: "beat"`) every beat and the manifest in the same session, without minting a token per artifact. See `PROTOCOL.md` §5.4 for the full claim shape (`kid`/`iat`/`exp`/`issuer`/`artifactId`) and rationale.
510
+
511
+ ## Upload-Metadata protocol
512
+
513
+ The TUS `Upload-Metadata` header is a comma-separated list of `<key> <base64>` pairs. PulseVault reads the following keys on `POST /upload`:
514
+
515
+ | Key | Required | Description |
516
+ |---|---|---|
517
+ | `artifactId` | Yes (or `videoid`/`projectid`) | Server-generated UUID for this upload. |
518
+ | `videoid` / `projectid` | Legacy aliases for `artifactId` | Accepted as synonyms indefinitely (protocol v1). Use `artifactId` for new code. |
519
+ | `filename` | Yes | Original filename. The extension is validated against the kind's allowed list. |
520
+ | `kind` | No | `video` (default), `project`, or `captions`. Determines the storage subdir and which hooks fire. |
521
+ | `relatedTo` | No | UUID of another artifact this one belongs to (e.g. a video's captions). Lets one capability token authorize a whole session. |
522
+ | `checksum` | No | `<algorithm>:<hex digest>` of the finished file, verified by `createChecksumValidator`/`createS3ChecksumValidator` if configured. |
523
+
524
+ Example (`kind=captions`, linked to a video):
525
+
526
+ ```
527
+ Upload-Metadata: artifactId <base64(uuid)>, filename <base64("clip.srt")>, kind <base64("captions")>, relatedTo <base64(videoArtifactId)>
528
+ ```
230
529
 
231
530
  ## Local storage
232
531
 
@@ -234,24 +533,31 @@ When the final PATCH lands, the plugin runs the following in order. Any step fai
234
533
  import { createLocalStorage } from "@mieweb/pulsevault";
235
534
 
236
535
  const storage = createLocalStorage({
237
- workspaceDir: "./data", // directory for uploads; created if absent
536
+ workspaceDir: "./data", // directory for uploads; created if absent
537
+ metaCacheLimit: 10_000, // optional — bounds the in-memory metadata cache
238
538
  });
239
539
  ```
240
540
 
241
541
  ### Filesystem layout (stable contract)
242
542
 
243
- The local adapter writes each upload into a self-describing per-video directory. Downstream tools may rely on this layout across minor versions:
543
+ The local adapter writes uploads into flat kind-scoped subdirectories. Downstream tools may rely on this layout across minor versions:
244
544
 
245
545
  ```text
246
- <workspaceRoot>/<videoid>/
247
- .pulsevault.json # sidecar: { version, ext, filename, status }
248
- video/<videoid><ext> # upload bytes (partial during upload, full when ready)
249
- video/<videoid><ext>.json # @tus/file-store's offset/metadata sidecar
546
+ <workspaceRoot>/
547
+ .pulsevault/<id>.json # sidecar: { version, ext, filename, status, kind, relatedTo, checksum }
548
+ video/<id><ext> # video upload bytes (kind="video")
549
+ video/<id><ext>.json # @tus/file-store offset/metadata sidecar
550
+ project/<id><ext> # project bundle bytes (kind="project")
551
+ project/<id><ext>.json # @tus/file-store offset/metadata sidecar
552
+ captions/<id><ext> # captions bytes (kind="captions")
553
+ captions/<id><ext>.json # @tus/file-store offset/metadata sidecar
250
554
  ```
251
555
 
252
- `status` is `"uploading"` between `reserveUpload` and the successful final PATCH; `"ready"` thereafter. `GET /:videoid` only serves `"ready"` uploads.
556
+ `status` is `"uploading"` between `reserveUpload` and the successful final PATCH; `"ready"` thereafter. `GET /artifacts/:id` only serves `"ready"` uploads. `kind` defaults to `"video"` when absent (back-compat with pre-kind sidecars).
557
+
558
+ The adapter exposes `storage.workspaceRoot` (absolute, resolved from `workspaceDir`) so consumers can compute per-resource paths without re-implementing the layout. `storage.getKind(id)` returns `"video" | "project" | "captions" | null`; `storage.getRelatedTo(id)` and `storage.getChecksum(id)` return the corresponding sidecar fields, each `null` if absent/unknown.
253
559
 
254
- The adapter exposes `storage.workspaceRoot` (absolute, resolved from `workspaceDir`) so consumers can compute per-video paths without re-implementing the layout.
560
+ **Horizontal scaling**: this adapter requires sticky-session routing or a shared filesystem across instances — see `OPERATIONS.md`.
255
561
 
256
562
  ### Post-processing (transcription, thumbnails, AI)
257
563
 
@@ -265,13 +571,14 @@ import pulseVault, { createLocalStorage } from "@mieweb/pulsevault";
265
571
  const storage = createLocalStorage({ workspaceDir: "./data" });
266
572
 
267
573
  await app.register(pulseVault, {
268
- prefix: "",
574
+ prefix: "/pulsevault",
269
575
  storage,
270
576
  maxUploadSize: 5 * 1024 * 1024 * 1024,
271
- onUploadComplete: async (_req, { videoid }) => {
272
- const root = path.join(storage.workspaceRoot, videoid);
273
- const pod = new ArtiPod({ id: videoid, useMainMount: false });
274
- pod.addMount(new ArtiMount("video", path.join(root, "video")));
577
+ onUploadComplete: async (_req, { artifactId, kind }) => {
578
+ if (kind !== "video") return;
579
+ const videoDir = path.join(storage.workspaceRoot, "video");
580
+ const pod = new ArtiPod({ id: artifactId, useMainMount: false });
581
+ pod.addMount(new ArtiMount("video", videoDir));
275
582
  // Create these lazily as your pipeline produces artifacts:
276
583
  // pod.addMount(new ArtiMount("transcripts", path.join(root, "transcripts")));
277
584
  // pod.addMount(new ArtiMount("frames", path.join(root, "frames")));
@@ -284,6 +591,89 @@ await app.register(pulseVault, {
284
591
 
285
592
  `@mieweb/artipod` is **not** a dependency of this plugin — install it in your app only if you want it. Any filesystem-native pipeline (ffmpeg, whisper, rsync) works equally well.
286
593
 
594
+ ## Object storage (Cloudflare R2 / AWS S3)
595
+
596
+ `createS3Storage` is a built-in adapter that streams uploads into an S3-compatible bucket via S3 multipart upload and serves playback by redirecting the client to a short-lived **presigned URL** (so bytes never flow back through your server). Because Cloudflare R2 speaks the S3 API, the same adapter covers both R2 and AWS S3 — they differ only by endpoint and credentials. It has no horizontal-scaling caveat — every instance talks to the same bucket.
597
+
598
+ It's **opt-in**: the AWS SDK and `@tus/s3-store` are `optionalDependencies`, loaded lazily only when you call `createS3Storage`. Install them in your app:
599
+
600
+ ```bash
601
+ npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @tus/s3-store
602
+ ```
603
+
604
+ `createS3Storage` is **async** (it lazily imports those packages), so `await` it before registering the plugin.
605
+
606
+ **Cloudflare R2:**
607
+
608
+ ```ts
609
+ import pulseVault, { createS3Storage, createS3Mp4Sniffer } from "@mieweb/pulsevault";
610
+
611
+ const storage = await createS3Storage({
612
+ bucket: "pulse-videos",
613
+ endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
614
+ accessKeyId: process.env.R2_ACCESS_KEY,
615
+ secretAccessKey: process.env.R2_SECRET_KEY,
616
+ });
617
+
618
+ await app.register(pulseVault, {
619
+ prefix: "/pulsevault",
620
+ storage,
621
+ maxUploadSize: 5 * 1024 * 1024 * 1024,
622
+ validatePayload: createS3Mp4Sniffer(storage), // ranged-read MP4 sniff
623
+ });
624
+ ```
625
+
626
+ **AWS S3** — drop the custom `endpoint` and set `region`:
627
+
628
+ ```ts
629
+ const storage = await createS3Storage({
630
+ bucket: "pulse-videos",
631
+ region: "us-east-1",
632
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
633
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
634
+ });
635
+ ```
636
+
637
+ Credentials are optional — omit `accessKeyId`/`secretAccessKey` to use the AWS SDK's default credential chain (env vars, IAM role, etc.). **Never hard-code keys; read them from the environment** — see `OPERATIONS.md` for a secrets-manager example.
638
+
639
+ ### Object layout
640
+
641
+ ```text
642
+ <bucket>/
643
+ .pulsevault/<id>.json # metadata sidecar: { version, ext, filename, status, kind, relatedTo, checksum }
644
+ video/<id><ext> # finalized video object (kind="video")
645
+ project/<id><ext> # finalized project object (kind="project")
646
+ captions/<id><ext> # finalized captions object (kind="captions")
647
+ <key>.info # @tus/s3-store multipart bookkeeping (transient)
648
+ ```
649
+
650
+ `resolve()` only returns a presigned URL once the sidecar `status` is `"ready"` (after the final byte lands and `validatePayload` passes), so in-progress or rejected uploads are never served. `getKind(id)` returns the kind without a full resolve; `getRelatedTo(id)`/`getChecksum(id)` mirror the local adapter.
651
+
652
+ ### Options
653
+
654
+ | Option | Default | Notes |
655
+ | --- | --- | --- |
656
+ | `bucket` | — | Required. Must already exist. |
657
+ | `endpoint` | — | Custom S3 endpoint (set for R2). Omit for AWS S3. |
658
+ | `region` | `"auto"` when `endpoint` is set | Required for AWS S3. |
659
+ | `accessKeyId` / `secretAccessKey` | SDK default chain | Optional; omit both to use env/IAM credentials. |
660
+ | `sessionToken` | — | Optional STS session token for temporary credentials. |
661
+ | `forcePathStyle` | `true` when `endpoint` is set | R2 and most S3-compatible stores need path-style. |
662
+ | `presignTtlSeconds` | `900` | Lifetime of the playback presigned URL. |
663
+ | `partSize` | computed | Preferred multipart part size (≥ 5 MiB), forwarded to `@tus/s3-store`. |
664
+ | `metaCacheLimit` | `10000` | Caps the in-memory metadata cache before evicting the oldest entry. |
665
+ | `clientConfig` | — | Advanced: extra `S3ClientConfig` merged into the client. |
666
+
667
+ > The runnable demo wires these to environment variables — see [`examples/rn-demo/.env.example`](examples/rn-demo/.env.example) for the full list (`STORAGE`, `S3_BUCKET`, `S3_ENDPOINT`, `AWS_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, …).
668
+
669
+ ### Payload validation on remote storage
670
+
671
+ The default `createMp4Sniffer`/`createChecksumValidator` read a local file path, which doesn't exist for a bucket object. Use **`createS3Mp4Sniffer(storage)`**/**`createS3ChecksumValidator(storage)`** instead: they fetch the bytes they need via the adapter (a small ranged GET for the MP4 sniff; a full object download for the checksum, since a digest needs every byte — streamed through the hash via `storage.digestAll` rather than buffered into memory at once) rather than assuming a local path.
672
+
673
+ ### Direct playback & CORS
674
+
675
+ Because playback is a 302 redirect to a presigned URL, a browser fetching it goes **directly** to R2/S3. If you play back from a web origin, add a bucket CORS rule allowing your origin (`GET`, and `Range` for seeking). Native clients that follow redirects need no CORS.
676
+
287
677
  ## Custom storage adapter
288
678
 
289
679
  Implement `PulseVaultStorage` to back uploads with any system (S3, GCS, database, etc.):
@@ -302,68 +692,86 @@ const storage: PulseVaultStorage = {
302
692
  async shutdown() {
303
693
  /* optional teardown */
304
694
  },
305
- async reserveUpload({ videoid, filename, ext }) {
695
+ async reserveUpload({ artifactId, filename, ext, kind, relatedTo, checksum }) {
306
696
  // Called by the TUS naming function. Return the file id for the datastore.
307
- await db.createVideo({ videoid, filename, status: "uploading" });
308
- return `${videoid}${ext}`;
697
+ await db.createArtifact({ artifactId, filename, kind, relatedTo, checksum, status: "uploading" });
698
+ return `${kind}/${artifactId}${ext}`;
309
699
  },
310
- async resolve(videoid): Promise<PulseVaultResolution | null> {
311
- const video = await db.findVideo(videoid);
312
- if (!video || video.status !== "ready") return null;
700
+ async resolve(artifactId): Promise<PulseVaultResolution | null> {
701
+ const artifact = await db.findArtifact(artifactId);
702
+ if (!artifact || artifact.status !== "ready") return null;
313
703
  // Stream from local disk:
314
- return { kind: "stream", root: "/uploads", filename: video.filename };
704
+ return { kind: "stream", root: "/uploads", filename: artifact.filename };
315
705
  // Or redirect to a CDN / presigned URL:
316
- // return { kind: "redirect", url: video.signedUrl, statusCode: 302 };
706
+ // return { kind: "redirect", url: artifact.signedUrl, statusCode: 302 };
317
707
  },
318
- async markReady(videoid) {
708
+ async markReady(artifactId) {
319
709
  // Called after `validatePayload` (if any) accepts the bytes. Flip your
320
710
  // state so `resolve` starts returning non-null. Omit this method if
321
711
  // your backend can't distinguish in-progress from finalized uploads.
322
- await db.updateVideo(videoid, { status: "ready" });
712
+ await db.updateArtifact(artifactId, { status: "ready" });
323
713
  },
324
- async remove(videoid) {
325
- // Called from DELETE /:videoid and from the plugin's cleanup path when
326
- // `validatePayload` rejects an upload. Return false if the videoid was
327
- // already absent.
328
- const result = await db.deleteVideo(videoid);
714
+ async remove(artifactId) {
715
+ // Called from DELETE /artifacts/:artifactId and from the plugin's
716
+ // cleanup path when `validatePayload` rejects an upload. Return false if
717
+ // the artifactId was already absent.
718
+ const result = await db.deleteArtifact(artifactId);
329
719
  return result.deleted;
330
720
  },
721
+ // Optional — only needed if you use relatedTo/checksum-aware hooks/validators:
722
+ async getKind(artifactId) { return (await db.findArtifact(artifactId))?.kind ?? null; },
723
+ async getRelatedTo(artifactId) { return (await db.findArtifact(artifactId))?.relatedTo ?? null; },
724
+ async getChecksum(artifactId) { return (await db.findArtifact(artifactId))?.checksum ?? null; },
331
725
  };
332
726
  ```
333
727
 
334
- ## Deep link helpers
728
+ ## Deep link helper
335
729
 
336
- Use these to generate `pulsecam://` deep links for pairing the Pulse mobile app with your server. Typically encoded as QR codes on a pairing page.
730
+ Use this to generate a `pulsecam://` deep link for pairing the Pulse mobile app with your server. Typically encoded as a QR code on a pairing page.
337
731
 
338
732
  ```ts
339
- import {
340
- buildConfigureDestinationLink,
341
- buildUploadLink,
342
- } from "@mieweb/pulsevault";
733
+ import { buildUploadLink } from "@mieweb/pulsevault";
343
734
  import { randomUUID } from "node:crypto";
344
735
 
345
- // Adds this server as a saved destination in the Pulse app.
346
- const configureLink = buildConfigureDestinationLink({
347
- server: "https://example.com",
348
- name: "My Server", // optional — shown in the app's destination list
349
- token: "secret", // optional — forwarded to your authorize hook
350
- });
351
-
352
- // Opens the app directly on the upload screen for a specific video.
736
+ // Opens the app directly on the upload screen for a specific artifact.
737
+ // `server` must include your `prefix` — e.g. "https://example.com/pulsevault",
738
+ // not just the bare origin — the client has no separate notion of a prefix.
353
739
  const uploadLink = buildUploadLink({
354
- server: "https://example.com",
355
- videoid: randomUUID(), // generate server-side; skip POST /reserve on the app
356
- token: "secret", // optional
740
+ server: "https://example.com/pulsevault",
741
+ artifactId: randomUUID(), // generate server-side; skip POST /reserve on the app
742
+ token: "secret", // optional — forwarded to your authorize hook; see Capability tokens
743
+ uploadUnit: "merged", // optional — see "Upload unit" below
357
744
  });
745
+ // pulsecam://?v=1&artifactId=...&server=https%3A%2F%2Fexample.com%2Fpulsevault&token=secret&uploadUnit=merged
358
746
  ```
359
747
 
748
+ `uploadUnit` on the link is a **per-session override** of whatever `GET /capabilities` currently reports (PROTOCOL.md §3, §8). Omit it and nothing changes — the client falls back to `/capabilities` exactly as before. Set it when you want "beat" and "merged" sessions live at the same time (staged rollout, A/B test, per-tenant policy) instead of one fixed value for the whole deployment — `/capabilities` can only ever report one current value, and a client reading it separately from opening the link is racing whatever the server happened to be serving at that moment, not the value this specific session was paired under.
749
+
360
750
  ## Tests
361
751
 
362
752
  ```sh
363
753
  npm test
364
754
  ```
365
755
 
366
- Runs a Node `--test` suite against the built plugin: TUS create/HEAD/PATCH resume, collision handling, extension rejection, range GETs, the ready-gate (`GET` returns 404 while uploading), `DELETE /:videoid`, `authorize` rejection on every phase, `validatePayload` + `createMp4Sniffer`, `onUploadComplete` dispatch, and sidecar corruption recovery.
756
+ Runs a Node `--test` suite against the built plugin. Coverage includes:
757
+
758
+ - TUS create/HEAD/PATCH resume, collision handling, extension rejection, range GETs
759
+ - Ready-gate (`GET` returns 404 while uploading)
760
+ - The generic `GET/DELETE /artifacts/:artifactId` route, and that the old per-kind routes are gone (not kept as a parallel API)
761
+ - `authorize` rejection on every phase; `kind`/`relatedTo` in the authorize context
762
+ - `validatePayload`/`onUploadComplete` running for every kind via `ctx.kind`, plus the deprecated `validateProjectPayload`/`onProjectUploadComplete` aliases
763
+ - `kind=project` and `kind=captions` happy paths — correct subdir, `Content-Type`, sidecar, `relatedTo` linking
764
+ - Extension mismatch rejections in both directions
765
+ - `artifactId`/`videoid`/`projectid` metadata aliases
766
+ - `getKind()`/`getRelatedTo()`/`getChecksum()` storage methods
767
+ - Legacy sidecars (no `kind` field) default to `"video"`, readable through the new generic route with no data migration
768
+ - Sidecar corruption recovery
769
+ - `allowedExtensions` object form
770
+ - `createChecksumValidator`/`createS3ChecksumValidator`: matching digest accepted, mismatch rejected with cleanup
771
+ - `issueCapabilityToken`/`verifyCapabilityToken`/`createCapabilityAuthorize`: round-trip, tampered signature/payload, expiry + clock tolerance, unknown `kid`, issuer mismatch, key-rotation overlap, `relatedTo`-based session authorization — both as fast unit tests (no server) and wired into real HTTP requests
772
+ - `GET /capabilities`
773
+ - S3/R2 backend (`createS3Storage`): full resumable upload → presigned-redirect playback, `createS3Mp4Sniffer`, `createS3ChecksumValidator`, `DELETE`, `kind=project`/`captions`, run against an in-process [s3rver](https://github.com/jamhall/s3rver) mock (no cloud credentials needed)
774
+ - `@mieweb/pulsevault/core` (the framework-agnostic entry point): the same protocol suite re-run against a bare `http.createServer` wrapping `core.handler`, plus an Express-specific smoke test proving `app.use(prefix, handler)` composition
367
775
 
368
776
  ## Accessing storage outside the plugin routes
369
777
 
@@ -372,15 +780,17 @@ The storage adapter is exposed as a Fastify decorator, so you can use it in your
372
780
  ```ts
373
781
  import "@mieweb/pulsevault/augment"; // once, for TypeScript types
374
782
 
375
- app.get("/admin/video/:id", async (req, reply) => {
783
+ app.get("/admin/artifact/:id", async (req, reply) => {
376
784
  const resolved = await app.pulseVault.resolve(req.params.id);
377
785
  if (!resolved) return reply.code(404).send();
378
786
  // custom logic...
379
787
  });
380
788
  ```
381
789
 
790
+ Under `@mieweb/pulsevault/core` there's no decorator (no Fastify instance to hang one off of) — just keep the `storage` object you passed into `createPulseVaultCore(...)` and call it directly in your own routes.
791
+
382
792
  ## License
383
793
 
384
- Source Available — free for non-commercial use under the terms in [LICENSE](LICENSE), which also requires that redistributions be published under an OSI-approved open source license. Commercial use requires a separate license from Medical Informatics Engineering, LLC — contact [helpdesk@mieweb.com](mailto:helpdesk@mieweb.com) or [mieweb.com](https://www.mieweb.com).
794
+ MIT — see [LICENSE](LICENSE).
385
795
 
386
- Copyright © 2026 Medical Informatics Engineering, LLC. All rights reserved.
796
+ Copyright © 2026 Medical Informatics Engineering, LLC.