Ctrl K
ComunidadGuías…

proposals/2026-07-16-guest-grants.md

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

Archivo original ↗Preguntar a KiBot
# Guest grants (time-limited access)

**Goal** — let the operator hand out a *temporary* pass: a device that pairs
through a "guest" window gets access that expires on its own after a chosen
duration, with no need to remember to revoke it.

**Tier** — **T2.** It sets a persisted value the auth gate already reads
(`grant.expiresAt`) and adds one new piece of live enforcement (cut connections
at expiry). It does NOT change the wire, the grant-store location (still
host-local, never replicated), or the revoke guarantee. The reserved-null field
means it is a value change, not a schema migration — as the grant-store header
always intended.

Scope for this MVP (confirmed with Tim): **time-limited only.** Library-subset
scopes (the reserved `paths`) are deliberately out — they are a much larger
per-request enforcement job across browse/stream/art, and belong in their own
proposal. Creation flow: **a guest pairing window with a duration** (not editing
expiry on existing devices — that can come later).

---

## What already exists (so this is small)

- `grant:` rows reserve `expiresAt: null` and `paths: null` for exactly this.
- `gate.decide()` **already** denies an expired grant at connect time:
  `if (grant.expiresAt && now > grant.expiresAt) return { allow:false, reason:'grant-expired' }`.
- Pairing (`pair.js`) already mints a grant when the operator has a window open;
  the window IS the operator's consent.
- `Connections.kill(deviceKey)` already destroys a device's live connections
  (built for revoke).

So the only genuinely new behaviour is: **choose a duration when opening a
window**, **stamp `expiresAt` on the grant it mints**, and **cut a guest's live
connection when its grant expires** (because the gate only runs at connect).

## Design

### 1. A guest pairing window carries a duration

`PairSession` gains an optional `expiresMs` (the operator's chosen duration).
The dashboard's pair-start gains a "Guest pass" mode with a duration picker
(presets: 1 day / 7 days / 30 days, plus a custom hours field). A normal
(non-guest) window keeps `expiresMs = null` and mints full, non-expiring grants
exactly as today.

The duration is **operator-set, host-side.** It is never read from the device's
`hello` — a guest must not be able to choose its own expiry. This mirrors the
existing rule "a device may name itself but may not set its own personId".

### 2. Pairing stamps `expiresAt`

`grants.grant()` takes an `expiresAt` param (default `null`). When a device pairs
through a guest window, `pair.js` passes `expiresAt = Date.now() + expiresMs` and
`grantedBy: 'qr-guest'`. Everything else about the grant is identical to a normal
pair.

Re-pairing an already-granted device through a guest window **refreshes** its
expiry (same idempotent path that already updates a label), so "extend the pass"
is just "scan again while a guest window is open". A full window leaves the expiry
alone, so re-pairing a permanent device never accidentally time-limits it.
(Promoting a guest to permanent is a follow-up, under dashboard expiry editing.)

### 3. Expiry cuts live connections (the new enforcement)

`gate.decide()` covers *connect*. But a guest connected five minutes before
expiry would keep streaming until it happened to reconnect — the same gap revoke
has, and the reason `Connections` exists. Time-based expiry has no event to hang a
`kill()` on, so the host runs a **periodic sweep** (every 30 s): for each device
with a live connection, load its grant and, if `decide()` now refuses it
(expired), `connections.kill(deviceKey)`. Bounded lag ≤ 30 s.

Why a sweep and not a per-grant `setTimeout`: it is restart-safe (a timer is lost
on restart; a sweep just runs again), it needs no bookkeeping when grants are
added/removed, and 30 s is fine for a *scheduled* expiry — this is not the
security-urgent, sub-second claw-back that a revoke is (revoke keeps its instant
`kill()` on the revoke event). The sweep also silently covers a grant that
expires while the phone is backgrounded.

### 4. The dashboard shows it

Guest device rows show the expiry: "Guest · expires in 3h", "expires tomorrow",
or "expired" (an expired-but-not-revoked grant renders like a spent pass, and
Delete cleans it up — same as a revoked row). `expiresAt` is already on the grant;
`/api/state` just surfaces it.

### 5. The phone needs no change (for MVP)

An expired guest experiences exactly what a revoked device does — the next
request is denied and the client shows "lost the connection to your library"
(graceful-reconnect, 2026-07-14). Telling the guest "your pass expires in 2h" is
nice but is polish, and it is additive later. MVP is host + dashboard only.

## Security review

- **Grant store stays host-local and never replicated.** Unchanged. ✓
- **Expiry is enforced in TWO places, like revoke:** `decide()` at connect, and
  the sweep for a live connection. Shipping only the first would let a guest
  stream past expiry until reconnect — the exact bug `Connections` was built to
  prevent, now for time instead of revoke.
- **The device cannot set its own expiry.** Duration is fixed by the operator when
  the window opens; the hello carries only a label. Self-declared identity must not
  become authority (2026-07-14 naming rule).
- **Revoke is unchanged and still instant.** A guest can still be revoked early;
  expiry is an *additional* cut, not a replacement.
- **Clock:** expiry uses the host's wall clock (`Date.now()`), same as
  `grantedAt`/`revokedAt`. A host clock jump would move expiry with it; acceptable
  (the host is the authority on time here, as on admission).

## Verify

- Unit: `gate.decide()` expiry branch (already tested — assert it stays green),
  plus the sweep's selection logic (which live devices to kill) as a pure function.
- Hardware (TCL + Umbrel): open a guest window with a **short** custom duration
  (e.g. 2 minutes), pair the TCL through it, confirm it streams; then wait past
  expiry and confirm (a) the live connection is cut within ~30 s (playback stops,
  "lost the connection"), and (b) a reconnect is denied (`grant-expired`). Confirm
  the dashboard row shows the countdown then "expired". Confirm a NORMAL pairing
  still mints a non-expiring grant (unchanged).

## Out of scope (follow-ups)

- Library-subset (`paths`) scopes — their own proposal.
- Editing/extending expiry on an existing device from the dashboard (this MVP
  extends by re-pairing through a guest window).
- A client-facing "guest, expires in X" banner.