@mieweb/pulsevault 0.0.2 → 0.1.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +258 -0
  2. package/LICENSE +15 -39
  3. package/OPERATIONS.md +223 -0
  4. package/PROTOCOL.md +345 -0
  5. package/README.md +548 -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 +91 -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 +494 -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 +134 -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 +105 -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/errors.d.ts +7 -0
  38. package/dist/lib/errors.d.ts.map +1 -1
  39. package/dist/lib/errors.js +18 -0
  40. package/dist/lib/errors.js.map +1 -1
  41. package/dist/lib/magic.d.ts +30 -5
  42. package/dist/lib/magic.d.ts.map +1 -1
  43. package/dist/lib/magic.js +51 -14
  44. package/dist/lib/magic.js.map +1 -1
  45. package/dist/lib/options.d.ts +41 -0
  46. package/dist/lib/options.d.ts.map +1 -0
  47. package/dist/lib/options.js +111 -0
  48. package/dist/lib/options.js.map +1 -0
  49. package/dist/lib/pulsevaultTus.d.ts +53 -26
  50. package/dist/lib/pulsevaultTus.d.ts.map +1 -1
  51. package/dist/lib/pulsevaultTus.js +174 -55
  52. package/dist/lib/pulsevaultTus.js.map +1 -1
  53. package/dist/lib/request.d.ts +26 -0
  54. package/dist/lib/request.d.ts.map +1 -0
  55. package/dist/lib/request.js +22 -0
  56. package/dist/lib/request.js.map +1 -0
  57. package/dist/lib/uuid.d.ts.map +1 -1
  58. package/dist/lib/uuid.js +1 -1
  59. package/dist/lib/uuid.js.map +1 -1
  60. package/dist/routes/pulsevault.d.ts +18 -16
  61. package/dist/routes/pulsevault.d.ts.map +1 -1
  62. package/dist/routes/pulsevault.js +121 -251
  63. package/dist/routes/pulsevault.js.map +1 -1
  64. package/dist/storage/local.d.ts +31 -10
  65. package/dist/storage/local.d.ts.map +1 -1
  66. package/dist/storage/local.js +205 -84
  67. package/dist/storage/local.js.map +1 -1
  68. package/dist/storage/s3.d.ts +109 -0
  69. package/dist/storage/s3.d.ts.map +1 -0
  70. package/dist/storage/s3.js +438 -0
  71. package/dist/storage/s3.js.map +1 -0
  72. package/dist/storage/types.d.ts +80 -17
  73. package/dist/storage/types.d.ts.map +1 -1
  74. package/dist/storage/types.js +13 -1
  75. package/dist/storage/types.js.map +1 -1
  76. package/package.json +29 -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,192 @@ 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
+ Four runnable example servers live under [`examples/`](examples):
177
+
178
+ - [`examples/fastify-demo`](examples/fastify-demo) — the smallest runnable
179
+ server: Fastify plugin mount, QR pairing, flat upload listing, no auth,
180
+ local storage. Start here.
181
+ - [`examples/fastify-auth-demo`](examples/fastify-auth-demo) —
182
+ production-shaped Fastify reference, run through Docker Compose (server +
183
+ Postgres, one `docker compose up --build`): capability tokens always on
184
+ (fails fast at boot without `PULSEVAULT_SECRET`), a [Better
185
+ Auth](https://better-auth.com)-protected React dashboard (email+password),
186
+ a Prisma schema holding both the auth tables and an artifact index
187
+ (`onUploadComplete` writes each finished upload once; `/pulses` is one
188
+ query instead of a sidecar crawl), uploads grouped by recording session
189
+ via `relatedTo`, WebVTT captions, Swagger UI, and an `onArtifactEvent`
190
+ live feed.
191
+ - [`examples/express-demo`](examples/express-demo) and
192
+ [`examples/meteor-demo`](examples/meteor-demo) — the same demo on
193
+ `@mieweb/pulsevault/core` instead of the plugin, proving the core needs
194
+ about the same amount of glue code under a different framework. Both are
195
+ verified against the real frameworks, not just the test suite —
196
+ `meteor-demo` in particular against a real `meteor create` app, since
197
+ Meteor's bundler needed the compatibility fixes described above.
198
+
199
+ ## How a pairing + upload session flows
200
+
201
+ ```mermaid
202
+ sequenceDiagram
203
+ participant Org as Your server
204
+ participant User
205
+ participant Pulse as Pulse app
206
+
207
+ Org->>Org: mint artifactId + issue a token
208
+ Org->>User: show QR / deep link
209
+ User->>Pulse: open link
210
+ Pulse->>Pulse: validate link, show pairing screen
211
+ User->>Pulse: confirm and choose draft
212
+ Pulse->>Org: POST upload, Bearer token, kind video, checksum
213
+ Org->>Pulse: 201 Location
214
+ Pulse->>Org: PATCH chunks, HEAD before resume
215
+ Org->>Org: validatePayload, markReady, onUploadComplete
216
+ Pulse->>Org: POST upload, kind captions, same session
217
+ Org->>Org: validatePayload, markReady, onUploadComplete
218
+ Org->>Pulse: ready for playback and captions fetch
219
+ ```
220
+
73
221
  ## Routes
74
222
 
75
- The plugin mounts the following routes under `prefix`:
223
+ 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
224
 
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) |
225
+ | Method | Path | Description |
226
+ | --- | --- | --- |
227
+ | `GET` | `/pulsevault/capabilities` | Unauthenticated discovery: protocol version, `uploadUnit`, allowed extensions, limits |
228
+ | `POST` | `/pulsevault/upload` | Create a TUS upload session |
229
+ | `PATCH` / `HEAD` / `DELETE` \* | `/pulsevault/upload/:id` | Upload chunks, probe offset, cancel upload (TUS) |
230
+ | `GET` | `/pulsevault/artifacts/:artifactId` | Stream or redirect to the uploaded artifact (any kind) |
231
+ | `DELETE` | `/pulsevault/artifacts/:artifactId` | Delete a finalized upload (bytes + sidecar) |
83
232
 
84
- \* `DELETE /upload/:id` is TUS's own "cancel in-flight upload" — distinct from `DELETE /:videoid`, which removes a finalized video.
233
+ \* `DELETE /pulsevault/upload/:id` is TUS's own "cancel in-flight upload" — distinct from `DELETE /pulsevault/artifacts/:artifactId`, which removes a finalized artifact.
85
234
 
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.
235
+ > `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
236
 
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.
237
+ `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
238
 
90
239
  ## Plugin options
91
240
 
@@ -94,22 +243,31 @@ type PulseVaultPluginOptions = {
94
243
  storage: PulseVaultStorage;
95
244
  prefix: string;
96
245
  maxUploadSize: number;
97
- decoratorName?: string; // default: "pulseVault"
98
- allowedExtensions?: string[]; // default: [".mp4"]
246
+ uploadUnit?: "segment" | "merged"; // default: "segment" — see "Upload unit"
247
+ decoratorName?: string; // default: "pulseVault"
248
+ allowedExtensions?:
249
+ | string[] // legacy — treated as video-only
250
+ | { video?: string[]; project?: string[]; captions?: string[]; thumbnail?: string[] }; // per-kind (recommended)
251
+ // defaults: { video: [".mp4"], project: [".pulse", ".zip"], captions: [".vtt"], thumbnail: [".jpg", ".jpeg", ".png"] }
99
252
  cache?: PulseVaultCacheOptions;
100
253
  authorize?: PulseVaultAuthorize;
101
- validatePayload?: PulseVaultValidatePayload;
102
- onUploadComplete?: PulseVaultOnUploadComplete;
254
+ validatePayload?: PulseVaultValidatePayload; // runs for every kind; branch on ctx.kind
255
+ onUploadComplete?: PulseVaultOnUploadComplete; // runs for every kind; branch on ctx.kind
256
+ onArtifactEvent?: PulseVaultOnArtifactEvent; // low-frequency hook for metrics + audit logging
257
+ /** @deprecated use validatePayload + ctx.kind === "project" */
258
+ validateProjectPayload?: PulseVaultValidatePayload;
259
+ /** @deprecated use onUploadComplete + ctx.kind === "project" */
260
+ onProjectUploadComplete?: PulseVaultOnUploadComplete;
103
261
  };
104
262
  ```
105
263
 
106
264
  ### `storage`
107
265
 
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.).
266
+ 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
267
 
110
268
  ### `prefix`
111
269
 
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 `""`.
270
+ 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
271
 
114
272
  > 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
273
 
@@ -117,6 +275,19 @@ URL prefix for all plugin routes. Use `""` to mount at the root or `"/pulsevault
117
275
 
118
276
  Maximum upload size in bytes. Use `Infinity` for no cap.
119
277
 
278
+ ### Upload unit
279
+
280
+ `uploadUnit: "segment" | "merged"` — purely advertised via `GET /pulsevault/capabilities`; **the plugin doesn't enforce either.** It tells the client which upload strategy this deployment expects:
281
+
282
+ - `"segment"` (default): the client uploads each recorded clip individually (no merge/re-encode pass), plus one `kind=project` ordering manifest (`<draftId>-segments.pulse`). No captions or thumbnail. Lower client-side cost, finer-grained resumability.
283
+ - `"merged"`: the client pre-merges a pulse's clips into one video before uploading, alongside its captions (`kind=captions`), a beat-timecode manifest (`kind=project`, `<draftId>-beats.pulse` — per-segment `startMs`/`endMs` on the merged timeline), and a poster `kind=thumbnail`. Simpler server-side mental model (one primary artifact per pulse), more client-side work.
284
+
285
+ (A "beat" is a timecode range on the merged timeline — one recorded segment's placement within the merged video — not an upload unit. The old `"beat"` upload unit was renamed to `"segment"`.)
286
+
287
+ A client reads `uploadUnit` at pairing time, before doing any merge or upload work, and branches accordingly. See `PROTOCOL.md` §8 for the full contract.
288
+
289
+ 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.
290
+
120
291
  ### `decoratorName`
121
292
 
122
293
  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 +300,24 @@ import "@mieweb/pulsevault/augment";
129
300
 
130
301
  ### `allowedExtensions`
131
302
 
132
- File extensions accepted in `Upload-Metadata.filename`. Must include the leading dot. Defaults to `[".mp4"]`.
303
+ File extensions accepted per artifact kind. Three accepted forms:
304
+
305
+ ```ts
306
+ // 1. Omit entirely — uses all three defaults:
307
+ // video: [".mp4"] project: [".pulse", ".zip"] captions: [".vtt"]
308
+
309
+ // 2. Flat array (legacy) — video-only; project/captions keep their defaults:
310
+ allowedExtensions: [".mp4"]
311
+
312
+ // 3. Per-kind object — unset keys fall back to their defaults:
313
+ allowedExtensions: { video: [".mp4"], project: [".pulse"], captions: [".vtt"] }
314
+ ```
315
+
316
+ All extensions must include the leading dot and are matched case-insensitively. The `kind` field in `Upload-Metadata` determines which list is checked.
133
317
 
134
318
  ### `cache`
135
319
 
136
- Cache-control options for the `GET /:videoid` route, forwarded to `@fastify/send`:
320
+ Cache-control options for the `GET /artifacts/:artifactId` route, forwarded to `@fastify/send`:
137
321
 
138
322
  ```ts
139
323
  type PulseVaultCacheOptions = {
@@ -149,12 +333,22 @@ Upload filenames are keyed by UUID, so `immutable: true` is safe when `maxAge` i
149
333
 
150
334
  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
335
 
336
+ 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"`.
337
+
152
338
  ```ts
153
339
  type PulseVaultAuthorize = (
154
- request: FastifyRequest,
340
+ // PulseVaultRequest only requires `.headers` — under the Fastify plugin
341
+ // this is always the real `FastifyRequest` object (cast if you need
342
+ // Fastify-specific fields); under @mieweb/pulsevault/core it's whatever
343
+ // the host framework hands the handler (Express's `req`, a raw
344
+ // `http.IncomingMessage`, etc.) — the same hook works under either.
345
+ request: PulseVaultRequest,
155
346
  ctx: {
156
347
  phase: "create" | "patch" | "resolve" | "delete";
157
- videoid: string;
348
+ artifactId: string;
349
+ kind: "video" | "project" | "captions"; // artifact kind; always present
350
+ token?: string; // only on "resolve" phase
351
+ relatedTo?: string; // the session-anchor artifact this one belongs to, if any
158
352
  },
159
353
  ) => void | Promise<void>;
160
354
  ```
@@ -162,38 +356,44 @@ type PulseVaultAuthorize = (
162
356
  ```ts
163
357
  await app.register(pulseVault, {
164
358
  // ...
165
- authorize: async (request, { phase, videoid }) => {
359
+ authorize: async (request, { phase, artifactId, kind }) => {
166
360
  const token = request.headers.authorization?.replace("Bearer ", "");
167
- if (!isValid(token, videoid)) {
361
+ if (!isValid(token, artifactId)) {
168
362
  throw Object.assign(new Error("Forbidden"), { statusCode: 403 });
169
363
  }
170
364
  },
171
365
  });
172
366
  ```
173
367
 
368
+ Don't want to write your own auth from scratch? See [Capability tokens](#capability-tokens) for a secure-by-default option.
369
+
174
370
  ### `validatePayload`
175
371
 
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.
372
+ 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
373
 
178
374
  ```ts
179
375
  type PulseVaultValidatePayload = (
180
- request: FastifyRequest,
376
+ request: PulseVaultRequest, // see the note under `authorize` above
181
377
  ctx: {
182
- videoid: string;
378
+ artifactId: string;
183
379
  size: number;
184
380
  uploadId: string;
381
+ kind: "video" | "project" | "captions";
185
382
  /** Absolute path to finalized bytes for adapters that expose `getLocalPath`. */
186
383
  localPath: string | null;
384
+ /** Client-supplied `<algorithm>:<hex>` digest, if `Upload-Metadata.checksum` was sent. */
385
+ checksum?: string;
187
386
  },
188
387
  ) => void | Promise<void>;
189
388
  ```
190
389
 
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:
390
+ 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
391
 
193
392
  ```ts
194
393
  import pulseVault, {
195
394
  createLocalStorage,
196
395
  createMp4Sniffer,
396
+ createChecksumValidator,
197
397
  } from "@mieweb/pulsevault";
198
398
 
199
399
  const storage = createLocalStorage({ workspaceDir: "./data" });
@@ -201,32 +401,147 @@ const storage = createLocalStorage({ workspaceDir: "./data" });
201
401
  await app.register(pulseVault, {
202
402
  // ...
203
403
  storage,
204
- validatePayload: createMp4Sniffer(storage),
404
+ // Chain validators: checksum runs first, then the MP4 sniff.
405
+ validatePayload: createChecksumValidator(createMp4Sniffer(storage)),
205
406
  });
206
407
  ```
207
408
 
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.
409
+ `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.
209
410
 
210
- The lower-level `sniffMp4(path)` is also exported if you want to drive your own validator.
411
+ `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.
412
+
413
+ `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:
414
+
415
+ ```ts
416
+ validatePayload: async (request, ctx) => {
417
+ if (ctx.kind === "video") return createMp4Sniffer(storage)(request, ctx);
418
+ // project/captions: no byte-level check, or your own.
419
+ },
420
+ ```
211
421
 
212
422
  ### `onUploadComplete`
213
423
 
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.
424
+ 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
425
 
216
426
  ```ts
217
427
  type PulseVaultOnUploadComplete = (
218
- request: FastifyRequest,
219
- ctx: { videoid: string; size: number; uploadId: string },
428
+ request: PulseVaultRequest, // see the note under `authorize` above
429
+ ctx: { artifactId: string; kind: "video" | "project" | "captions"; size: number; uploadId: string },
220
430
  ) => void | Promise<void>;
221
431
  ```
222
432
 
433
+ ### `onArtifactEvent`
434
+
435
+ 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:
436
+
437
+ ```ts
438
+ type PulseVaultArtifactEvent = {
439
+ phase: "authorize" | "complete" | "reject";
440
+ artifactId: string;
441
+ kind: "video" | "project" | "captions";
442
+ size?: number;
443
+ reason?: string; // present for "authorize" and "reject"
444
+ };
445
+
446
+ onArtifactEvent: (event) => {
447
+ app.log.info(event, "pulsevault artifact event"); // or push to a metrics/audit sink
448
+ },
449
+ ```
450
+
451
+ See `OPERATIONS.md` for a Prometheus-counter example and what to alert on.
452
+
453
+ ### `validateProjectPayload` / `onProjectUploadComplete` (deprecated)
454
+
455
+ 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.
456
+
223
457
  ### Upload-complete sequencing
224
458
 
225
- When the final PATCH lands, the plugin runs the following in order. Any step failing short-circuits the rest.
459
+ When the final PATCH lands the plugin runs the following steps in order, for every kind. Any step failing short-circuits the rest.
460
+
461
+ 1. **`validatePayload`** (optional, runs for every kind) — throws → `storage.remove(artifactId)`, HTTP 4xx (default 422).
462
+ 2. **`storage.markReady(artifactId)`** — flips the sidecar so `resolve()` will serve the bytes.
463
+ 3. **`onUploadComplete`** (optional, runs for every kind) — throws → HTTP 500; bytes remain ready unless the consumer removes them.
464
+
465
+ (`validateProjectPayload`/`onProjectUploadComplete`, if passed, run instead of the generic hooks specifically for `kind=project` — see the deprecation note above.)
466
+
467
+ ## Capabilities discovery
468
+
469
+ `GET {prefix}/capabilities` is unauthenticated (no secrets in the response) and lets a client detect protocol compatibility and this deployment's configuration before pairing:
470
+
471
+ ```json
472
+ {
473
+ "protocolVersion": 1,
474
+ "minSupportedVersion": 1,
475
+ "maxSupportedVersion": 1,
476
+ "uploadUnit": "segment",
477
+ "kinds": ["video", "project", "captions", "thumbnail"],
478
+ "allowedExtensions": { "video": [".mp4"], "project": [".pulse", ".zip"], "captions": [".vtt"], "thumbnail": [".jpg", ".jpeg", ".png"] },
479
+ "maxUploadSize": 5368709120,
480
+ "checksum": { "algorithms": ["sha256", "sha1", "md5"] }
481
+ }
482
+ ```
483
+
484
+ `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).
226
485
 
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.
486
+ See `PROTOCOL.md` §2 for the full normative shape.
487
+
488
+ ## Capability tokens
489
+
490
+ 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.
491
+
492
+ ```ts
493
+ import pulseVault, {
494
+ createLocalStorage,
495
+ createCapabilityAuthorize,
496
+ issueCapabilityToken,
497
+ buildUploadLink,
498
+ } from "@mieweb/pulsevault";
499
+ import { randomUUID } from "node:crypto";
500
+
501
+ const keys = { "2026-06": process.env.PULSEVAULT_KEY_2026_06! }; // add the previous key during rotation
502
+ const issuer = "https://vault.example.org"; // identity claim — no path, just who issued the token
503
+ const prefix = "/pulsevault";
504
+
505
+ await app.register(pulseVault, {
506
+ prefix,
507
+ storage: createLocalStorage({ workspaceDir: "./data" }),
508
+ maxUploadSize: 5 * 1024 * 1024 * 1024,
509
+ authorize: createCapabilityAuthorize((kid) => keys[kid] ?? null, { issuer }),
510
+ });
511
+
512
+ app.post("/pair", async (_req, reply) => {
513
+ const artifactId = randomUUID();
514
+ const token = issueCapabilityToken(artifactId, keys["2026-06"], {
515
+ keyId: "2026-06",
516
+ issuer,
517
+ expirySeconds: 1800, // 30 minutes — long enough for one upload session
518
+ });
519
+ // `server` is the full base URL the client uploads to — origin *plus* the
520
+ // plugin's prefix — not just `issuer`'s bare origin.
521
+ return reply.send({ link: buildUploadLink({ server: `${issuer}${prefix}`, artifactId, token }) });
522
+ });
523
+ ```
524
+
525
+ A token authorizes either the artifact it names, or any artifact that declares that one as its `relatedTo` — so one token issued for a merged video also covers its captions, beat manifest and thumbnail (or, under `uploadUnit: "segment"`, every clip and the ordering 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.
526
+
527
+ ## Upload-Metadata protocol
528
+
529
+ The TUS `Upload-Metadata` header is a comma-separated list of `<key> <base64>` pairs. PulseVault reads the following keys on `POST /upload`:
530
+
531
+ | Key | Required | Description |
532
+ |---|---|---|
533
+ | `artifactId` | Yes (or `videoid`/`projectid`) | Server-generated UUID for this upload. |
534
+ | `videoid` / `projectid` | Legacy aliases for `artifactId` | Accepted as synonyms indefinitely (protocol v1). Use `artifactId` for new code. |
535
+ | `filename` | Yes | Original filename. The extension is validated against the kind's allowed list. |
536
+ | `kind` | No | `video` (default), `project`, or `captions`. Determines the storage subdir and which hooks fire. |
537
+ | `relatedTo` | No | UUID of another artifact this one belongs to (e.g. a video's captions). Lets one capability token authorize a whole session. |
538
+ | `checksum` | No | `<algorithm>:<hex digest>` of the finished file, verified by `createChecksumValidator`/`createS3ChecksumValidator` if configured. |
539
+
540
+ Example (`kind=captions`, linked to a video):
541
+
542
+ ```
543
+ Upload-Metadata: artifactId <base64(uuid)>, filename <base64("clip.vtt")>, kind <base64("captions")>, relatedTo <base64(videoArtifactId)>
544
+ ```
230
545
 
231
546
  ## Local storage
232
547
 
@@ -234,24 +549,31 @@ When the final PATCH lands, the plugin runs the following in order. Any step fai
234
549
  import { createLocalStorage } from "@mieweb/pulsevault";
235
550
 
236
551
  const storage = createLocalStorage({
237
- workspaceDir: "./data", // directory for uploads; created if absent
552
+ workspaceDir: "./data", // directory for uploads; created if absent
553
+ metaCacheLimit: 10_000, // optional — bounds the in-memory metadata cache
238
554
  });
239
555
  ```
240
556
 
241
557
  ### Filesystem layout (stable contract)
242
558
 
243
- The local adapter writes each upload into a self-describing per-video directory. Downstream tools may rely on this layout across minor versions:
559
+ The local adapter writes uploads into flat kind-scoped subdirectories. Downstream tools may rely on this layout across minor versions:
244
560
 
245
561
  ```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
562
+ <workspaceRoot>/
563
+ .pulsevault/<id>.json # sidecar: { version, ext, filename, status, kind, relatedTo, checksum }
564
+ video/<id><ext> # video upload bytes (kind="video")
565
+ video/<id><ext>.json # @tus/file-store offset/metadata sidecar
566
+ project/<id><ext> # project bundle bytes (kind="project")
567
+ project/<id><ext>.json # @tus/file-store offset/metadata sidecar
568
+ captions/<id><ext> # captions bytes (kind="captions")
569
+ captions/<id><ext>.json # @tus/file-store offset/metadata sidecar
250
570
  ```
251
571
 
252
- `status` is `"uploading"` between `reserveUpload` and the successful final PATCH; `"ready"` thereafter. `GET /:videoid` only serves `"ready"` uploads.
572
+ `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).
253
573
 
254
- The adapter exposes `storage.workspaceRoot` (absolute, resolved from `workspaceDir`) so consumers can compute per-video paths without re-implementing the layout.
574
+ 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.
575
+
576
+ **Horizontal scaling**: this adapter requires sticky-session routing or a shared filesystem across instances — see `OPERATIONS.md`.
255
577
 
256
578
  ### Post-processing (transcription, thumbnails, AI)
257
579
 
@@ -265,13 +587,14 @@ import pulseVault, { createLocalStorage } from "@mieweb/pulsevault";
265
587
  const storage = createLocalStorage({ workspaceDir: "./data" });
266
588
 
267
589
  await app.register(pulseVault, {
268
- prefix: "",
590
+ prefix: "/pulsevault",
269
591
  storage,
270
592
  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")));
593
+ onUploadComplete: async (_req, { artifactId, kind }) => {
594
+ if (kind !== "video") return;
595
+ const videoDir = path.join(storage.workspaceRoot, "video");
596
+ const pod = new ArtiPod({ id: artifactId, useMainMount: false });
597
+ pod.addMount(new ArtiMount("video", videoDir));
275
598
  // Create these lazily as your pipeline produces artifacts:
276
599
  // pod.addMount(new ArtiMount("transcripts", path.join(root, "transcripts")));
277
600
  // pod.addMount(new ArtiMount("frames", path.join(root, "frames")));
@@ -284,6 +607,89 @@ await app.register(pulseVault, {
284
607
 
285
608
  `@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
609
 
610
+ ## Object storage (Cloudflare R2 / AWS S3)
611
+
612
+ `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.
613
+
614
+ 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:
615
+
616
+ ```bash
617
+ npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @tus/s3-store
618
+ ```
619
+
620
+ `createS3Storage` is **async** (it lazily imports those packages), so `await` it before registering the plugin.
621
+
622
+ **Cloudflare R2:**
623
+
624
+ ```ts
625
+ import pulseVault, { createS3Storage, createS3Mp4Sniffer } from "@mieweb/pulsevault";
626
+
627
+ const storage = await createS3Storage({
628
+ bucket: "pulse-videos",
629
+ endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
630
+ accessKeyId: process.env.R2_ACCESS_KEY,
631
+ secretAccessKey: process.env.R2_SECRET_KEY,
632
+ });
633
+
634
+ await app.register(pulseVault, {
635
+ prefix: "/pulsevault",
636
+ storage,
637
+ maxUploadSize: 5 * 1024 * 1024 * 1024,
638
+ validatePayload: createS3Mp4Sniffer(storage), // ranged-read MP4 sniff
639
+ });
640
+ ```
641
+
642
+ **AWS S3** — drop the custom `endpoint` and set `region`:
643
+
644
+ ```ts
645
+ const storage = await createS3Storage({
646
+ bucket: "pulse-videos",
647
+ region: "us-east-1",
648
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID,
649
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
650
+ });
651
+ ```
652
+
653
+ 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.
654
+
655
+ ### Object layout
656
+
657
+ ```text
658
+ <bucket>/
659
+ .pulsevault/<id>.json # metadata sidecar: { version, ext, filename, status, kind, relatedTo, checksum }
660
+ video/<id><ext> # finalized video object (kind="video")
661
+ project/<id><ext> # finalized project object (kind="project")
662
+ captions/<id><ext> # finalized captions object (kind="captions")
663
+ <key>.info # @tus/s3-store multipart bookkeeping (transient)
664
+ ```
665
+
666
+ `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.
667
+
668
+ ### Options
669
+
670
+ | Option | Default | Notes |
671
+ | --- | --- | --- |
672
+ | `bucket` | — | Required. Must already exist. |
673
+ | `endpoint` | — | Custom S3 endpoint (set for R2). Omit for AWS S3. |
674
+ | `region` | `"auto"` when `endpoint` is set | Required for AWS S3. |
675
+ | `accessKeyId` / `secretAccessKey` | SDK default chain | Optional; omit both to use env/IAM credentials. |
676
+ | `sessionToken` | — | Optional STS session token for temporary credentials. |
677
+ | `forcePathStyle` | `true` when `endpoint` is set | R2 and most S3-compatible stores need path-style. |
678
+ | `presignTtlSeconds` | `900` | Lifetime of the playback presigned URL. |
679
+ | `partSize` | computed | Preferred multipart part size (≥ 5 MiB), forwarded to `@tus/s3-store`. |
680
+ | `metaCacheLimit` | `10000` | Caps the in-memory metadata cache before evicting the oldest entry. |
681
+ | `clientConfig` | — | Advanced: extra `S3ClientConfig` merged into the client. |
682
+
683
+ > A typical deployment wires these to environment variables (e.g. `S3_BUCKET`, `S3_ENDPOINT`, `AWS_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`) and picks the storage adapter with a `STORAGE=local|s3` switch — the options table above maps one-to-one onto `createS3Storage(...)`.
684
+
685
+ ### Payload validation on remote storage
686
+
687
+ 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.
688
+
689
+ ### Direct playback & CORS
690
+
691
+ 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.
692
+
287
693
  ## Custom storage adapter
288
694
 
289
695
  Implement `PulseVaultStorage` to back uploads with any system (S3, GCS, database, etc.):
@@ -302,68 +708,86 @@ const storage: PulseVaultStorage = {
302
708
  async shutdown() {
303
709
  /* optional teardown */
304
710
  },
305
- async reserveUpload({ videoid, filename, ext }) {
711
+ async reserveUpload({ artifactId, filename, ext, kind, relatedTo, checksum }) {
306
712
  // 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}`;
713
+ await db.createArtifact({ artifactId, filename, kind, relatedTo, checksum, status: "uploading" });
714
+ return `${kind}/${artifactId}${ext}`;
309
715
  },
310
- async resolve(videoid): Promise<PulseVaultResolution | null> {
311
- const video = await db.findVideo(videoid);
312
- if (!video || video.status !== "ready") return null;
716
+ async resolve(artifactId): Promise<PulseVaultResolution | null> {
717
+ const artifact = await db.findArtifact(artifactId);
718
+ if (!artifact || artifact.status !== "ready") return null;
313
719
  // Stream from local disk:
314
- return { kind: "stream", root: "/uploads", filename: video.filename };
720
+ return { kind: "stream", root: "/uploads", filename: artifact.filename };
315
721
  // Or redirect to a CDN / presigned URL:
316
- // return { kind: "redirect", url: video.signedUrl, statusCode: 302 };
722
+ // return { kind: "redirect", url: artifact.signedUrl, statusCode: 302 };
317
723
  },
318
- async markReady(videoid) {
724
+ async markReady(artifactId) {
319
725
  // Called after `validatePayload` (if any) accepts the bytes. Flip your
320
726
  // state so `resolve` starts returning non-null. Omit this method if
321
727
  // your backend can't distinguish in-progress from finalized uploads.
322
- await db.updateVideo(videoid, { status: "ready" });
728
+ await db.updateArtifact(artifactId, { status: "ready" });
323
729
  },
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);
730
+ async remove(artifactId) {
731
+ // Called from DELETE /artifacts/:artifactId and from the plugin's
732
+ // cleanup path when `validatePayload` rejects an upload. Return false if
733
+ // the artifactId was already absent.
734
+ const result = await db.deleteArtifact(artifactId);
329
735
  return result.deleted;
330
736
  },
737
+ // Optional — only needed if you use relatedTo/checksum-aware hooks/validators:
738
+ async getKind(artifactId) { return (await db.findArtifact(artifactId))?.kind ?? null; },
739
+ async getRelatedTo(artifactId) { return (await db.findArtifact(artifactId))?.relatedTo ?? null; },
740
+ async getChecksum(artifactId) { return (await db.findArtifact(artifactId))?.checksum ?? null; },
331
741
  };
332
742
  ```
333
743
 
334
- ## Deep link helpers
744
+ ## Deep link helper
335
745
 
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.
746
+ 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
747
 
338
748
  ```ts
339
- import {
340
- buildConfigureDestinationLink,
341
- buildUploadLink,
342
- } from "@mieweb/pulsevault";
749
+ import { buildUploadLink } from "@mieweb/pulsevault";
343
750
  import { randomUUID } from "node:crypto";
344
751
 
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.
752
+ // Opens the app directly on the upload screen for a specific artifact.
753
+ // `server` must include your `prefix` — e.g. "https://example.com/pulsevault",
754
+ // not just the bare origin — the client has no separate notion of a prefix.
353
755
  const uploadLink = buildUploadLink({
354
- server: "https://example.com",
355
- videoid: randomUUID(), // generate server-side; skip POST /reserve on the app
356
- token: "secret", // optional
756
+ server: "https://example.com/pulsevault",
757
+ artifactId: randomUUID(), // generate server-side; skip POST /reserve on the app
758
+ token: "secret", // optional — forwarded to your authorize hook; see Capability tokens
759
+ uploadUnit: "merged", // optional — see "Upload unit" below
357
760
  });
761
+ // pulsecam://?v=1&artifactId=...&server=https%3A%2F%2Fexample.com%2Fpulsevault&token=secret&uploadUnit=merged
358
762
  ```
359
763
 
764
+ `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 "segment" 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.
765
+
360
766
  ## Tests
361
767
 
362
768
  ```sh
363
769
  npm test
364
770
  ```
365
771
 
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.
772
+ Runs a Node `--test` suite against the built plugin. Coverage includes:
773
+
774
+ - TUS create/HEAD/PATCH resume, collision handling, extension rejection, range GETs
775
+ - Ready-gate (`GET` returns 404 while uploading)
776
+ - The generic `GET/DELETE /artifacts/:artifactId` route, and that the old per-kind routes are gone (not kept as a parallel API)
777
+ - `authorize` rejection on every phase; `kind`/`relatedTo` in the authorize context
778
+ - `validatePayload`/`onUploadComplete` running for every kind via `ctx.kind`, plus the deprecated `validateProjectPayload`/`onProjectUploadComplete` aliases
779
+ - `kind=project` and `kind=captions` happy paths — correct subdir, `Content-Type`, sidecar, `relatedTo` linking
780
+ - Extension mismatch rejections in both directions
781
+ - `artifactId`/`videoid`/`projectid` metadata aliases
782
+ - `getKind()`/`getRelatedTo()`/`getChecksum()` storage methods
783
+ - Legacy sidecars (no `kind` field) default to `"video"`, readable through the new generic route with no data migration
784
+ - Sidecar corruption recovery
785
+ - `allowedExtensions` object form
786
+ - `createChecksumValidator`/`createS3ChecksumValidator`: matching digest accepted, mismatch rejected with cleanup
787
+ - `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
788
+ - `GET /capabilities`
789
+ - S3/R2 backend (`createS3Storage`): full resumable upload → presigned-redirect playback, `createS3Mp4Sniffer`, `createS3ChecksumValidator`, `DELETE`, `kind=project`/`captions`, run against an in-process zero-dependency S3 mock (`test/mock-s3.mjs`, no cloud credentials needed)
790
+ - `@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
791
 
368
792
  ## Accessing storage outside the plugin routes
369
793
 
@@ -372,15 +796,17 @@ The storage adapter is exposed as a Fastify decorator, so you can use it in your
372
796
  ```ts
373
797
  import "@mieweb/pulsevault/augment"; // once, for TypeScript types
374
798
 
375
- app.get("/admin/video/:id", async (req, reply) => {
799
+ app.get("/admin/artifact/:id", async (req, reply) => {
376
800
  const resolved = await app.pulseVault.resolve(req.params.id);
377
801
  if (!resolved) return reply.code(404).send();
378
802
  // custom logic...
379
803
  });
380
804
  ```
381
805
 
806
+ 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.
807
+
382
808
  ## License
383
809
 
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).
810
+ MIT — see [LICENSE](LICENSE).
385
811
 
386
- Copyright © 2026 Medical Informatics Engineering, LLC. All rights reserved.
812
+ Copyright © 2026 Medical Informatics Engineering, LLC.