@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.
- package/CHANGELOG.md +184 -0
- package/LICENSE +15 -39
- package/OPERATIONS.md +223 -0
- package/PROTOCOL.md +312 -0
- package/README.md +532 -122
- package/augment.d.ts +1 -0
- package/augment.js +3 -0
- package/core.d.ts +1 -0
- package/core.js +7 -0
- package/dist/app.d.ts +90 -34
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +23 -33
- package/dist/app.js.map +1 -1
- package/dist/augment.d.ts +14 -7
- package/dist/augment.d.ts.map +1 -1
- package/dist/augment.js.map +1 -1
- package/dist/core.d.ts +108 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +431 -0
- package/dist/core.js.map +1 -0
- package/dist/lib/authorize.d.ts +19 -0
- package/dist/lib/authorize.d.ts.map +1 -0
- package/dist/lib/authorize.js +2 -0
- package/dist/lib/authorize.js.map +1 -0
- package/dist/lib/capability-token.d.ts +80 -0
- package/dist/lib/capability-token.d.ts.map +1 -0
- package/dist/lib/capability-token.js +126 -0
- package/dist/lib/capability-token.js.map +1 -0
- package/dist/lib/checksum.d.ts +45 -0
- package/dist/lib/checksum.d.ts.map +1 -0
- package/dist/lib/checksum.js +102 -0
- package/dist/lib/checksum.js.map +1 -0
- package/dist/lib/deeplinks.d.ts +37 -21
- package/dist/lib/deeplinks.d.ts.map +1 -1
- package/dist/lib/deeplinks.js +54 -13
- package/dist/lib/deeplinks.js.map +1 -1
- package/dist/lib/magic.d.ts +30 -5
- package/dist/lib/magic.d.ts.map +1 -1
- package/dist/lib/magic.js +51 -14
- package/dist/lib/magic.js.map +1 -1
- package/dist/lib/options.d.ts +39 -0
- package/dist/lib/options.d.ts.map +1 -0
- package/dist/lib/options.js +103 -0
- package/dist/lib/options.js.map +1 -0
- package/dist/lib/pulsevaultTus.d.ts +52 -26
- package/dist/lib/pulsevaultTus.d.ts.map +1 -1
- package/dist/lib/pulsevaultTus.js +111 -43
- package/dist/lib/pulsevaultTus.js.map +1 -1
- package/dist/lib/request.d.ts +24 -0
- package/dist/lib/request.d.ts.map +1 -0
- package/dist/lib/request.js +16 -0
- package/dist/lib/request.js.map +1 -0
- package/dist/lib/uuid.d.ts.map +1 -1
- package/dist/lib/uuid.js +1 -1
- package/dist/lib/uuid.js.map +1 -1
- package/dist/routes/pulsevault.d.ts +18 -16
- package/dist/routes/pulsevault.d.ts.map +1 -1
- package/dist/routes/pulsevault.js +120 -251
- package/dist/routes/pulsevault.js.map +1 -1
- package/dist/storage/local.d.ts +31 -10
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/local.js +166 -82
- package/dist/storage/local.js.map +1 -1
- package/dist/storage/s3.d.ts +109 -0
- package/dist/storage/s3.d.ts.map +1 -0
- package/dist/storage/s3.js +432 -0
- package/dist/storage/s3.js.map +1 -0
- package/dist/storage/types.d.ts +69 -17
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +35 -7
package/README.md
CHANGED
|
@@ -1,45 +1,37 @@
|
|
|
1
1
|
# @mieweb/pulsevault
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
57
|
+
// Your server owns artifactId creation — attach auth, DB records, quotas here.
|
|
66
58
|
app.post("/reserve", async (_req, reply) => {
|
|
67
|
-
return reply.send({
|
|
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
|
|
78
|
-
|
|
|
79
|
-
| `
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
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 /:
|
|
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
|
|
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 /:
|
|
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
|
-
|
|
98
|
-
|
|
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 (
|
|
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.
|
|
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
|
|
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 /:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
343
|
+
authorize: async (request, { phase, artifactId, kind }) => {
|
|
166
344
|
const token = request.headers.authorization?.replace("Bearer ", "");
|
|
167
|
-
if (!isValid(token,
|
|
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
|
|
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:
|
|
360
|
+
request: PulseVaultRequest, // see the note under `authorize` above
|
|
181
361
|
ctx: {
|
|
182
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
219
|
-
ctx: {
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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",
|
|
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
|
|
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
|
|
247
|
-
.pulsevault
|
|
248
|
-
video/<
|
|
249
|
-
video/<
|
|
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 /:
|
|
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
|
-
|
|
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, {
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
pod
|
|
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({
|
|
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.
|
|
308
|
-
return `${
|
|
697
|
+
await db.createArtifact({ artifactId, filename, kind, relatedTo, checksum, status: "uploading" });
|
|
698
|
+
return `${kind}/${artifactId}${ext}`;
|
|
309
699
|
},
|
|
310
|
-
async resolve(
|
|
311
|
-
const
|
|
312
|
-
if (!
|
|
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:
|
|
704
|
+
return { kind: "stream", root: "/uploads", filename: artifact.filename };
|
|
315
705
|
// Or redirect to a CDN / presigned URL:
|
|
316
|
-
// return { kind: "redirect", url:
|
|
706
|
+
// return { kind: "redirect", url: artifact.signedUrl, statusCode: 302 };
|
|
317
707
|
},
|
|
318
|
-
async markReady(
|
|
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.
|
|
712
|
+
await db.updateArtifact(artifactId, { status: "ready" });
|
|
323
713
|
},
|
|
324
|
-
async remove(
|
|
325
|
-
// Called from DELETE /:
|
|
326
|
-
// `validatePayload` rejects an upload. Return false if
|
|
327
|
-
// already absent.
|
|
328
|
-
const result = await db.
|
|
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
|
|
728
|
+
## Deep link helper
|
|
335
729
|
|
|
336
|
-
Use
|
|
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
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
794
|
+
MIT — see [LICENSE](LICENSE).
|
|
385
795
|
|
|
386
|
-
Copyright © 2026 Medical Informatics Engineering, LLC.
|
|
796
|
+
Copyright © 2026 Medical Informatics Engineering, LLC.
|