SEC-10 (account pre-emption / "pre-hijack"): a password set on an account whose
email was never verified is untrusted — whoever registered the address (to
pre-empt the real owner) could have chosen it. Both the Google-adoption callback
and admin activation (_activate_user_by_email) now drop that pre-set password and
bump the session epoch when the account isn't email_verified, so a later Google
login / admin approval can't hand the pre-set password a working login. No-op for
a verified account (the legit no-SMTP flow). +2 tests.
SEC-11 (link/upgrade bound to the session default): the OAuth link/upgrade GETs
resolved to the session DEFAULT account, so a tab acting as a secondary account
would link Google / the YouTube grant to the wrong account. The frontend now
threads the active account through the navigation URL (withActiveAccount ->
?account=<active>), and _complete_link accepts any wallet account, not only the
default, so the identity lands on the account that started the flow.
Machine-baseline harvest (ruff + knip), all tsc/parse-green, no runtime change:
- backend: remove 3 unused imports (channels/playlists/youtube) via ruff; drop the
unused `job` binding in unshare_download (keep the _own_job ownership guard call).
- frontend: remove `export` from 23 internally-used-only symbols (knip "unused
exports") to shrink the public surface; delete 2 genuinely-dead declarations
(PlexBrowseResult — leftover from the removed /browse route; WIDGET_TITLES —
hardcoded English titles superseded by i18n).
Held back for a decision (unused here = possibly-unwired, NOT dead — flagged, not
removed): e2ee.lock()/clearDevice() (security primitives never wired to logout / a
"forget device" feature) and loadDefaultViewFilters (App reimplements it inline — a
DRY issue). See siftlode-ops/CODE-HYGIENE.md.
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
- errorDialog + hints now use createStore (drop their bespoke listener arrays).
- theme/sidebarLayout/notifications/App filters/Playlists plSort use readMerged/
readJSON/writeJSON instead of inline try/JSON.parse/catch.
- Stats, SettingsPanel and App's channel filter/view tabs use usePersistedState
(the 3 sites that reinvented usePersistedTab inline).
- Every siftlode.* key now sourced from the LS registry (no scattered literals).
Replace the leftover 'subfeed' name across logger names + log_config,
frontend localStorage keys, Postgres user/db/volume defaults in the
compose files, .env.example, config.py, backup/restore scripts and the
README. Pure rename; no behavioural change. localStorage keys move from
subfeed.* to siftlode.* (one-time UI-state reset is acceptable).
- /api/me exposes instance-wide google_enabled. The onboarding nudge no longer auto-opens, and
Settings hides the YouTube-access section + the 'Connect Google' option, when the instance has
no Google OAuth configured (e.g. an email+password-only self-host). A linked account still shows
its 'Connected' status.
Login page quietly probes /auth/demo (debounced) as a valid email is
typed/pasted and reloads into the app on a match — no visible button.
Demo sessions default to the whole library, get a one-time shared-account
warning, never see the YouTube-connect onboarding/access UI or sync
actions, and admins get a demo whitelist + reset panel in Settings.
After the clean name/email sign-in, a wizard walks the user through granting
YouTube read (then optionally write) one step at a time, each with a plain
rationale and an up-front heads-up about Google's "unverified app" screen.
The visible step is derived from the granted scopes (can_read/can_write) so the
flow resumes correctly across the full-page consent redirect; it's dismissible
and reopenable from Settings -> Account, which now lists read and write as
separate, individually-grantable access rows.