Repository navigation
feat: push privacy-filtered local playback state to hosted cards #140
Description
Activity
Follow-up that depends on this: #218 (private, auth-gated usage dashboard reading the same Upstash Redis).
- added a commit that references this issue
on Sep 23, 2026 - addedenhancementNew feature or requestNew feature or request
on Sep 23, 2026 - added 7 commits that reference this issue
on Sep 23, 2026 10 remaining items
#140 done-when audit against main 5a66d16
Full suite on 5a66d16: 885 pass, 0 fail. Live checks against https://nowplaying-hosted.vercel.app on 24 Sep 2026, 21:35 BST.
- Zero provider credentials, no inbound connection - ticked.
hosted/has no dependencies (hosted/package.json) and no provider code: grepping hosted/lib and hosted/api for jellyfin/plex/navidrome/emby/spotify finds nothing. The only outbound calls are Upstash (hosted/lib/redis.js) and api.github.com for sign-in (GitHub identity check sends the token only to api.github.comin test/hosted-users.test.js). The PC pushes to the service and the service never connects back. - Disabled fields never leave the process - ticked. test/hosted-projection.test.js:
fields turned off for the card never leave the process,never sends artwork, provider or server details,privacy redaction applies before upload,TV and film details never leave the PC (#143). test/hosted-uploader.test.js:disabled card fields and artwork never reach the wirechecks the actual request body. test/hosted-card.e2e.test.js checks it end to end. - DRP works with upload disabled or host offline - ticked once test: Discord keeps working with hosted upload off or the host offline (#140) #487 merges. The code already worked this way (the hosted loop and the Discord loop are separate, and
provider and upload failures never throw out of the loop), but no test proved it at app level. test: Discord keeps working with hosted upload off or the host offline (#140) #487 adds test/hosted-drp-independence.test.js: the full app from config, Discord gets the track with hosted off (host never called) and with hosted on while every hosted request fails. - Schema-validated, size-bounded, replay-resistant, rate-limited - ticked. test/hosted-service.test.js:
validation rejects unknown fields, skew, oversize text and bad times,ingest rejects bad tokens, replays and out-of-order sequences,ingest is rate limited per device,registration is rate limited per client. test/hosted-users.test.js:per-user ingest limit covers all of a user's PCs together. test/hosted-http.test.js:ingest enforces auth, content type and size. Live: an unauthenticated ingest returns 401, a 20 KB body returns 413. - Multi-device ordering, simultaneous playback, pause, clear, replay, clock skew - ticked. test/hosted-users.test.js:
two PCs playing at once: the one that started later wins, heartbeats don't flip it,paused never replaces a playing PC; clear falls back to the next PC,stale PCs expire; replays are refused per device,clock skew: order uses server time and progress stays in range,the 11th PC pushes out the one quiet for longest,resolver ignores idle/garbage and breaks ties deterministically. - Stale state expires into idle/private behaviour - ticked.
state expires into idle instead of showing stale media(hosted-service),stale PCs expire(hosted-users). Private mode and suppressed kinds upload idle only (private mode and suppressed kinds upload an idle state only, hosted-projection). The card has no other idle option, so idle and private both mean the idle card, the same as the local card. - Durable TTL storage on Vercel; nothing depends on function memory - ticked. Production uses Upstash via
redisFromEnv(hosted/lib/default.js). It throws without an https URL and token, so there's no silent in-memory fallback, and the memory adapter is used only in tests. State, sequence numbers, rate limits and device lists are all Redis keys with EX (STATE_TTL_SECONDS 600). The only module-level variable is the lazily built service handle. - SVG ETag/cache, no identifier leaks - ticked. hosted/lib/http.js sets a sha256 ETag,
cache-control: public, max-age=30and answers If-None-Match with 304. Tests:register, ingest and render a card over HTTPasserts 304 and that the SVG has no token or card id;card layout options ... cache separately;unknown ids never leak detail; the sign-in HTTP test asserts the /u/.svg body has no device key. Live: /u/rowkav09.svg returns 200 with an etag, and a repeat with If-None-Match returns 304. - Disconnect/revoke deletes state and invalidates credentials - ticked.
idle clears state and revoke invalidates the token(hosted-service),device list, rename, remove, sign out this PC and everywhere(hosted-users),disconnect revokes on the service and forgets the deviceanda revoked token stops uploads and clears credentials(hosted-uploader), plus the Settings devices tests (Settings: hosted card devices list with rename, sign out and sign out everywhere (#140) #475). Live E2E on 24 Sep: after signing out the test PC, its key got 401 and the card went back to idle. - Plain-language setup/privacy docs - ticked. docs/hosted-upload.md ("What is sent" / "What is never sent" / "How it's stored" / signed-in vs not / "Turning it off"), the wiki README-card and Privacy pages (docs(hosted): GitHub sign-in, one card per user, what the service stores (#140) #474, docs(wiki): GitHub sign-in section and devices management for the hosted card (#140) #482), and the in-app preview that lists the exact upload fields (feat(hosted): setup API for the card hosting step, upload preview and self-hosted check (#140) #468, feat: add a first-run install and setup wizard (v0.2 gate) #141 box 5).
- Ships as feat/minor - left for the release cut.
No product gaps found. The one missing proof (box 3) is the test-only PR #487.
- Zero provider credentials, no inbound connection - ticked.
Scoping pass against the current issue and its September 24 audit: this architecture is already built, not a fresh implementation backlog. The issue marks 10 of 11 done criteria complete; #487 (the then-pending Discord-offline proof) merged on September 24. Reopening the implementation slices below would duplicate work.
Reviewable slices, with existing landed work:
- Provider-neutral authenticated ingest, schema/limits, durable TTL state, SVG route, and Vercel/self-hosted adapter: feat(hosted): push-based ingest and SVG card service (#140) #241, feat(hosted): rate limit ingest per device (#140) #313 and their tests. Complete.
- Local privacy projection separate from Discord, desktop uploader, credential storage, and upload loop: feat(hosted): privacy projection for hosted card uploads (#140) #289, feat(hosted): desktop uploader client for hosted cards (#140) #295, feat(hosted): credential store and polling loop for hosted uploads (#140) #297, feat(hosted): run hosted upload from the app config (#140) #304. Complete; packet/body and Discord-offline tests cover the boundary (test: Discord keeps working with hosted upload off or the host offline (#140) #487).
- Multi-device registration, ordering, revocation and Settings controls: the issue's audit and Settings: hosted card devices list with rename, sign out and sign out everywhere (#140) #475. Complete.
- Plain-language setup/privacy docs and hosting choices: docs(hosted): plain-language page on what the hosted card sends (#140) #309, Setup: Card hosting step (Not now, hosted, self-hosted) with the upload preview #472 and docs/hosted-upload.md. Complete.
- Release/versioning: the one unticked criterion says shipping core architecture as a
featshould mean a minor version. The source package still says 0.2.0; the open Release Please PR chore(main): release 0.3.0 #572 proposes 0.2.1. This is a release decision, not a missing implementation slice. Do not silently retag, merge the release PR, or ship a stable build here. Rowan needs to decide whether the existing hosted core is already accounted for by 0.2.0 or whether the next stable release should be a minor bump. Keep feat: push privacy-filtered local playback state to hosted cards #140 open until that decision is recorded and the release path is complete.
Potential future product choices (not prerequisites for the implemented base): artwork handling beyond the current omit-by-default policy, preferred-device pinning/naming, and additional public idle behavior. Those need their own scope and Rowan's input rather than broadening #140 during closeout.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsBacklog
Decision
Adopt a push-based split runtime for hosted cards:
The hosted card service must not connect back to a user's Plex, Jellyfin, Navidrome, Emby, browser or Discord client. The local app detects once, sends only the fields the user explicitly enabled for the card, and uses the same full local state for Discord Rich Presence.
Why this changes the Vercel fit
A request-driven Vercel function becomes practical when it only accepts small authenticated updates, reads short-lived sanitized state from a durable store, and renders/caches an SVG. It no longer needs LAN/tailnet access, provider credentials, background media-server polling or long-lived processes. A self-hosted service can implement the same protocol.
Privacy and security contract
Identity, devices and conflicts
Offline and stale behavior
Scope
Done when
featand therefore a minor release under project versioning.Refs #127 #135 #136