6.0 KiB
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 clearingpb_active; a kid switch shadows (but does not delete) a parent session; resolving order ispb_activefirst, thenpb_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 }:
- Request must carry
pb_token_<userId>(the profile must be on this device). No session required — the picker is reachable with no active user. - 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).
- In-memory rate limit: 5 fails → 30s lockout per userId (3-digit space).
- Refresh the device cookie's token; if expired, re-auth with the derived password (heals sessions on switch).
- 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.mdsettled 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 onpagehide— 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/settingswould force PIN mode on every device including a parent's phone.localStorage['fam_shared_device']is the source of truth; a plainfam_shared_device=1cookie 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
/switchredirect unchanged — the picker is harmless on - non-shared devices and the server can't do better without the cookie.)
- Profile switcher (
- PIN reminders on enable (
GET /api/pins/status, set/unset only — values never leave the server except via the settingsrevealPinaction):- 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:
- If the device already has other child sessions → PIN setup (required).
- 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).
- 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".