124 lines
6.0 KiB
Markdown
124 lines
6.0 KiB
Markdown
# 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".
|