Direct user request 2026-05-30: "we need to change the multi session login on nix cafe,
we can login on the same account on multiple device, but NOT for the POS."
Part A lifts R8.2 (drops the JWT sid vs tenant_users.active_session_token
check in auth()) so web logins are multi-device. Part B extends R8.4
from PIN-only to all POS actors via a new
tenant_users.active_pos_session_token column +
unified lib/db/active_pos_session.ts DAO — Manager Override
unlocks now kick prior terminals where the same user is active. Part C
adds a KickBanner naming the kicker on the lock screen.
lib/auth.ts. Backend still mints the sid claim on login (dormant; no cafe-side read). tenant_users.active_session_token column kept for back-compat.lib/db/active_pos_session.ts unified DAO dispatching on actorKind: 'pin' → commerce.pin_identities.active_session_token; 'user' → tenant_users.active_pos_session_token. readActiveCashier now performs the sid match for both kinds via the unified DAO.unlockRegisterAsUserAction (Pro manager override), openStarterShift (Starter in-app open), ensureStarterCashierCookie (Starter cookie restore). Impersonation sessions skip the mint so admins don't kick real users.peekKickReason() helper re-reads the cookie + DB and returns the kicker's display name when mismatched. Lock screen pages call it server-side; kickedActorName prop flows through both Pro fullscreen and Starter lockable shells into LockScreen. KickBanner client component renders the amber banner "Logged out — {name} unlocked the POS on another device just now. Pick a cashier to continue on this terminal." + fires-and-forgets clearStaleActiveCashierCookieAction on mount so a refresh doesn't keep flashing it.| ✓ | Pre-flight: lumiere owner has POS PIN + active register resolves |
| ✓ | Pre-flight: force-close any pre-existing open session; zero owner POS sid |
| ✓ | Web multi-device login: terminal A reaches /cafe/dashboard after login |
| ✓ | Load-bearing R8.2-lifted assertion: terminal B logs in fresh + reaches /cafe/dashboard + terminal A is STILL authenticated (no kick) |
| ✓ | POS unlock: terminal A as Manager (active-cashier cookie set) |
| ✓ | DB: tenant_users.active_pos_session_token populated after terminal A unlock |
| ✓ | POS unlock: terminal B unlocks same register as Manager (kicks terminal A) |
| ✓ | DB: active_pos_session_token rotated by terminal B's unlock (sid differs) |
| ✓ | Kick UX: terminal A refreshes register URL → lock screen with pos-kick-banner visible naming "Sokha Lim"; banner copy contains "another device" |
| ✓ | Banner-mount useEffect clears the stale cookie → next refresh shows no banner |
| ✓ | No HTTP 5xx during the U16 flow on either terminal |
| ✓ | Cleanup: force-close test sessions + zero owner POS sid |
project_hyperdrive_cache_stale_reads.018a79b: wrapped both SELECT paths in getActivePosSessionToken (pin + user) in db.transaction(async tx => tx.select()...) — bypasses Hyperdrive's read cache so kicks land within seconds.active_pos_session_token) not reuse of active_session_token. Stale R8.2 values (from pre-U16 logins) can't accidentally match or mismatch a U16 POS sid.active_pos_session_token stays untouched during impersonation-driven unlocks./cafe/pos page doesn't render KickBanner — kicks there silently rotate cookies (user sees no banner). Acceptable for v1; the lockable terminal (the obvious "POS terminal" device shape) sees the banner.nix_manager_override from Item #3l) is OUT of scope. It's a transient backend-access override, not a persistent POS session.| test-phase1-prod.mjs | 11/11 |
| test-phase2-sso-outdoor-prod.mjs | 6/6 |
| test-m1-prod.mjs | 10/10 |
| test-r7-prod.mjs | 14/14 |
| test-r8-prod.mjs | 4/4 |
| test-phase2-cafe-multishop-prod.mjs | 6/6 |
feedback_phase2_cafe_multishop_solo_retry.