@mieweb/pulsevault 0.0.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 (43) hide show
  1. package/LICENSE +45 -0
  2. package/README.md +386 -0
  3. package/dist/app.d.ts +114 -0
  4. package/dist/app.d.ts.map +1 -0
  5. package/dist/app.js +54 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/augment.d.ts +45 -0
  8. package/dist/augment.d.ts.map +1 -0
  9. package/dist/augment.js +2 -0
  10. package/dist/augment.js.map +1 -0
  11. package/dist/lib/deeplinks.d.ts +32 -0
  12. package/dist/lib/deeplinks.d.ts.map +1 -0
  13. package/dist/lib/deeplinks.js +24 -0
  14. package/dist/lib/deeplinks.js.map +1 -0
  15. package/dist/lib/errors.d.ts +6 -0
  16. package/dist/lib/errors.d.ts.map +1 -0
  17. package/dist/lib/errors.js +4 -0
  18. package/dist/lib/errors.js.map +1 -0
  19. package/dist/lib/magic.d.ts +50 -0
  20. package/dist/lib/magic.d.ts.map +1 -0
  21. package/dist/lib/magic.js +66 -0
  22. package/dist/lib/magic.js.map +1 -0
  23. package/dist/lib/pulsevaultTus.d.ts +56 -0
  24. package/dist/lib/pulsevaultTus.d.ts.map +1 -0
  25. package/dist/lib/pulsevaultTus.js +146 -0
  26. package/dist/lib/pulsevaultTus.js.map +1 -0
  27. package/dist/lib/uuid.d.ts +2 -0
  28. package/dist/lib/uuid.d.ts.map +1 -0
  29. package/dist/lib/uuid.js +5 -0
  30. package/dist/lib/uuid.js.map +1 -0
  31. package/dist/routes/pulsevault.d.ts +30 -0
  32. package/dist/routes/pulsevault.d.ts.map +1 -0
  33. package/dist/routes/pulsevault.js +315 -0
  34. package/dist/routes/pulsevault.js.map +1 -0
  35. package/dist/storage/local.d.ts +34 -0
  36. package/dist/storage/local.d.ts.map +1 -0
  37. package/dist/storage/local.js +167 -0
  38. package/dist/storage/local.js.map +1 -0
  39. package/dist/storage/types.d.ts +72 -0
  40. package/dist/storage/types.d.ts.map +1 -0
  41. package/dist/storage/types.js +2 -0
  42. package/dist/storage/types.js.map +1 -0
  43. package/package.json +75 -0
package/LICENSE ADDED
@@ -0,0 +1,45 @@
1
+ @mieweb/pulsevault - Source Available License
2
+
3
+ Copyright (c) 2026 Medical Informatics Engineering, LLC. All rights reserved.
4
+
5
+ This software and associated documentation files (the "Software") are the
6
+ proprietary property of Medical Informatics Engineering, LLC. ("MIE").
7
+
8
+ OPEN SOURCE / NON-COMMERCIAL USE
9
+
10
+ Permission is hereby granted, free of charge, to any person or organization
11
+ obtaining a copy of this Software, to use, copy, modify, merge, and distribute
12
+ the Software for non-commercial purposes, subject to the following conditions:
13
+
14
+ 1. Attribution Required: The above copyright notice, this permission notice,
15
+ and clear attribution to Medical Informatics Engineering, LLC. shall be
16
+ included in all copies or substantial portions of the Software.
17
+
18
+ 2. Open Source Requirement: If you distribute the Software or any derivative
19
+ works, you must do so under an open source license approved by the Open
20
+ Source Initiative (OSI) and make the source code publicly available.
21
+
22
+ 3. Non-Commercial Use Only: This license does not grant permission to use the
23
+ Software for commercial purposes. "Commercial purposes" means any use
24
+ intended for or directed toward commercial advantage or monetary
25
+ compensation.
26
+
27
+ COMMERCIAL USE
28
+
29
+ For commercial use, including but not limited to:
30
+ - Use in proprietary/closed-source products
31
+ - Use in products or services sold for profit
32
+ - Use by for-profit organizations for internal business operations
33
+
34
+ You must obtain a separate commercial license from Medical Informatics
35
+ Engineering, LLC. Contact: https://www.mieweb.com or helpdesk@mieweb.com
36
+
37
+ NO WARRANTY
38
+
39
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
40
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
41
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
42
+ MEDICAL INFORMATICS ENGINEERING, LLC. OR ITS AFFILIATES BE LIABLE FOR ANY
43
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
44
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
45
+ OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,386 @@
1
+ # @mieweb/pulsevault
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.
36
+
37
+ 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
+
39
+ ## Requirements
40
+
41
+ - Fastify `^5.x`
42
+ - Node.js `>=18`
43
+
44
+ ## Installation
45
+
46
+ ```sh
47
+ npm install @mieweb/pulsevault
48
+ ```
49
+
50
+ ## Quick start
51
+
52
+ ```ts
53
+ import Fastify from "fastify";
54
+ import { randomUUID } from "node:crypto";
55
+ import pulseVault, { createLocalStorage } from "@mieweb/pulsevault";
56
+
57
+ const app = Fastify();
58
+
59
+ await app.register(pulseVault, {
60
+ prefix: "",
61
+ storage: createLocalStorage({ workspaceDir: "./data" }),
62
+ maxUploadSize: 5 * 1024 * 1024 * 1024, // 5 GiB
63
+ });
64
+
65
+ // Your server owns videoid creation — attach auth, DB records, quotas here.
66
+ app.post("/reserve", async (_req, reply) => {
67
+ return reply.send({ videoid: randomUUID() });
68
+ });
69
+
70
+ await app.listen({ port: 3030 });
71
+ ```
72
+
73
+ ## Routes
74
+
75
+ The plugin mounts the following routes under `prefix`:
76
+
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) |
83
+
84
+ \* `DELETE /upload/:id` is TUS's own "cancel in-flight upload" — distinct from `DELETE /:videoid`, which removes a finalized video.
85
+
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.
87
+
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.
89
+
90
+ ## Plugin options
91
+
92
+ ```ts
93
+ type PulseVaultPluginOptions = {
94
+ storage: PulseVaultStorage;
95
+ prefix: string;
96
+ maxUploadSize: number;
97
+ decoratorName?: string; // default: "pulseVault"
98
+ allowedExtensions?: string[]; // default: [".mp4"]
99
+ cache?: PulseVaultCacheOptions;
100
+ authorize?: PulseVaultAuthorize;
101
+ validatePayload?: PulseVaultValidatePayload;
102
+ onUploadComplete?: PulseVaultOnUploadComplete;
103
+ };
104
+ ```
105
+
106
+ ### `storage`
107
+
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.).
109
+
110
+ ### `prefix`
111
+
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 `""`.
113
+
114
+ > 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
+
116
+ ### `maxUploadSize`
117
+
118
+ Maximum upload size in bytes. Use `Infinity` for no cap.
119
+
120
+ ### `decoratorName`
121
+
122
+ 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.
123
+
124
+ For TypeScript access to the default decorator, add a side-effect import once in your app:
125
+
126
+ ```ts
127
+ import "@mieweb/pulsevault/augment";
128
+ ```
129
+
130
+ ### `allowedExtensions`
131
+
132
+ File extensions accepted in `Upload-Metadata.filename`. Must include the leading dot. Defaults to `[".mp4"]`.
133
+
134
+ ### `cache`
135
+
136
+ Cache-control options for the `GET /:videoid` route, forwarded to `@fastify/send`:
137
+
138
+ ```ts
139
+ type PulseVaultCacheOptions = {
140
+ cacheControl?: boolean; // default: true
141
+ maxAge?: string | number; // ms or ms-style string e.g. "1y". default: 0
142
+ immutable?: boolean; // requires maxAge > 0. default: false
143
+ };
144
+ ```
145
+
146
+ Upload filenames are keyed by UUID, so `immutable: true` is safe when `maxAge` is non-zero.
147
+
148
+ ### `authorize`
149
+
150
+ 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
+
152
+ ```ts
153
+ type PulseVaultAuthorize = (
154
+ request: FastifyRequest,
155
+ ctx: {
156
+ phase: "create" | "patch" | "resolve" | "delete";
157
+ videoid: string;
158
+ },
159
+ ) => void | Promise<void>;
160
+ ```
161
+
162
+ ```ts
163
+ await app.register(pulseVault, {
164
+ // ...
165
+ authorize: async (request, { phase, videoid }) => {
166
+ const token = request.headers.authorization?.replace("Bearer ", "");
167
+ if (!isValid(token, videoid)) {
168
+ throw Object.assign(new Error("Forbidden"), { statusCode: 403 });
169
+ }
170
+ },
171
+ });
172
+ ```
173
+
174
+ ### `validatePayload`
175
+
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.
177
+
178
+ ```ts
179
+ type PulseVaultValidatePayload = (
180
+ request: FastifyRequest,
181
+ ctx: {
182
+ videoid: string;
183
+ size: number;
184
+ uploadId: string;
185
+ /** Absolute path to finalized bytes for adapters that expose `getLocalPath`. */
186
+ localPath: string | null;
187
+ },
188
+ ) => void | Promise<void>;
189
+ ```
190
+
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:
192
+
193
+ ```ts
194
+ import pulseVault, {
195
+ createLocalStorage,
196
+ createMp4Sniffer,
197
+ } from "@mieweb/pulsevault";
198
+
199
+ const storage = createLocalStorage({ workspaceDir: "./data" });
200
+
201
+ await app.register(pulseVault, {
202
+ // ...
203
+ storage,
204
+ validatePayload: createMp4Sniffer(storage),
205
+ });
206
+ ```
207
+
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.
209
+
210
+ The lower-level `sniffMp4(path)` is also exported if you want to drive your own validator.
211
+
212
+ ### `onUploadComplete`
213
+
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.
215
+
216
+ ```ts
217
+ type PulseVaultOnUploadComplete = (
218
+ request: FastifyRequest,
219
+ ctx: { videoid: string; size: number; uploadId: string },
220
+ ) => void | Promise<void>;
221
+ ```
222
+
223
+ ### Upload-complete sequencing
224
+
225
+ When the final PATCH lands, the plugin runs the following in order. Any step failing short-circuits the rest.
226
+
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.
230
+
231
+ ## Local storage
232
+
233
+ ```ts
234
+ import { createLocalStorage } from "@mieweb/pulsevault";
235
+
236
+ const storage = createLocalStorage({
237
+ workspaceDir: "./data", // directory for uploads; created if absent
238
+ });
239
+ ```
240
+
241
+ ### Filesystem layout (stable contract)
242
+
243
+ The local adapter writes each upload into a self-describing per-video directory. Downstream tools may rely on this layout across minor versions:
244
+
245
+ ```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
250
+ ```
251
+
252
+ `status` is `"uploading"` between `reserveUpload` and the successful final PATCH; `"ready"` thereafter. `GET /:videoid` only serves `"ready"` uploads.
253
+
254
+ The adapter exposes `storage.workspaceRoot` (absolute, resolved from `workspaceDir`) so consumers can compute per-video paths without re-implementing the layout.
255
+
256
+ ### Post-processing (transcription, thumbnails, AI)
257
+
258
+ The filesystem layout is the integration surface. Use the `onUploadComplete` hook as your trigger. For example, to hydrate an [ArtiPod](https://github.com/mieweb/artipod) with the video plus sibling artifact directories:
259
+
260
+ ```ts
261
+ import path from "node:path";
262
+ import { ArtiPod, ArtiMount } from "@mieweb/artipod";
263
+ import pulseVault, { createLocalStorage } from "@mieweb/pulsevault";
264
+
265
+ const storage = createLocalStorage({ workspaceDir: "./data" });
266
+
267
+ await app.register(pulseVault, {
268
+ prefix: "",
269
+ storage,
270
+ 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")));
275
+ // Create these lazily as your pipeline produces artifacts:
276
+ // pod.addMount(new ArtiMount("transcripts", path.join(root, "transcripts")));
277
+ // pod.addMount(new ArtiMount("frames", path.join(root, "frames")));
278
+ await pod.initialize();
279
+ // Run a containerized transcription step, build an LLM prompt from
280
+ // collected artifacts, etc. See the @mieweb/artipod docs for details.
281
+ },
282
+ });
283
+ ```
284
+
285
+ `@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
+
287
+ ## Custom storage adapter
288
+
289
+ Implement `PulseVaultStorage` to back uploads with any system (S3, GCS, database, etc.):
290
+
291
+ ```ts
292
+ import type {
293
+ PulseVaultStorage,
294
+ PulseVaultResolution,
295
+ } from "@mieweb/pulsevault";
296
+
297
+ const storage: PulseVaultStorage = {
298
+ datastore, // @tus/server DataStore instance
299
+ async initialize() {
300
+ /* optional setup */
301
+ },
302
+ async shutdown() {
303
+ /* optional teardown */
304
+ },
305
+ async reserveUpload({ videoid, filename, ext }) {
306
+ // 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}`;
309
+ },
310
+ async resolve(videoid): Promise<PulseVaultResolution | null> {
311
+ const video = await db.findVideo(videoid);
312
+ if (!video || video.status !== "ready") return null;
313
+ // Stream from local disk:
314
+ return { kind: "stream", root: "/uploads", filename: video.filename };
315
+ // Or redirect to a CDN / presigned URL:
316
+ // return { kind: "redirect", url: video.signedUrl, statusCode: 302 };
317
+ },
318
+ async markReady(videoid) {
319
+ // Called after `validatePayload` (if any) accepts the bytes. Flip your
320
+ // state so `resolve` starts returning non-null. Omit this method if
321
+ // your backend can't distinguish in-progress from finalized uploads.
322
+ await db.updateVideo(videoid, { status: "ready" });
323
+ },
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);
329
+ return result.deleted;
330
+ },
331
+ };
332
+ ```
333
+
334
+ ## Deep link helpers
335
+
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.
337
+
338
+ ```ts
339
+ import {
340
+ buildConfigureDestinationLink,
341
+ buildUploadLink,
342
+ } from "@mieweb/pulsevault";
343
+ import { randomUUID } from "node:crypto";
344
+
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.
353
+ const uploadLink = buildUploadLink({
354
+ server: "https://example.com",
355
+ videoid: randomUUID(), // generate server-side; skip POST /reserve on the app
356
+ token: "secret", // optional
357
+ });
358
+ ```
359
+
360
+ ## Tests
361
+
362
+ ```sh
363
+ npm test
364
+ ```
365
+
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.
367
+
368
+ ## Accessing storage outside the plugin routes
369
+
370
+ The storage adapter is exposed as a Fastify decorator, so you can use it in your own routes:
371
+
372
+ ```ts
373
+ import "@mieweb/pulsevault/augment"; // once, for TypeScript types
374
+
375
+ app.get("/admin/video/:id", async (req, reply) => {
376
+ const resolved = await app.pulseVault.resolve(req.params.id);
377
+ if (!resolved) return reply.code(404).send();
378
+ // custom logic...
379
+ });
380
+ ```
381
+
382
+ ## License
383
+
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).
385
+
386
+ Copyright © 2026 Medical Informatics Engineering, LLC. All rights reserved.
package/dist/app.d.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { FastifyPluginAsync } from "fastify";
2
+ import { type PulseVaultAuthorize } from "./routes/pulsevault.js";
3
+ import type { PulseVaultOnUploadComplete } from "./lib/pulsevaultTus.js";
4
+ import type { PulseVaultValidatePayload } from "./lib/magic.js";
5
+ import type { PulseVaultStorage } from "./storage/types.js";
6
+ /**
7
+ * Subset of `@fastify/send`'s cache-related options forwarded to the GET
8
+ * route. All upload filenames are content-addressable (keyed by the upload
9
+ * UUID), so `immutable: true` is safe whenever you also set a non-zero
10
+ * `maxAge`.
11
+ */
12
+ export type PulseVaultCacheOptions = {
13
+ /** Enable the `Cache-Control` response header. Defaults to `true`. */
14
+ cacheControl?: boolean;
15
+ /**
16
+ * `max-age` for the `Cache-Control` header. Accepts a number of
17
+ * milliseconds or an `ms`-style string such as `"1y"`. Defaults to `0`.
18
+ */
19
+ maxAge?: string | number;
20
+ /**
21
+ * Add the `immutable` directive to `Cache-Control`. Requires `maxAge > 0`
22
+ * to take effect. Defaults to `false`.
23
+ */
24
+ immutable?: boolean;
25
+ };
26
+ export type PulseVaultPluginOptions = {
27
+ /** Storage adapter. Use `createLocalStorage(...)` for filesystem-backed deployments. */
28
+ storage: PulseVaultStorage;
29
+ /**
30
+ * URL prefix where the plugin's routes are mounted, e.g. `"/pulsevault"`.
31
+ * Must be set explicitly: because this plugin is wrapped with
32
+ * `fastify-plugin` (so its decorator escapes encapsulation), Fastify's own
33
+ * `register(..., { prefix })` is a no-op and must be routed through this
34
+ * option instead. Use `""` to mount at the root.
35
+ */
36
+ prefix: string;
37
+ /**
38
+ * Max TUS upload size in bytes. Required — consumers must choose an
39
+ * explicit cap for their deployment. Use `Infinity` for no cap.
40
+ */
41
+ maxUploadSize: number;
42
+ /**
43
+ * Fastify instance decorator name under which the storage adapter is
44
+ * exposed. Defaults to `"pulseVault"`. Override when registering this
45
+ * plugin more than once in the same process so the decorators don't
46
+ * collide.
47
+ *
48
+ * For typed access to the default decorator, add a single side-effect
49
+ * import somewhere in your app: `import "@mieweb/pulsevault/augment"`.
50
+ * Consumers using a custom name must skip that import and write their own
51
+ * `declare module "fastify"` augmentation — see `./augment.ts` for the
52
+ * template.
53
+ */
54
+ decoratorName?: string;
55
+ /**
56
+ * File extensions allowed in the upload `filename` metadata. Must include
57
+ * the leading dot; matched case-insensitively. Defaults to `[".mp4"]`.
58
+ */
59
+ allowedExtensions?: readonly string[];
60
+ /**
61
+ * Cache-control options forwarded to `@fastify/send` for the GET route.
62
+ * When omitted, `@fastify/send`'s defaults apply (`Cache-Control: public,
63
+ * max-age=0`).
64
+ */
65
+ cache?: PulseVaultCacheOptions;
66
+ /**
67
+ * Optional authorization hook. Runs before the TUS create/PATCH lifecycle
68
+ * and before `resolve` on GET. Throw to reject the request; a thrown
69
+ * `statusCode`/`status_code` number on the error is honored (default 403).
70
+ *
71
+ * The hook is called with the `FastifyRequest`, so consumers can look up
72
+ * sessions, API keys, JWTs, etc. using whatever auth system they have
73
+ * registered higher up in the Fastify tree.
74
+ *
75
+ * When omitted, the plugin performs no authorization. For production
76
+ * deployments you almost certainly want to set this — register your auth
77
+ * plugin on the parent scope and let the hook verify ownership of the
78
+ * `videoid` before any bytes are written.
79
+ */
80
+ authorize?: PulseVaultAuthorize;
81
+ /**
82
+ * Optional payload-validation hook. Runs *after* TUS writes the final byte
83
+ * but *before* the upload is marked ready or `onUploadComplete` fires.
84
+ * Throw to reject — the plugin will call `storage.remove` to free the
85
+ * bytes and return a 4xx (default 422) to the client. The sidecar never
86
+ * flips to `"ready"`, so the video is never served.
87
+ *
88
+ * Use this for magic-byte sniffing, virus scanning, or any check that
89
+ * needs the final bytes. Ship-ready helpers are exported from this
90
+ * package — see `createMp4Sniffer`.
91
+ */
92
+ validatePayload?: PulseVaultValidatePayload;
93
+ /**
94
+ * Optional post-upload hook. Fires once TUS writes the final byte *and*
95
+ * any `validatePayload` has passed, and after the adapter's `markReady`
96
+ * has run. Use this to flip consumer state (DB row, queue job, audit
97
+ * log). Throwing turns the upload into a 500 response; the video is
98
+ * marked ready at this point, so consumers that want all-or-nothing
99
+ * semantics should `storage.remove` before throwing.
100
+ */
101
+ onUploadComplete?: PulseVaultOnUploadComplete;
102
+ };
103
+ declare const _default: FastifyPluginAsync<PulseVaultPluginOptions>;
104
+ export default _default;
105
+ export { createLocalStorage } from "./storage/local.js";
106
+ export type { LocalStorage, LocalStorageOptions } from "./storage/local.js";
107
+ export type { PulseVaultResolution, PulseVaultStorage, ReserveUploadParams, } from "./storage/types.js";
108
+ export type { PulseVaultAuthorize, PulseVaultAuthorizeContext, PulseVaultAuthorizePhase, } from "./routes/pulsevault.js";
109
+ export type { PulseVaultOnUploadComplete } from "./lib/pulsevaultTus.js";
110
+ export { sniffMp4, createMp4Sniffer } from "./lib/magic.js";
111
+ export type { PulseVaultValidatePayload } from "./lib/magic.js";
112
+ export { buildConfigureDestinationLink, buildUploadLink, } from "./lib/deeplinks.js";
113
+ export type { ConfigureDestinationLinkOptions, UploadLinkOptions, } from "./lib/deeplinks.js";
114
+ //# sourceMappingURL=app.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAElD,OAAyB,EACvB,KAAK,mBAAmB,EAGzB,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAChE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D;;;;;GAKG;AACH,MAAM,MAAM,sBAAsB,GAAG;IACnC,sEAAsE;IACtE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,wFAAwF;IACxF,OAAO,EAAE,iBAAiB,CAAC;IAC3B;;;;;;OAMG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;;;;;;OAWG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC;;;;OAIG;IACH,KAAK,CAAC,EAAE,sBAAsB,CAAC;IAC/B;;;;;;;;;;;;;OAaG;IACH,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAChC;;;;;;;;;;OAUG;IACH,eAAe,CAAC,EAAE,yBAAyB,CAAC;IAC5C;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,0BAA0B,CAAC;CAC/C,CAAC;;AAgEF,wBAGG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,YAAY,EACV,oBAAoB,EACpB,iBAAiB,EACjB,mBAAmB,GACpB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,mBAAmB,EACnB,0BAA0B,EAC1B,wBAAwB,GACzB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AACzE,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAC5D,YAAY,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAChE,OAAO,EACL,6BAA6B,EAC7B,eAAe,GAChB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,+BAA+B,EAC/B,iBAAiB,GAClB,MAAM,oBAAoB,CAAC"}
package/dist/app.js ADDED
@@ -0,0 +1,54 @@
1
+ import fp from "fastify-plugin";
2
+ import pulseVaultRoutes, {} from "./routes/pulsevault.js";
3
+ const DEFAULT_DECORATOR_NAME = "pulseVault";
4
+ const DEFAULT_ALLOWED_EXTENSIONS = [".mp4"];
5
+ const EXTENSION_REGEX = /^\.[^.\s/\\]+$/;
6
+ function validateOptions(opts) {
7
+ if (opts.prefix !== "" && (!opts.prefix.startsWith("/") || opts.prefix.endsWith("/"))) {
8
+ throw new TypeError("`prefix` must be '' or start with '/' with no trailing slash (e.g. '/pulsevault')");
9
+ }
10
+ if (!(opts.maxUploadSize > 0)) {
11
+ throw new TypeError("`maxUploadSize` must be a positive number (use Infinity for no cap)");
12
+ }
13
+ if (opts.allowedExtensions) {
14
+ if (opts.allowedExtensions.length === 0) {
15
+ throw new TypeError("`allowedExtensions` must not be empty");
16
+ }
17
+ for (const ext of opts.allowedExtensions) {
18
+ if (!EXTENSION_REGEX.test(ext)) {
19
+ throw new TypeError(`\`allowedExtensions\` entry ${JSON.stringify(ext)} must start with '.' and contain no nested dots, slashes, or whitespace (e.g. '.mp4')`);
20
+ }
21
+ }
22
+ }
23
+ }
24
+ const app = async (fastify, opts) => {
25
+ validateOptions(opts);
26
+ // Register the shutdown hook *before* awaiting initialize() so any partial
27
+ // state the adapter allocates mid-init still gets cleaned up if Fastify
28
+ // later tears the plugin down.
29
+ fastify.addHook("onClose", async () => {
30
+ await opts.storage.shutdown?.();
31
+ });
32
+ await opts.storage.initialize?.();
33
+ const decoratorName = opts.decoratorName ?? DEFAULT_DECORATOR_NAME;
34
+ const allowedExtensions = (opts.allowedExtensions ?? DEFAULT_ALLOWED_EXTENSIONS).map((e) => e.toLowerCase());
35
+ fastify.decorate(decoratorName, opts.storage);
36
+ await fastify.register(pulseVaultRoutes, {
37
+ prefix: opts.prefix,
38
+ storage: opts.storage,
39
+ maxUploadSize: opts.maxUploadSize,
40
+ allowedExtensions,
41
+ cache: opts.cache,
42
+ authorize: opts.authorize,
43
+ validatePayload: opts.validatePayload,
44
+ onUploadComplete: opts.onUploadComplete,
45
+ });
46
+ };
47
+ export default fp(app, {
48
+ name: "pulsevault",
49
+ fastify: "5.x",
50
+ });
51
+ export { createLocalStorage } from "./storage/local.js";
52
+ export { sniffMp4, createMp4Sniffer } from "./lib/magic.js";
53
+ export { buildConfigureDestinationLink, buildUploadLink, } from "./lib/deeplinks.js";
54
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAChC,OAAO,gBAAgB,EAAE,EAIxB,MAAM,wBAAwB,CAAC;AAwGhC,MAAM,sBAAsB,GAAG,YAAY,CAAC;AAC5C,MAAM,0BAA0B,GAAsB,CAAC,MAAM,CAAC,CAAC;AAC/D,MAAM,eAAe,GAAG,gBAAgB,CAAC;AAEzC,SAAS,eAAe,CAAC,IAA6B;IACpD,IAAI,IAAI,CAAC,MAAM,KAAK,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;QACtF,MAAM,IAAI,SAAS,CACjB,mFAAmF,CACpF,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,CAAC,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC,EAAE,CAAC;QAC9B,MAAM,IAAI,SAAS,CACjB,qEAAqE,CACtE,CAAC;IACJ,CAAC;IACD,IAAI,IAAI,CAAC,iBAAiB,EAAE,CAAC;QAC3B,IAAI,IAAI,CAAC,iBAAiB,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACxC,MAAM,IAAI,SAAS,CAAC,uCAAuC,CAAC,CAAC;QAC/D,CAAC;QACD,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,iBAAiB,EAAE,CAAC;YACzC,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;gBAC/B,MAAM,IAAI,SAAS,CACjB,+BAA+B,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,uFAAuF,CAC1I,CAAC;YACJ,CAAC;QACH,CAAC;IACH,CAAC;AACH,CAAC;AAED,MAAM,GAAG,GAAgD,KAAK,EAC5D,OAAO,EACP,IAAI,EACJ,EAAE;IACF,eAAe,CAAC,IAAI,CAAC,CAAC;IAEtB,2EAA2E;IAC3E,wEAAwE;IACxE,+BAA+B;IAC/B,OAAO,CAAC,OAAO,CAAC,SAAS,EAAE,KAAK,IAAI,EAAE;QACpC,MAAM,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC;IAClC,CAAC,CAAC,CAAC;IACH,MAAM,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC;IAElC,MAAM,aAAa,GAAG,IAAI,CAAC,aAAa,IAAI,sBAAsB,CAAC;IACnE,MAAM,iBAAiB,GAAG,CACxB,IAAI,CAAC,iBAAiB,IAAI,0BAA0B,CACrD,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;IAE9B,OAAO,CAAC,QAAQ,CAAC,aAAa,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IAE9C,MAAM,OAAO,CAAC,QAAQ,CAAC,gBAAgB,EAAE;QACvC,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,aAAa,EAAE,IAAI,CAAC,aAAa;QACjC,iBAAiB;QACjB,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,eAAe,EAAE,IAAI,CAAC,eAAe;QACrC,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;KACxC,CAAC,CAAC;AACL,CAAC,CAAC;AAEF,eAAe,EAAE,CAAC,GAAG,EAAE;IACrB,IAAI,EAAE,YAAY;IAClB,OAAO,EAAE,KAAK;CACf,CAAC,CAAC;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAaxD,OAAO,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAE5D,OAAO,EACL,6BAA6B,EAC7B,eAAe,GAChB,MAAM,oBAAoB,CAAC"}
@@ -0,0 +1,45 @@
1
+ import type { PulseVaultStorage } from "./storage/types.js";
2
+ /**
3
+ * Opt-in TypeScript augmentation for the default `pulseVault` decorator and
4
+ * the per-request `request.pulseVault` context that the plugin sets when it
5
+ * recognizes a videoid on the incoming request. Import for side effects in
6
+ * one place in your app when you register the plugin with the default
7
+ * `decoratorName`:
8
+ *
9
+ * ```ts
10
+ * import "@mieweb/pulsevault/augment";
11
+ * ```
12
+ *
13
+ * If you pass a custom `decoratorName`, skip this import and write your own
14
+ * augmentation:
15
+ *
16
+ * ```ts
17
+ * declare module "fastify" {
18
+ * interface FastifyInstance {
19
+ * myCustomName: PulseVaultStorage;
20
+ * }
21
+ * interface FastifyRequest {
22
+ * pulseVault?: { videoid: string };
23
+ * }
24
+ * }
25
+ * ```
26
+ */
27
+ declare module "fastify" {
28
+ interface FastifyInstance {
29
+ pulseVault: PulseVaultStorage;
30
+ }
31
+ interface FastifyRequest {
32
+ /**
33
+ * Populated by the plugin when it can extract a videoid from the
34
+ * incoming request (from `Upload-Metadata` on POST, the URL id on
35
+ * PATCH/HEAD/DELETE, or the route param on GET). Present on every
36
+ * request the plugin routes serve that refer to a valid UUID — use it
37
+ * from your own `preHandler` / auth hooks to avoid re-parsing.
38
+ */
39
+ pulseVault?: {
40
+ videoid: string;
41
+ };
42
+ }
43
+ }
44
+ export {};
45
+ //# sourceMappingURL=augment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"augment.d.ts","sourceRoot":"","sources":["../src/augment.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,eAAe;QACvB,UAAU,EAAE,iBAAiB,CAAC;KAC/B;IACD,UAAU,cAAc;QACtB;;;;;;WAMG;QACH,UAAU,CAAC,EAAE;YAAE,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC;KAClC;CACF;AAED,OAAO,EAAE,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=augment.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"augment.js","sourceRoot":"","sources":["../src/augment.ts"],"names":[],"mappings":"AA2CA,OAAO,EAAE,CAAC"}