Files
famdone/shared-device.md
T
2026-09-14 15:33:07 +01:00

6.0 KiB

Shared-device switching (PIN)

Design decisions for children sharing a family computer/tablet — recorded 2026-09-11. Implements: multi-session cookies, pins collection, profile picker, and the idle lock.

Model

  • The PIN is NOT a login. It selects among sessions that already exist on the device. A PIN alone mints nothing on a fresh device; a stolen cookie alone selects nothing without the PIN. Threat model: sibling mischief on a family device (devtools-level bypass is explicitly accepted — data is family-scoped and actions are reversible).
  • Per-device state lives in cookies:
    • pb_token_<userId> — one httpOnly cookie per child with a session on this device (the "device is logged into both accounts" property).
    • pb_active — which child session is currently active.
    • pb_token — unchanged single session for parents (and legacy children). Parent login supersedes kid mode by clearing pb_active; a kid switch shadows (but does not delete) a parent session; resolving order is pb_active first, then pb_token.
  • Sessions are never the security boundary: the OTP gate still guards each child's first-ever join on a device, and switching re-mints an expired token server-side via the derived password (never exposed to the client).

pins collection

  • Superuser-only rules (like otp); all reads/writes via server endpoints with session-role checks. Plaintext 3-digit PIN — deliberately recoverable so a parent can read it out (Q1: View, not reset). Child changes their own PIN in Preferences (requires current PIN).
  • Fields: famId (rel), userId (rel), pin (text).

Switch flow

POST /api/switch-user { userId, pin }:

  1. Request must carry pb_token_<userId> (the profile must be on this device). No session required — the picker is reachable with no active user.
  2. PIN verified server-side via superuser (children must NOT be able to read each other's pins, so collection rules can't be the check).
  3. In-memory rate limit: 5 fails → 30s lockout per userId (3-digit space).
  4. Refresh the device cookie's token; if expired, re-auth with the derived password (heals sessions on switch).
  5. Set pb_token_<userId> + pb_active; client full-reloads to that child's dashboard URL (/famSlug/handle).

Per-profile removal: POST /api/device/remove { userId } — deletes that cookie (+ pb_active if it was active). No session required (device-local cookie surgery only).

Picker

One component (SharedPicker) used everywhere:

  • TopNav switcher button (always visible when the device has child sessions).
  • Idle-lock return (overlay).
  • /{famSlug}/switch — standalone landing when the device has child sessions but no active session (e.g. after per-profile logout). The fam layout redirects unauthenticated device requests to it. Links: "Add a child with a code" → /{famSlug}/join, "Parent login" → /login.

Every pick (including "resume" as the current kid) requires the PIN.

Idle lock

  • Default 10 min; parent-adjustable in Family Settings: Off / 2 / 10 (settings.lockMins, 0 = off; shared-device.md settled default 10).
  • Client-side only, child sessions only (parent sessions never lock).
  • Only fires when shared-computer mode is ON for the device (see below) — a personal device never locks, even with lockMins set.
  • localStorage['fam_last_active'] updated on click/key/touch (throttled 10s) and force-written on pagehide — survives browser close, sleep and restarts. Next launch compares elapsed time; past the timeout → picker. Live timer (15s interval) does the same mid-session.
  • Resets: join wizard and picker switch write a fresh timestamp before navigating, so freshly-activated sessions don't instantly lock.

Shared-computer toggle (TopNav)

  • Storage: localStorage, NOT the DB. Shared-ness is a property of this browser — a DB flag on fams/settings would force PIN mode on every device including a parent's phone. localStorage['fam_shared_device'] is the source of truth; a plain fam_shared_device=1 cookie mirrors it so server loads see it (data.sharedDevice). Neither is a security boundary — the PIN + device session cookies remain the gate.
  • Location: monitor icon in the TopNav (fam layout children slot, next to the profile switcher). Rendered for any role, even logged out — whoever holds the device can mark it shared. Active state: indigo fill.
  • What it gates:
    • Profile switcher (kid-switch) button — only shown when shared mode is ON and the device holds child sessions.
    • Idle lock — only fires for child sessions on shared devices.
    • (Server /switch redirect unchanged — the picker is harmless on
    • non-shared devices and the server can't do better without the cookie.)
  • PIN reminders on enable (GET /api/pins/status, set/unset only — values never leave the server except via the settings revealPin action):
    • Parent toggling sees the roster: who still needs a PIN (→ Settings → Members) vs who is ready (→ read the PINs out to them).
    • A child toggling sees only their own state: "remember your PIN" or
    • "ask a parent to set one up" (no PIN → picker shows "ask a parent").
  • Client flag helpers: lib/client/shared-device.ts (isSharedDevice / setSharedDevice, writes the cookie mirror too).

Join flow (child)

After OTP redeem:

  1. If the device already has other child sessions → PIN setup (required).
  2. Else ask "Will this computer be shared with a sibling?" — yes → PIN setup; no → skip (PIN can be added later in Preferences; parents can set it in Family Settings).
  3. Land on the child's dashboard.

Parents keep the existing join (set own password, single session).

Edge cases

  • Two tabs share the timestamp → the active tab keeps the other from locking.
  • Tab switches don't lock (kids alt-tab constantly).
  • Expired sessions: the picker still shows the profile; switching re-mints.
  • No PIN set on a profile → picker shows "ask a parent to set it up".