@mieweb/pulsevault 0.3.0 → 0.4.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 (69) hide show
  1. package/CHANGELOG.md +98 -1
  2. package/OPERATIONS.md +24 -8
  3. package/PROTOCOL.md +187 -105
  4. package/README.md +102 -41
  5. package/dist/app.d.ts +28 -12
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +17 -4
  8. package/dist/app.js.map +1 -1
  9. package/dist/core.d.ts +18 -8
  10. package/dist/core.d.ts.map +1 -1
  11. package/dist/core.js +91 -23
  12. package/dist/core.js.map +1 -1
  13. package/dist/lib/authorize.d.ts +7 -1
  14. package/dist/lib/authorize.d.ts.map +1 -1
  15. package/dist/lib/capabilities.d.ts +30 -0
  16. package/dist/lib/capabilities.d.ts.map +1 -0
  17. package/dist/lib/capabilities.js +23 -0
  18. package/dist/lib/capabilities.js.map +1 -0
  19. package/dist/lib/capability-token.d.ts +41 -7
  20. package/dist/lib/capability-token.d.ts.map +1 -1
  21. package/dist/lib/capability-token.js +68 -13
  22. package/dist/lib/capability-token.js.map +1 -1
  23. package/dist/lib/deeplinks.d.ts +0 -13
  24. package/dist/lib/deeplinks.d.ts.map +1 -1
  25. package/dist/lib/deeplinks.js +0 -5
  26. package/dist/lib/deeplinks.js.map +1 -1
  27. package/dist/lib/options.d.ts +6 -1
  28. package/dist/lib/options.d.ts.map +1 -1
  29. package/dist/lib/options.js +9 -4
  30. package/dist/lib/options.js.map +1 -1
  31. package/dist/lib/protocol.d.ts +31 -0
  32. package/dist/lib/protocol.d.ts.map +1 -0
  33. package/dist/lib/protocol.js +55 -0
  34. package/dist/lib/protocol.js.map +1 -0
  35. package/dist/lib/pulsevaultTus.d.ts +7 -2
  36. package/dist/lib/pulsevaultTus.d.ts.map +1 -1
  37. package/dist/lib/pulsevaultTus.js +80 -6
  38. package/dist/lib/pulsevaultTus.js.map +1 -1
  39. package/dist/lib/retention.d.ts +54 -0
  40. package/dist/lib/retention.d.ts.map +1 -0
  41. package/dist/lib/retention.js +119 -0
  42. package/dist/lib/retention.js.map +1 -0
  43. package/dist/lib/view-links.d.ts +49 -0
  44. package/dist/lib/view-links.d.ts.map +1 -0
  45. package/dist/lib/view-links.js +35 -0
  46. package/dist/lib/view-links.js.map +1 -0
  47. package/dist/routes/pulsevault.d.ts +2 -2
  48. package/dist/routes/pulsevault.d.ts.map +1 -1
  49. package/dist/routes/pulsevault.js +51 -30
  50. package/dist/routes/pulsevault.js.map +1 -1
  51. package/dist/storage/local.d.ts +5 -1
  52. package/dist/storage/local.d.ts.map +1 -1
  53. package/dist/storage/local.js +55 -1
  54. package/dist/storage/local.js.map +1 -1
  55. package/dist/storage/s3.d.ts +5 -1
  56. package/dist/storage/s3.d.ts.map +1 -1
  57. package/dist/storage/s3.js +52 -5
  58. package/dist/storage/s3.js.map +1 -1
  59. package/dist/storage/types.d.ts +34 -6
  60. package/dist/storage/types.d.ts.map +1 -1
  61. package/package.json +15 -3
  62. package/protocol/openapi.json +1058 -0
  63. package/protocol/schemas/beat-manifest.schema.json +54 -0
  64. package/protocol/schemas/capabilities.schema.json +65 -0
  65. package/protocol/schemas/capability-token.schema.json +36 -0
  66. package/protocol/schemas/deep-link.schema.json +29 -0
  67. package/protocol/schemas/pulse-client.schema.json +8 -0
  68. package/protocol/schemas/upload-metadata.schema.json +45 -0
  69. package/protocol/schemas/view-link.schema.json +23 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,102 @@ breaking changes, called out explicitly below.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-09-30
11
+
12
+ Protocol 2.2 (`PROTOCOL.md` §7.4). Protocol 2 records the `uploadUnit`
13
+ removal below as the breaking change it is; clients built for protocol 1
14
+ can't pair with this release.
15
+
16
+ ### Added
17
+
18
+ - **Read-only view links** (protocol 2.2). The pairing token is a full
19
+ capability, so a watch link carrying it let anyone holding the link
20
+ upload to the artifact or delete it. The new `issueViewLink` option turns
21
+ on `POST {prefix}/artifacts/:id/view-link` (authorized as the new
22
+ `"share"` phase): for a finished artifact it returns `{ token, expiresAt }`,
23
+ a token that only opens the artifact and the artifacts `relatedTo` it
24
+ (`GET ?token=`). The host decides each link's lifetime, or refuses one;
25
+ PulseVault sets none. `createViewLinkIssuer` / `issueViewToken` /
26
+ `verifyViewToken` implement it for capability tokens: view tokens carry
27
+ `use: "view"`, are signed with a key derived from the same secret (so a
28
+ verifier that predates them rejects one), and `createCapabilityAuthorize`
29
+ accepts them only for `resolve`. `/capabilities` reports `viewLinks`, and
30
+ `protocol/schemas/view-link.schema.json` defines the response.
31
+ - **Opt-in cleanup of abandoned uploads.** The `retention` option
32
+ (`{ abandonedAfterSeconds, sweepIntervalSeconds? }`, on the plugin and
33
+ `createPulseVaultCore`) sweeps on a timer, removing uploads left
34
+ unfinished past the cutoff and the related artifacts (never videos) of a
35
+ video that never finished; finished videos are never touched.
36
+ `sweepAbandonedUploads` runs one sweep for your own scheduler. Storage
37
+ adapters gain an optional `listArtifacts`, implemented by both built-in
38
+ adapters.
39
+ - **`onArtifactEvent` reports removals:** `phase: "remove"`, with `reason:
40
+ "deleted"` (`DELETE /artifacts/:id` or a TUS `DELETE`) or `"abandoned"` (the
41
+ `retention` sweep), so a host keeping its own index of artifacts can drop
42
+ one. A consumer that switches exhaustively on `phase` sees a new value.
43
+
44
+ - **The protocol is written down as files, and CI keeps it honest.**
45
+ `protocol/schemas/*.schema.json` define `/capabilities`, pairing links,
46
+ `Upload-Metadata`, the beat manifest, capability-token claims and the
47
+ `Pulse-Client` header; `protocol/openapi.json` is generated from the
48
+ route schemas (`npm run protocol`), and so are the field tables in
49
+ `PROTOCOL.md`. `scripts/check-protocol.mjs` fails a change that alters the
50
+ protocol without the right version bump (oasdiff decides what's breaking
51
+ for the HTTP routes). The `protocol/` folder is published with the package.
52
+ - **`Pulse-Client` request header and `426 Upgrade Required`** (protocol
53
+ 2.1). A client that says its newest protocol is older than this server's
54
+ oldest gets 426 with the supported range on uploads and artifact
55
+ requests; `/capabilities` always answers. No header, no change.
56
+ - **`Upload-Metadata.appVersion`** (protocol 2.1): stored with the artifact
57
+ by both storage adapters and reported on the `complete`/`reject` events
58
+ (`PulseVaultArtifactEvent.appVersion`).
59
+ - **`protocolRevision`** in `/capabilities`: the spec revision, `major.minor`.
60
+ - **CI** (`.github/workflows/ci.yml`): tests on Node 22 and 24, the protocol
61
+ checks, the e2e against Postgres, and the Pulse app's contract suite
62
+ against each PR's build.
63
+
64
+ ### Changed
65
+
66
+ - **A TUS `DELETE` is authorized as `"delete"`, not `"patch"`.** It now
67
+ removes the artifact (below), the same as `DELETE /artifacts/:id`, so an
68
+ `authorize` hook sees one phase for every removal — and a rejected one is
69
+ reported on `onArtifactEvent` like any other rejected delete. If your hook
70
+ allows `"patch"` but refuses `"delete"`, clients can no longer cancel an
71
+ upload through TUS; allow `"delete"` for the artifact's own token.
72
+ - **Breaking: protocol 2.** `/capabilities` reports `protocolVersion: 2` and
73
+ accepts protocol majors 2–2, read from the new `package.json`
74
+ `pulseProtocol` field (`{ "version": "2.1", "min": 2, "max": 2 }`).
75
+
76
+ ### Removed
77
+
78
+ - **Breaking: `uploadUnit` is gone — a pulse uploads as one video; beats are
79
+ timestamps within it** (#64). A pulse is one video (the artifact named by
80
+ the pairing link) plus its related captions, beat manifest and thumbnail;
81
+ a beat is a `startMs`/`endMs` range inside that video, not an upload
82
+ strategy (`PROTOCOL.md` §8). Concretely:
83
+ - The `uploadUnit` option is removed from the Fastify plugin and from
84
+ `createPulseVaultCore`. Passing it now throws a `TypeError` at boot
85
+ ("`uploadUnit` was removed — delete the option") instead of being
86
+ silently ignored — delete the option from your config.
87
+ - `buildUploadLink` no longer accepts or emits an `uploadUnit` param.
88
+ Links minted by older releases stay valid: clients ignore unknown params
89
+ (`PROTOCOL.md` §3).
90
+ - `GET /capabilities` no longer returns an `uploadUnit` field.
91
+ - Needs Pulse 2.1.0 or later (mieweb/pulse#213) —
92
+ older Pulse builds require `uploadUnit` in `/capabilities`.
93
+
94
+ ### Fixed
95
+
96
+ - **A TUS `DELETE` removes the whole artifact.** It used to reach only the
97
+ tus datastore, so PulseVault's sidecar stayed behind: the artifactId
98
+ answered every later create with `409`, a finished upload deleted on the
99
+ local adapter kept a `ready` sidecar pointing at nothing, and on S3 a
100
+ finished upload wasn't deleted at all (aborting its completed multipart
101
+ upload fails with `NoSuchUpload` before anything is removed). Termination
102
+ now runs `storage.remove` under tus's per-upload lock — bytes, tus record
103
+ and sidecar, in flight or finished. The S3 adapter's `remove` also deletes
104
+ the incomplete `.part` object @tus/s3-store parks between PATCHes.
105
+
10
106
  ## [0.3.0] - 2026-09-16
11
107
 
12
108
  ### Changed
@@ -290,6 +386,7 @@ if you've evaluated against an intermediate build.
290
386
  lock, so this is now at least surfaced: a one-time `console.warn` fires
291
387
  per process the first time a deployment falls into this degraded mode.
292
388
 
293
- [Unreleased]: https://github.com/mieweb/pulsevault/compare/v0.3.0...HEAD
389
+ [Unreleased]: https://github.com/mieweb/pulsevault/compare/v0.4.0...HEAD
390
+ [0.4.0]: https://github.com/mieweb/pulsevault/compare/v0.3.0...v0.4.0
294
391
  [0.3.0]: https://github.com/mieweb/pulsevault/compare/v0.2.0...v0.3.0
295
392
  [0.2.0]: https://github.com/mieweb/pulsevault/compare/v0.0.1...v0.2.0
package/OPERATIONS.md CHANGED
@@ -157,8 +157,8 @@ The entire state of the local adapter lives under `workspaceDir`:
157
157
  (bytes). Back it up as a normal filesystem tree — there's no separate database to keep in
158
158
  sync. To restore, copy the tree back and restart; in-progress uploads at
159
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.
160
+ once the client retries, or will sit as abandoned partial uploads until
161
+ `retention` removes them (see "Retention" below) if the client never retries.
162
162
 
163
163
  ## Web-ready playback (faststart remux + H.264 transcode)
164
164
 
@@ -227,11 +227,16 @@ integrity for artifacts this feature has touched.
227
227
 
228
228
  ## Retention
229
229
 
230
- `pulsevault` has no built-in retention/expiry feature — this is intentionally
231
- left to the operator (see `PROTOCOL.md` §1 on lifecycle ownership). A sample
232
- cron script for the local adapter, deleting artifacts whose sidecar has been
233
- `"uploading"` for longer than a cutoff (abandoned uploads) or that are older
234
- than a retention window (compliance-driven deletion):
230
+ **Abandoned uploads** — unfinished uploads a client gave up on, and the
231
+ captions, manifest or thumbnail of a video that never finished — are cleaned
232
+ up by the opt-in `retention` option (or `sweepAbandonedUploads` from your own
233
+ scheduler); see the README's `retention` option. Both adapters support it.
234
+
235
+ How long to keep **finished** content is a policy decision `pulsevault`
236
+ leaves to the operator (see `PROTOCOL.md` §1 on lifecycle ownership). A
237
+ sample cron script for the local adapter, deleting artifacts older than a
238
+ retention window (compliance-driven deletion), alongside the abandoned-upload
239
+ cutoff it predates:
235
240
 
236
241
  ```ts
237
242
  import { readdir, readFile, stat } from "node:fs/promises";
@@ -263,7 +268,9 @@ for (const file of await readdir(sidecarDir)) {
263
268
 
264
269
  Run this on whatever schedule your retention policy requires. For S3/R2
265
270
  storage, prefer your bucket provider's native lifecycle-policy feature (S3
266
- Lifecycle Rules, R2 Object Lifecycle) over a custom script where available.
271
+ Lifecycle Rules, R2 Object Lifecycle) over a custom script where available —
272
+ and let `retention` handle abandoned uploads, which a lifecycle rule can't tell
273
+ apart from finished ones (it doesn't read the sidecar's status).
267
274
 
268
275
  ## Secrets management
269
276
 
@@ -286,3 +293,12 @@ async function loadKey(kid: string) {
286
293
  Rotate by adding the new `kid` to your lookup table alongside the old one,
287
294
  switching issuance to the new `kid`, and removing the old entry only after
288
295
  its longest-lived outstanding token has expired.
296
+
297
+ View links (`issueViewLink` / `createViewLinkIssuer`) are signed with a key
298
+ derived from the same secrets, so they rotate with them — and since they can
299
+ be much longer-lived than an upload token, keep a retired `kid` in the table
300
+ as long as the view links you still want working, or remove it to revoke
301
+ every view link signed under it at once. Deleting a video stops its view
302
+ links opening the video (the `GET` then 404s), but the links keep opening its
303
+ related captions, manifest and thumbnail until those are deleted too — by the
304
+ client, or by `retention` once the video has been gone past its cutoff.
package/PROTOCOL.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # Pulse Upload Protocol
2
2
 
3
- **Version 1**
3
+ <!-- BEGIN GENERATED: protocol-version (from package.json pulseProtocol — edit that, then run `npm run protocol`) -->
4
+ | | |
5
+ |---|---|
6
+ | Spec revision | `2.2` |
7
+ | Protocol majors accepted | 2 |
8
+ <!-- END GENERATED: protocol-version -->
4
9
 
5
10
  This document specifies the wire contract between a Pulse client and any
6
11
  server that wants to receive uploads from it. It is independent of
@@ -30,41 +35,33 @@ never the policy behind it.
30
35
  ## 2. Capability discovery
31
36
 
32
37
  A server MUST expose an unauthenticated `GET {prefix}/capabilities` endpoint.
33
- The response body MUST be a JSON object with at least the following fields:
34
-
35
- ```json
36
- {
37
- "protocolVersion": 1,
38
- "minSupportedVersion": 1,
39
- "maxSupportedVersion": 1,
40
- "uploadUnit": "segment",
41
- "kinds": ["video", "project", "captions", "thumbnail"],
42
- "allowedExtensions": { "video": [".mp4"], "project": [".pulse", ".zip"], "captions": [".vtt"], "thumbnail": [".jpg", ".jpeg", ".png"] },
43
- "maxUploadSize": 5368709120,
44
- "checksum": { "algorithms": ["sha256", "sha1", "md5"] }
45
- }
46
- ```
47
-
48
- - `protocolVersion` (integer, REQUIRED): the version of this document the
49
- server implements.
50
- - `minSupportedVersion`/`maxSupportedVersion` (integer, REQUIRED): the
51
- inclusive range of client protocol versions this server accepts. A client
52
- SHOULD refuse to pair if its own version falls outside this range, and
53
- SHOULD show the user an actionable message ("update the app" /
54
- "this server needs an update") rather than a generic error.
55
- - `uploadUnit` (`"segment"` or `"merged"`, REQUIRED): which upload strategy this
56
- deployment expects (§8). The client MUST read this before doing any
57
- merge/upload work and branch accordingly. (Note: "beat" no longer refers to
58
- an upload unit — it now names a timecode range on the merged timeline; see §8.)
59
- - `kinds` (array of string, REQUIRED): artifact kinds this server accepts.
60
- - `allowedExtensions` (object, REQUIRED): allowed file extensions per kind.
61
- - `maxUploadSize` (integer, REQUIRED): maximum artifact size in bytes.
62
- - `checksum.algorithms` (array of string, OPTIONAL): digest algorithms this
63
- server can verify (§6.3). Absent or empty means the server does not
64
- support checksum verification. Note for implementers: this field
65
- describes capability, not whether verification is actually wired in for
66
- every upload — a server MAY list an algorithm it's capable of checking
67
- even for a deployment where the operator hasn't enabled that check.
38
+ Its JSON body is defined by
39
+ [`protocol/schemas/capabilities.schema.json`](protocol/schemas/capabilities.schema.json):
40
+
41
+ <!-- BEGIN GENERATED: capabilities (from protocol/schemas/capabilities.schema.json — edit that, then run `npm run protocol`) -->
42
+ | Field | Type | Required | Description |
43
+ |---|---|---|---|
44
+ | `protocolVersion` | integer | Yes | Protocol major this server implements. Also sent as the `Protocol-Version` header on every response. |
45
+ | `protocolRevision` | string | Yes | Spec revision this server implements, `major.minor`. The minor counts additions older clients can ignore. |
46
+ | `minSupportedVersion` | integer | Yes | Oldest protocol major this server accepts. Clients whose newest protocol is older get 426 Upgrade Required. |
47
+ | `maxSupportedVersion` | integer | Yes | Newest protocol major this server accepts. |
48
+ | `kinds` | string[] | Yes | Artifact kinds this server accepts. |
49
+ | `allowedExtensions` | object | Yes | Allowed file extensions per kind, lowercase with the leading dot. |
50
+ | `maxUploadSize` | number | Yes | Largest artifact the server accepts, in bytes. |
51
+ | `checksum` | object | Yes | Checksum algorithms accepted in `Upload-Metadata.checksum`. |
52
+ | `viewLinks` | boolean | No | Whether `POST {prefix}/artifacts/<id>/view-link` mints read-only view links (§6.4). Absent before protocol 2.2, which a client treats as false. |
53
+ <!-- END GENERATED: capabilities -->
54
+
55
+ A client pairs only if its protocol range overlaps
56
+ `[minSupportedVersion, maxSupportedVersion]` (§7). If it doesn't, the client
57
+ MUST NOT pair, and SHOULD show an actionable message ("update the app" /
58
+ "this server needs an update") rather than a generic error. A client MUST
59
+ ignore fields it doesn't recognize.
60
+
61
+ `checksum.algorithms` describes capability, not whether verification is
62
+ actually wired in for every upload — a server MAY list an algorithm it's
63
+ capable of checking even where the operator hasn't enabled that check. A
64
+ client MUST treat an absent or empty list as "no checksum verification".
68
65
 
69
66
  A server response to this endpoint MUST NOT include any secret. Every other
70
67
  response from the server MUST include a `Protocol-Version` header carrying
@@ -75,7 +72,7 @@ the integer from `protocolVersion`.
75
72
  A server presents a pairing link or QR code of the form:
76
73
 
77
74
  ```
78
- pulsecam://?v=1&artifactId=<uuid>&server=<origin>&token=<opaque>&uploadUnit=<segment|merged>
75
+ pulsecam://?v=1&artifactId=<uuid>&server=<origin>&token=<opaque>
79
76
  ```
80
77
 
81
78
  - `v` (REQUIRED): the deep-link schema version. A client MUST refuse and
@@ -97,17 +94,10 @@ pulsecam://?v=1&artifactId=<uuid>&server=<origin>&token=<opaque>&uploadUnit=<seg
97
94
  `artifactId` (or a session it anchors, §5.4), not a standing general
98
95
  credential — see §5.4 for the recommended (but not required) capability-
99
96
  token shape.
100
- - `uploadUnit` (OPTIONAL, `"segment"` or `"merged"`): per-session override of the
101
- deployment-wide value reported by `GET /capabilities` (§2, §8). When
102
- present, a client MUST use this value for the session anchored to this
103
- link instead of whatever `/capabilities` currently reports, and MUST NOT
104
- perform a separate `/capabilities` fetch just to decide merge/upload
105
- strategy for it. When absent, a client MUST fall back to `/capabilities`
106
- exactly as before this field existed — an operator that never sets it sees
107
- no change in client behavior. This lets one deployment run "segment" and
108
- "merged" sessions concurrently (e.g. a staged rollout) without racing a
109
- single, deployment-wide `/capabilities` value against whichever moment a
110
- client happened to fetch it.
97
+
98
+ A client SHOULD ignore query params it does not recognize, so links minted by
99
+ an older or newer server stay valid. The parameters are defined by
100
+ [`protocol/schemas/deep-link.schema.json`](protocol/schemas/deep-link.schema.json).
111
101
 
112
102
  A client SHOULD display the server's origin (and, where feasible, its TLS
113
103
  certificate fingerprint) to the user before uploading anything, rather than
@@ -124,7 +114,7 @@ core protocol (creation + core resumable upload). A server MUST mount:
124
114
  | `POST` | `{prefix}/upload` | Create a resumable upload |
125
115
  | `PATCH` | `{prefix}/upload/<id>` | Append a chunk at `Upload-Offset` |
126
116
  | `HEAD` | `{prefix}/upload/<id>` | Query the current offset |
127
- | `DELETE` | `{prefix}/upload/<id>` | Cancel an in-flight upload |
117
+ | `DELETE` | `{prefix}/upload/<id>` | Delete the upload and its artifact, in flight or finished |
128
118
 
129
119
  `PATCH` bodies MUST be the raw bytes for that offset
130
120
  (`Content-Type: application/offset+octet-stream`) — never base64-encoded or
@@ -135,14 +125,21 @@ wrapped in another envelope.
135
125
  The TUS `Upload-Metadata` header (comma-separated `<key> <base64(value)>`
136
126
  pairs) MUST be parsed for at least:
137
127
 
138
- | Key | Required | Description |
139
- |---|---|---|
140
- | `artifactId` | Yes (or a legacy `videoid`/`projectid` alias) | UUID for this artifact. |
141
- | `filename` | Yes | Original filename; extension validated against `kind`'s allowed list. |
142
- | `kind` | No, defaults to `video` | `video`, `project`, `captions`, or `thumbnail`. |
143
- | `relatedTo` | No | UUID of another artifact this one belongs to (§8). |
144
- | `checksum` | No | `<algorithm>:<hex digest>` of the finished file (§6.3). |
145
- | `name` | No | Free-form UTF-8 display title for the artifact (e.g. the draft name typed on the capture device). Trimmed and length-capped by the server; persisted for consumers to label the artifact. Display metadata only — it MUST NOT influence storage paths, routing, or authorization, and consumers MUST escape it for their output context. |
128
+ <!-- BEGIN GENERATED: upload-metadata (from protocol/schemas/upload-metadata.schema.json — edit that, then run `npm run protocol`) -->
129
+ | Key | Type | Required | Description |
130
+ |---|---|---|---|
131
+ | `artifactId` | string (uuid) | Yes | The artifact's id. For the video it's the pairing link's `artifactId`; related artifacts use a fresh UUID. Legacy aliases `videoid` and `projectid` are still accepted. |
132
+ | `filename` | string | Yes | Original file name. Its extension must be allowed for `kind` (see `/capabilities`). |
133
+ | `kind` | `video`, `project`, `captions`, `thumbnail` | No | Artifact kind. Defaults to `video`. |
134
+ | `relatedTo` | string (uuid) | No | The artifact this one belongs to: the video, for its captions, beat manifest and thumbnail. A capability token for the video also authorizes artifacts related to it. |
135
+ | `checksum` | string | No | `<algorithm>:<hex digest>` of the finished file, verified by servers that check checksums. |
136
+ | `name` | string | No | Display title (the draft name), sent on the video. Display-only; servers trim and cap it. |
137
+ | `appVersion` | string | No | Since 2.1. Version of the app that uploaded it, e.g. `2.1.0 (45)`. Stored with the artifact. Display-only; servers trim and cap it. |
138
+ <!-- END GENERATED: upload-metadata -->
139
+
140
+ `name` and `appVersion` are display metadata only: they MUST NOT influence
141
+ storage paths, routing, or authorization, and consumers MUST escape them for
142
+ their output context.
146
143
 
147
144
  Metadata values are base64 per the TUS spec; a free-form value such as `name`
148
145
  MUST be base64 of its **UTF-8** bytes. A client that base64-encodes a raw
@@ -151,9 +148,9 @@ emoji), so encode the UTF-8 bytes explicitly.
151
148
 
152
149
  A client MUST always send `artifactId` (not only the legacy aliases) on new
153
150
  uploads. A server MUST continue accepting `videoid`/`projectid` as aliases
154
- for `artifactId` for back-compat with clients built against protocol
155
- version 1 before this alias was the only spelling — this alias requirement
156
- holds for the lifetime of protocol version 1.
151
+ for `artifactId` for back-compat with clients built before `artifactId`
152
+ was the only spelling — this alias requirement holds until a protocol major
153
+ removes it (§7).
157
154
 
158
155
  ### 4.2 Resumption
159
156
 
@@ -246,14 +243,27 @@ expired token. `issuer` prevents a token minted by one deployment from being
246
243
  replayed against a different one that happens to share a secret. A token MAY
247
244
  authorize an artifact other than the one it names if that artifact declares
248
245
  the token's `artifactId` as its `relatedTo` (§8) — this lets one token cover
249
- an entire upload session (a merged video plus its captions, beat manifest and
250
- thumbnail, or every clip plus the ordering manifest under
251
- `uploadUnit: "segment"`) rather than requiring one token per artifact.
246
+ an entire upload session (a pulse's video plus its captions, beat manifest and
247
+ thumbnail) rather than requiring one token per artifact.
252
248
 
253
249
  This shape is exactly what `@mieweb/pulsevault`'s `issueCapabilityToken`/
254
250
  `verifyCapabilityToken`/`createCapabilityAuthorize` implement, but any server
255
251
  is free to use its own scheme entirely — only §5.1–§5.3 are normative.
256
252
 
253
+ ### 5.5 View tokens (non-normative)
254
+
255
+ The pairing token is a full capability: whoever holds it can upload to the
256
+ artifact, delete it, and create artifacts related to it. A watch link that
257
+ carries it is unsafe to share. A server that offers view links (§6.4) mints a
258
+ separate **view token** that only opens an artifact and the artifacts
259
+ `relatedTo` it. `@mieweb/pulsevault`'s view token has the §5.4 claims plus
260
+ `"use": "view"`, and is signed with a key derived from the deployment's
261
+ secret, so a verifier that predates view tokens rejects one outright instead of
262
+ treating it as an upload capability. `createCapabilityAuthorize` accepts it
263
+ only for `GET {prefix}/artifacts/<id>` (the `resolve` phase); minting a view
264
+ link (`share`) needs the pairing token itself, so a view link can't extend
265
+ itself.
266
+
257
267
  ## 6. Storage and validation
258
268
 
259
269
  ### 6.1 Readiness
@@ -269,6 +279,8 @@ A server MUST expose `GET {prefix}/artifacts/<artifactId>` returning either
269
279
  the bytes directly or a redirect to a URL serving them (e.g. a presigned
270
280
  object-storage URL). The kind is resolved server-side; it is not encoded in
271
281
  this URL. A server MUST also expose `DELETE {prefix}/artifacts/<artifactId>`.
282
+ A TUS `DELETE {prefix}/upload/<id>` removes the artifact the same way,
283
+ whether its upload is in flight or finished.
272
284
 
273
285
  ### 6.3 Checksum (optional)
274
286
 
@@ -280,63 +292,133 @@ verification on the finished artifact — it does not substitute for
280
292
  in-transit (TLS) integrity, and does not verify individual chunks as they
281
293
  arrive.
282
294
 
283
- ## 7. Versioning
295
+ ### 6.4 View links (optional, protocol 2.2)
284
296
 
285
- A server MUST report `protocolVersion`, `minSupportedVersion`, and
286
- `maxSupportedVersion` via `/capabilities` (§2) and `Protocol-Version` on
287
- every response. A client encountering a server whose supported range
288
- excludes its own version MUST NOT attempt to pair, and SHOULD surface a
289
- clear, specific message rather than a generic failure.
290
-
291
- ## 8. Artifact relationships (`relatedTo`) and `uploadUnit`
292
-
293
- A pulse (a short composed of one or more recorded **segments**) MAY be uploaded
294
- as a single pre-merged video (`uploadUnit: "merged"`) or as individual
295
- per-segment artifacts plus an ordering manifest (`uploadUnit: "segment"`) — the
296
- operator declares which via `/capabilities` (§2), optionally overridden per
297
- session via the pairing link's `uploadUnit` param (§3); this document does not
298
- prefer one over the other.
299
-
300
- > **Terminology:** a **segment** is a recorded clip (the source unit). A
301
- > **beat** is a *timecode range on the merged timeline* — one recorded
302
- > segment's start/end within the merged video — carried only in the merged
303
- > mode's beat manifest below. (Earlier revisions used "beat" for what is now
304
- > called the `segment` upload unit; that meaning is retired.)
305
-
306
- Under `uploadUnit: "segment"`, a client uploads each segment under its own
307
- `artifactId`, plus exactly one **ordering manifest** artifact (`kind:
308
- "project"`, e.g. `<draftId>-segments.pulse`, a JSON document listing the
309
- ordered segment `artifactId`s). Segmented mode carries **no** captions and
310
- **no** thumbnail.
297
+ A server that reports `viewLinks: true` in `/capabilities` (§2) MUST expose
298
+ `POST {prefix}/artifacts/<artifactId>/view-link`. Authorized with the pairing
299
+ token (§5.1), it returns a read-only link to a **finished** artifact:
311
300
 
312
301
  ```json
313
- { "version": 1, "segments": [ { "artifactId": "<uuid>", "order": 0 } ] }
302
+ { "token": "<view token>", "expiresAt": 1790000000 }
303
+ ```
304
+
305
+ The token opens the artifact, and the artifacts `relatedTo` it, as
306
+ `GET {prefix}/artifacts/<artifactId>?token=<token>` until `expiresAt` (seconds
307
+ since the Unix epoch) — and does nothing else, so it is safe to share (§5.5).
308
+ The server decides how long each link works. It returns `404` for an artifact
309
+ that isn't finished (or when view links aren't enabled), and `403` when it
310
+ won't link to that artifact. A client that wants a shareable link asks for one
311
+ right after the upload finishes, while its pairing token is still valid; a
312
+ server without `viewLinks` offers no shareable link, and the client MUST NOT
313
+ share the pairing token in its place.
314
+
315
+ ## 7. Versioning
316
+
317
+ The protocol has a version `major.minor`, written once in `@mieweb/pulsevault`'s
318
+ `package.json` (`pulseProtocol`) — the reference implementation reads it from
319
+ there, and npm keeps it for every published release.
320
+
321
+ - The **major** changes only for breaking changes. It's `protocolVersion` in
322
+ `/capabilities` and the `Protocol-Version` header, and it's what pairing
323
+ compares.
324
+ - The **minor** counts additions an older client or server can safely ignore
325
+ (a new optional field, key or header). It's `protocolRevision`.
326
+
327
+ A server MUST report `protocolVersion`, `protocolRevision`,
328
+ `minSupportedVersion` and `maxSupportedVersion` via `/capabilities` (§2), and
329
+ `Protocol-Version` on every response. A client supports a range of majors;
330
+ it pairs only if that range overlaps the server's, and speaks the highest
331
+ major both support. If they don't overlap, it MUST NOT pair, and SHOULD
332
+ surface a clear, specific message rather than a generic failure.
333
+
334
+ ### 7.1 Changing the protocol
335
+
336
+ Every fact about the protocol lives in `protocol/`: the JSON Schemas in
337
+ `protocol/schemas/`, and `protocol/openapi.json`, generated from the plugin's
338
+ route schemas. The field tables in this document are generated from the same
339
+ files (`npm run protocol`). CI enforces the rules:
340
+
341
+ - Anything under `protocol/` changes → `pulseProtocol.version` MUST change.
342
+ - A **breaking** change — removing a field, key or parameter; making one
343
+ required; removing an allowed value; changing a type — MUST bump the major.
344
+ Anything else bumps the minor.
345
+ - Every version MUST have a row in the history below saying what changed.
346
+
347
+ ### 7.2 `Pulse-Client` (since 2.1)
348
+
349
+ A client SHOULD send `Pulse-Client` on every request, saying what it is and
350
+ which protocol majors it speaks:
351
+
352
+ ```
353
+ Pulse-Client: Pulse/2.1.0 (45; ios); protocol=1-2
314
354
  ```
315
355
 
316
- Under `uploadUnit: "merged"`, a client uploads one pre-merged video as the
317
- session anchor, plus (all as related artifacts):
356
+ On uploads and artifact requests, a server MUST answer
357
+ `426 Upgrade Required` when the client's newest major is older than the
358
+ server's `minSupportedVersion`, with a JSON body carrying `error`,
359
+ `minSupportedVersion` and `maxSupportedVersion`. `/capabilities` MUST always
360
+ answer, so the client can learn why. A request without the header is handled
361
+ as before this header existed. A client that gets `426` SHOULD tell the user
362
+ to update the app. See
363
+ [`protocol/schemas/pulse-client.schema.json`](protocol/schemas/pulse-client.schema.json).
364
+
365
+ A client SHOULD also check `/capabilities` again before starting an upload to
366
+ a server it paired with earlier: a server can be upgraded in between.
367
+
368
+ ### 7.3 Where it's defined
369
+
370
+ <!-- BEGIN GENERATED: schemas (from protocol/schemas/ — edit that, then run `npm run protocol`) -->
371
+ | File | Defines |
372
+ |---|---|
373
+ | [`openapi.json`](protocol/openapi.json) | Every HTTP route, generated from the plugin |
374
+ | [`beat-manifest.schema.json`](protocol/schemas/beat-manifest.schema.json) | Beat manifest |
375
+ | [`capabilities.schema.json`](protocol/schemas/capabilities.schema.json) | GET /capabilities response |
376
+ | [`capability-token.schema.json`](protocol/schemas/capability-token.schema.json) | Capability token claims |
377
+ | [`deep-link.schema.json`](protocol/schemas/deep-link.schema.json) | Pairing link query parameters |
378
+ | [`pulse-client.schema.json`](protocol/schemas/pulse-client.schema.json) | Pulse-Client request header |
379
+ | [`upload-metadata.schema.json`](protocol/schemas/upload-metadata.schema.json) | TUS Upload-Metadata keys |
380
+ | [`view-link.schema.json`](protocol/schemas/view-link.schema.json) | POST /artifacts/<id>/view-link response |
381
+ <!-- END GENERATED: schemas -->
382
+
383
+ ### 7.4 History
384
+
385
+ | Version | Change |
386
+ |---|---|
387
+ | 1.0 | First version. `/capabilities` and pairing links carried `uploadUnit` (`segment` or `merged`). |
388
+ | 2.0 | **Breaking:** `uploadUnit` removed from `/capabilities` and pairing links — a pulse always uploads as one video (§8). Clients built for 1.0 required it. |
389
+ | 2.1 | Added `protocolRevision` to `/capabilities`, the `Pulse-Client` header with `426 Upgrade Required` (§7.2), and `Upload-Metadata.appVersion` (§4.1). |
390
+ | 2.2 | Added read-only view links: `viewLinks` in `/capabilities`, `POST {prefix}/artifacts/<id>/view-link` (§6.4), and the view token's `use` claim (§5.5). |
391
+
392
+ ## 8. Artifact relationships (`relatedTo`)
393
+
394
+ A pulse (a short composed of one or more recorded clips) uploads as **one
395
+ video** — the session anchor, named by the pairing link's `artifactId` (§3) —
396
+ plus these related artifacts:
318
397
 
319
398
  - **captions** (`kind: "captions"`, `<draftId>.vtt`) — WebVTT for the whole
320
- merged video, OPTIONAL (absent when the video has no speech or no on-device
321
- model was available); MAY carry word-level inline cue timestamps like
399
+ video, OPTIONAL (absent when the video has no speech or no on-device model
400
+ was available); MAY carry word-level inline cue timestamps like
322
401
  `<00:00:01.500>word` for karaoke rendering;
323
- - a **beat manifest** (`kind: "project"`, e.g. `<draftId>-beats.pulse`) giving
324
- each recorded segment's precise `startMs`/`endMs` on the merged timeline,
325
- contiguous and summing to the true merged `durationMs` (groundwork for
326
- keyframe-aligned deep links / HLS);
402
+ - a **beat manifest** (`kind: "project"`, e.g. `<draftId>-beats.pulse`)
403
+ listing the pulse's beats (below);
327
404
  - a **thumbnail** (`kind: "thumbnail"`, `<draftId>.jpg`) — the pulse's poster
328
405
  frame.
329
406
 
407
+ A **beat** is a timestamp range inside the video: where one recorded clip
408
+ starts and ends (`startMs`/`endMs`). A pulse's beats are contiguous and sum to
409
+ the video's `durationMs`. The beat manifest is groundwork for keyframe-aligned
410
+ deep links / HLS; it does not change how the video is uploaded.
411
+
330
412
  ```json
331
413
  { "version": 1, "type": "beat-manifest", "durationMs": 47320,
332
414
  "beats": [ { "segmentId": "<local id>", "order": 0, "startMs": 0, "endMs": 4210 } ] }
333
415
  ```
334
416
 
335
- Every non-anchor artifact in either session SHOULD declare `relatedTo` pointing
336
- at the session's anchor `artifactId` (the one named in the pairing link) so a
337
- single capability token can authorize the whole session (§5.4).
417
+ Every non-anchor artifact SHOULD declare `relatedTo` pointing at the anchor
418
+ `artifactId` so a single capability token can authorize the whole session
419
+ (§5.4).
338
420
 
339
- The relational graph of replies between segments/pulses (who replied to what,
421
+ The relational graph of replies between pulses (who replied to what,
340
422
  rendering a thread) is explicitly **out of scope** for this document. That
341
423
  graph is a query/relational concern for the operator's own systems, built
342
424
  from `relatedTo` and whatever additional metadata the operator chooses to
@@ -347,5 +429,5 @@ record — not something a Pulse-compatible server is required to implement.
347
429
  A client or server MAY support additional, non-normative extensions to this
348
430
  contract (additional `Upload-Metadata` keys, additional kinds, additional
349
431
  `/capabilities` fields) as long as doing so does not break a client/server
350
- that only implements what's written here. New REQUIRED fields MUST NOT be
351
- added to a response without a `protocolVersion` bump.
432
+ that only implements what's written here. Changes to the protocol itself
433
+ follow §7.1.