Skip to content

Player redesign 8: Listening in the house and cast tokens (household, cast capabilities) - #125

Merged
KodeStar merged 13 commits into
mainfrom
feat/player-8-household
Oct 13, 2026
Merged

KodeStar merged 13 commits into
mainfrom
feat/player-8-household

Conversation

@KodeStar

Copy link
Copy Markdown
Owner

Player redesign Phase 8 (Household), server side. Two additive capabilities, both mirrored in the player (frontend PR to follow; this merges first).

Listening in the house (household)

  • GET/PUT /api/v1/me/household {share_listening} (off by default; demo 403; strict body).
  • GET /api/v1/household/listening -> {sharing, listening[]}: reciprocal (empty unless the caller shares), other enabled non-demo users who share, one row per person (newest listening session within 24 h; playing/paused/recent), dropped when the book is outside the caller's scopes, never device/IP/app/token fields. One query driven from household_sharing (plan pinned in a comment).
  • Migration 0041 household_sharing; in backups; purged with the user.

Cast tokens (cast)

  • POST /api/v1/cast/token {library_id, path} -> 201 {token, expires_at} (24 h). A cast token is a tokens kind with a cast_grants row and a parent (sender) token: signing out the sending device ends it. API keys refused (a token cannot create tokens); demo allowed.
  • Accepted only on GET /libraries/{id}/cover and /stream, only as ?token= with no Authorization header, only for its own indexed book (not a nested book in its folder; segment-boundary paths via the shared internal/relpath). Answers carry Cross-Origin-Resource-Policy: cross-origin (the receiver is a gstatic.com app). A receiver's transcode marks the sender's session. Dead tokens pruned on mint and daily. Migration 0042.
  • Web CSP: script-src adds exactly the Cast sender's three gstatic paths (not the origin: gstatic also serves AngularJS).

Tests

Allowed + denied for every rule (other book, Cradle vs Cradle 2, nested book, other library, non-media routes incl. /meta/cover, bearer header, Basic header beside a cast token, pairing token on media, expired, disabled user, revoked scope, sender signed out); household privacy rules incl. no ip/device keys; backups; CSP pinned exactly.

Quality passes

/simplify (cast tokens redesigned from a parallel table into a tokens kind) then /code-review max --fix (13 fixed). Skipped: the per-person cap of 20 can evict a token a receiver still plays from (needs a client re-mint on 401 or a per-device cap: product decision).

Manager: builds/tests clean against this branch (no change).

…) only

The web player loads cast_sender.js and the Cast framework from gstatic on
Chromium to cast (capability cast). No other directive changes; the test pins
the whole policy string and checks the console's site-wide CSP stays
same-origin.
…ity household)

Migration 0041 household_sharing (user_id PK, CASCADE; no row = off).
GET/PUT /me/household {share_listening} (strict, field required; demo 403)
and GET /household/listening {sharing, listening[]}: catalog.HouseListening
answers [] unless the caller shares, and otherwise one row per other enabled,
non-demo, sharing person: their newest recorded session with listening in it
within 24 h, dropped when its book is outside the caller's scopes. Rows carry
no device, address, app or token. The chapter-at-position lookup is shared
with LiveSessions (chapterCache).
…ability cast)

Migration 0042 cast_tokens (SHA-256 hash only, user CASCADE, library_id +
rel_path, fixed-width ms expiry). POST /cast/token {library_id, path} -> 201
{token, expires_at}: resolved and scope-checked like the media routes (a part
path stores its book's path), 24 h life, expired rows purged and the newest 20
per person kept on mint; demo may mint, an API key may not.

Accepted only as the ?token= of GET /libraries/{id}/cover and /stream
(requireBookMediaAuth), never with an Authorization header and never on any
other route (/meta/cover included), only while unexpired and its user enabled,
and only for the token's book or a file inside it on a segment boundary
(auth.CastGrant.Covers); the handler then checks the person's current scope.
A cast-authenticated answer sets CORP cross-origin so the receiver can load
it; the media limiter keys cast tokens apart; a cast transcode does not mark a
device's session. Backup test for 0041/0042.
…next) cast tokens

catalog's pathAllowedBy moves to its own tiny package so auth can apply the
same rule to a cast token's book without importing catalog's whole graph.
0042 (unreleased, rewritten in place) drops the parallel cast_tokens store: a
cast token is a tokens row of kind cast (hashed, expires_at 24 h) with a
cast_grants row naming its book and its parent, the token that minted it.
lookupToken stays the one validity check and also refuses a cast token whose
parent is signed out or expired, so logging out the sending device now ends its
cast tokens (the old known limit).

auth owns the whole cast decision: ResolveCastRequest accepts a cast token only
when its grant covers the requested path (relpath.Within, empty guards kept);
ResolveRequest never does. authenticate takes the accepted kinds and does one
lookup; the cast path only for ?token= with no Authorization header on a
book's cover/stream. CORP cross-origin is set in one place from the credential
kind, the media limiter keys every credential by its token id (mediaBucket
gone), and Credential.DeviceID (the parent for a cast token) lets a cast
transcode mark the sender's session without a branch. The wire is unchanged.
…n-sharer's poll loads no scopes

The handler asks HouseholdSharing first and answers {false, []} before any
scope query. catalog.HouseListening is one query from the caller's own sharing
row (still reciprocal by itself) into each other sharer's newest session via
idx_sessions_user_last, joining books only for those winners (CROSS JOIN pins
the order; the verified EXPLAIN QUERY PLAN is in the comment), with NULLIF in
place of the shadow fields. The wire-only envelopes move to the handler, the
HousePlaying/HousePaused aliases go (SessionPlaying/SessionPaused), houseWindow
is unexported, and the catalog tests run on the session fixture so the indexed
book's title, author, length and chapter are covered. The wire is unchanged.
…he Cast sender's paths; dead cast tokens pruned

- A cast token's path must belong to its own indexed book (no nested books in its folder).
- script-src lists only the Cast sender's three gstatic paths, never the bare origin.
- PruneCastTokens deletes expired, grantless or sender-signed-out cast tokens on mint and in daily retention.
- relpath.Clean shared by catalog and auth; ResolveCastRequest cleans the path itself.
- lookupToken back to one two-table query; the cast grant is read only for cast tokens.
- recordHeartbeat keys transcode marks on DeviceID.
- Tests: pairing token refused on media, Basic header beside a cast token refused, cast covers 200 + CORP, a receiver's transcode marks the sender, nested books refused.
- CLAUDE.md and comments match the wiring.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant