@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/CHANGELOG.md ADDED
@@ -0,0 +1,258 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
5
+ semantic versioning once it reaches `1.0.0` — pre-1.0, minor bumps may include
6
+ breaking changes, called out explicitly below.
7
+
8
+ ## [Unreleased]
9
+
10
+ This release reworks the upload contract for genuine multi-tenant use — any
11
+ third party can implement a compatible server from `PROTOCOL.md` alone, not
12
+ just by reading this package's source. It bundles several breaking changes
13
+ into one release rather than spreading them across several; see "Upgrading"
14
+ in `OPERATIONS.md` for the migration path.
15
+
16
+ ### Security
17
+
18
+ The `videoid` → `artifactId` / generic-route rework above introduced two
19
+ related authorization bugs in `core.ts`'s `PATCH`/`HEAD` handling. Both are
20
+ fixed in this same release; upgrade before this reaches a published version
21
+ if you've evaluated against an intermediate build.
22
+
23
+ - **`authorize` was never actually invoked for `PATCH`/`HEAD`/in-flight-`DELETE`
24
+ under `{prefix}/upload/<id>`**, regardless of configuration — including the
25
+ built-in `createCapabilityAuthorize`. The helper that recovered an
26
+ artifactId from the tus resource URL for the `authorize` context assumed
27
+ the wrong shape for the current `<kind>/<artifactId><ext>` tus id and never
28
+ successfully extracted a UUID, so the "no artifactId, so allow" fallback
29
+ silently took over on every such request. Since an artifactId is not a
30
+ secret (it's carried in the pairing deep link/QR code by design, per
31
+ `PROTOCOL.md` §3), this let anyone who had seen a pairing link write bytes
32
+ into that upload with no token at all. Fixed by resolving the artifactId
33
+ through the same parser the actual upload-completion path uses, and by
34
+ making authorization-context resolution failure a hard reject rather than
35
+ a silent allow (`PROTOCOL.md` §5.2).
36
+ - **A crafted multi-segment `PATCH` URL could write bytes to a different
37
+ artifact than the one `authorize()` checked.** The `authorize`-context URL
38
+ parser took the first path segment after `/upload/`, while `@tus/server`'s
39
+ own request routing (which decides what's actually read/written) takes the
40
+ URL's last segment — a divergence exploitable via extra path segments
41
+ (accepted by the `/upload/*` wildcard route). A party holding a valid
42
+ token for their own artifact could append a second, victim artifactId as a
43
+ trailing segment: `authorize()` saw and approved their own id, while the
44
+ request body landed on the victim's file. Fixed by resolving the
45
+ authorization-context artifactId via the exact same last-segment
46
+ extraction `@tus/server` itself uses (`PROTOCOL.md` §4.4, a new normative
47
+ requirement on server implementations generally, not just this package).
48
+ - Both are covered by new regression tests in `test/plugin.test.mjs` that
49
+ fail against the pre-fix code and pass against the fix.
50
+ - **Removed `s3rver`** (unmaintained) from the dev/test toolchain, clearing
51
+ its open `npm audit` findings: its dependency chain carried high-severity
52
+ advisories (`busboy`/`dicer`) and forced pinning `fast-xml-parser` via an
53
+ `overrides` entry plus a hand-written API-compat shim. The S3 suite now runs
54
+ against `test/mock-s3.mjs`, a zero-dependency in-memory S3 double
55
+ implementing exactly the eleven operations the code under test uses. Unlike
56
+ s3rver it enforces `If-None-Match: "*"` (412), so the adapter's atomic
57
+ reserve collision guard is now genuinely exercised — a concurrent-reserve
58
+ test that was impossible against s3rver has been added. The `ListParts` shim
59
+ (s3rver never implemented it) is gone too.
60
+ - **5xx responses no longer echo internal error messages.** A `markReady`,
61
+ `onUploadComplete`, or 5xx-class `validatePayload` failure now logs the real
62
+ error server-side and returns a generic body, so a consumer's DB/storage error
63
+ text (schema names, paths, infra detail) can't reach the uploading client. 4xx
64
+ validation rejections (e.g. checksum mismatch) keep their descriptive messages.
65
+ - **Storage subdirectories are created with mode `0o750`** so the upload tree
66
+ isn't world-readable under a permissive umask.
67
+
68
+ ### Breaking
69
+
70
+ - **Renamed `videoid` → `artifactId`** across the storage interface
71
+ (`ReserveUploadParams`, `PulseVaultStorage` methods), the `authorize`
72
+ context (`PulseVaultAuthorizeContext`), the `validatePayload`/
73
+ `onUploadComplete` hook contexts, the `request.pulseVault` TypeScript
74
+ augmentation (`./augment.ts` — a separate breaking change from the wire
75
+ rename, since it's the published type surface, not the HTTP contract), and
76
+ the deep-link query parameter built by `buildUploadLink`. `videoid`/
77
+ `projectid` remain accepted as legacy aliases in `Upload-Metadata` — only
78
+ the *primary* name changed, not backward compatibility for existing
79
+ clients' requests.
80
+ - **Collapsed the per-kind routes into one generic route.** `GET/DELETE
81
+ /:videoid` and `GET/DELETE /project/:projectid` are gone; replaced by
82
+ `GET/DELETE /artifacts/:artifactId`, which resolves the kind from storage
83
+ rather than the URL. This also means `kind=captions` artifacts (new, see
84
+ below) don't need a third route pair.
85
+ - **`validateProjectPayload`/`onProjectUploadComplete` are deprecated** (not
86
+ yet removed) in favor of the now-generic `validatePayload`/
87
+ `onUploadComplete`, which receive `ctx.kind` and run for every artifact
88
+ kind. Passing either deprecated option now emits a one-time
89
+ `DeprecationWarning` at plugin registration.
90
+ - **`allowedExtensions` requires a `captions` default to be considered** if
91
+ you were relying on the exact shape of the normalized object internally
92
+ (public consumers passing `allowedExtensions` as documented are unaffected
93
+ — the new key just has a default like `video`/`project` already did).
94
+
95
+ ### Added
96
+
97
+ - **`kind: "captions"`** artifact type (default extension `.vtt`), running
98
+ through the same generic `validatePayload`/`onUploadComplete` hooks as
99
+ every other kind. WebVTT carries word-level inline cue timestamps
100
+ (`<00:00:01.500>word`) for karaoke rendering, and both storage adapters
101
+ serve `.vtt` artifacts with the `text/vtt` content type.
102
+ - **`relatedTo`** — an optional `Upload-Metadata` key (and matching
103
+ `ReserveUploadParams`/storage field) linking one artifact to another (e.g.
104
+ a merged video's captions/beat-manifest/thumbnail, or a clip belonging to a
105
+ segment ordering manifest's session).
106
+ Storage adapters expose it via the new optional `getRelatedTo` method.
107
+ - **`checksum`** — an optional `Upload-Metadata` key (`<algorithm>:<hex
108
+ digest>`) verified post-upload via the new `createChecksumValidator`
109
+ (local storage) / `createS3ChecksumValidator` (S3/R2) helpers, chainable
110
+ with `createMp4Sniffer`. This is at-rest integrity verification on the
111
+ finished file, not a per-chunk check — `@tus/server`'s installed version
112
+ doesn't implement the TUS Checksum extension, which would have covered
113
+ chunks in flight.
114
+ - **`src/lib/capability-token.ts`**: `issueCapabilityToken`,
115
+ `verifyCapabilityToken`, `createCapabilityAuthorize` — a stateless,
116
+ HMAC-signed capability-token scheme with `kid` (key rotation with an
117
+ overlap window), `iat` (clock-skew tolerance), and `issuer` (cross-deployment
118
+ replay protection) claims. Fully optional — bring your own `authorize` if
119
+ you have existing auth.
120
+ - **`GET {prefix}/capabilities`** — unauthenticated discovery route reporting
121
+ `protocolVersion`, `minSupportedVersion`/`maxSupportedVersion`,
122
+ `uploadUnit`, `kinds`, `allowedExtensions`, `maxUploadSize`, and supported
123
+ checksum algorithms. Lets independently-versioned deployments and clients
124
+ detect compatibility before pairing.
125
+ - **`uploadUnit` plugin option** (`"segment" | "merged"`, default `"segment"`) —
126
+ purely advertised via `/capabilities`; the operator declares which upload
127
+ strategy this deployment expects, the client branches on it. Not enforced
128
+ by the plugin. `"segment"` uploads each recorded clip plus an ordering
129
+ manifest; `"merged"` uploads one pre-merged video plus its captions, a
130
+ beat-timecode manifest, and a thumbnail. (A "beat" is a timecode range on
131
+ the merged timeline, not an upload unit.)
132
+ - **`kind: "thumbnail"`** artifact type (default extensions `.jpg`/`.jpeg`/
133
+ `.png`) — the pulse poster frame uploaded alongside a merged video, running
134
+ through the same generic `validatePayload`/`onUploadComplete` hooks and a
135
+ `thumbnail/` storage subdir like every other kind.
136
+ - **`buildUploadLink({ uploadUnit })`** (PROTOCOL.md §3, §8) — optional
137
+ per-session override of the deployment-wide `uploadUnit`, carried on the
138
+ pairing link itself instead of only `/capabilities`. Lets an operator run
139
+ `"segment"` and `"merged"` sessions concurrently (staged rollout, A/B test,
140
+ per-tenant policy) without racing a client's separate `/capabilities` fetch
141
+ against a single, deployment-wide value. Fully backward compatible: omit it
142
+ and a client falls back to `/capabilities` exactly as before.
143
+ - **`onArtifactEvent` plugin option** — one low-frequency hook (authorize
144
+ rejection, completion, validation rejection — never per chunk) covering
145
+ both ops metrics and a compliance audit trail from a single integration
146
+ point.
147
+ - **`Protocol-Version` response header** on every route this plugin mounts.
148
+ - **`@mieweb/pulsevault/core`** — a framework-agnostic entry point with no
149
+ Fastify dependency. `createPulseVaultCore(...)` returns a connect-style
150
+ `handler(req, res, next?)` usable directly as Express middleware
151
+ (`app.use(prefix, handler)`), Meteor middleware
152
+ (`WebApp.connectHandlers.use(prefix, handler)`), or a bare
153
+ `http.createServer` callback — same options, hooks, and storage adapters as
154
+ the Fastify plugin (which is now itself a thin adapter over this core, so
155
+ behavior can't drift between the two). See "Non-Fastify hosts" in
156
+ `README.md`, and `examples/express-demo`/`examples/meteor-demo` for full
157
+ runnable servers verified against the real frameworks.
158
+ - As part of this, `PulseVaultAuthorize`/`PulseVaultValidatePayload`/
159
+ `PulseVaultOnUploadComplete`'s `request` parameter is now typed as the
160
+ generic `PulseVaultRequest` (just `{ headers }`) instead of
161
+ `FastifyRequest` — `FastifyRequest` still satisfies it structurally, so
162
+ untyped/inferred hook callbacks (the documented pattern) are unaffected;
163
+ only an explicit `(request: FastifyRequest, ctx) => ...` annotation on
164
+ one of these three hooks would need loosening.
165
+ - Bounded, insertion-order-evicting in-memory metadata cache in both storage
166
+ adapters (`metaCacheLimit` option, default 10,000 entries) — previously
167
+ unbounded for the life of the process.
168
+ - `S3Storage.readAll` — full-object read, used by `createS3ChecksumValidator`.
169
+ - `S3Storage.digestAll` — streams a finalized object through a hash digest
170
+ without buffering the whole thing into memory first; the streaming
171
+ counterpart to `readAll`, now used internally by
172
+ `createS3ChecksumValidator` (see "Fixed" below).
173
+
174
+ ### Changed
175
+
176
+ - Replaced `examples/rn-demo` with two focused Fastify examples:
177
+ `examples/fastify-demo` (the smallest runnable server — no auth, local
178
+ storage, QR pairing; start here) and `examples/fastify-auth-demo`
179
+ (production-shaped reference — capability tokens always on, failing fast at
180
+ boot without `PULSEVAULT_SECRET`, pulse-session grouping via `relatedTo`,
181
+ WebVTT captions, Swagger UI, artifact-event feed). Both pairing pages
182
+ are React (pinned ESM CDN builds, no bundler). Both examples are
183
+ local-storage only; S3 wiring is documented in the README's S3 section
184
+ rather than demonstrated in an example. `npm run e2e` now drives
185
+ `fastify-auth-demo` and additionally asserts the fail-fast boot contract.
186
+ - `examples/fastify-auth-demo` gained a [Better Auth](https://better-auth.com)
187
+ dashboard layer: the human-facing routes (`/deeplinks`, `/pulses`,
188
+ `/events`, `/reserve`) require an email+password session, while the vault
189
+ routes the Pulse app talks to stay capability-token authorized — two auth
190
+ systems for two audiences, per PROTOCOL.md §5. The demo is Postgres-only,
191
+ run through Docker Compose (`compose.yaml` + `Dockerfile`; `npm run db:up`
192
+ + `npm start` for bare-metal dev against the same db), with one Prisma
193
+ schema (`prisma/schema.prisma`, migrations committed and applied by
194
+ `npm start`) holding both the auth tables and an **artifact index**:
195
+ `onUploadComplete` writes each finished upload once, authorized deletes
196
+ prune it, a boot-time reconcile keeps it honest against the filesystem
197
+ sidecars (still the source of truth), and `/pulses` becomes one indexed
198
+ query instead of a per-request sidecar crawl. `npm run e2e` provisions the
199
+ compose db, applies migrations, and covers the 401 gate plus the
200
+ sign-up/sign-in flow.
201
+
202
+ ### Fixed
203
+
204
+ - **Artifact `GET`/`DELETE` handlers now fail closed.** They run on a hijacked
205
+ socket, so a thrown storage error (S3 unreachable, disk fault) previously left
206
+ the socket hung until timeout; each now emits a `500` (or destroys the socket
207
+ if headers are already sent), mirroring the TUS handler. `GET` streams also
208
+ catch a mid-stream read error instead of letting an unhandled `'error'` crash
209
+ the process, and destroy the source stream on client disconnect so file
210
+ descriptors aren't leaked. Covered by `test/fail-closed.test.mjs`.
211
+ - A consumer error carrying an out-of-range `statusCode` (e.g. `42`) now degrades
212
+ to the handler's fallback status instead of crashing `res.writeHead`; a
213
+ malformed request-target that `URL` can't parse returns `400` instead of an
214
+ uncaught throw.
215
+ - Checksum verification requested against an adapter with no local path now
216
+ reports `500` (server misconfiguration) instead of `422` (which wrongly
217
+ implied the client's file was rejected).
218
+ - `verifyCapabilityToken` no longer throws on a token whose payload is valid
219
+ JSON but not an object (e.g. the literal `null`) — it returns `null` like
220
+ every other failure, per its single-failure-shape contract.
221
+ - Bearer-token extraction accepts the auth scheme case-insensitively per
222
+ RFC 7235 (`bearer`/`BEARER`), not only the canonical `Bearer `.
223
+ - `maxUploadSize` enforcement and the in-progress-upload 404 behavior now
224
+ have explicit test coverage proving bytes are rejected/hidden at the right
225
+ point, not just implied by the implementation.
226
+ - **Meteor compatibility.** Meteor's bundler doesn't resolve `package.json`
227
+ `"exports"` subpath maps, which broke both this package's own `"./core"`
228
+ entry and, transitively, `@tus/server`'s dependency on `srvx` (which as of
229
+ `@tus/server@2.1.0` ships *only* subpath exports, no legacy `main`
230
+ fallback). Fixed two ways: `@tus/server`/`@tus/file-store` are pinned to
231
+ `2.0.0` (the last release before the `srvx` migration — everything added
232
+ in `2.1.0`–`2.4.1` was either the `srvx` migration itself, follow-up
233
+ fixes for regressions it introduced, or one unused `exposedHeaders`
234
+ option, so nothing this package relies on is lost); and plain root-level
235
+ `core.js`/`core.d.ts`/`augment.js`/`augment.d.ts` files now ship alongside
236
+ the `"exports"` map, so resolvers that ignore `"exports"` (Meteor's
237
+ included) still find these entry points via ordinary relative-path
238
+ resolution. Verified against a real Meteor 3.4 app: `WebApp.connectHandlers`
239
+ resolves `@mieweb/pulsevault/core` cleanly and a full TUS
240
+ create → PATCH → GET round-trip works.
241
+ - **Unbounded memory use during checksum validation.** `createChecksumValidator`
242
+ read the whole finalized file into a single `Buffer` via `fs.readFile`
243
+ before hashing; combined with the documented-supported `maxUploadSize:
244
+ Infinity`, a large upload could exhaust process memory just to verify its
245
+ checksum. Now streams the file through the hash via `createReadStream` +
246
+ a stream pipeline. `createS3ChecksumValidator` had the same issue via
247
+ `S3Storage.readAll`'s full-object buffer — now uses the new streaming
248
+ `S3Storage.digestAll` instead.
249
+ - **Silent TOCTOU on S3-compatible backends without `IfNoneMatch` support.**
250
+ `reserveUpload`'s conditional-write collision guard falls back to a
251
+ weaker check-then-write on backends that reject `IfNoneMatch` (some
252
+ S3-compatible stores) — this reopens the race between concurrent/retried
253
+ creates
254
+ for the same artifactId. There's no way to close it without an external
255
+ lock, so this is now at least surfaced: a one-time `console.warn` fires
256
+ per process the first time a deployment falls into this degraded mode.
257
+
258
+ [Unreleased]: https://github.com/mieweb/pulsevault/compare/v0.0.1...HEAD
package/LICENSE CHANGED
@@ -1,45 +1,21 @@
1
- @mieweb/pulsevault - Source Available License
1
+ MIT License
2
2
 
3
- Copyright (c) 2026 Medical Informatics Engineering, LLC. All rights reserved.
3
+ Copyright (c) 2026 Medical Informatics Engineering, LLC
4
4
 
5
- This software and associated documentation files (the "Software") are the
6
- proprietary property of Medical Informatics Engineering, LLC. ("MIE").
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
7
11
 
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
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
38
14
 
39
15
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
40
16
  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.
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/OPERATIONS.md ADDED
@@ -0,0 +1,223 @@
1
+ # Operations
2
+
3
+ Deployment, scaling, and maintenance guidance for running `@mieweb/pulsevault`
4
+ in production. See `README.md` for the quick-start and plugin options, and
5
+ `PROTOCOL.md` for the wire contract.
6
+
7
+ ## Upgrading to this release
8
+
9
+ This release renames `videoid` → `artifactId` and collapses the per-kind
10
+ routes into one generic route (see `CHANGELOG.md`). **No on-disk data
11
+ migration is required**: the local and S3 storage layouts never stored the
12
+ id as a field inside the sidecar JSON — the id is the filename — so existing
13
+ uploaded artifacts are immediately readable through the new
14
+ `GET /artifacts/:artifactId` route with zero changes to the files on disk.
15
+
16
+ What *does* need updating:
17
+
18
+ - Any of your own code calling the old routes directly (`GET/DELETE
19
+ /:videoid`, `GET/DELETE /project/:projectid`) must move to `GET/DELETE
20
+ /artifacts/:artifactId` — the old routes return `404`, not a redirect.
21
+ - Any code reading `request.pulseVault.videoid` (if you imported
22
+ `@mieweb/pulsevault/augment`) must read `.artifactId` instead.
23
+ - If you use `validateProjectPayload`/`onProjectUploadComplete`, you'll see a
24
+ one-time `DeprecationWarning` at startup. They still work this release —
25
+ migrate to `validatePayload`/`onUploadComplete` with a `ctx.kind ===
26
+ "project"` branch when convenient, before a future major version removes
27
+ the deprecated options.
28
+ - Clients still sending `Upload-Metadata: videoid ...` (instead of
29
+ `artifactId`) keep working — the alias is accepted indefinitely for
30
+ protocol version 1 (see `PROTOCOL.md` §4.1) — but new client code should
31
+ send `artifactId`.
32
+
33
+ There is no automated migration script because there is nothing on disk to
34
+ migrate. If a future release ever does change the storage layout, that
35
+ release's `OPERATIONS.md` entry will include one.
36
+
37
+ ## Horizontal scaling
38
+
39
+ **The local storage adapter (`createLocalStorage`) requires either
40
+ sticky-session load-balancer routing or a shared filesystem (NFS/EFS/etc.)
41
+ across instances.** TUS resumable uploads need subsequent `PATCH`/`HEAD`
42
+ requests to reach an instance that can see the partial upload's bytes and
43
+ `@tus/file-store` offset metadata. If you run multiple instances behind a
44
+ load balancer without either of these, a retried `PATCH` that lands on a
45
+ different instance will silently fail to resume — the client's `HEAD` will
46
+ see no offset and effectively restart from byte zero, with no error
47
+ surfaced anywhere.
48
+
49
+ Two ways to actually support multiple instances:
50
+
51
+ 1. **Sticky sessions**: configure your load balancer to route by client IP
52
+ or a session cookie so all requests for one upload land on the same
53
+ instance.
54
+ 2. **Shared filesystem**: point every instance's `workspaceDir` at the same
55
+ NFS/EFS mount. Verify your mount's durability/consistency guarantees
56
+ under concurrent writes from multiple instances before relying on this in
57
+ production.
58
+
59
+ **The S3/R2 adapter (`createS3Storage`) has no such requirement** — every
60
+ instance talks to the same bucket, so it scales horizontally with zero
61
+ additional configuration. Prefer it for any multi-instance deployment unless
62
+ you have a specific reason to use local disk.
63
+
64
+ ### S3-compatible backend collision-guard fallback
65
+
66
+ `reserveUpload`'s collision guard normally uses `PutObjectCommand`'s
67
+ `IfNoneMatch: "*"` to atomically reject a second create for an artifactId
68
+ that already has an upload. Some S3-compatible backends (older/less-complete
69
+ implementations) don't support conditional writes and reject that header
70
+ outright.
71
+ On those backends, `reserveUpload` falls back to a weaker check-then-write —
72
+ functionally the same guard, but with the original race reopened: two
73
+ truly concurrent (or retried) creates for the same artifactId can both pass
74
+ the check before either writes, and the second silently clobbers the
75
+ first's metadata. There's no way to close this without an external lock,
76
+ since it's a limitation of the backend, not this package. If your bucket
77
+ provider doesn't support `IfNoneMatch`, this fallback logs a one-time
78
+ `console.warn` per process the first time it's used — treat that warning as
79
+ a signal to either move to a backend that supports conditional writes, or
80
+ serialize artifactId creation yourself (e.g. in your own `/reserve`
81
+ endpoint) if concurrent creates for the same id are a real possibility in
82
+ your deployment.
83
+
84
+ ## Resource limits and abuse prevention
85
+
86
+ `pulsevault` does not implement rate limiting or a concurrent-upload cap
87
+ itself — that's left to the operator, consistent with this project's general
88
+ principle that policy decisions belong to the deployment, not the library.
89
+ A minimal example using `@fastify/rate-limit`, scoped to just the upload
90
+ routes:
91
+
92
+ ```ts
93
+ import rateLimit from "@fastify/rate-limit";
94
+
95
+ await app.register(rateLimit, {
96
+ max: 20,
97
+ timeWindow: "1 minute",
98
+ // Scope to the pulsevault prefix only — don't rate-limit your whole app
99
+ // with upload-sized limits.
100
+ allowList: (req) => !req.url.startsWith("/pulsevault/upload"),
101
+ });
102
+ ```
103
+
104
+ Under `@mieweb/pulsevault/core` (Express, Meteor, plain `http`), use the equivalent middleware for your host — e.g. `express-rate-limit` mounted ahead of `pulseVault.handler`, scoped the same way.
105
+
106
+ ## Monitoring and audit logging
107
+
108
+ Wire `onArtifactEvent` once to get both ops metrics and a compliance audit
109
+ trail from the same hook — it fires at low-frequency, audit-worthy moments
110
+ only (never per chunk): authorize rejection (on `create`/`delete`/`resolve`,
111
+ not `patch`), successful completion, and validation rejection.
112
+
113
+ **Plain structured logging** (zero new dependencies):
114
+
115
+ ```ts
116
+ onArtifactEvent: (event) => {
117
+ app.log.info(event, "pulsevault artifact event");
118
+ },
119
+ ```
120
+
121
+ **Prometheus counters**:
122
+
123
+ ```ts
124
+ import { Counter } from "prom-client";
125
+ const artifactEvents = new Counter({
126
+ name: "pulsevault_artifact_events_total",
127
+ help: "pulsevault artifact lifecycle events",
128
+ labelNames: ["phase", "kind"],
129
+ });
130
+
131
+ onArtifactEvent: (event) => {
132
+ artifactEvents.inc({ phase: event.phase, kind: event.kind });
133
+ if (event.phase === "reject" || event.phase === "authorize") {
134
+ app.log.warn(event, "pulsevault artifact event");
135
+ }
136
+ },
137
+ ```
138
+
139
+ `onArtifactEvent` isn't request-scoped (no Fastify instance to hang a logger
140
+ off of either way), so the examples above read the same under
141
+ `@mieweb/pulsevault/core` — just close over whatever logger (or `console`)
142
+ your app already uses instead of `app.log`. The core's own internal
143
+ diagnostics (authorize-rejection/error logging separate from
144
+ `onArtifactEvent`) accept an explicit `logger` option on
145
+ `createPulseVaultCore(...)` for the same reason — see "Non-Fastify hosts" in
146
+ `README.md`.
147
+
148
+ What to alert on: a sustained rise in `reject`/`authorize`-rejection events
149
+ (misconfigured auth, or an attacker probing), disk usage on the local
150
+ adapter's `workspaceDir` approaching capacity, and any `5xx` rate on the
151
+ upload routes.
152
+
153
+ ## Backup and restore (local storage)
154
+
155
+ The entire state of the local adapter lives under `workspaceDir`:
156
+ `.pulsevault/` (sidecars), `video/`, `project/`, `captions/`, `thumbnail/`
157
+ (bytes). Back it up as a normal filesystem tree — there's no separate database to keep in
158
+ sync. To restore, copy the tree back and restart; in-progress uploads at
159
+ backup time will resume correctly via the normal TUS `HEAD`-then-resume path
160
+ once the client retries, or will simply sit as abandoned partial uploads
161
+ (see "Retention" below) if the client never retries.
162
+
163
+ ## Retention
164
+
165
+ `pulsevault` has no built-in retention/expiry feature — this is intentionally
166
+ left to the operator (see `PROTOCOL.md` §1 on lifecycle ownership). A sample
167
+ cron script for the local adapter, deleting artifacts whose sidecar has been
168
+ `"uploading"` for longer than a cutoff (abandoned uploads) or that are older
169
+ than a retention window (compliance-driven deletion):
170
+
171
+ ```ts
172
+ import { readdir, readFile, stat } from "node:fs/promises";
173
+ import path from "node:path";
174
+ import { createLocalStorage } from "@mieweb/pulsevault";
175
+
176
+ const storage = createLocalStorage({ workspaceDir: "./data" });
177
+ const sidecarDir = path.join(storage.workspaceRoot, ".pulsevault");
178
+ const ABANDONED_AFTER_MS = 24 * 60 * 60 * 1000; // 24h stuck "uploading"
179
+ const RETAIN_FOR_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
180
+
181
+ for (const file of await readdir(sidecarDir)) {
182
+ if (!file.endsWith(".json")) continue;
183
+ const artifactId = file.slice(0, -".json".length);
184
+ const sidecarPath = path.join(sidecarDir, file);
185
+ const [sidecar, stats] = await Promise.all([
186
+ readFile(sidecarPath, "utf8").then(JSON.parse),
187
+ stat(sidecarPath),
188
+ ]);
189
+ const ageMs = Date.now() - stats.mtimeMs;
190
+ const stuckUploading = sidecar.status === "uploading" && ageMs > ABANDONED_AFTER_MS;
191
+ const pastRetention = sidecar.status === "ready" && ageMs > RETAIN_FOR_MS;
192
+ if (stuckUploading || pastRetention) {
193
+ await storage.remove(artifactId);
194
+ console.log(`removed ${artifactId} (${stuckUploading ? "abandoned" : "retention"})`);
195
+ }
196
+ }
197
+ ```
198
+
199
+ Run this on whatever schedule your retention policy requires. For S3/R2
200
+ storage, prefer your bucket provider's native lifecycle-policy feature (S3
201
+ Lifecycle Rules, R2 Object Lifecycle) over a custom script where available.
202
+
203
+ ## Secrets management
204
+
205
+ If you use `createCapabilityAuthorize`, you supply the HMAC secret(s)
206
+ yourself via `lookupSecret`. For local development, reading from an
207
+ environment variable is fine. For production, read from your organization's
208
+ actual secrets manager instead of a raw env var — e.g.:
209
+
210
+ ```ts
211
+ import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
212
+
213
+ const client = new SecretsManagerClient({});
214
+ const keys: Record<string, string> = {};
215
+ async function loadKey(kid: string) {
216
+ const res = await client.send(new GetSecretValueCommand({ SecretId: `pulsevault/${kid}` }));
217
+ keys[kid] = res.SecretString!;
218
+ }
219
+ ```
220
+
221
+ Rotate by adding the new `kid` to your lookup table alongside the old one,
222
+ switching issuance to the new `kid`, and removing the old entry only after
223
+ its longest-lived outstanding token has expired.