@mieweb/pulsevault 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/LICENSE +15 -39
  3. package/OPERATIONS.md +223 -0
  4. package/PROTOCOL.md +312 -0
  5. package/README.md +532 -122
  6. package/augment.d.ts +1 -0
  7. package/augment.js +3 -0
  8. package/core.d.ts +1 -0
  9. package/core.js +7 -0
  10. package/dist/app.d.ts +90 -34
  11. package/dist/app.d.ts.map +1 -1
  12. package/dist/app.js +23 -33
  13. package/dist/app.js.map +1 -1
  14. package/dist/augment.d.ts +14 -7
  15. package/dist/augment.d.ts.map +1 -1
  16. package/dist/augment.js.map +1 -1
  17. package/dist/core.d.ts +108 -0
  18. package/dist/core.d.ts.map +1 -0
  19. package/dist/core.js +431 -0
  20. package/dist/core.js.map +1 -0
  21. package/dist/lib/authorize.d.ts +19 -0
  22. package/dist/lib/authorize.d.ts.map +1 -0
  23. package/dist/lib/authorize.js +2 -0
  24. package/dist/lib/authorize.js.map +1 -0
  25. package/dist/lib/capability-token.d.ts +80 -0
  26. package/dist/lib/capability-token.d.ts.map +1 -0
  27. package/dist/lib/capability-token.js +126 -0
  28. package/dist/lib/capability-token.js.map +1 -0
  29. package/dist/lib/checksum.d.ts +45 -0
  30. package/dist/lib/checksum.d.ts.map +1 -0
  31. package/dist/lib/checksum.js +102 -0
  32. package/dist/lib/checksum.js.map +1 -0
  33. package/dist/lib/deeplinks.d.ts +37 -21
  34. package/dist/lib/deeplinks.d.ts.map +1 -1
  35. package/dist/lib/deeplinks.js +54 -13
  36. package/dist/lib/deeplinks.js.map +1 -1
  37. package/dist/lib/magic.d.ts +30 -5
  38. package/dist/lib/magic.d.ts.map +1 -1
  39. package/dist/lib/magic.js +51 -14
  40. package/dist/lib/magic.js.map +1 -1
  41. package/dist/lib/options.d.ts +39 -0
  42. package/dist/lib/options.d.ts.map +1 -0
  43. package/dist/lib/options.js +103 -0
  44. package/dist/lib/options.js.map +1 -0
  45. package/dist/lib/pulsevaultTus.d.ts +52 -26
  46. package/dist/lib/pulsevaultTus.d.ts.map +1 -1
  47. package/dist/lib/pulsevaultTus.js +111 -43
  48. package/dist/lib/pulsevaultTus.js.map +1 -1
  49. package/dist/lib/request.d.ts +24 -0
  50. package/dist/lib/request.d.ts.map +1 -0
  51. package/dist/lib/request.js +16 -0
  52. package/dist/lib/request.js.map +1 -0
  53. package/dist/lib/uuid.d.ts.map +1 -1
  54. package/dist/lib/uuid.js +1 -1
  55. package/dist/lib/uuid.js.map +1 -1
  56. package/dist/routes/pulsevault.d.ts +18 -14
  57. package/dist/routes/pulsevault.d.ts.map +1 -1
  58. package/dist/routes/pulsevault.js +127 -247
  59. package/dist/routes/pulsevault.js.map +1 -1
  60. package/dist/storage/local.d.ts +31 -10
  61. package/dist/storage/local.d.ts.map +1 -1
  62. package/dist/storage/local.js +166 -82
  63. package/dist/storage/local.js.map +1 -1
  64. package/dist/storage/s3.d.ts +109 -0
  65. package/dist/storage/s3.d.ts.map +1 -0
  66. package/dist/storage/s3.js +432 -0
  67. package/dist/storage/s3.js.map +1 -0
  68. package/dist/storage/types.d.ts +69 -17
  69. package/dist/storage/types.d.ts.map +1 -1
  70. package/package.json +35 -7
package/CHANGELOG.md ADDED
@@ -0,0 +1,184 @@
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
+
51
+ ### Breaking
52
+
53
+ - **Renamed `videoid` → `artifactId`** across the storage interface
54
+ (`ReserveUploadParams`, `PulseVaultStorage` methods), the `authorize`
55
+ context (`PulseVaultAuthorizeContext`), the `validatePayload`/
56
+ `onUploadComplete` hook contexts, the `request.pulseVault` TypeScript
57
+ augmentation (`./augment.ts` — a separate breaking change from the wire
58
+ rename, since it's the published type surface, not the HTTP contract), and
59
+ the deep-link query parameter built by `buildUploadLink`. `videoid`/
60
+ `projectid` remain accepted as legacy aliases in `Upload-Metadata` — only
61
+ the *primary* name changed, not backward compatibility for existing
62
+ clients' requests.
63
+ - **Collapsed the per-kind routes into one generic route.** `GET/DELETE
64
+ /:videoid` and `GET/DELETE /project/:projectid` are gone; replaced by
65
+ `GET/DELETE /artifacts/:artifactId`, which resolves the kind from storage
66
+ rather than the URL. This also means `kind=captions` artifacts (new, see
67
+ below) don't need a third route pair.
68
+ - **`validateProjectPayload`/`onProjectUploadComplete` are deprecated** (not
69
+ yet removed) in favor of the now-generic `validatePayload`/
70
+ `onUploadComplete`, which receive `ctx.kind` and run for every artifact
71
+ kind. Passing either deprecated option now emits a one-time
72
+ `DeprecationWarning` at plugin registration.
73
+ - **`allowedExtensions` requires a `captions` default to be considered** if
74
+ you were relying on the exact shape of the normalized object internally
75
+ (public consumers passing `allowedExtensions` as documented are unaffected
76
+ — the new key just has a default like `video`/`project` already did).
77
+
78
+ ### Added
79
+
80
+ - **`kind: "captions"`** artifact type (default extension `.srt`), running
81
+ through the same generic `validatePayload`/`onUploadComplete` hooks as
82
+ every other kind.
83
+ - **`relatedTo`** — an optional `Upload-Metadata` key (and matching
84
+ `ReserveUploadParams`/storage field) linking one artifact to another (e.g.
85
+ a video's captions, or a beat belonging to a pulse manifest's session).
86
+ Storage adapters expose it via the new optional `getRelatedTo` method.
87
+ - **`checksum`** — an optional `Upload-Metadata` key (`<algorithm>:<hex
88
+ digest>`) verified post-upload via the new `createChecksumValidator`
89
+ (local storage) / `createS3ChecksumValidator` (S3/R2) helpers, chainable
90
+ with `createMp4Sniffer`. This is at-rest integrity verification on the
91
+ finished file, not a per-chunk check — `@tus/server`'s installed version
92
+ doesn't implement the TUS Checksum extension, which would have covered
93
+ chunks in flight.
94
+ - **`src/lib/capability-token.ts`**: `issueCapabilityToken`,
95
+ `verifyCapabilityToken`, `createCapabilityAuthorize` — a stateless,
96
+ HMAC-signed capability-token scheme with `kid` (key rotation with an
97
+ overlap window), `iat` (clock-skew tolerance), and `issuer` (cross-deployment
98
+ replay protection) claims. Fully optional — bring your own `authorize` if
99
+ you have existing auth.
100
+ - **`GET {prefix}/capabilities`** — unauthenticated discovery route reporting
101
+ `protocolVersion`, `minSupportedVersion`/`maxSupportedVersion`,
102
+ `uploadUnit`, `kinds`, `allowedExtensions`, `maxUploadSize`, and supported
103
+ checksum algorithms. Lets independently-versioned deployments and clients
104
+ detect compatibility before pairing.
105
+ - **`uploadUnit` plugin option** (`"beat" | "merged"`, default `"beat"`) —
106
+ purely advertised via `/capabilities`; the operator declares which upload
107
+ strategy this deployment expects, the client branches on it. Not enforced
108
+ by the plugin.
109
+ - **`buildUploadLink({ uploadUnit })`** (PROTOCOL.md §3, §8) — optional
110
+ per-session override of the deployment-wide `uploadUnit`, carried on the
111
+ pairing link itself instead of only `/capabilities`. Lets an operator run
112
+ `"beat"` and `"merged"` sessions concurrently (staged rollout, A/B test,
113
+ per-tenant policy) without racing a client's separate `/capabilities` fetch
114
+ against a single, deployment-wide value. Fully backward compatible: omit it
115
+ and a client falls back to `/capabilities` exactly as before.
116
+ - **`onArtifactEvent` plugin option** — one low-frequency hook (authorize
117
+ rejection, completion, validation rejection — never per chunk) covering
118
+ both ops metrics and a compliance audit trail from a single integration
119
+ point.
120
+ - **`Protocol-Version` response header** on every route this plugin mounts.
121
+ - **`@mieweb/pulsevault/core`** — a framework-agnostic entry point with no
122
+ Fastify dependency. `createPulseVaultCore(...)` returns a connect-style
123
+ `handler(req, res, next?)` usable directly as Express middleware
124
+ (`app.use(prefix, handler)`), Meteor middleware
125
+ (`WebApp.connectHandlers.use(prefix, handler)`), or a bare
126
+ `http.createServer` callback — same options, hooks, and storage adapters as
127
+ the Fastify plugin (which is now itself a thin adapter over this core, so
128
+ behavior can't drift between the two). See "Non-Fastify hosts" in
129
+ `README.md`, and `examples/express-demo`/`examples/meteor-demo` for full
130
+ runnable servers verified against the real frameworks.
131
+ - As part of this, `PulseVaultAuthorize`/`PulseVaultValidatePayload`/
132
+ `PulseVaultOnUploadComplete`'s `request` parameter is now typed as the
133
+ generic `PulseVaultRequest` (just `{ headers }`) instead of
134
+ `FastifyRequest` — `FastifyRequest` still satisfies it structurally, so
135
+ untyped/inferred hook callbacks (the documented pattern) are unaffected;
136
+ only an explicit `(request: FastifyRequest, ctx) => ...` annotation on
137
+ one of these three hooks would need loosening.
138
+ - Bounded, insertion-order-evicting in-memory metadata cache in both storage
139
+ adapters (`metaCacheLimit` option, default 10,000 entries) — previously
140
+ unbounded for the life of the process.
141
+ - `S3Storage.readAll` — full-object read, used by `createS3ChecksumValidator`.
142
+ - `S3Storage.digestAll` — streams a finalized object through a hash digest
143
+ without buffering the whole thing into memory first; the streaming
144
+ counterpart to `readAll`, now used internally by
145
+ `createS3ChecksumValidator` (see "Fixed" below).
146
+
147
+ ### Fixed
148
+
149
+ - `maxUploadSize` enforcement and the in-progress-upload 404 behavior now
150
+ have explicit test coverage proving bytes are rejected/hidden at the right
151
+ point, not just implied by the implementation.
152
+ - **Meteor compatibility.** Meteor's bundler doesn't resolve `package.json`
153
+ `"exports"` subpath maps, which broke both this package's own `"./core"`
154
+ entry and, transitively, `@tus/server`'s dependency on `srvx` (which as of
155
+ `@tus/server@2.1.0` ships *only* subpath exports, no legacy `main`
156
+ fallback). Fixed two ways: `@tus/server`/`@tus/file-store` are pinned to
157
+ `2.0.0` (the last release before the `srvx` migration — everything added
158
+ in `2.1.0`–`2.4.1` was either the `srvx` migration itself, follow-up
159
+ fixes for regressions it introduced, or one unused `exposedHeaders`
160
+ option, so nothing this package relies on is lost); and plain root-level
161
+ `core.js`/`core.d.ts`/`augment.js`/`augment.d.ts` files now ship alongside
162
+ the `"exports"` map, so resolvers that ignore `"exports"` (Meteor's
163
+ included) still find these entry points via ordinary relative-path
164
+ resolution. Verified against a real Meteor 3.4 app: `WebApp.connectHandlers`
165
+ resolves `@mieweb/pulsevault/core` cleanly and a full TUS
166
+ create → PATCH → GET round-trip works.
167
+ - **Unbounded memory use during checksum validation.** `createChecksumValidator`
168
+ read the whole finalized file into a single `Buffer` via `fs.readFile`
169
+ before hashing; combined with the documented-supported `maxUploadSize:
170
+ Infinity`, a large upload could exhaust process memory just to verify its
171
+ checksum. Now streams the file through the hash via `createReadStream` +
172
+ a stream pipeline. `createS3ChecksumValidator` had the same issue via
173
+ `S3Storage.readAll`'s full-object buffer — now uses the new streaming
174
+ `S3Storage.digestAll` instead.
175
+ - **Silent TOCTOU on S3-compatible backends without `IfNoneMatch` support.**
176
+ `reserveUpload`'s conditional-write collision guard falls back to a
177
+ weaker check-then-write on backends that reject `IfNoneMatch` (some
178
+ S3-compatible stores, and the `s3rver` mock this repo's own test suite
179
+ runs against) — this reopens the race between concurrent/retried creates
180
+ for the same artifactId. There's no way to close it without an external
181
+ lock, so this is now at least surfaced: a one-time `console.warn` fires
182
+ per process the first time a deployment falls into this degraded mode.
183
+
184
+ [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; the `s3rver` mock this package's own test suite runs
70
+ against) don't support conditional writes and reject that header 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/` (bytes). Back it
157
+ 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.