Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: warn users about E2EE key loss on first visit (#671)' (#390) from fix/671-key-loss-warning into main

scott df741673 3c15359c

+542
+4
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ### Added 11 + - 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) 12 + 10 13 ### Fixed 11 14 - 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) 12 15 ··· 54 57 - Forms: answer piping — `{{Q1}}` placeholders in question labels resolve to prior answers in real-time (#665) 55 58 56 59 ### Fixed 60 + - Enforce share link expiry in backend or remove UI (#673) 57 61 - Docs: TOC generator no longer double-encodes HTML entities (e.g. `&` → `&`) 58 62 59 63 ## [0.43.0] — 2026-04-15
+2
src/calendar/main.ts
··· 11 11 import { importKey } from '../lib/crypto.js'; 12 12 import { EncryptedProvider } from '../lib/provider.js'; 13 13 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 14 + import { wireKeyWarningForSession } from '../lib/key-warning.js'; 14 15 import { setupTooltips } from '../lib/tooltips.js'; 15 16 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 16 17 import { createCommandPalette } from '../command-palette.js'; ··· 2330 2331 if (cryptoKey) { 2331 2332 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 2332 2333 installDocGoneHandler(provider); 2334 + wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar')); 2333 2335 2334 2336 provider.on('sync', () => { 2335 2337 loadEventsFromYjs();
+2
src/diagrams/main.ts
··· 8 8 import { importKey } from '../lib/crypto.js'; 9 9 import { EncryptedProvider } from '../lib/provider.js'; 10 10 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 11 + import { wireKeyWarningForSession } from '../lib/key-warning.js'; 11 12 import { setupTooltips } from '../lib/tooltips.js'; 12 13 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 13 14 import { ··· 381 382 if (cryptoKey) { 382 383 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 383 384 installDocGoneHandler(provider); 385 + wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar')); 384 386 provider.on('sync', () => { 385 387 loadFromYjs(); 386 388 pushHistory();
+2
src/docs/main.ts
··· 36 36 import { ensureWrappingKey } from '../lib/key-passphrase.js'; 37 37 import { EncryptedProvider } from '../lib/provider.js'; 38 38 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 39 + import { wireKeyWarningForSession } from '../lib/key-warning.js'; 39 40 import { FontSize } from './extensions/font-size.js'; 40 41 import { Indent } from './extensions/indent.js'; 41 42 import { Comment } from './extensions/comment.js'; ··· 139 140 const ydoc = new Y.Doc(); 140 141 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 141 142 installDocGoneHandler(provider); 143 + wireKeyWarningForSession(docId, document.getElementById('collab-avatars')); 142 144 143 145 // Wait for snapshot to load before creating the editor — prevents CRDT conflict 144 146 // where TipTap writes default content that conflicts with loaded data
+2
src/forms/main.ts
··· 9 9 import { importKey } from '../lib/crypto.js'; 10 10 import { EncryptedProvider } from '../lib/provider.js'; 11 11 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 12 + import { wireKeyWarningForSession } from '../lib/key-warning.js'; 12 13 import { setupTooltips } from '../lib/tooltips.js'; 13 14 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 14 15 import { createForm, setTargetSheet, type FormSchema } from './form-builder.js'; ··· 212 213 if (cryptoKey) { 213 214 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 214 215 installDocGoneHandler(provider); 216 + wireKeyWarningForSession(docId, document.querySelector<HTMLElement>('.app-topbar')); 215 217 216 218 provider.on('sync', () => { 217 219 loadFormFromYjs();
+440
src/lib/key-warning.ts
··· 1 + /** 2 + * #671 — E2EE key-loss warning. 3 + * 4 + * E2EE documents in this app carry their AES-256-GCM key in the URL fragment. 5 + * If the user closes the tab without bookmarking or copying the URL, and the 6 + * key isn't backed up (server-synced via `key-sync.ts` and a Tailscale login), 7 + * the document is permanently unreadable — the server only holds ciphertext. 8 + * 9 + * This module: 10 + * - Classifies the current risk level for a given doc+user context. 11 + * - Persists per-doc dismissal of the warning in localStorage. 12 + * - Renders a one-time modal and a small always-visible shield icon that 13 + * re-opens the modal. 14 + */ 15 + 16 + import type { TailscaleUser } from '../../server/types.js'; 17 + 18 + export type KeyLossRiskLevel = 'anonymous' | 'at-risk' | 'safe'; 19 + 20 + export interface RiskClassifyInput { 21 + user: TailscaleUser | null | undefined; 22 + hasServerSyncedKey: boolean; 23 + } 24 + 25 + /** 26 + * Decide which variant of the warning this user should see. 27 + * 28 + * - `anonymous` — no Tailscale identity; only the URL keeps the key. Full warning. 29 + * - `at-risk` — has an identity but the key isn't synced yet. Friendly reminder + sync nudge. 30 + * - `safe` — identity + synced key. Short reassurance line. 31 + */ 32 + export function classifyKeyLossRisk(input: RiskClassifyInput): KeyLossRiskLevel { 33 + if (!input.user) return 'anonymous'; 34 + if (!input.hasServerSyncedKey) return 'at-risk'; 35 + return 'safe'; 36 + } 37 + 38 + const SEEN_PREFIX = 'tools-key-warning-seen:'; 39 + 40 + export function hasSeenKeyWarning(docId: string): boolean { 41 + if (!docId) return false; 42 + try { 43 + return localStorage.getItem(SEEN_PREFIX + docId) === '1'; 44 + } catch { 45 + return false; 46 + } 47 + } 48 + 49 + export function markKeyWarningSeen(docId: string): void { 50 + if (!docId) return; 51 + try { 52 + localStorage.setItem(SEEN_PREFIX + docId, '1'); 53 + } catch { 54 + // Storage quota/denied — fine, worst case we re-show next visit. 55 + } 56 + } 57 + 58 + export function resetKeyWarningSeen(docId: string): void { 59 + if (!docId) return; 60 + try { 61 + localStorage.removeItem(SEEN_PREFIX + docId); 62 + } catch { 63 + // ignore 64 + } 65 + } 66 + 67 + // ---------- UI ---------- 68 + 69 + export interface MountKeyWarningOptions { 70 + docId: string; 71 + user: TailscaleUser | null | undefined; 72 + hasServerSyncedKey: boolean; 73 + /** Skip the one-time check; always open. Used by the shield-icon re-open path. */ 74 + force?: boolean; 75 + onDismiss?: () => void; 76 + } 77 + 78 + function isBrowser(): boolean { 79 + return typeof document !== 'undefined' && typeof window !== 'undefined'; 80 + } 81 + 82 + function copyCurrentURL(): Promise<boolean> { 83 + try { 84 + const url = window.location.href; 85 + if (navigator?.clipboard?.writeText) { 86 + return navigator.clipboard.writeText(url).then(() => true).catch(() => fallbackCopy(url)); 87 + } 88 + return Promise.resolve(fallbackCopy(url)); 89 + } catch { 90 + return Promise.resolve(false); 91 + } 92 + } 93 + 94 + function fallbackCopy(text: string): boolean { 95 + try { 96 + const ta = document.createElement('textarea'); 97 + ta.value = text; 98 + ta.style.position = 'fixed'; 99 + ta.style.left = '-9999px'; 100 + document.body.appendChild(ta); 101 + ta.select(); 102 + const ok = document.execCommand('copy'); 103 + document.body.removeChild(ta); 104 + return ok; 105 + } catch { 106 + return false; 107 + } 108 + } 109 + 110 + interface ModalCopy { 111 + title: string; 112 + body: string; 113 + cta: string; 114 + tone: 'warn' | 'info'; 115 + showSignInNudge: boolean; 116 + } 117 + 118 + function copyForLevel(level: KeyLossRiskLevel): ModalCopy { 119 + switch (level) { 120 + case 'anonymous': 121 + return { 122 + title: 'Save this link before you close the tab', 123 + body: 124 + 'This document is end-to-end encrypted. The decryption key lives in the URL after the "#". ' + 125 + 'If you lose this URL, nothing — not even the server operator — can recover the document. ' + 126 + 'Sign in with Tailscale to sync the key across your devices, or copy the link somewhere safe.', 127 + cta: 'I understand', 128 + tone: 'warn', 129 + showSignInNudge: true, 130 + }; 131 + case 'at-risk': 132 + return { 133 + title: 'Back up this document\'s key', 134 + body: 135 + 'This document is end-to-end encrypted. We haven\'t backed up the key for your Tailscale account yet. ' + 136 + 'Keep this tab open until the key syncs, or copy the URL as a manual backup.', 137 + cta: 'Got it', 138 + tone: 'warn', 139 + showSignInNudge: false, 140 + }; 141 + case 'safe': 142 + return { 143 + title: 'Your document is safely encrypted', 144 + body: 145 + 'This document is end-to-end encrypted. Its key is backed up to your Tailscale account, ' + 146 + 'so you can re-open it from any of your devices.', 147 + cta: 'Close', 148 + tone: 'info', 149 + showSignInNudge: false, 150 + }; 151 + } 152 + } 153 + 154 + let activeModal: HTMLElement | null = null; 155 + 156 + function renderModal(docId: string, level: KeyLossRiskLevel, opts: MountKeyWarningOptions): void { 157 + if (!isBrowser()) return; 158 + if (activeModal) return; // dedupe 159 + 160 + const copy = copyForLevel(level); 161 + 162 + const overlay = document.createElement('div'); 163 + overlay.className = 'key-warning-overlay'; 164 + overlay.setAttribute('role', 'alertdialog'); 165 + overlay.setAttribute('aria-modal', 'true'); 166 + overlay.setAttribute('aria-labelledby', 'key-warning-title'); 167 + overlay.style.cssText = [ 168 + 'position:fixed', 169 + 'inset:0', 170 + 'z-index:10001', 171 + 'background:rgba(0,0,0,0.45)', 172 + 'display:flex', 173 + 'align-items:center', 174 + 'justify-content:center', 175 + 'padding:16px', 176 + 'font-family:system-ui,-apple-system,sans-serif', 177 + ].join(';'); 178 + 179 + const card = document.createElement('div'); 180 + card.style.cssText = [ 181 + 'background:var(--bg,#fff)', 182 + 'color:var(--fg,#111)', 183 + 'max-width:480px', 184 + 'width:100%', 185 + 'padding:24px', 186 + 'border-radius:12px', 187 + 'box-shadow:0 16px 40px rgba(0,0,0,0.25)', 188 + ].join(';'); 189 + 190 + const title = document.createElement('h2'); 191 + title.id = 'key-warning-title'; 192 + title.textContent = copy.title; 193 + title.style.cssText = [ 194 + 'margin:0 0 12px', 195 + 'font-size:18px', 196 + 'font-weight:600', 197 + 'display:flex', 198 + 'align-items:center', 199 + 'gap:8px', 200 + ].join(';'); 201 + 202 + const iconSpan = document.createElement('span'); 203 + iconSpan.setAttribute('aria-hidden', 'true'); 204 + iconSpan.textContent = copy.tone === 'warn' ? '\u26A0\uFE0F' : '\uD83D\uDD12'; 205 + title.prepend(iconSpan); 206 + 207 + const body = document.createElement('p'); 208 + body.textContent = copy.body; 209 + body.style.cssText = 'margin:0 0 20px;line-height:1.55;opacity:0.9;'; 210 + 211 + const actions = document.createElement('div'); 212 + actions.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;'; 213 + 214 + const copyBtn = document.createElement('button'); 215 + copyBtn.type = 'button'; 216 + copyBtn.textContent = 'Copy link'; 217 + copyBtn.style.cssText = buttonSecondaryStyle(); 218 + copyBtn.addEventListener('click', () => { 219 + void copyCurrentURL().then(ok => { 220 + copyBtn.textContent = ok ? 'Copied!' : 'Copy failed'; 221 + window.setTimeout(() => { copyBtn.textContent = 'Copy link'; }, 1500); 222 + }); 223 + }); 224 + actions.appendChild(copyBtn); 225 + 226 + if (copy.showSignInNudge) { 227 + const signInBtn = document.createElement('a'); 228 + signInBtn.href = 'https://tailscale.com/kb/1308/funnel-internal-services'; 229 + signInBtn.target = '_blank'; 230 + signInBtn.rel = 'noopener noreferrer'; 231 + signInBtn.textContent = 'Sign in with Tailscale'; 232 + signInBtn.style.cssText = buttonSecondaryStyle() + ';text-decoration:none;display:inline-flex;align-items:center;'; 233 + actions.appendChild(signInBtn); 234 + } 235 + 236 + const okBtn = document.createElement('button'); 237 + okBtn.type = 'button'; 238 + okBtn.textContent = copy.cta; 239 + okBtn.style.cssText = buttonPrimaryStyle(); 240 + okBtn.addEventListener('click', () => { 241 + markKeyWarningSeen(docId); 242 + closeModal(overlay); 243 + opts.onDismiss?.(); 244 + }); 245 + actions.appendChild(okBtn); 246 + 247 + card.append(title, body, actions); 248 + overlay.appendChild(card); 249 + 250 + // Dismiss on backdrop click (but not on card click) 251 + overlay.addEventListener('click', (e) => { 252 + if (e.target === overlay) { 253 + markKeyWarningSeen(docId); 254 + closeModal(overlay); 255 + opts.onDismiss?.(); 256 + } 257 + }); 258 + 259 + // Dismiss on Escape 260 + const escHandler = (e: KeyboardEvent): void => { 261 + if (e.key === 'Escape') { 262 + markKeyWarningSeen(docId); 263 + closeModal(overlay); 264 + document.removeEventListener('keydown', escHandler); 265 + opts.onDismiss?.(); 266 + } 267 + }; 268 + document.addEventListener('keydown', escHandler); 269 + 270 + document.body.appendChild(overlay); 271 + activeModal = overlay; 272 + // Focus the primary action for keyboard users. 273 + window.setTimeout(() => okBtn.focus(), 0); 274 + } 275 + 276 + function closeModal(overlay: HTMLElement): void { 277 + overlay.remove(); 278 + if (activeModal === overlay) activeModal = null; 279 + } 280 + 281 + function buttonPrimaryStyle(): string { 282 + return [ 283 + 'appearance:none', 284 + 'border:none', 285 + 'background:var(--accent,#2563eb)', 286 + 'color:#fff', 287 + 'padding:10px 18px', 288 + 'border-radius:8px', 289 + 'font-size:14px', 290 + 'cursor:pointer', 291 + 'font-weight:500', 292 + ].join(';'); 293 + } 294 + 295 + function buttonSecondaryStyle(): string { 296 + return [ 297 + 'appearance:none', 298 + 'border:1px solid var(--border,#d1d5db)', 299 + 'background:transparent', 300 + 'color:inherit', 301 + 'padding:10px 14px', 302 + 'border-radius:8px', 303 + 'font-size:14px', 304 + 'cursor:pointer', 305 + ].join(';'); 306 + } 307 + 308 + /** 309 + * Show the key-loss warning for the current document, if the user hasn't seen it yet. 310 + * 311 + * Safe to call more than once per page; subsequent calls are no-ops unless `force: true`. 312 + */ 313 + export function mountKeyWarning(opts: MountKeyWarningOptions): void { 314 + if (!isBrowser()) return; 315 + if (!opts.docId) return; 316 + if (!opts.force && hasSeenKeyWarning(opts.docId)) return; 317 + const level = classifyKeyLossRisk({ user: opts.user, hasServerSyncedKey: opts.hasServerSyncedKey }); 318 + renderModal(opts.docId, level, opts); 319 + } 320 + 321 + /** 322 + * Mount a small shield icon in a header/topbar container that re-opens the warning modal. 323 + * 324 + * Returns a handle with `update(nextOpts)` so entry points can refresh the 325 + * tooltip / classification when identity or sync state changes. 326 + */ 327 + export interface KeyShieldHandle { 328 + element: HTMLElement; 329 + update(next: Partial<MountKeyWarningOptions>): void; 330 + } 331 + 332 + export function mountKeyShieldIcon( 333 + container: HTMLElement, 334 + initial: MountKeyWarningOptions, 335 + ): KeyShieldHandle { 336 + let current: MountKeyWarningOptions = { ...initial }; 337 + 338 + const btn = document.createElement('button'); 339 + btn.type = 'button'; 340 + btn.className = 'key-warning-shield'; 341 + btn.setAttribute('aria-label', 'About encryption and key safety'); 342 + btn.style.cssText = [ 343 + 'appearance:none', 344 + 'border:none', 345 + 'background:transparent', 346 + 'cursor:pointer', 347 + 'padding:6px', 348 + 'border-radius:6px', 349 + 'color:inherit', 350 + 'opacity:0.75', 351 + 'font-size:16px', 352 + 'line-height:1', 353 + ].join(';'); 354 + btn.textContent = '\uD83D\uDD12'; 355 + 356 + const updateTitle = (): void => { 357 + const level = classifyKeyLossRisk({ 358 + user: current.user, 359 + hasServerSyncedKey: current.hasServerSyncedKey, 360 + }); 361 + const titles: Record<KeyLossRiskLevel, string> = { 362 + anonymous: 'End-to-end encrypted — save this link to keep access', 363 + 'at-risk': 'End-to-end encrypted — key not yet backed up', 364 + safe: 'End-to-end encrypted — key backed up to your account', 365 + }; 366 + btn.title = titles[level]; 367 + }; 368 + updateTitle(); 369 + 370 + btn.addEventListener('click', () => { 371 + mountKeyWarning({ ...current, force: true }); 372 + }); 373 + 374 + container.appendChild(btn); 375 + 376 + return { 377 + element: btn, 378 + update(next) { 379 + current = { ...current, ...next }; 380 + updateTitle(); 381 + }, 382 + }; 383 + } 384 + 385 + // ---------- Session wiring helper ---------- 386 + 387 + interface MeResponse { 388 + login?: string; 389 + name?: string; 390 + profilePic?: string | null; 391 + } 392 + 393 + async function fetchCurrentUser(): Promise<TailscaleUser | null> { 394 + try { 395 + const res = await fetch('/api/me'); 396 + if (!res.ok) return null; 397 + const data: MeResponse = await res.json(); 398 + if (!data.login) return null; 399 + return { 400 + login: data.login, 401 + name: data.name ?? data.login, 402 + profilePic: data.profilePic ?? null, 403 + }; 404 + } catch { 405 + return null; 406 + } 407 + } 408 + 409 + /** 410 + * Drop-in helper for editor entry points. 411 + * 412 + * - Fetches `/api/me` to determine Tailscale identity. 413 + * - Checks the server key bundle for `docId` to see if the key is already backed up. 414 + * - Shows the one-time warning modal (if not previously dismissed for this doc). 415 + * - Mounts the always-visible shield icon in `shieldContainer` if provided. 416 + * 417 + * Called once per editor session; safe to await or fire-and-forget. 418 + */ 419 + export async function wireKeyWarningForSession( 420 + docId: string, 421 + shieldContainer?: HTMLElement | null, 422 + ): Promise<void> { 423 + if (!isBrowser() || !docId) return; 424 + try { 425 + // Load key-sync lazily to keep this module standalone for testing. 426 + const { fetchServerKeys } = await import('./key-sync.js'); 427 + const [user, serverKeys] = await Promise.all([ 428 + fetchCurrentUser(), 429 + fetchServerKeys().catch(() => null), 430 + ]); 431 + const hasServerSyncedKey = !!(user && serverKeys && serverKeys[docId]); 432 + 433 + mountKeyWarning({ docId, user, hasServerSyncedKey }); 434 + if (shieldContainer) { 435 + mountKeyShieldIcon(shieldContainer, { docId, user, hasServerSyncedKey }); 436 + } 437 + } catch { 438 + // Never block editor init on warning wiring. 439 + } 440 + }
+2
src/sheets/session-bootstrap.ts
··· 4 4 import { ensureWrappingKey } from '../lib/key-passphrase.js'; 5 5 import { EncryptedProvider } from '../lib/provider.js'; 6 6 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 7 + import { wireKeyWarningForSession } from '../lib/key-warning.js'; 7 8 8 9 export interface BootstrapResult { 9 10 docId: string; ··· 55 56 const ydoc = new Y.Doc(); 56 57 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 57 58 installDocGoneHandler(provider); 59 + wireKeyWarningForSession(docId, document.getElementById('collab-avatars')); 58 60 await provider.whenReady; 59 61 60 62 const ySheets = ydoc.getMap('sheets') as Y.Map<Y.Map<unknown>>;
+2
src/slides/main.ts
··· 10 10 import { importKey } from '../lib/crypto.js'; 11 11 import { EncryptedProvider } from '../lib/provider.js'; 12 12 import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 13 + import { wireKeyWarningForSession } from '../lib/key-warning.js'; 13 14 import { setupTooltips } from '../lib/tooltips.js'; 14 15 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 15 16 import { createDeck, slideCount } from './canvas-engine.js'; ··· 174 175 if (state.cryptoKey) { 175 176 const provider = new EncryptedProvider(ydoc, state.docId, state.cryptoKey); 176 177 installDocGoneHandler(provider); 178 + wireKeyWarningForSession(state.docId, document.querySelector<HTMLElement>('.app-topbar')); 177 179 provider.on('sync', () => { 178 180 loadDeckFromYjs(); 179 181 actions.render();
+86
tests/key-warning.test.ts
··· 1 + // @vitest-environment jsdom 2 + /** 3 + * #671 — E2EE key-loss warning logic. 4 + * 5 + * The pure classifier decides which variant of the warning to show, and the 6 + * dismissal-persistence helpers key off the docId. UI construction is tested 7 + * manually in the browser; here we lock down the branching logic. 8 + */ 9 + 10 + import { describe, it, expect, beforeEach } from 'vitest'; 11 + import type { TailscaleUser } from '../server/types.js'; 12 + 13 + // jsdom ships a partial localStorage — stub a full impl so setItem/removeItem/clear all work. 14 + const lsStore: Record<string, string> = {}; 15 + const mockLS = { 16 + getItem: (key: string) => lsStore[key] ?? null, 17 + setItem: (key: string, val: string) => { lsStore[key] = val; }, 18 + removeItem: (key: string) => { delete lsStore[key]; }, 19 + clear: () => { for (const k of Object.keys(lsStore)) delete lsStore[k]; }, 20 + get length() { return Object.keys(lsStore).length; }, 21 + key: (i: number) => Object.keys(lsStore)[i] ?? null, 22 + }; 23 + Object.defineProperty(globalThis, 'localStorage', { value: mockLS, writable: true }); 24 + 25 + // Import AFTER the localStorage stub so the module picks it up if it caches. 26 + const { 27 + classifyKeyLossRisk, 28 + hasSeenKeyWarning, 29 + markKeyWarningSeen, 30 + resetKeyWarningSeen, 31 + } = await import('../src/lib/key-warning.js'); 32 + 33 + const authedUser: TailscaleUser = { login: 'scott@example.com', name: 'Scott', profilePic: null }; 34 + 35 + describe('classifyKeyLossRisk', () => { 36 + it('returns "safe" when user is authed and key is synced to server', () => { 37 + expect(classifyKeyLossRisk({ user: authedUser, hasServerSyncedKey: true })).toBe('safe'); 38 + }); 39 + 40 + it('returns "at-risk" when user is authed but key is NOT yet server-synced', () => { 41 + // They have an identity so we tell them to sync, rather than the full scare copy. 42 + expect(classifyKeyLossRisk({ user: authedUser, hasServerSyncedKey: false })).toBe('at-risk'); 43 + }); 44 + 45 + it('returns "anonymous" when there is no Tailscale identity', () => { 46 + expect(classifyKeyLossRisk({ user: null, hasServerSyncedKey: false })).toBe('anonymous'); 47 + expect(classifyKeyLossRisk({ user: undefined, hasServerSyncedKey: false })).toBe('anonymous'); 48 + }); 49 + 50 + it('anonymous stays "anonymous" even if a server key exists — we cannot trust it without a login', () => { 51 + expect(classifyKeyLossRisk({ user: null, hasServerSyncedKey: true })).toBe('anonymous'); 52 + }); 53 + }); 54 + 55 + describe('hasSeenKeyWarning / markKeyWarningSeen', () => { 56 + beforeEach(() => { 57 + localStorage.clear(); 58 + }); 59 + 60 + it('is false before it has been marked', () => { 61 + expect(hasSeenKeyWarning('doc-a')).toBe(false); 62 + }); 63 + 64 + it('is true after it has been marked', () => { 65 + markKeyWarningSeen('doc-a'); 66 + expect(hasSeenKeyWarning('doc-a')).toBe(true); 67 + }); 68 + 69 + it('is scoped to docId', () => { 70 + markKeyWarningSeen('doc-a'); 71 + expect(hasSeenKeyWarning('doc-b')).toBe(false); 72 + }); 73 + 74 + it('reset clears the flag', () => { 75 + markKeyWarningSeen('doc-a'); 76 + resetKeyWarningSeen('doc-a'); 77 + expect(hasSeenKeyWarning('doc-a')).toBe(false); 78 + }); 79 + 80 + it('tolerates missing docId (defensive)', () => { 81 + expect(hasSeenKeyWarning('')).toBe(false); 82 + // mark with empty is a no-op — we don't want collisions on unrelated docs 83 + markKeyWarningSeen(''); 84 + expect(hasSeenKeyWarning('')).toBe(false); 85 + }); 86 + });