Ctrl K
ComunidadGuías…

proposals/2026-08-31-per-person-folders.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Per-person folders: choose what each person can hear

**Goal** - a library owner narrows one person to chosen folders: the kids hear the kids'
folder, a friend hears the shared drive, and neither is told there is more. Ported from
PearCinema's proven design (its proposal 2026-08-30, PR #225/#230/#231, 28/28 checks on
real hardware), reshaped for music.

**Tier** - T3. It adds a rule to the authorisation surface, and a rule that lets one
track through by mistake is a security bug of the same class as the two inherited ones
in CLAUDE.md.

## Why

A grant today is all or nothing. `grant.paths` has been a reserved null on every grant
since the store was designed, "so library-subset scopes are a value change, not a schema
migration" (host/grants.js) - this fills it. Folders are the shape that is always
answerable (every file has a path) and the shape libraries are actually organised in
(an artist tree, a Kids folder, an audiobooks drive).

## What it looks like

On the dashboard's People page, each person gets one line under their name:

    Can hear: everything

Tapping it opens a tree of the folder source's roots and the folders under them, to any
depth, opening on demand, with ticks. A ticked folder covers everything beneath it.
"Everything" is the default and the state of every existing grant. The owner's own
devices are never filtered and never get the control.

A pairing window carries the same choice, and the panel ASKS - everything or chosen
folders, neither preselected, no code until answered. Both decisions are Tim's from the
PearCinema round (any depth tickable; "Can see: everything is a line somebody has to
notice and disagree with"), adopted here rather than re-asked.

The phone sees a smaller library and nothing else. Favorites, resume points and
playlists referencing hidden tracks are filtered on the way out, never deleted, so a
widening brings them back.

## Where the rule lives

`grant.paths`: null (everything, as today) or a list of `{ root, rel }` prefixes, where
`root` is the folder root's absolute path as configured and `rel` a normalised
forward-slash prefix under it. Every device of a person carries the same value, like
scope. The store stays host-local and never replicated.

The check is one module, `host/visibility.js`, ported nearly verbatim:

    visibleTo(grant, loc) -> boolean        loc = { root, rel } or null
    viewOf(adapter, grant) -> adapter view  (the real adapter when not narrowed)

Module-level functions, not host methods - PearCinema's rule, because detached callers
(the cast token fetch) must be able to ask without holding the host. Three deliberate
rules, easy to get backwards:

- An OWNER is never filtered, whatever the row says.
- An item the adapter cannot place is HIDDEN from a narrowed grant. Failing open would
  make "narrow" mean "narrow, except whatever we could not place".
- An unnarrowed grant gets the real adapter back, so the common case costs nothing.

## Music is derived entities, and that is the one new design piece

PearCinema filters file-shaped items. Music adds albums, artists and genres that only
exist as groupings, so:

- A TRACK is visible when its path falls under a granted prefix.
- An ALBUM, ARTIST or GENRE is visible when AT LEAST ONE of its tracks is, and its
  wire row (songCount, trackIds, albums list) is recomputed from the visible subset -
  a "12 songs" album showing 3 is the honest count.

The folder adapter can answer this for free: it holds the whole catalog in memory, every
track carries its root-relative path, and every album/artist/genre row carries trackIds.
`locationOf` is a map lookup; group visibility is a membership scan over in-memory
arrays, memoized per (grant.paths, scannedAt).

## The chokepoint

Every wire method that hands out content reads the adapter through `viewOf`, built PER
DISPATCH from the connection's LIVE grant snapshot - the same snapshot #402's `setGrant`
refreshes, so a narrowing lands on open connections with no reconnect, exactly like a
reassignment. Methods covered: library.stats/list/get/search, art.get, media.stream,
and the state lists (fav.list, resume.latest/get, count.top, playlist.get) filter hidden
ids on the way out.

Two paths need their own care, found in the adapter survey:

- **art.get and media.stream do not resolve through get()**. The folder adapter's
  stream is a direct map lookup and its art treats coverId as an album id - both get an
  explicit visibility check in the view, so a hidden track is not one guessed id away.
- **The speaker path is not a HyperDHT connection.** The cast token fetch
  (host/cast.js) already re-reads the grant live per fetch; it asks `visibleTo` there
  too, and voice resolution resolves through the view. As with revoke: a cast already
  playing is not stopped by a narrowing - narrowing is not revoke.

## Sources that cannot enforce it fail CLOSED

The Subsonic adapter is a pure proxy: it discards the upstream path field, its cursors
are upstream offsets and its art passes raw upstream ids through. It cannot enforce a
prefix. So in v1:

- Narrowing is only OFFERED (dashboard and pairing) while the active source is the
  folder adapter, and `setPersonPaths` refuses otherwise, saying why.
- If a narrowing EXISTS and the operator then swaps to a source that cannot enforce it,
  the narrowed person is served an EMPTY library and the dashboard says so on the
  People page. Hidden-except-what-we-cannot-place is not on the table.

Jellyfin reports a real per-item path, so it is the designed-in follow-up: the same
`{ root, rel }` shape with Jellyfin's libraries as roots, behind the same seam. Not v1.

## The phone

Nothing on the wire changes shape - a filtered list is a list. Two touches ride #402's
plumbing:

- `setPersonPaths` refreshes live handles and pushes `grant:changed` to each device.
- On `grant:changed` the worklet also rebuilds the merged index, not just tells the UI -
  PearCinema's follow-on lesson: without the rebuild "the films could not be opened,
  but the list lied".

Downloads already on the phone keep playing (the person was allowed the track when they
took it); the People page says so in one line when a narrowing is saved. Same v1 answer
as PearCinema, same recorded reason: the host cannot reach an offline phone, and a rule
enforced "sometimes" reads as a bug.

## Dashboard plumbing

- `GET/POST /api/sharing/folders` - the folder tree, one level per call to any depth
  (a big library cannot ship as one payload). Backed by two new folder-adapter methods,
  `rootsForSharing()` and `foldersUnder({ root, rel })`.
- `POST /api/sharing/set { personId, paths }` - through the host (`setPersonPaths`),
  which writes every live grant of the person, refreshes handles and pushes.
- `startPairing({ paths })` stamps the choice on the grant at pair time, so a person
  let in narrowly never has a wide window.

## Compat and rollback

- `paths` is already on every row as null. Old hosts ignore it; old phones never see
  it; the wire is unchanged.
- Rollback: set paths back to null (the People page's "everything") or revert the host;
  the check returns true and the host is exactly today's. No migration either way.

## Verify

Unit: the visibleTo/under/normalRel truth tables; group visibility (album with one
visible track shows, with zero hides, counts recomputed); unplaceable item hidden;
owner never filtered; view stats match filtered lists; setPersonPaths refuses on a
non-folder source. Integration over the DHT testnet: two paired devices, one narrowed -
list/get/search/stats/art/stream all refuse hidden items for the narrowed one and not
the other; narrowing lands mid-connection without reconnect; widening restores; revoke
still cuts a narrowed device within a second. Hardware: TCL against a real multi-folder
library - narrowed browse, hidden track unplayable, favorites of hidden tracks absent
and back after widening.

## Open questions

1. The wire currently sends each track's root-relative `path` to clients. Narrowed
   clients only ever receive visible tracks, so nothing hidden leaks - but the paths
   themselves reveal folder names the person was granted. Fine for v1?
2. Genres: a narrowed person's genre list shrinks to genres with visible tracks. A
   genre NAMED like a hidden folder ("Audiobooks") can still vanish confusingly. Accept
   for v1.