···7788## [Unreleased]
991010+### Added
1111+- E2EE key-loss warning: one-time modal on first visit to an encrypted document, tailored to whether the user is anonymous, signed in without synced key, or fully backed up. Shield icon in the topbar re-opens the explanation. (#671)
1212+1013### Fixed
1114- Share links: enforce expiry server-side on document, snapshot, and save endpoints. Client surfaces a blocking "link has expired" overlay; owners are never gated. (#673)
1215···5457- Forms: answer piping — `{{Q1}}` placeholders in question labels resolve to prior answers in real-time (#665)
55585659### Fixed
6060+- Enforce share link expiry in backend or remove UI (#673)
5761- Docs: TOC generator no longer double-encodes HTML entities (e.g. `&` → `&`)
58625963## [0.43.0] — 2026-04-15
+2
src/calendar/main.ts
···1111import { importKey } from '../lib/crypto.js';
1212import { EncryptedProvider } from '../lib/provider.js';
1313import { installDocGoneHandler } from '../lib/doc-gone-handler.js';
1414+import { wireKeyWarningForSession } from '../lib/key-warning.js';
1415import { setupTooltips } from '../lib/tooltips.js';
1516import { mountOfflineIndicator } from '../lib/offline-indicator.js';
1617import { createCommandPalette } from '../command-palette.js';
···23302331 if (cryptoKey) {
23312332 const provider = new EncryptedProvider(ydoc, docId, cryptoKey);
23322333 installDocGoneHandler(provider);
23342334+ wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar'));
2333233523342336 provider.on('sync', () => {
23352337 loadEventsFromYjs();
+2
src/diagrams/main.ts
···88import { importKey } from '../lib/crypto.js';
99import { EncryptedProvider } from '../lib/provider.js';
1010import { installDocGoneHandler } from '../lib/doc-gone-handler.js';
1111+import { wireKeyWarningForSession } from '../lib/key-warning.js';
1112import { setupTooltips } from '../lib/tooltips.js';
1213import { mountOfflineIndicator } from '../lib/offline-indicator.js';
1314import {
···381382 if (cryptoKey) {
382383 const provider = new EncryptedProvider(ydoc, docId, cryptoKey);
383384 installDocGoneHandler(provider);
385385+ wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar'));
384386 provider.on('sync', () => {
385387 loadFromYjs();
386388 pushHistory();
+2
src/docs/main.ts
···3636import { ensureWrappingKey } from '../lib/key-passphrase.js';
3737import { EncryptedProvider } from '../lib/provider.js';
3838import { installDocGoneHandler } from '../lib/doc-gone-handler.js';
3939+import { wireKeyWarningForSession } from '../lib/key-warning.js';
3940import { FontSize } from './extensions/font-size.js';
4041import { Indent } from './extensions/indent.js';
4142import { Comment } from './extensions/comment.js';
···139140const ydoc = new Y.Doc();
140141const provider = new EncryptedProvider(ydoc, docId, cryptoKey);
141142installDocGoneHandler(provider);
143143+wireKeyWarningForSession(docId, document.getElementById('collab-avatars'));
142144143145// Wait for snapshot to load before creating the editor — prevents CRDT conflict
144146// where TipTap writes default content that conflicts with loaded data
+2
src/forms/main.ts
···99import { importKey } from '../lib/crypto.js';
1010import { EncryptedProvider } from '../lib/provider.js';
1111import { installDocGoneHandler } from '../lib/doc-gone-handler.js';
1212+import { wireKeyWarningForSession } from '../lib/key-warning.js';
1213import { setupTooltips } from '../lib/tooltips.js';
1314import { mountOfflineIndicator } from '../lib/offline-indicator.js';
1415import { createForm, setTargetSheet, type FormSchema } from './form-builder.js';
···212213 if (cryptoKey) {
213214 const provider = new EncryptedProvider(ydoc, docId, cryptoKey);
214215 installDocGoneHandler(provider);
216216+ wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar'));
215217216218 provider.on('sync', () => {
217219 loadFormFromYjs();
+440
src/lib/key-warning.ts
···11+/**
22+ * #671 — E2EE key-loss warning.
33+ *
44+ * E2EE documents in this app carry their AES-256-GCM key in the URL fragment.
55+ * If the user closes the tab without bookmarking or copying the URL, and the
66+ * key isn't backed up (server-synced via `key-sync.ts` and a Tailscale login),
77+ * the document is permanently unreadable — the server only holds ciphertext.
88+ *
99+ * This module:
1010+ * - Classifies the current risk level for a given doc+user context.
1111+ * - Persists per-doc dismissal of the warning in localStorage.
1212+ * - Renders a one-time modal and a small always-visible shield icon that
1313+ * re-opens the modal.
1414+ */
1515+1616+import type { TailscaleUser } from '../../server/types.js';
1717+1818+export type KeyLossRiskLevel = 'anonymous' | 'at-risk' | 'safe';
1919+2020+export interface RiskClassifyInput {
2121+ user: TailscaleUser | null | undefined;
2222+ hasServerSyncedKey: boolean;
2323+}
2424+2525+/**
2626+ * Decide which variant of the warning this user should see.
2727+ *
2828+ * - `anonymous` — no Tailscale identity; only the URL keeps the key. Full warning.
2929+ * - `at-risk` — has an identity but the key isn't synced yet. Friendly reminder + sync nudge.
3030+ * - `safe` — identity + synced key. Short reassurance line.
3131+ */
3232+export function classifyKeyLossRisk(input: RiskClassifyInput): KeyLossRiskLevel {
3333+ if (!input.user) return 'anonymous';
3434+ if (!input.hasServerSyncedKey) return 'at-risk';
3535+ return 'safe';
3636+}
3737+3838+const SEEN_PREFIX = 'tools-key-warning-seen:';
3939+4040+export function hasSeenKeyWarning(docId: string): boolean {
4141+ if (!docId) return false;
4242+ try {
4343+ return localStorage.getItem(SEEN_PREFIX + docId) === '1';
4444+ } catch {
4545+ return false;
4646+ }
4747+}
4848+4949+export function markKeyWarningSeen(docId: string): void {
5050+ if (!docId) return;
5151+ try {
5252+ localStorage.setItem(SEEN_PREFIX + docId, '1');
5353+ } catch {
5454+ // Storage quota/denied — fine, worst case we re-show next visit.
5555+ }
5656+}
5757+5858+export function resetKeyWarningSeen(docId: string): void {
5959+ if (!docId) return;
6060+ try {
6161+ localStorage.removeItem(SEEN_PREFIX + docId);
6262+ } catch {
6363+ // ignore
6464+ }
6565+}
6666+6767+// ---------- UI ----------
6868+6969+export interface MountKeyWarningOptions {
7070+ docId: string;
7171+ user: TailscaleUser | null | undefined;
7272+ hasServerSyncedKey: boolean;
7373+ /** Skip the one-time check; always open. Used by the shield-icon re-open path. */
7474+ force?: boolean;
7575+ onDismiss?: () => void;
7676+}
7777+7878+function isBrowser(): boolean {
7979+ return typeof document !== 'undefined' && typeof window !== 'undefined';
8080+}
8181+8282+function copyCurrentURL(): Promise<boolean> {
8383+ try {
8484+ const url = window.location.href;
8585+ if (navigator?.clipboard?.writeText) {
8686+ return navigator.clipboard.writeText(url).then(() => true).catch(() => fallbackCopy(url));
8787+ }
8888+ return Promise.resolve(fallbackCopy(url));
8989+ } catch {
9090+ return Promise.resolve(false);
9191+ }
9292+}
9393+9494+function fallbackCopy(text: string): boolean {
9595+ try {
9696+ const ta = document.createElement('textarea');
9797+ ta.value = text;
9898+ ta.style.position = 'fixed';
9999+ ta.style.left = '-9999px';
100100+ document.body.appendChild(ta);
101101+ ta.select();
102102+ const ok = document.execCommand('copy');
103103+ document.body.removeChild(ta);
104104+ return ok;
105105+ } catch {
106106+ return false;
107107+ }
108108+}
109109+110110+interface ModalCopy {
111111+ title: string;
112112+ body: string;
113113+ cta: string;
114114+ tone: 'warn' | 'info';
115115+ showSignInNudge: boolean;
116116+}
117117+118118+function copyForLevel(level: KeyLossRiskLevel): ModalCopy {
119119+ switch (level) {
120120+ case 'anonymous':
121121+ return {
122122+ title: 'Save this link before you close the tab',
123123+ body:
124124+ 'This document is end-to-end encrypted. The decryption key lives in the URL after the "#". ' +
125125+ 'If you lose this URL, nothing — not even the server operator — can recover the document. ' +
126126+ 'Sign in with Tailscale to sync the key across your devices, or copy the link somewhere safe.',
127127+ cta: 'I understand',
128128+ tone: 'warn',
129129+ showSignInNudge: true,
130130+ };
131131+ case 'at-risk':
132132+ return {
133133+ title: 'Back up this document\'s key',
134134+ body:
135135+ 'This document is end-to-end encrypted. We haven\'t backed up the key for your Tailscale account yet. ' +
136136+ 'Keep this tab open until the key syncs, or copy the URL as a manual backup.',
137137+ cta: 'Got it',
138138+ tone: 'warn',
139139+ showSignInNudge: false,
140140+ };
141141+ case 'safe':
142142+ return {
143143+ title: 'Your document is safely encrypted',
144144+ body:
145145+ 'This document is end-to-end encrypted. Its key is backed up to your Tailscale account, ' +
146146+ 'so you can re-open it from any of your devices.',
147147+ cta: 'Close',
148148+ tone: 'info',
149149+ showSignInNudge: false,
150150+ };
151151+ }
152152+}
153153+154154+let activeModal: HTMLElement | null = null;
155155+156156+function renderModal(docId: string, level: KeyLossRiskLevel, opts: MountKeyWarningOptions): void {
157157+ if (!isBrowser()) return;
158158+ if (activeModal) return; // dedupe
159159+160160+ const copy = copyForLevel(level);
161161+162162+ const overlay = document.createElement('div');
163163+ overlay.className = 'key-warning-overlay';
164164+ overlay.setAttribute('role', 'alertdialog');
165165+ overlay.setAttribute('aria-modal', 'true');
166166+ overlay.setAttribute('aria-labelledby', 'key-warning-title');
167167+ overlay.style.cssText = [
168168+ 'position:fixed',
169169+ 'inset:0',
170170+ 'z-index:10001',
171171+ 'background:rgba(0,0,0,0.45)',
172172+ 'display:flex',
173173+ 'align-items:center',
174174+ 'justify-content:center',
175175+ 'padding:16px',
176176+ 'font-family:system-ui,-apple-system,sans-serif',
177177+ ].join(';');
178178+179179+ const card = document.createElement('div');
180180+ card.style.cssText = [
181181+ 'background:var(--bg,#fff)',
182182+ 'color:var(--fg,#111)',
183183+ 'max-width:480px',
184184+ 'width:100%',
185185+ 'padding:24px',
186186+ 'border-radius:12px',
187187+ 'box-shadow:0 16px 40px rgba(0,0,0,0.25)',
188188+ ].join(';');
189189+190190+ const title = document.createElement('h2');
191191+ title.id = 'key-warning-title';
192192+ title.textContent = copy.title;
193193+ title.style.cssText = [
194194+ 'margin:0 0 12px',
195195+ 'font-size:18px',
196196+ 'font-weight:600',
197197+ 'display:flex',
198198+ 'align-items:center',
199199+ 'gap:8px',
200200+ ].join(';');
201201+202202+ const iconSpan = document.createElement('span');
203203+ iconSpan.setAttribute('aria-hidden', 'true');
204204+ iconSpan.textContent = copy.tone === 'warn' ? '\u26A0\uFE0F' : '\uD83D\uDD12';
205205+ title.prepend(iconSpan);
206206+207207+ const body = document.createElement('p');
208208+ body.textContent = copy.body;
209209+ body.style.cssText = 'margin:0 0 20px;line-height:1.55;opacity:0.9;';
210210+211211+ const actions = document.createElement('div');
212212+ actions.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;';
213213+214214+ const copyBtn = document.createElement('button');
215215+ copyBtn.type = 'button';
216216+ copyBtn.textContent = 'Copy link';
217217+ copyBtn.style.cssText = buttonSecondaryStyle();
218218+ copyBtn.addEventListener('click', () => {
219219+ void copyCurrentURL().then(ok => {
220220+ copyBtn.textContent = ok ? 'Copied!' : 'Copy failed';
221221+ window.setTimeout(() => { copyBtn.textContent = 'Copy link'; }, 1500);
222222+ });
223223+ });
224224+ actions.appendChild(copyBtn);
225225+226226+ if (copy.showSignInNudge) {
227227+ const signInBtn = document.createElement('a');
228228+ signInBtn.href = 'https://tailscale.com/kb/1308/funnel-internal-services';
229229+ signInBtn.target = '_blank';
230230+ signInBtn.rel = 'noopener noreferrer';
231231+ signInBtn.textContent = 'Sign in with Tailscale';
232232+ signInBtn.style.cssText = buttonSecondaryStyle() + ';text-decoration:none;display:inline-flex;align-items:center;';
233233+ actions.appendChild(signInBtn);
234234+ }
235235+236236+ const okBtn = document.createElement('button');
237237+ okBtn.type = 'button';
238238+ okBtn.textContent = copy.cta;
239239+ okBtn.style.cssText = buttonPrimaryStyle();
240240+ okBtn.addEventListener('click', () => {
241241+ markKeyWarningSeen(docId);
242242+ closeModal(overlay);
243243+ opts.onDismiss?.();
244244+ });
245245+ actions.appendChild(okBtn);
246246+247247+ card.append(title, body, actions);
248248+ overlay.appendChild(card);
249249+250250+ // Dismiss on backdrop click (but not on card click)
251251+ overlay.addEventListener('click', (e) => {
252252+ if (e.target === overlay) {
253253+ markKeyWarningSeen(docId);
254254+ closeModal(overlay);
255255+ opts.onDismiss?.();
256256+ }
257257+ });
258258+259259+ // Dismiss on Escape
260260+ const escHandler = (e: KeyboardEvent): void => {
261261+ if (e.key === 'Escape') {
262262+ markKeyWarningSeen(docId);
263263+ closeModal(overlay);
264264+ document.removeEventListener('keydown', escHandler);
265265+ opts.onDismiss?.();
266266+ }
267267+ };
268268+ document.addEventListener('keydown', escHandler);
269269+270270+ document.body.appendChild(overlay);
271271+ activeModal = overlay;
272272+ // Focus the primary action for keyboard users.
273273+ window.setTimeout(() => okBtn.focus(), 0);
274274+}
275275+276276+function closeModal(overlay: HTMLElement): void {
277277+ overlay.remove();
278278+ if (activeModal === overlay) activeModal = null;
279279+}
280280+281281+function buttonPrimaryStyle(): string {
282282+ return [
283283+ 'appearance:none',
284284+ 'border:none',
285285+ 'background:var(--accent,#2563eb)',
286286+ 'color:#fff',
287287+ 'padding:10px 18px',
288288+ 'border-radius:8px',
289289+ 'font-size:14px',
290290+ 'cursor:pointer',
291291+ 'font-weight:500',
292292+ ].join(';');
293293+}
294294+295295+function buttonSecondaryStyle(): string {
296296+ return [
297297+ 'appearance:none',
298298+ 'border:1px solid var(--border,#d1d5db)',
299299+ 'background:transparent',
300300+ 'color:inherit',
301301+ 'padding:10px 14px',
302302+ 'border-radius:8px',
303303+ 'font-size:14px',
304304+ 'cursor:pointer',
305305+ ].join(';');
306306+}
307307+308308+/**
309309+ * Show the key-loss warning for the current document, if the user hasn't seen it yet.
310310+ *
311311+ * Safe to call more than once per page; subsequent calls are no-ops unless `force: true`.
312312+ */
313313+export function mountKeyWarning(opts: MountKeyWarningOptions): void {
314314+ if (!isBrowser()) return;
315315+ if (!opts.docId) return;
316316+ if (!opts.force && hasSeenKeyWarning(opts.docId)) return;
317317+ const level = classifyKeyLossRisk({ user: opts.user, hasServerSyncedKey: opts.hasServerSyncedKey });
318318+ renderModal(opts.docId, level, opts);
319319+}
320320+321321+/**
322322+ * Mount a small shield icon in a header/topbar container that re-opens the warning modal.
323323+ *
324324+ * Returns a handle with `update(nextOpts)` so entry points can refresh the
325325+ * tooltip / classification when identity or sync state changes.
326326+ */
327327+export interface KeyShieldHandle {
328328+ element: HTMLElement;
329329+ update(next: Partial<MountKeyWarningOptions>): void;
330330+}
331331+332332+export function mountKeyShieldIcon(
333333+ container: HTMLElement,
334334+ initial: MountKeyWarningOptions,
335335+): KeyShieldHandle {
336336+ let current: MountKeyWarningOptions = { ...initial };
337337+338338+ const btn = document.createElement('button');
339339+ btn.type = 'button';
340340+ btn.className = 'key-warning-shield';
341341+ btn.setAttribute('aria-label', 'About encryption and key safety');
342342+ btn.style.cssText = [
343343+ 'appearance:none',
344344+ 'border:none',
345345+ 'background:transparent',
346346+ 'cursor:pointer',
347347+ 'padding:6px',
348348+ 'border-radius:6px',
349349+ 'color:inherit',
350350+ 'opacity:0.75',
351351+ 'font-size:16px',
352352+ 'line-height:1',
353353+ ].join(';');
354354+ btn.textContent = '\uD83D\uDD12';
355355+356356+ const updateTitle = (): void => {
357357+ const level = classifyKeyLossRisk({
358358+ user: current.user,
359359+ hasServerSyncedKey: current.hasServerSyncedKey,
360360+ });
361361+ const titles: Record<KeyLossRiskLevel, string> = {
362362+ anonymous: 'End-to-end encrypted — save this link to keep access',
363363+ 'at-risk': 'End-to-end encrypted — key not yet backed up',
364364+ safe: 'End-to-end encrypted — key backed up to your account',
365365+ };
366366+ btn.title = titles[level];
367367+ };
368368+ updateTitle();
369369+370370+ btn.addEventListener('click', () => {
371371+ mountKeyWarning({ ...current, force: true });
372372+ });
373373+374374+ container.appendChild(btn);
375375+376376+ return {
377377+ element: btn,
378378+ update(next) {
379379+ current = { ...current, ...next };
380380+ updateTitle();
381381+ },
382382+ };
383383+}
384384+385385+// ---------- Session wiring helper ----------
386386+387387+interface MeResponse {
388388+ login?: string;
389389+ name?: string;
390390+ profilePic?: string | null;
391391+}
392392+393393+async function fetchCurrentUser(): Promise<TailscaleUser | null> {
394394+ try {
395395+ const res = await fetch('/api/me');
396396+ if (!res.ok) return null;
397397+ const data: MeResponse = await res.json();
398398+ if (!data.login) return null;
399399+ return {
400400+ login: data.login,
401401+ name: data.name ?? data.login,
402402+ profilePic: data.profilePic ?? null,
403403+ };
404404+ } catch {
405405+ return null;
406406+ }
407407+}
408408+409409+/**
410410+ * Drop-in helper for editor entry points.
411411+ *
412412+ * - Fetches `/api/me` to determine Tailscale identity.
413413+ * - Checks the server key bundle for `docId` to see if the key is already backed up.
414414+ * - Shows the one-time warning modal (if not previously dismissed for this doc).
415415+ * - Mounts the always-visible shield icon in `shieldContainer` if provided.
416416+ *
417417+ * Called once per editor session; safe to await or fire-and-forget.
418418+ */
419419+export async function wireKeyWarningForSession(
420420+ docId: string,
421421+ shieldContainer?: HTMLElement | null,
422422+): Promise<void> {
423423+ if (!isBrowser() || !docId) return;
424424+ try {
425425+ // Load key-sync lazily to keep this module standalone for testing.
426426+ const { fetchServerKeys } = await import('./key-sync.js');
427427+ const [user, serverKeys] = await Promise.all([
428428+ fetchCurrentUser(),
429429+ fetchServerKeys().catch(() => null),
430430+ ]);
431431+ const hasServerSyncedKey = !!(user && serverKeys && serverKeys[docId]);
432432+433433+ mountKeyWarning({ docId, user, hasServerSyncedKey });
434434+ if (shieldContainer) {
435435+ mountKeyShieldIcon(shieldContainer, { docId, user, hasServerSyncedKey });
436436+ }
437437+ } catch {
438438+ // Never block editor init on warning wiring.
439439+ }
440440+}
+2
src/sheets/session-bootstrap.ts
···44import { ensureWrappingKey } from '../lib/key-passphrase.js';
55import { EncryptedProvider } from '../lib/provider.js';
66import { installDocGoneHandler } from '../lib/doc-gone-handler.js';
77+import { wireKeyWarningForSession } from '../lib/key-warning.js';
7889export interface BootstrapResult {
910 docId: string;
···5556 const ydoc = new Y.Doc();
5657 const provider = new EncryptedProvider(ydoc, docId, cryptoKey);
5758 installDocGoneHandler(provider);
5959+ wireKeyWarningForSession(docId, document.getElementById('collab-avatars'));
5860 await provider.whenReady;
59616062 const ySheets = ydoc.getMap('sheets') as Y.Map<Y.Map<unknown>>;
+2
src/slides/main.ts
···1010import { importKey } from '../lib/crypto.js';
1111import { EncryptedProvider } from '../lib/provider.js';
1212import { installDocGoneHandler } from '../lib/doc-gone-handler.js';
1313+import { wireKeyWarningForSession } from '../lib/key-warning.js';
1314import { setupTooltips } from '../lib/tooltips.js';
1415import { mountOfflineIndicator } from '../lib/offline-indicator.js';
1516import { createDeck, slideCount } from './canvas-engine.js';
···174175 if (state.cryptoKey) {
175176 const provider = new EncryptedProvider(ydoc, state.docId, state.cryptoKey);
176177 installDocGoneHandler(provider);
178178+ wireKeyWarningForSession(state.docId, document.querySelector<HTMLElement>('.app-topbar'));
177179 provider.on('sync', () => {
178180 loadDeckFromYjs();
179181 actions.render();
+86
tests/key-warning.test.ts
···11+// @vitest-environment jsdom
22+/**
33+ * #671 — E2EE key-loss warning logic.
44+ *
55+ * The pure classifier decides which variant of the warning to show, and the
66+ * dismissal-persistence helpers key off the docId. UI construction is tested
77+ * manually in the browser; here we lock down the branching logic.
88+ */
99+1010+import { describe, it, expect, beforeEach } from 'vitest';
1111+import type { TailscaleUser } from '../server/types.js';
1212+1313+// jsdom ships a partial localStorage — stub a full impl so setItem/removeItem/clear all work.
1414+const lsStore: Record<string, string> = {};
1515+const mockLS = {
1616+ getItem: (key: string) => lsStore[key] ?? null,
1717+ setItem: (key: string, val: string) => { lsStore[key] = val; },
1818+ removeItem: (key: string) => { delete lsStore[key]; },
1919+ clear: () => { for (const k of Object.keys(lsStore)) delete lsStore[k]; },
2020+ get length() { return Object.keys(lsStore).length; },
2121+ key: (i: number) => Object.keys(lsStore)[i] ?? null,
2222+};
2323+Object.defineProperty(globalThis, 'localStorage', { value: mockLS, writable: true });
2424+2525+// Import AFTER the localStorage stub so the module picks it up if it caches.
2626+const {
2727+ classifyKeyLossRisk,
2828+ hasSeenKeyWarning,
2929+ markKeyWarningSeen,
3030+ resetKeyWarningSeen,
3131+} = await import('../src/lib/key-warning.js');
3232+3333+const authedUser: TailscaleUser = { login: 'scott@example.com', name: 'Scott', profilePic: null };
3434+3535+describe('classifyKeyLossRisk', () => {
3636+ it('returns "safe" when user is authed and key is synced to server', () => {
3737+ expect(classifyKeyLossRisk({ user: authedUser, hasServerSyncedKey: true })).toBe('safe');
3838+ });
3939+4040+ it('returns "at-risk" when user is authed but key is NOT yet server-synced', () => {
4141+ // They have an identity so we tell them to sync, rather than the full scare copy.
4242+ expect(classifyKeyLossRisk({ user: authedUser, hasServerSyncedKey: false })).toBe('at-risk');
4343+ });
4444+4545+ it('returns "anonymous" when there is no Tailscale identity', () => {
4646+ expect(classifyKeyLossRisk({ user: null, hasServerSyncedKey: false })).toBe('anonymous');
4747+ expect(classifyKeyLossRisk({ user: undefined, hasServerSyncedKey: false })).toBe('anonymous');
4848+ });
4949+5050+ it('anonymous stays "anonymous" even if a server key exists — we cannot trust it without a login', () => {
5151+ expect(classifyKeyLossRisk({ user: null, hasServerSyncedKey: true })).toBe('anonymous');
5252+ });
5353+});
5454+5555+describe('hasSeenKeyWarning / markKeyWarningSeen', () => {
5656+ beforeEach(() => {
5757+ localStorage.clear();
5858+ });
5959+6060+ it('is false before it has been marked', () => {
6161+ expect(hasSeenKeyWarning('doc-a')).toBe(false);
6262+ });
6363+6464+ it('is true after it has been marked', () => {
6565+ markKeyWarningSeen('doc-a');
6666+ expect(hasSeenKeyWarning('doc-a')).toBe(true);
6767+ });
6868+6969+ it('is scoped to docId', () => {
7070+ markKeyWarningSeen('doc-a');
7171+ expect(hasSeenKeyWarning('doc-b')).toBe(false);
7272+ });
7373+7474+ it('reset clears the flag', () => {
7575+ markKeyWarningSeen('doc-a');
7676+ resetKeyWarningSeen('doc-a');
7777+ expect(hasSeenKeyWarning('doc-a')).toBe(false);
7878+ });
7979+8080+ it('tolerates missing docId (defensive)', () => {
8181+ expect(hasSeenKeyWarning('')).toBe(false);
8282+ // mark with empty is a no-op — we don't want collisions on unrelated docs
8383+ markKeyWarningSeen('');
8484+ expect(hasSeenKeyWarning('')).toBe(false);
8585+ });
8686+});