@mieweb/pulsevault 0.4.1 → 0.5.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 (56) hide show
  1. package/CHANGELOG.md +102 -1
  2. package/OPERATIONS.md +24 -10
  3. package/PROTOCOL.md +38 -1
  4. package/README.md +168 -29
  5. package/assets/README.md +33 -0
  6. package/assets/badge-app-store.svg +46 -0
  7. package/assets/badge-google-play.png +0 -0
  8. package/assets/pulse-logo-mono.svg +10 -0
  9. package/assets/pulse-logo.svg +14 -0
  10. package/dist/app.d.ts +24 -0
  11. package/dist/app.d.ts.map +1 -1
  12. package/dist/app.js +20 -2
  13. package/dist/app.js.map +1 -1
  14. package/dist/augment.d.ts +3 -0
  15. package/dist/augment.d.ts.map +1 -1
  16. package/dist/augment.js.map +1 -1
  17. package/dist/core.d.ts +89 -4
  18. package/dist/core.d.ts.map +1 -1
  19. package/dist/core.js +301 -40
  20. package/dist/core.js.map +1 -1
  21. package/dist/lib/authorize.d.ts +28 -6
  22. package/dist/lib/authorize.d.ts.map +1 -1
  23. package/dist/lib/capability-token.d.ts +26 -0
  24. package/dist/lib/capability-token.d.ts.map +1 -1
  25. package/dist/lib/capability-token.js +73 -5
  26. package/dist/lib/capability-token.js.map +1 -1
  27. package/dist/lib/completion.d.ts +135 -0
  28. package/dist/lib/completion.d.ts.map +1 -0
  29. package/dist/lib/completion.js +288 -0
  30. package/dist/lib/completion.js.map +1 -0
  31. package/dist/lib/options.d.ts.map +1 -1
  32. package/dist/lib/options.js +2 -0
  33. package/dist/lib/options.js.map +1 -1
  34. package/dist/lib/protocol-version.gen.js +1 -1
  35. package/dist/lib/pulsevaultTus.d.ts +28 -26
  36. package/dist/lib/pulsevaultTus.d.ts.map +1 -1
  37. package/dist/lib/pulsevaultTus.js +114 -31
  38. package/dist/lib/pulsevaultTus.js.map +1 -1
  39. package/dist/routes/pulsevault.d.ts +12 -1
  40. package/dist/routes/pulsevault.d.ts.map +1 -1
  41. package/dist/routes/pulsevault.js +92 -1
  42. package/dist/routes/pulsevault.js.map +1 -1
  43. package/dist/storage/local.d.ts +7 -1
  44. package/dist/storage/local.d.ts.map +1 -1
  45. package/dist/storage/local.js +173 -8
  46. package/dist/storage/local.js.map +1 -1
  47. package/dist/storage/s3.d.ts +7 -1
  48. package/dist/storage/s3.d.ts.map +1 -1
  49. package/dist/storage/s3.js +236 -14
  50. package/dist/storage/s3.js.map +1 -1
  51. package/dist/storage/types.d.ts +73 -0
  52. package/dist/storage/types.d.ts.map +1 -1
  53. package/package.json +6 -4
  54. package/protocol/openapi.json +247 -1
  55. package/protocol/schemas/artifact-status.schema.json +49 -0
  56. package/protocol/schemas/capability-token.schema.json +3 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,106 @@ breaking changes, called out explicitly below.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-10-05
11
+
12
+ Protocol 2.3 (`PROTOCOL.md` §7.4). What every host rebuilt around PulseVault
13
+ (#80), in the core: the shape of a pulse, a completion that survives a throw
14
+ or a restart, a lock on finished pulses, the token's own context, a status
15
+ route, background web-ready, a poster route and `getPulse`, and the content
16
+ types phones send. Two defaults change; both have an escape hatch.
17
+
18
+ ### Changed (breaking)
19
+
20
+ - **A create must have the shape of a pulse** (`pulseShape`, on by default):
21
+ a video with no `relatedTo`; a thumbnail, beat manifest or captions under
22
+ its own id, `relatedTo` the video. With `createCapabilityAuthorize` the ids
23
+ are also tied to the token: the video only under the token's own
24
+ `artifactId`, the rest only `relatedTo` it. Before, one token could create
25
+ any number of artifacts under ids the client picked, or take the video's
26
+ own id with a thumbnail so the real video answered `409` for good. The
27
+ Pulse app has always uploaded in this shape. For uploads that aren't
28
+ pulses, pass `pulseShape: false` to the plugin or core *and* to
29
+ `createCapabilityAuthorize`.
30
+ - **A create reclaims an idle unfinished upload of the same artifactId**
31
+ (`reclaim`, `{ idleSeconds: 300 }` by default): an app killed mid-upload
32
+ sends no TUS `DELETE`, so re-scanning the same link answered `409` until
33
+ `retention` ran. Only an upload of the same kind and `relatedTo` is taken
34
+ over (the same token authorized both), and only once the local adapter has
35
+ seen no bytes for `idleSeconds`. `reclaim: false` keeps the old `409`.
36
+ - **`onUploadComplete` is at-least-once.** PulseVault records that the hook
37
+ returned (`acknowledged` in the sidecar) and fires it again, with
38
+ `ctx.replay: true`, for a finished artifact it never recorded: the hook
39
+ threw, or the process stopped first. Every 300 seconds, with the first pass
40
+ shortly after start (`replayCompletions`; `false` turns it off, and
41
+ `core.replayCompletions()` / `fastify.pulseVaultCore.replayCompletions()`
42
+ runs a pass on demand). Hosts must check their own record before writing
43
+ it again. Finished sidecars written before this release read as acknowledged, so an
44
+ upgrade replays nothing; one still uploading at the upgrade completes
45
+ normally. A retried final `PATCH` no longer fires the hook a
46
+ second time.
47
+ - **`authorize` gains the `status` phase** (`GET /artifacts/:id/status`).
48
+ An exhaustive `switch` on the phase needs a case for it.
49
+ - **Content types**: the `.pulse` beat manifest is served as
50
+ `application/json`, and `.mov`, `.m4v` and `.srt` — when a host allows them
51
+ in `allowedExtensions`; the defaults stay what the Pulse app sends — as
52
+ `video/quicktime`, `video/x-m4v` and `application/x-subrip`, instead of
53
+ `application/octet-stream`, which `<video>` downloads instead of playing.
54
+
55
+ ### Added
56
+
57
+ - **Hook context**: `authorize` and `onUploadComplete` receive `relatedTo`,
58
+ `name`, `appVersion`, `filename`, `ext` and the token's `context`, so a
59
+ host maps a finished file to its own record without reading storage back.
60
+ - **`context` in the capability token** (`issueCapabilityToken(id, secret,
61
+ { context })`, the `ctx` claim, at most 1 KiB of JSON): an owner, a
62
+ destination — signed, stored with the artifact at create (`authorize` may
63
+ return `{ context }`), and handed to every hook, `getStatus` and `getPulse`.
64
+ Most hosts need no table keyed by artifactId any more.
65
+ - **`GET /artifacts/:id/status`** and `core.getStatus(id)`: `unknown`,
66
+ `uploading` (with `bytesReceived` against `size`), `processing` or
67
+ `ready`, plus `acknowledged` and whatever the host recorded with
68
+ `recordOutcome(id, outcome)`. Authorized as `status`;
69
+ `createCapabilityAuthorize` grants it to the pairing token and a view token.
70
+ - **`webReady`**: the conversion `ensureWebReady` does, run by the core in
71
+ the background after the final `PATCH` is answered, `concurrency` at a
72
+ time, with a `processing` status meanwhile, a `processed` event when done,
73
+ and `completeAfter: true` to hold `onUploadComplete` until the conversion
74
+ has finished (`ctx.webReady` says what it did). Whether it finished is
75
+ recorded on the sidecar (`converted`), so a conversion a restart
76
+ interrupted is redone by the replay. Local storage only.
77
+ - **`lockWhenReady`**: a finished artifact, and anything `relatedTo` a
78
+ finished video, can't be deleted through the routes (`403`); the host
79
+ removes through `storage.remove` on its own terms.
80
+ - **`core.getPulse(videoId)`** → `{ video, thumbnail, captions, manifest }`
81
+ and **`GET /artifacts/:videoId/poster`**, both from a relation index the
82
+ adapters keep at reserve time (`storage.listRelated`), so neither scans
83
+ storage and nothing is backfilled.
84
+ - **Storage adapter contract**: optional `describeArtifact`, `patchArtifact`
85
+ and `listRelated`, and `context` in `reserveUpload` (both built-in
86
+ adapters implement them). The local adapter's relation index lives at
87
+ `.pulsevault/related/<videoId>/<artifactId>`; the S3 adapter's under the
88
+ same key prefix.
89
+ - **`onArtifactEvent`**: `phase: "processed"` (with `webReady`), and
90
+ `reason: "reclaimed"` on `remove`.
91
+ - **Fastify**: `fastify.pulseVaultCore` (`coreDecoratorName`) exposes
92
+ `getStatus`, `getPulse`, `recordOutcome` and `replayCompletions`.
93
+ - **`examples/meteor-demo`** is now the reference host integration, shaped
94
+ like a team app: people, destinations, a reserve that signs the owner and
95
+ destination into the token, delivery from `onUploadComplete`, status
96
+ polling, and video cards with posters — with no table of uploads, no shape
97
+ check, no stale-upload cleanup and no content-type fix-up of its own.
98
+
99
+ ## [0.4.2] - 2026-10-02
100
+
101
+ ### Added
102
+
103
+ - **The Pulse logo and the store badges ship with the package**: the logo in
104
+ full colour and one colour (`assets/pulse-logo.svg`,
105
+ `assets/pulse-logo-mono.svg`), and the App Store and Google Play badges
106
+ (`assets/badge-app-store.svg`, `assets/badge-google-play.png`), exported as
107
+ `@mieweb/pulsevault/assets/*`. An app can put the logo on the button that
108
+ opens Pulse and link the badges to Pulse's listings. See `assets/README.md`.
109
+
10
110
  ## [0.4.1] - 2026-09-30
11
111
 
12
112
  ### Fixed
@@ -400,7 +500,8 @@ if you've evaluated against an intermediate build.
400
500
  lock, so this is now at least surfaced: a one-time `console.warn` fires
401
501
  per process the first time a deployment falls into this degraded mode.
402
502
 
403
- [Unreleased]: https://github.com/mieweb/pulsevault/compare/v0.4.1...HEAD
503
+ [Unreleased]: https://github.com/mieweb/pulsevault/compare/v0.4.2...HEAD
504
+ [0.4.2]: https://github.com/mieweb/pulsevault/compare/v0.4.1...v0.4.2
404
505
  [0.4.1]: https://github.com/mieweb/pulsevault/compare/v0.4.0...v0.4.1
405
506
  [0.4.0]: https://github.com/mieweb/pulsevault/compare/v0.3.0...v0.4.0
406
507
  [0.3.0]: https://github.com/mieweb/pulsevault/compare/v0.2.0...v0.3.0
package/OPERATIONS.md CHANGED
@@ -183,25 +183,29 @@ logs one warning and the original bytes keep serving exactly as before.
183
183
  `brew install ffmpeg` (macOS). Nothing else changes; the hook detects it at
184
184
  first use.
185
185
 
186
- **New uploads** — wire it into `onUploadComplete` (the fastify-demo ships with
187
- this enabled):
186
+ **New uploads** — turn on the `webReady` option (the fastify-demo and the
187
+ meteor-demo ship with it enabled). The core runs the conversion in the
188
+ background after the final `PATCH` is answered, one video at a time by
189
+ default, and reports the artifact as `processing` on the status route
190
+ meanwhile:
188
191
 
189
192
  ```js
190
- const storage = createLocalStorage({ workspaceDir: dataDir });
191
193
  await app.register(pulseVault, {
192
- storage,
193
- onUploadComplete: async (_request, { artifactId, kind }) => {
194
- if (kind !== "video") return;
195
- const localPath = await storage.getLocalPath(artifactId);
196
- if (localPath) await ensureWebReady(localPath, { logger: app.log });
197
- },
194
+ storage: createLocalStorage({ workspaceDir: dataDir }),
195
+ webReady: { concurrency: 1, completeAfter: true },
198
196
  });
199
197
  ```
200
198
 
201
199
  Options: `{ transcode: false }` restricts it to the lossless remux (no CPU
202
200
  cost beyond a file rewrite); `crf`/`preset` tune the transcode
203
201
  (defaults `23`/`veryfast`); `ffmpegPath`/`ffprobePath` point at binaries off
204
- `PATH`.
202
+ `PATH`; `concurrency` runs several at once; `completeAfter: true` holds
203
+ `onUploadComplete` until the conversion has finished, for a host that
204
+ publishes the video from the hook. `onArtifactEvent` fires `processed` with
205
+ what was done. A conversion a restart interrupted is resumed by the
206
+ completion replay (`replayCompletions`). Calling `ensureWebReady` yourself
207
+ from `onUploadComplete` still works, but holds the client's final `PATCH`
208
+ for the whole conversion.
205
209
 
206
210
  **Existing artifacts** — uploads that landed before the hook existed are fixed
207
211
  once with the bundled migration script (idempotent; interrupt and rerun
@@ -231,6 +235,16 @@ integrity for artifacts this feature has touched.
231
235
  captions, manifest or thumbnail of a video that never finished — are cleaned
232
236
  up by the opt-in `retention` option (or `sweepAbandonedUploads` from your own
233
237
  scheduler); see the README's `retention` option. Both adapters support it.
238
+ Separately, a create that arrives for an unfinished upload of the same id
239
+ (the person scanned the same link again after the app died) takes it over
240
+ once it has been idle for `reclaim.idleSeconds` (300 by default), so nobody
241
+ waits for the sweep.
242
+
243
+ **Completions the host never recorded** — an `onUploadComplete` that threw,
244
+ or a restart between the final byte and the hook — are fired again by the
245
+ completion replay (`replayCompletions`, every 300 seconds by default, and
246
+ `pulseVaultCore.replayCompletions()` on demand). Nothing to schedule; make the
247
+ hook idempotent.
234
248
 
235
249
  How long to keep **finished** content is a policy decision `pulsevault`
236
250
  leaves to the operator (see `PROTOCOL.md` §1 on lifecycle ownership). A
package/PROTOCOL.md CHANGED
@@ -3,7 +3,7 @@
3
3
  <!-- BEGIN GENERATED: protocol-version (from package.json pulseProtocol — edit that, then run `npm run protocol`) -->
4
4
  | | |
5
5
  |---|---|
6
- | Spec revision | `2.2` |
6
+ | Spec revision | `2.3` |
7
7
  | Protocol majors accepted | 2 |
8
8
  <!-- END GENERATED: protocol-version -->
9
9
 
@@ -246,6 +246,18 @@ the token's `artifactId` as its `relatedTo` (§8) — this lets one token cover
246
246
  an entire upload session (a pulse's video plus its captions, beat manifest and
247
247
  thumbnail) rather than requiring one token per artifact.
248
248
 
249
+ A token MAY also carry an opaque `ctx` claim (protocol 2.3): host data signed
250
+ with the rest — who the upload is for, where it goes — that the server stores
251
+ with the artifact the token creates and hands back to its own hooks, so the
252
+ host needs no table keyed by artifactId. A client never reads it.
253
+
254
+ When a token names a pulse's video, a server SHOULD hold the token to the
255
+ shape of a pulse (§8): the video only under the token's own `artifactId`, with
256
+ no `relatedTo`, and a thumbnail, beat manifest or captions only under another
257
+ id, `relatedTo` the token's. Otherwise one token can create any number of
258
+ artifacts under ids of the client's choosing, or take the video's own id with a
259
+ thumbnail so the video itself can never land.
260
+
249
261
  This shape is exactly what `@mieweb/pulsevault`'s `issueCapabilityToken`/
250
262
  `verifyCapabilityToken`/`createCapabilityAuthorize` implement, but any server
251
263
  is free to use its own scheme entirely — only §5.1–§5.3 are normative.
@@ -312,6 +324,29 @@ right after the upload finishes, while its pairing token is still valid; a
312
324
  server without `viewLinks` offers no shareable link, and the client MUST NOT
313
325
  share the pairing token in its place.
314
326
 
327
+ ### 6.5 Status and poster (optional, protocol 2.3)
328
+
329
+ A server MAY expose `GET {prefix}/artifacts/<artifactId>/status`, authorized
330
+ like opening the artifact (§5.1; a view token, §5.5, is enough), reporting
331
+ where an upload is so a page waiting on it needs nothing from the host. Its
332
+ body is defined by
333
+ [`protocol/schemas/artifact-status.schema.json`](protocol/schemas/artifact-status.schema.json):
334
+ `state` is `unknown`, `uploading`, `processing` (the server is converting the
335
+ bytes for the web) or `ready`, with the bytes received against the declared
336
+ length while uploading, and whatever outcome the host recorded once finished.
337
+ The response MUST NOT be cached.
338
+
339
+ A server MAY also expose `GET {prefix}/artifacts/<videoId>/poster`: the finished
340
+ thumbnail `relatedTo` the video (§8), served exactly as §6.2 serves an artifact
341
+ and authorized as opening the video, so whoever may watch it may see its poster.
342
+ It returns `404` until the thumbnail has landed.
343
+
344
+ An unfinished upload whose client died (an app killed mid-upload sends no
345
+ TUS `DELETE`) would otherwise answer `409` to every later create of the same
346
+ `artifactId`. A server SHOULD let a create authorized by the same token take
347
+ over such an upload once it has been idle for a while, instead of refusing it
348
+ until cleanup runs.
349
+
315
350
  ## 7. Versioning
316
351
 
317
352
  The protocol has a version `major.minor`, written once in `@mieweb/pulsevault`'s
@@ -371,6 +406,7 @@ a server it paired with earlier: a server can be upgraded in between.
371
406
  | File | Defines |
372
407
  |---|---|
373
408
  | [`openapi.json`](protocol/openapi.json) | Every HTTP route, generated from the plugin |
409
+ | [`artifact-status.schema.json`](protocol/schemas/artifact-status.schema.json) | GET /artifacts/<id>/status response |
374
410
  | [`beat-manifest.schema.json`](protocol/schemas/beat-manifest.schema.json) | Beat manifest |
375
411
  | [`capabilities.schema.json`](protocol/schemas/capabilities.schema.json) | GET /capabilities response |
376
412
  | [`capability-token.schema.json`](protocol/schemas/capability-token.schema.json) | Capability token claims |
@@ -388,6 +424,7 @@ a server it paired with earlier: a server can be upgraded in between.
388
424
  | 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
425
  | 2.1 | Added `protocolRevision` to `/capabilities`, the `Pulse-Client` header with `426 Upgrade Required` (§7.2), and `Upload-Metadata.appVersion` (§4.1). |
390
426
  | 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). |
427
+ | 2.3 | Added `GET {prefix}/artifacts/<id>/status` and `GET {prefix}/artifacts/<id>/poster` (§6.5), the capability token's `ctx` claim (§5.4), and the pulse-shape rule for creates (§5.4). |
391
428
 
392
429
  ## 8. Artifact relationships (`relatedTo`)
393
430
 
package/README.md CHANGED
@@ -188,13 +188,17 @@ Four runnable example servers live under [`examples/`](examples):
188
188
  query instead of a sidecar crawl), uploads grouped by recording session
189
189
  via `relatedTo`, WebVTT captions, Swagger UI, and an `onArtifactEvent`
190
190
  live feed.
191
- - [`examples/express-demo`](examples/express-demo) and
192
- [`examples/meteor-demo`](examples/meteor-demo) — the same demo on
193
- `@mieweb/pulsevault/core` instead of the plugin, proving the core needs
194
- about the same amount of glue code under a different framework. Both are
195
- verified against the real frameworks, not just the test suite —
196
- `meteor-demo` in particular against a real `meteor create` app, since
197
- Meteor's bundler needed the compatibility fixes described above.
191
+ - [`examples/meteor-demo`](examples/meteor-demo) — the **reference host
192
+ integration**, on `@mieweb/pulsevault/core` under Meteor: shaped like a
193
+ team app (people, destinations), it reserves an upload by signing the owner
194
+ and destination into the capability token, delivers the video from
195
+ `onUploadComplete` to where it was started, polls the status route from
196
+ the pairing popup, and shows every video as a card with its poster — with
197
+ no table of uploads, no shape check, no stale-upload cleanup and no
198
+ content-type fix-up of its own. Verified against a real `meteor create`
199
+ app, since Meteor's bundler needed the compatibility fixes described above.
200
+ - [`examples/express-demo`](examples/express-demo) — the fastify-demo on
201
+ `@mieweb/pulsevault/core` under Express.
198
202
 
199
203
  ## How a pairing + upload session flows
200
204
 
@@ -231,7 +235,9 @@ The plugin mounts the following routes under `prefix` (`@mieweb/pulsevault/core`
231
235
  | `PATCH` / `HEAD` / `DELETE` \* | `/pulsevault/upload/:id` | Upload chunks, probe offset, delete the upload (TUS) |
232
236
  | `GET` | `/pulsevault/artifacts/:artifactId` | Stream or redirect to the uploaded artifact (any kind) |
233
237
  | `POST` | `/pulsevault/artifacts/:artifactId/view-link` | Mint a read-only view link to a finished artifact (only with [`issueViewLink`](#issueviewlink)) |
234
- | `DELETE` | `/pulsevault/artifacts/:artifactId` | Delete a finalized upload (bytes + sidecar) |
238
+ | `GET` | `/pulsevault/artifacts/:artifactId/status` | Where an upload is: `uploading` (with progress), `processing`, `ready`, plus the host's recorded outcome (see [Status, outcome and pulses](#status-outcome-and-pulses)) |
239
+ | `GET` | `/pulsevault/artifacts/:artifactId/poster` | A video's poster frame: the finished thumbnail `relatedTo` it, served like the artifact route |
240
+ | `DELETE` | `/pulsevault/artifacts/:artifactId` | Delete a finalized upload (bytes + sidecar); refused once finished with [`lockWhenReady`](#lockwhenready) |
235
241
 
236
242
  \* `DELETE /pulsevault/upload/:id` is TUS termination, addressed by the upload URL: it removes the artifact whether the upload is still in flight (a cancel) or finished, exactly as `DELETE /pulsevault/artifacts/:artifactId` does by artifactId.
237
243
 
@@ -250,12 +256,20 @@ type PulseVaultPluginOptions = {
250
256
  allowedExtensions?:
251
257
  | string[] // legacy — treated as video-only
252
258
  | { video?: string[]; project?: string[]; captions?: string[]; thumbnail?: string[] }; // per-kind (recommended)
253
- // defaults: { video: [".mp4"], project: [".pulse", ".zip"], captions: [".vtt"], thumbnail: [".jpg", ".jpeg", ".png"] }
259
+ // defaults: { video: [".mp4"], project: [".pulse", ".zip"], captions: [".vtt"], thumbnail: [".jpg", ".jpeg", ".png"] } — what Pulse sends
254
260
  cache?: PulseVaultCacheOptions;
255
261
  authorize?: PulseVaultAuthorize;
256
262
  validatePayload?: PulseVaultValidatePayload; // runs for every kind; branch on ctx.kind
257
- onUploadComplete?: PulseVaultOnUploadComplete; // runs for every kind; branch on ctx.kind
263
+ onUploadComplete?: PulseVaultOnUploadComplete; // runs for every kind; branch on ctx.kind; at-least-once
258
264
  onArtifactEvent?: PulseVaultOnArtifactEvent; // low-frequency hook for metrics + audit logging
265
+ issueViewLink?: PulseVaultIssueViewLink; // read-only view links
266
+ retention?: { abandonedAfterSeconds: number; sweepIntervalSeconds?: number };
267
+ pulseShape?: boolean; // default true: creates must have the shape of a pulse
268
+ reclaim?: { idleSeconds?: number } | false; // default { idleSeconds: 300 }: a create takes over an idle unfinished upload
269
+ lockWhenReady?: boolean; // default false: a finished pulse can't be deleted through the routes
270
+ webReady?: PulseVaultWebReadyOptions | boolean; // default off: background faststart remux / H.264 transcode
271
+ replayCompletions?: { intervalSeconds?: number } | false; // default every 300 s: re-fire unacknowledged completions
272
+ coreDecoratorName?: string; // default "pulseVaultCore": getStatus, getPulse, recordOutcome, replayCompletions
259
273
  /** @deprecated use validatePayload + ctx.kind === "project" */
260
274
  validateProjectPayload?: PulseVaultValidatePayload;
261
275
  /** @deprecated use onUploadComplete + ctx.kind === "project" */
@@ -322,7 +336,7 @@ Upload filenames are keyed by UUID, so `immutable: true` is safe when `maxAge` i
322
336
 
323
337
  Optional async hook called before TUS create/patch, before GET resolve, before DELETE, and before minting a view link. Throw to reject — a `statusCode` or `status_code` number on the thrown error is used as the HTTP status (default `403`).
324
338
 
325
- Phase mapping: `"create"` is the initial TUS `POST`; `"patch"` covers `PATCH` chunks and `HEAD` offset queries on the upload routes; `"resolve"` is `GET {prefix}/artifacts/<id>`; `"delete"` is **both** ways of removing an artifact — `DELETE {prefix}/artifacts/<id>` and the TUS `DELETE {prefix}/upload/<id>` (a cancel of an in-flight upload, or removal of a finished one). Gating `phase === "delete"` gates every removal. `"share"` is `POST {prefix}/artifacts/<id>/view-link` — minting a read-only link — and only happens when [`issueViewLink`](#issueviewlink) is set.
339
+ Phase mapping: `"create"` is the initial TUS `POST`; `"patch"` covers `PATCH` chunks and `HEAD` offset queries on the upload routes; `"resolve"` is `GET {prefix}/artifacts/<id>` and the poster route (authorized on the video); `"status"` is `GET {prefix}/artifacts/<id>/status`; `"delete"` is **both** ways of removing an artifact — `DELETE {prefix}/artifacts/<id>` and the TUS `DELETE {prefix}/upload/<id>` (a cancel of an in-flight upload, or removal of a finished one). Gating `phase === "delete"` gates every removal. `"share"` is `POST {prefix}/artifacts/<id>/view-link` — minting a read-only link — and only happens when [`issueViewLink`](#issueviewlink) is set.
326
340
 
327
341
  ```ts
328
342
  type PulseVaultAuthorize = (
@@ -333,15 +347,22 @@ type PulseVaultAuthorize = (
333
347
  // `http.IncomingMessage`, etc.) — the same hook works under either.
334
348
  request: PulseVaultRequest,
335
349
  ctx: {
336
- phase: "create" | "patch" | "resolve" | "delete" | "share";
350
+ phase: "create" | "patch" | "resolve" | "status" | "delete" | "share";
337
351
  artifactId: string;
338
- kind: "video" | "project" | "captions"; // artifact kind; always present
339
- token?: string; // only on "resolve" phase
340
- relatedTo?: string; // the session-anchor artifact this one belongs to, if any
352
+ kind: "video" | "project" | "captions" | "thumbnail"; // artifact kind; always present
353
+ token?: string; // only on the "resolve" and "status" phases
354
+ relatedTo?: string; // the video this artifact belongs to, if it declared one
355
+ name?: string; // Upload-Metadata.name, when sent
356
+ appVersion?: string; // Upload-Metadata.appVersion, when sent
357
+ filename?: string; // Upload-Metadata.filename
358
+ ext?: string; // its lowercase extension, with the dot
359
+ context?: unknown; // the token's context, read back from storage (absent on "create")
341
360
  },
342
- ) => void | Promise<void>;
361
+ ) => void | { context?: unknown } | Promise<void | { context?: unknown }>;
343
362
  ```
344
363
 
364
+ On `create` the hook may return `{ context }`: opaque host data (an owner, a destination) stored with the artifact and handed to every later `authorize`, to `onUploadComplete`, to `getStatus` and to `getPulse`. `createCapabilityAuthorize` returns the token's own `context` claim (see [Capability tokens](#capability-tokens)), so most hosts never touch this.
365
+
345
366
  ```ts
346
367
  await app.register(pulseVault, {
347
368
  // ...
@@ -414,25 +435,42 @@ Optional async hook fired once the final byte is written, `validatePayload` has
414
435
 
415
436
  ```ts
416
437
  type PulseVaultOnUploadComplete = (
417
- request: PulseVaultRequest, // see the note under `authorize` above
418
- ctx: { artifactId: string; kind: "video" | "project" | "captions"; size: number; uploadId: string },
438
+ request: PulseVaultRequest, // see the note under `authorize` above; `{ headers: {} }` on a replay
439
+ ctx: {
440
+ artifactId: string;
441
+ kind: "video" | "project" | "captions" | "thumbnail";
442
+ size: number;
443
+ uploadId: string;
444
+ filename: string; // Upload-Metadata.filename
445
+ ext: string; // its lowercase extension, with the dot
446
+ relatedTo?: string; // the video a thumbnail, beat manifest or captions belongs to
447
+ name?: string; // Upload-Metadata.name (the draft's title), when sent
448
+ appVersion?: string; // Upload-Metadata.appVersion, when sent
449
+ context?: unknown; // the capability token's context (see Capability tokens)
450
+ replay: boolean; // true when PulseVault is firing a completion it never recorded
451
+ webReady?: WebReadyResult; // with `webReady.completeAfter`: what the conversion did
452
+ },
419
453
  ) => void | Promise<void>;
420
454
  ```
421
455
 
456
+ **Completion is at-least-once.** PulseVault records that the hook returned (`acknowledged` in the artifact's sidecar). A finished artifact whose hook never returned — it threw, or the process stopped between the final byte and the hook — is fired again later with `ctx.replay: true` (see [`replayCompletions`](#replaycompletions)). So the hook must check its own record before writing it again: an upsert, or a claim that only one caller wins. A retried final `PATCH` for an acknowledged upload does not fire the hook a second time.
457
+
422
458
  ### `onArtifactEvent`
423
459
 
424
460
  Optional low-frequency hook — fired on authorize rejection (every phase but per-chunk `patch`), upload completion, payload-validation rejection, and removal. One hook covers both ops metrics and a compliance audit trail instead of hand-wiring both from the lower-level hooks above, and `remove` is where a host keeping its own index of artifacts drops one:
425
461
 
426
462
  ```ts
427
463
  type PulseVaultArtifactEvent = {
428
- phase: "authorize" | "complete" | "reject" | "remove";
464
+ phase: "authorize" | "complete" | "reject" | "remove" | "processed";
429
465
  artifactId: string;
430
466
  kind: "video" | "project" | "captions" | "thumbnail";
431
467
  size?: number;
432
468
  // present for "authorize" and "reject"; on "remove", "deleted" (DELETE /artifacts/:id or a
433
- // TUS DELETE) or "abandoned" (the `retention` sweep)
469
+ // TUS DELETE), "abandoned" (the `retention` sweep) or "reclaimed" (a create took over an
470
+ // idle unfinished upload); on "processed", what the web-ready conversion did
434
471
  reason?: string;
435
472
  appVersion?: string; // the uploading app's version, on "complete"/"reject" (protocol 2.1)
473
+ webReady?: WebReadyResult; // on "processed": { action, reason }
436
474
  };
437
475
 
438
476
  onArtifactEvent: (event) => {
@@ -482,6 +520,59 @@ Finished videos, and anything whose video finished, are never touched; this is n
482
520
 
483
521
  A sweep reads the metadata of every artifact older than the cutoff — one `GetObject` each on S3 — so on a large bucket sweep daily (or off-peak from a cron job) rather than hourly. Several instances may sweep the same storage (a removal that finds nothing is a no-op), but each instance caches metadata: on S3, an id another instance removed can still answer `409` on this one until the entry is evicted — the same as for `DELETE /artifacts/:id`. To schedule it yourself (a cron job, a queue), call `sweepAbandonedUploads(storage, { abandonedAfterSeconds, onRemoved? })` instead; it resolves the removed artifactIds.
484
522
 
523
+ ### `pulseShape`
524
+
525
+ On by default. Every create must have the shape of a pulse (`PROTOCOL.md` §8): a video is created with no `relatedTo`, and a thumbnail, beat manifest or captions under its own id, `relatedTo` the video it belongs to. Anything else is refused at create with `403`, before any bytes move. With [`createCapabilityAuthorize`](#capability-tokens) the ids are also tied to the token: the video only under the token's own `artifactId`, the rest only `relatedTo` it. Without this, one token could create any number of videos under ids the client picks (never attached to anything, still converted, never removed), or take the video's own id with a thumbnail so the real video answers `409` for good.
526
+
527
+ The Pulse app has always uploaded in this shape. For uploads that aren't pulses, pass `pulseShape: false` here **and** to `createCapabilityAuthorize`.
528
+
529
+ ### `reclaim`
530
+
531
+ `{ idleSeconds: 300 }` by default; `false` turns it off. An app killed mid-upload sends no TUS `DELETE`, so its unfinished upload keeps its artifactId and every later create of that id — the person scanning the same link again — answers `409` until `retention` removes it. With `reclaim`, a create takes over an unfinished upload of the same artifactId once it has been idle for `idleSeconds` — no bytes received: the bytes file's mtime on the local adapter, the newest multipart part (or the incomplete part parked between `PATCH`es) on S3: the old upload is removed (`onArtifactEvent` `remove` with `reason: "reclaimed"`) and the new one starts from byte 0. Only an upload of the same kind and `relatedTo` is taken over, so the token that authorized the new create authorized the old one; only one whose last activity the adapter knows; and creates for one artifactId run one at a time per instance, so two rescans can't both take it over.
532
+
533
+ ### `lockWhenReady`
534
+
535
+ Off by default. Once an artifact is finished, `DELETE {prefix}/artifacts/<id>` and the TUS `DELETE` answer `403` for it, and for anything `relatedTo` a finished video: a pulse that landed stays, whatever token is presented. Removal then goes through `storage.remove` on the host's own terms (an admin action, a retention policy). The check reads storage (not the per-instance cache) just before the removal. For the TUS `DELETE` it runs inside tus's per-upload lock, so a cancel racing the final chunk of the same upload waits for it and then finds the artifact finished. `DELETE {prefix}/artifacts/<id>` takes no such lock — the Pulse app never uses it while uploading — so a delete and a final chunk arriving in the same instant can both succeed there.
536
+
537
+ ### `webReady`
538
+
539
+ Off by default. `true`, or `ensureWebReady`'s options plus:
540
+
541
+ ```ts
542
+ webReady: {
543
+ concurrency: 1, // conversions at once; a transcode is CPU-bound
544
+ completeAfter: false, // true: run onUploadComplete only once the conversion has finished
545
+ transcode: true, crf: 23, preset: "veryfast", ffmpegPath: "ffmpeg", ffprobePath: "ffprobe",
546
+ },
547
+ ```
548
+
549
+ Every finished video is made web-playable in the background, after the final `PATCH` is answered: a lossless faststart remux when the `moov` atom is at the end, or a one-time H.264 transcode when the codec is one browsers can't play (see `OPERATIONS.md`). While it runs the artifact's status is `processing` (the original bytes serve meanwhile; the rewrite is atomic), and when it's done `onArtifactEvent` fires `processed` with what it did. With `completeAfter: true`, `onUploadComplete` waits for the conversion and receives `ctx.webReady`, so a host that publishes from the hook never publishes a video that's still being rewritten. A conversion a restart interrupted is resumed by the replay below. Needs the local storage adapter and ffmpeg on the host; without ffmpeg it logs once and serves the original bytes.
550
+
551
+ ### `replayCompletions`
552
+
553
+ On by default, every 300 seconds, with the first pass shortly after start; `{ intervalSeconds }` changes the interval and `false` turns it off. Each pass applies the one completion rule to every finished artifact that isn't settled: convert it if `webReady` applies and it isn't converted yet, then fire `onUploadComplete` again (`ctx.replay: true`) if the host hasn't acknowledged it. Both facts live on the artifact's sidecar (`converted`, `acknowledged`), written `false` at reserve, so nothing depends on what a process remembered. Turning `webReady` on later converts, on the first pass, the videos uploaded since this release that weren't. `fastify.pulseVaultCore.replayCompletions()` (or `core.replayCompletions()`) runs a pass on demand, for example at boot once the host's own services are up. Finished sidecars written before PulseVault recorded acknowledgements read as acknowledged, so an upgrade replays nothing (one still uploading at the upgrade completes normally).
554
+
555
+ ### Status, outcome and pulses
556
+
557
+ The core behind the routes is `fastify.pulseVaultCore` (rename it with `coreDecoratorName`; non-Fastify hosts have it as the object `createPulseVaultCore` returns):
558
+
559
+ ```ts
560
+ // Where an upload is — what GET /artifacts/:id/status reports — for a host that polls server-side.
561
+ await app.pulseVaultCore.getStatus(videoId);
562
+ // → { artifactId, state: "unknown" | "uploading" | "processing" | "ready", kind, relatedTo?, name?,
563
+ // bytesReceived?, size?, acknowledged, outcome?, context? }
564
+ // `context` (the token's) is for the host — an owner check is a comparison on it; the route leaves it out.
565
+
566
+ // Where it went, or why it didn't: any JSON, reported as `outcome` by the status route. `null` clears it.
567
+ await app.pulseVaultCore.recordOutcome(videoId, { state: "done", note: "Posted to Huddle" });
568
+
569
+ // A video and the finished files relatedTo it, by kind — from the adapters' relation index, never a scan.
570
+ const { video, thumbnail, captions, manifest } = await app.pulseVaultCore.getPulse(videoId);
571
+ // One per kind; when a file was re-sent, the one that finished last (`readyAt`) wins.
572
+ ```
573
+
574
+ A page waiting on an upload polls `GET {prefix}/artifacts/<id>/status` (never cached) with the pairing token or a view token, and reads `outcome` once the host recorded one — no table, SSE route or polling method of the host's own. `GET {prefix}/artifacts/<videoId>/poster` serves the video's thumbnail the way the artifact route serves it, authorized as `resolve` on the video, and `404`s until the poster has landed, so a card asks by the video's id and nothing is copied onto the host's records.
575
+
485
576
  ### `validateProjectPayload` / `onProjectUploadComplete` (deprecated)
486
577
 
487
578
  Same lifecycle as `validatePayload`/`onUploadComplete`, but only fired for `kind=project` uploads. **Deprecated** — use the generic `validatePayload`/`onUploadComplete` with a `ctx.kind === "project"` branch instead. Still honored this release (passing either emits a one-time `DeprecationWarning` at registration); will be removed in a future major version.
@@ -492,7 +583,7 @@ When the final PATCH lands the plugin runs the following steps in order, for eve
492
583
 
493
584
  1. **`validatePayload`** (optional, runs for every kind) — throws → `storage.remove(artifactId)`, HTTP 4xx (default 422).
494
585
  2. **`storage.markReady(artifactId)`** — flips the sidecar so `resolve()` will serve the bytes.
495
- 3. **`onUploadComplete`** (optional, runs for every kind) — throws → HTTP 500; bytes remain ready unless the consumer removes them.
586
+ 3. **`onUploadComplete`** (optional, runs for every kind) — throws → HTTP 500; bytes remain ready unless the consumer removes them, and the completion is replayed later (`ctx.replay: true`). Once it returns, the artifact is acknowledged. With `webReady`, the conversion is queued here; with `webReady.completeAfter`, the hook runs after it instead.
496
587
 
497
588
  (`validateProjectPayload`/`onProjectUploadComplete`, if passed, run instead of the generic hooks specifically for `kind=project` — see the deprecation note above.)
498
589
 
@@ -568,7 +659,19 @@ app.post("/pair", async (_req, reply) => {
568
659
  });
569
660
  ```
570
661
 
571
- A token authorizes either the artifact it names, or any artifact that declares that one as its `relatedTo` — so one token issued for a pulse's video also covers its captions, beat manifest and thumbnail in the same session, without minting a token per artifact. See `PROTOCOL.md` §5.4 for the full claim shape (`kid`/`iat`/`exp`/`issuer`/`artifactId`) and rationale.
662
+ A token authorizes either the artifact it names, or any artifact that declares that one as its `relatedTo` — so one token issued for a pulse's video also covers its captions, beat manifest and thumbnail in the same session, without minting a token per artifact. With [`pulseShape`](#pulseshape) (the default, on both the plugin and `createCapabilityAuthorize`), a create is held to that shape: the video only under the token's own `artifactId`, with no `relatedTo`; the rest only under other ids, `relatedTo` it. See `PROTOCOL.md` §5.4 for the full claim shape (`kid`/`iat`/`exp`/`issuer`/`artifactId`/`ctx`) and rationale.
663
+
664
+ **Context.** The token can carry the host's own data about the upload — who it's for, where it goes — signed with the rest, so whoever holds the token can't change it:
665
+
666
+ ```ts
667
+ const token = issueCapabilityToken(artifactId, keys["2026-06"], {
668
+ keyId: "2026-06",
669
+ issuer,
670
+ context: { userId: user.id, destination: { kind: "huddle", teamId } }, // any JSON, at most 1 KiB encoded
671
+ });
672
+ ```
673
+
674
+ `createCapabilityAuthorize` returns it from the `create` phase, PulseVault stores it with the artifact (and with every file uploaded `relatedTo` it under the same token), and every later `authorize`, `onUploadComplete` (`ctx.context`, on replays too), `getStatus` and `getPulse` get it back. Most hosts then keep no table keyed by artifactId, and "is this the person who uploaded it?" is a comparison, not a lookup.
572
675
 
573
676
  ## Upload-Metadata protocol
574
677
 
@@ -612,7 +715,9 @@ The local adapter writes uploads into flat kind-scoped subdirectories. Downstrea
612
715
 
613
716
  ```text
614
717
  <workspaceRoot>/
615
- .pulsevault/<id>.json # sidecar: { version, ext, filename, status, kind, relatedTo, checksum, name }
718
+ .pulsevault/<id>.json # sidecar: { version, ext, filename, status, kind, relatedTo, checksum, name,
719
+ # appVersion, context, acknowledged, converted, readyAt, outcome }
720
+ .pulsevault/related/<videoId>/<id> # relation index: an empty marker per artifact relatedTo the video
616
721
  video/<id><ext> # video upload bytes (kind="video")
617
722
  video/<id><ext>.json # @tus/file-store offset/metadata sidecar
618
723
  project/<id><ext> # project bundle bytes (kind="project")
@@ -623,7 +728,7 @@ The local adapter writes uploads into flat kind-scoped subdirectories. Downstrea
623
728
 
624
729
  `status` is `"uploading"` between `reserveUpload` and the successful final PATCH; `"ready"` thereafter. `GET /artifacts/:id` only serves `"ready"` uploads. `kind` defaults to `"video"` when absent (back-compat with pre-kind sidecars).
625
730
 
626
- The adapter exposes `storage.workspaceRoot` (absolute, resolved from `workspaceDir`) so consumers can compute per-resource paths without re-implementing the layout. `storage.getKind(id)` returns `"video" | "project" | "captions" | null`; `storage.getRelatedTo(id)`, `storage.getChecksum(id)`, and `storage.getName(id)` return the corresponding sidecar fields, each `null` if absent/unknown.
731
+ The adapter exposes `storage.workspaceRoot` (absolute, resolved from `workspaceDir`) so consumers can compute per-resource paths without re-implementing the layout. `storage.describeArtifact(id)` returns everything the sidecar holds plus `ready`, `acknowledged`, `processing` and `updatedAt` (`null` if unknown); `storage.listRelated(videoId)` walks the relation index; `storage.getKind(id)`, `storage.getRelatedTo(id)`, `storage.getChecksum(id)` and `storage.getName(id)` return single fields, each `null` if absent/unknown.
627
732
 
628
733
  **Horizontal scaling**: this adapter requires sticky-session routing or a shared filesystem across instances — see `OPERATIONS.md`.
629
734
 
@@ -760,9 +865,13 @@ const storage: PulseVaultStorage = {
760
865
  async shutdown() {
761
866
  /* optional teardown */
762
867
  },
763
- async reserveUpload({ artifactId, filename, ext, kind, relatedTo, checksum, name }) {
764
- // Called by the TUS naming function. Return the file id for the datastore.
765
- await db.createArtifact({ artifactId, filename, kind, relatedTo, checksum, name, status: "uploading" });
868
+ async reserveUpload({ artifactId, filename, ext, kind, relatedTo, checksum, name, appVersion, context }) {
869
+ // Called by the TUS naming function. Return the file id for the datastore. `context` is what
870
+ // `authorize` returned for this create (the capability token's context); keep it with the row.
871
+ await db.createArtifact({
872
+ artifactId, filename, ext, kind, relatedTo, checksum, name, appVersion, context,
873
+ status: "uploading", acknowledged: false, converted: false,
874
+ });
766
875
  return `${kind}/${artifactId}${ext}`;
767
876
  },
768
877
  async resolve(artifactId): Promise<PulseVaultResolution | null> {
@@ -793,13 +902,36 @@ const storage: PulseVaultStorage = {
793
902
  async getRelatedTo(artifactId) { return (await db.findArtifact(artifactId))?.relatedTo ?? null; },
794
903
  async getChecksum(artifactId) { return (await db.findArtifact(artifactId))?.checksum ?? null; },
795
904
  async getName(artifactId) { return (await db.findArtifact(artifactId))?.name ?? null; },
796
- // Optional — needed only by `retention` / `sweepAbandonedUploads`. `updatedAt` is when the
797
- // upload started while it's unfinished, and when it finished once ready (ms since epoch).
905
+ // Optional — needed by `retention` / `sweepAbandonedUploads` and by the completion replay.
906
+ // `updatedAt` is when the upload last received bytes (or started) while it's unfinished, and
907
+ // when it finished once ready (ms since epoch).
798
908
  async *listArtifacts({ changedBefore } = {}) {
799
909
  for (const row of await db.listArtifacts({ changedBefore })) {
800
910
  yield { artifactId: row.id, kind: row.kind, relatedTo: row.relatedTo, ready: row.ready, updatedAt: row.updatedAt };
801
911
  }
802
912
  },
913
+ // Optional — the richer hook context, `getStatus`, `getPulse`, `reclaim`, `webReady` and the
914
+ // completion replay need these three.
915
+ async describeArtifact(artifactId) {
916
+ const row = await db.findArtifact(artifactId);
917
+ if (!row) return null;
918
+ return {
919
+ artifactId, kind: row.kind, ext: row.ext, filename: row.filename, relatedTo: row.relatedTo,
920
+ checksum: row.checksum, name: row.name, appVersion: row.appVersion, context: row.context,
921
+ ready: row.status === "ready", acknowledged: row.acknowledged, converted: row.converted,
922
+ outcome: row.outcome, updatedAt: row.updatedAt, readyAt: row.readyAt,
923
+ };
924
+ },
925
+ async patchArtifact(artifactId, { acknowledged, converted, outcome }) {
926
+ // Each field is optional; `outcome: null` clears it. Return false for an unknown id.
927
+ return (await db.updateArtifact(artifactId, { acknowledged, converted, outcome })).matched;
928
+ },
929
+ async *listRelated(artifactId) {
930
+ // Every artifact whose `relatedTo` is this one — from an index, not a scan.
931
+ for (const row of await db.listArtifacts({ relatedTo: artifactId })) {
932
+ yield { artifactId: row.id, kind: row.kind, relatedTo: artifactId, ready: row.ready, updatedAt: row.updatedAt };
933
+ }
934
+ },
803
935
  };
804
936
  ```
805
937
 
@@ -822,6 +954,13 @@ const uploadLink = buildUploadLink({
822
954
  // pulsecam://?v=1&artifactId=...&server=https%3A%2F%2Fexample.com%2Fpulsevault&token=secret
823
955
  ```
824
956
 
957
+ ## Pulse logo and store badges
958
+
959
+ The package ships the Pulse logo for the button that opens Pulse
960
+ (`@mieweb/pulsevault/assets/pulse-logo.svg` in full colour,
961
+ `pulse-logo-mono.svg` in one colour), and the App Store and Google Play badges
962
+ to link to Pulse's listings. See [assets/README.md](assets/README.md).
963
+
825
964
  ## Tests
826
965
 
827
966
  ```sh
@@ -0,0 +1,33 @@
1
+ # Pulse assets
2
+
3
+ For any app that pairs with Pulse through PulseVault: the Pulse logo, to put
4
+ on the button that opens Pulse, and the store badges, to link people who
5
+ don't have Pulse yet to its listings.
6
+
7
+ | File | What it is |
8
+ | ----------------------- | ------------------------------------------------------------- |
9
+ | `pulse-logo.svg` | Full colour, the Pulse red gradient. Use it on light or dark. |
10
+ | `pulse-logo-mono.svg` | One colour, `currentColor` (black when used as an `<img>`). |
11
+ | `badge-app-store.svg` | Apple's "Download on the App Store" badge (black). |
12
+ | `badge-google-play.png` | Google's "Get it on Google Play" badge. |
13
+
14
+ The logos are 87 × 100 (width × height) and scale to any size. The heartbeat
15
+ is cut out, so whatever is behind the logo shows through it.
16
+
17
+ The badges are Apple's and Google's own artwork, used under their badge
18
+ guidelines: show them unaltered, at the same height side by side, and link
19
+ them to Pulse's listings:
20
+
21
+ - App Store: `https://apps.apple.com/us/app/pulse-cam/id6748621024`
22
+ - Google Play: `https://play.google.com/store/apps/details?id=com.mieweb.pulse`
23
+
24
+ They ship in the package, so a bundler can import them:
25
+
26
+ ```js
27
+ import pulseLogo from "@mieweb/pulsevault/assets/pulse-logo.svg";
28
+
29
+ button.innerHTML = `<img src="${pulseLogo}" alt="" width="14" height="16"> Record with Pulse`;
30
+ ```
31
+
32
+ Inline the mono file's markup to colour it with CSS `color`, or use it as a
33
+ CSS `mask-image` over any background.