Repository navigation
Open
Description
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
featand therefore a minor release under project versioning.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsBacklog
Activity
rowkav09 commentedon Sep 23, 2026
Follow-up that depends on this: #218 (private, auth-gated usage dashboard reading the same Upstash Redis).
feat(hosted): push-based ingest and SVG card service (#140) (#241)
feat(hosted): privacy projection for hosted card uploads (#140)
feat(hosted): privacy projection for hosted card uploads (#140)
feat(hosted): privacy projection for hosted card uploads (#140) (#289)
feat(hosted): desktop uploader client for hosted cards (#140)
feat(hosted): desktop uploader client for hosted cards (#140) (#295)
feat(hosted): credential store and polling loop for hosted uploads (#140
feat(hosted): credential store and polling loop for hosted uploads (#140
10 remaining items
fix(hosted): create ingest rate-limit bucket with its TTL (#140)
feat(hosted): rate limit ingest per device (#140) (#313)
feat(hosted): setup API for the card hosting step, upload preview and…
feat(hosted): GitHub sign-in, one card per user updated from several …
rowkav09 commentedon Sep 24, 2026
#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.
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.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.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.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.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.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.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.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.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.No product gaps found. The one missing proof (box 3) is the test-only PR #487.
rowkav09 commentedon Sep 27, 2026
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:
featshould mean a minor version. The source package still says 0.2.0; the open Release Please PR chore(main): release 0.2.1 #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.