Skip to content

feat: push privacy-filtered local playback state to hosted cards #140

Description

@rowkav09

Decision

Adopt a push-based split runtime for hosted cards:

media server / Spotify / browser plugin
              |
              v
local nowplaying app
  |-- local normalized state --> Discord Rich Presence (never leaves PC)
  `-- privacy-filtered card state --> authenticated ingest API
                                          |
                                          v
                              Vercel or self-hosted state store
                                          |
                                          v
                                    public SVG endpoint

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

  • Provider credentials and raw provider responses stay on the PC.
  • Card-field allowlist is applied before upload; disabled title, artist, user, server, artwork, progress or links never reach the host.
  • Discord formatting/state remains local and may use fields that the public card is not allowed to receive.
  • Uploads use per-install/device credentials, TLS, replay resistance, bounded payloads and schema/version validation.
  • Hosted logs, errors and metrics contain no titles, artists, usernames, artwork URLs or raw payloads.
  • State has a short expiry and explicit delete/disconnect; no listening history by default.
  • Artwork needs a separate decision: sanitized bounded bytes, approved public URL, or omit. Never let the host fetch arbitrary client-supplied URLs.

Identity, devices and conflicts

  • One account can register multiple device IDs.
  • Each update carries account, device, monotonic sequence, observed-at and expiry.
  • Reject old/replayed/out-of-order updates.
  • Define active-device policy. Initial default: most recent actively-playing device wins; paused cannot replace another currently-playing device until that state expires. Let users pin or name a preferred device later.
  • A device can clear only its own state. Account-level revoke clears every device credential/state.
  • Public card URLs use an opaque, rotatable card ID, never email or media-server identity.

Offline and stale behavior

  • Hosted state expires after a documented TTL if heartbeats/updates stop.
  • Local app sends state transitions plus a low-frequency heartbeat, not constant progress ticks.
  • Renderer derives progress from uploaded position + observed-at while state is fresh.
  • After expiry, serve the configured idle/private card or 204, never stale media forever.
  • Queue updates briefly during transient network failure; bound queue size/time and never persist history by accident.

Scope

  • Write a versioned provider-neutral ingest schema and threat model/ADR.
  • Implement local privacy projection separate from Discord projection.
  • Implement authenticated ingest, durable TTL state and public SVG read path behind a storage adapter.
  • Ship one Vercel adapter and keep framework-neutral/self-hosted compatibility.
  • Add device registration/revocation and multi-device resolution.
  • Document costs/caching and a migration path from direct provider polling.

Done when

  • Hosted service has zero provider credentials and needs no inbound connection to the PC/LAN.
  • Packet/payload tests prove fields disabled for card output never leave the local process.
  • DRP still works with network upload disabled or host offline.
  • Authenticated updates are schema-validated, size-bounded, replay-resistant and rate-limited.
  • Multi-device ordering, simultaneous playback, pause, clear, replay and clock skew are tested.
  • Stale state expires into configured idle/private behavior.
  • Vercel deployment uses durable TTL storage; no correctness depends on function memory.
  • SVG route has ETag/cache behavior and never leaks ingest/auth identifiers.
  • Disconnect/revoke deletes state and invalidates device credentials.
  • Plain-language setup/privacy docs show exactly what leaves the PC.
  • Shipping core architecture is a feat and therefore a minor release under project versioning.

Refs #127 #135 #136

Activity

  1. rowkav09 commented on Sep 23, 2026

    @rowkav09
    MemberAuthor

    Follow-up that depends on this: #218 (private, auth-gated usage dashboard reading the same Upstash Redis).

  2. moved this to In Progress in nowplayingon Sep 23, 2026
  3. 10 remaining items

  4. moved this from In Progress to Todo in nowplayingon Sep 24, 2026
  5. moved this from Todo to In Progress in nowplayingon Sep 24, 2026
  6. rowkav09 commented on Sep 24, 2026

    @rowkav09
    MemberAuthor

    #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.

    1. 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.com in test/hosted-users.test.js). The PC pushes to the service and the service never connects back.
    2. 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 wire checks the actual request body. test/hosted-card.e2e.test.js checks it end to end.
    3. 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.
    4. 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.
    5. 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.
    6. 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.
    7. 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.
    8. SVG ETag/cache, no identifier leaks - ticked. hosted/lib/http.js sets a sha256 ETag, cache-control: public, max-age=30 and answers If-None-Match with 304. Tests: register, ingest and render a card over HTTP asserts 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.
    9. 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 device and a 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.
    10. 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).
    11. 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.

  7. rowkav09 commented on Sep 27, 2026

    @rowkav09
    MemberAuthor

    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:

    1. 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.
    2. 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).
    3. 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.
    4. 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.
    5. Release/versioning: the one unticked criterion says shipping core architecture as a feat should 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions