Ctrl K
ComunidadGuías…

proposals/2026-08-31-grant-fixes-trio.md

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

Archivo original ↗Preguntar a KiBot
# Three grant fixes ported from the shared host package

**Goal** - three behaviors PearCinema already fixed in `../peerloom-host` land in PearTune's
host: a dashboard rename stops rewriting what a phone calls itself, a revoked device is told
so once instead of knocking forever, and a grant change reaches a connection that is already
open instead of waiting for a reconnect.

**Tier** - T3 for the goodbye (it changes what the auth gate says to a refused peer) and T2
for the other two (a new persisted field, a new push kind). One proposal because they ship
together as one port and share the rollback story.

## Where this comes from

PearCinema extracted its host core into `../peerloom-host` and fixed all three there, each
with hardware verification (peerloom-host #10, #12, #13; pearcinema #126, #164/#165, #226).
The confirmedUser commit says outright that PearTune has the same overwrite bug. PearTune
does not consume the package, so the fixes are ported by hand into `host/`, keeping
PearTune's own store and server code. Adopting the package wholesale is a separate, larger
decision and is not this proposal.

## Fix 1 - the operator's label is not the device's name (T2)

**The bug, in PearTune today.** `host/grants.js` `renamePerson()` loops every live grant of
that person and rewrites `claimedUser` to the new name. `claimedUser` is the name the DEVICE
declared for itself; the dashboard compares it with the person's name to decide whether a
claim is confirmed. So an operator fixing a typo silently rewrites what somebody's own phone
calls them, in the field the phone set.

**The fix.** Record confirmation instead of deriving it:

- New persisted field on a grant: `confirmedUser` (string or null). Null means "never
  confirmed", which is exactly what every existing row already says, so there is no
  migration and no flag day.
- A pure `confirmedClaim(grant, person)` rule answers "does this claim match this person"
  in one place. The dashboard and the store both use it.
- `renamePerson()` backfills BEFORE renaming: any live grant whose claim matches the OLD
  name gets `confirmedUser = claimedUser` written down, then the person is renamed and no
  grant is touched again. The comparison happens while the old name still exists to compare
  against; afterwards nothing can derive it.
- `settleClaim(deviceKey)`: the dashboard action for the other direction - the device
  renamed itself and the operator wants to accept the new name and leave the device where
  it is. Sets `confirmedUser = claimedUser` on that one grant. This is the case Tim walked
  into on PearCinema: he renamed the TCL from the phone and the dashboard offered nothing
  that worked.
- A grant with `confirmedUser` null falls back to today's name comparison, so behavior on
  existing data is unchanged until a rename or a settle writes the field.

## Fix 2 - say goodbye to a revoked device (T3)

**The bug, in PearTune today.** `_firewall` denies a revoked device at connect time with no
explanation, exactly as it denies a stranger. The phone shows "could not reach the host" and
redials every ~9 seconds forever; the person blames their network. PearCinema watched this
on the TCL and measured six or seven denials a minute.

**Why telling is safe.** Never explaining a refusal is right for a stranger: telling an
attacker why is free intelligence. A device holding a tombstoned grant is not a stranger.
It proved possession of a key this host once granted, so it already knows the library
exists, knows it had access and holds the host key. Saying "not any more" leaks nothing.
`no-grant` stays silent - an unknown key learns nothing, exactly as today.

**The mechanism, ported as is:**

- `host/gate.js` gains `mayBeToldWhy(reason)`: true only for `device-revoked`,
  `person-revoked` and `grant-expired`.
- `_firewall` admits such a device ONCE per key per minute (a rate-limit map capped at 256
  keys, so minted keys cannot grow it without bound). Every other attempt is denied exactly
  as today, so a phone that ignores the goodbye cannot keep the host answering.
- In `_onconnection`, when the media handshake finds no valid grant and a farewell is owed,
  `serveFarewell` opens the media channel with NO method table on it, sends one push
  (`kind: 'access:revoked'`, with the reason), then destroys the connection after 250ms.
  It is a separate function from `serveMedia` on purpose: a `goodbye: true` flag on
  `serveMedia` would be one `if` away from admitting a revoked device to the whole API.
- Worklet (`src/bare.js`): on `access:revoked`, write the verdict TO DISK, stop redialing
  that library and tell the UI. On disk because PearCinema found in-memory forgot it on an
  airplane-mode relaunch. The verdict is cleared by the next dial that lands, because a
  dial landing IS a grant existing - that is also what makes re-pair work without a special
  case. The UI shows it as a standing notice on the library, never as an error line (the
  next successful list of another library wipes error lines).

**The acceptance test changes shape.** Today: the socket does not open. After: one socket
opens, every method on it is refused, the reason is given, the host drops it, and the next
attempt inside the minute is refused outright. Revoke's guarantee is unchanged: nothing NEW
after the cut - the goodbye channel can only say goodbye.

## Fix 3 - a grant change reaches open connections (T2)

**The bug, in PearTune today.** `assign()` (and any other grant edit) writes the store, but
a phone with a live connection keeps its `grant` snapshot until it reconnects - so a device
reassigned mid-song keeps filing resume points and favorites under the old owner for as long
as the connection lives.

**The fix, ported as is:**

- `serveMedia` returns a handle with `setGrant(row)`, which swaps the grant snapshot the
  method table reads. The host remembers handles per device key, pruned on close.
- `assign()` (dashboard and API paths) calls `setGrant` on every live handle for that
  device and pushes `kind: 'grant:changed'` through presence, the same push path
  `devices:changed` already rides.
- Worklet: on `grant:changed`, refresh per-person state (resume, favorites) for that
  library. PearCinema's follow-on lesson applies here: the phone must actually rebuild what
  it derived from the old grant, or the UI keeps drawing a stale picture.

## Compatibility and rollback

- Old phone, new host: `access:revoked` and `grant:changed` are pushes with new kinds; the
  worklet's dispatcher ignores kinds it does not know, so an old app sees today's behavior
  (silence and reconnect-to-refresh). No wire method changes shape.
- New phone, old host: it simply never receives the new kinds. The on-disk revoked verdict
  is only ever written on receipt, so it cannot appear against an old host.
- `confirmedUser` is a new nullable field on rows only this host reads. The grant store is
  host-local and never replicated, so nothing else can see it.
- Rollback: revert the host; the field is ignored by old code, the pushes stop, phones fall
  back to reconnect semantics. The worklet's verdict-clearing rule (any successful dial)
  means a rolled-back host also clears any goodbye a phone recorded.

## Verify

- Unit: `mayBeToldWhy` list is exactly the three reasons; the rate limit admits once per
  key per minute and its map stays capped; `confirmedClaim` truth table;
  `renamePerson` backfills a matching claim and leaves a mismatched one unconfirmed;
  `settleClaim` settles one device only; `setGrant` swaps what the method table reads.
- Hardware, per the canonical smoke: revoke the TCL mid-song from the dashboard - within a
  second reconnect is denied and browse fails, the phone shows the revoked notice once and
  stops knocking, and the notice survives killing and relaunching the app in airplane mode.
  Re-pair the TCL - the notice clears with no extra step. Rename a person on the dashboard
  while their phone is connected - the phone's own name is untouched. Reassign a playing
  device - the next resume point lands under the new person without a reconnect.