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

Configure Feed

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

feat: server-side key sync for cross-device access (#206)

scott 027d6df7 94aef1c3

+324 -21
+40
server/index.ts
··· 112 112 upsertUser: Statement; 113 113 getUser: Statement; 114 114 getAllUsers: Statement; 115 + getKeys: Statement; 116 + putKeys: Statement; 115 117 } 116 118 117 119 // --- Setup --- ··· 206 208 ) 207 209 `); 208 210 211 + // --- User key bundles (cross-device encryption key sync) --- 212 + db.exec(` 213 + CREATE TABLE IF NOT EXISTS user_keys ( 214 + login TEXT PRIMARY KEY REFERENCES users(login), 215 + keys_json TEXT NOT NULL DEFAULT '{}', 216 + updated_at TEXT DEFAULT (datetime('now')) 217 + ) 218 + `); 219 + 209 220 // Migration: add owner column to documents 210 221 try { 211 222 db.prepare("SELECT owner FROM documents LIMIT 1").get(); ··· 251 262 ON CONFLICT(login) DO UPDATE SET name=excluded.name, profile_pic=excluded.profile_pic, last_seen=datetime('now')`), 252 263 getUser: db.prepare('SELECT * FROM users WHERE login = ?'), 253 264 getAllUsers: db.prepare('SELECT login, name, profile_pic FROM users ORDER BY last_seen DESC'), 265 + // Key sync 266 + getKeys: db.prepare('SELECT keys_json FROM user_keys WHERE login = ?'), 267 + putKeys: db.prepare(`INSERT INTO user_keys (login, keys_json, updated_at) VALUES (?, ?, datetime('now')) 268 + ON CONFLICT(login) DO UPDATE SET keys_json = excluded.keys_json, updated_at = datetime('now')`), 254 269 }; 255 270 256 271 // --- Express --- ··· 303 318 // List all known users 304 319 app.get('/api/users', (_req: Request, res: Response) => { 305 320 res.json(stmts.getAllUsers.all() as Pick<UserRow, 'login' | 'name' | 'profile_pic'>[]); 321 + }); 322 + 323 + // --- Key sync (cross-device encryption key access) --- 324 + app.get('/api/keys', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 325 + if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 326 + const row = stmts.getKeys.get(req.tsUser.login) as { keys_json: string } | undefined; 327 + res.json({ keys: row ? JSON.parse(row.keys_json) : {} }); 328 + }); 329 + 330 + app.put('/api/keys', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 331 + if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 332 + const incoming = req.body?.keys; 333 + if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { 334 + res.status(400).json({ error: 'keys must be an object' }); return; 335 + } 336 + for (const [docId, keyStr] of Object.entries(incoming)) { 337 + if (typeof keyStr !== 'string' || keyStr.length === 0) { 338 + res.status(400).json({ error: `Invalid key for doc ${docId}` }); return; 339 + } 340 + } 341 + // Server-side merge: read existing, overlay incoming, write back 342 + const existing = stmts.getKeys.get(req.tsUser.login) as { keys_json: string } | undefined; 343 + const merged = { ...(existing ? JSON.parse(existing.keys_json) : {}), ...incoming }; 344 + stmts.putKeys.run(req.tsUser.login, JSON.stringify(merged)); 345 + res.json({ ok: true }); 306 346 }); 307 347 308 348 app.post('/api/documents', (req: Request<Record<string, string>, unknown, CreateDocumentBody> & { tsUser?: TailscaleUser | null }, res: Response) => {
+13 -5
src/docs/main.ts
··· 29 29 import CollaborationCursor from '@tiptap/extension-collaboration-cursor'; 30 30 31 31 import { importKey, encrypt, decrypt, encryptString, decryptString } from '../lib/crypto.js'; 32 + import { storeKey, pushKeysToServer, fetchServerKeys, getLocalKeys } from '../lib/key-sync.js'; 32 33 import { EncryptedProvider } from '../lib/provider.js'; 33 34 import { FontSize } from './extensions/font-size.js'; 34 35 import { Indent } from './extensions/indent.js'; ··· 87 88 localStorage.removeItem('crypt-username'); 88 89 } 89 90 90 - const storedKeysInit = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 91 - const keyString = hash || storedKeysInit[docId]; 91 + // Resolve key: URL hash > localStorage > server (cross-device sync) 92 + const storedKeysInit = getLocalKeys(); 93 + let keyString = hash || storedKeysInit[docId]; 94 + 95 + if (!keyString) { 96 + const serverKeys = await fetchServerKeys(); 97 + if (serverKeys?.[docId]) { 98 + keyString = serverKeys[docId]; 99 + } 100 + } 92 101 93 102 if (!docId || !keyString) { 94 103 location.href = '/'; 95 104 throw new Error('No document ID or key'); 96 105 } 97 106 98 - const storedKeys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 99 - storedKeys[docId] = keyString; 100 - localStorage.setItem('tools-keys', JSON.stringify(storedKeys)); 107 + storeKey(docId, keyString); 108 + pushKeysToServer({ [docId]: keyString }); 101 109 102 110 // --- Initialize --- 103 111 const cryptoKey = await importKey(keyString);
+8 -10
src/landing.ts
··· 1 1 import type { DocumentMeta, Folder, FolderAssignments, StarMap, SortLabels } from './landing-types.js'; 2 2 import { generateKey, exportKey, importKey, decryptString } from './lib/crypto.js'; 3 + import { syncKeys, storeKey, pushKeysToServer } from './lib/key-sync.js'; 3 4 import { createCommandPalette, type PaletteAction } from './command-palette.js'; 4 5 import { parseTags, addTag, removeTag, saveDocumentTags, collectAllTags, filterByTag } from './tags.js'; 5 6 import { formatDailyNoteName, findDailyNote, getDailyNoteTemplate } from './daily-notes.js'; ··· 203 204 }); 204 205 const { id } = await res.json(); 205 206 206 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 207 - keys[id] = keyStr; 208 - localStorage.setItem('tools-keys', JSON.stringify(keys)); 207 + storeKey(id, keyStr); 208 + pushKeysToServer({ [id]: keyStr }); 209 209 210 210 // If we're inside a folder, assign the new doc to it 211 211 if (currentFolderId) { ··· 239 239 }); 240 240 const { id } = await res.json(); 241 241 242 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 243 - keys[id] = keyStr; 244 - localStorage.setItem('tools-keys', JSON.stringify(keys)); 242 + storeKey(id, keyStr); 243 + pushKeysToServer({ [id]: keyStr }); 245 244 246 245 if (currentFolderId) { 247 246 folderAssignments = moveToFolder(folderAssignments, id, currentFolderId); ··· 293 292 }); 294 293 const { id } = await res.json(); 295 294 296 - const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 297 - keys[id] = keyStr; 298 - localStorage.setItem('tools-keys', JSON.stringify(keys)); 295 + storeKey(id, keyStr); 296 + pushKeysToServer({ [id]: keyStr }); 299 297 300 298 // Store template for the editor to pick up 301 299 const template = getDailyNoteTemplate(); ··· 1230 1228 1231 1229 // --- Init --- 1232 1230 initUsername(); 1233 - loadDocuments(); 1231 + syncKeys().then(() => loadDocuments()); 1234 1232 initDesktopDownload();
+97
src/lib/key-sync.ts
··· 1 + /** 2 + * Key sync — stores per-document encryption keys server-side (keyed by Tailscale identity) 3 + * so users can seamlessly access documents across devices. 4 + * 5 + * Local keys (localStorage) are the primary store for instant access. 6 + * Server keys are synced in the background for cross-device availability. 7 + */ 8 + 9 + export interface KeyBundle { 10 + [docId: string]: string; 11 + } 12 + 13 + const STORAGE_KEY = 'tools-keys'; 14 + 15 + /** Read the local key bundle from localStorage */ 16 + export function getLocalKeys(): KeyBundle { 17 + try { 18 + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); 19 + } catch { 20 + return {}; 21 + } 22 + } 23 + 24 + /** Write the local key bundle to localStorage */ 25 + export function setLocalKeys(keys: KeyBundle): void { 26 + localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); 27 + } 28 + 29 + /** Store a single key locally */ 30 + export function storeKey(docId: string, keyStr: string): void { 31 + const keys = getLocalKeys(); 32 + keys[docId] = keyStr; 33 + setLocalKeys(keys); 34 + } 35 + 36 + /** Merge local and server key bundles. Local wins on conflict. */ 37 + export function mergeKeys(local: KeyBundle, server: KeyBundle): KeyBundle { 38 + return { ...server, ...local }; 39 + } 40 + 41 + /** Fetch the server-side key bundle for the authenticated user */ 42 + export async function fetchServerKeys(): Promise<KeyBundle | null> { 43 + try { 44 + const res = await fetch('/api/keys'); 45 + if (!res.ok) return null; 46 + const data = await res.json(); 47 + return data.keys ?? null; 48 + } catch { 49 + return null; 50 + } 51 + } 52 + 53 + /** Push keys to the server (server does merge) */ 54 + export async function pushKeysToServer(keys: KeyBundle): Promise<boolean> { 55 + try { 56 + const res = await fetch('/api/keys', { 57 + method: 'PUT', 58 + headers: { 'Content-Type': 'application/json' }, 59 + body: JSON.stringify({ keys }), 60 + }); 61 + return res.ok; 62 + } catch { 63 + return false; 64 + } 65 + } 66 + 67 + /** 68 + * Full sync: fetch server keys, merge with local, update both sides. 69 + * Returns the merged key bundle. Safe to call on every page load. 70 + */ 71 + export async function syncKeys(): Promise<KeyBundle> { 72 + const local = getLocalKeys(); 73 + const server = await fetchServerKeys(); 74 + 75 + if (!server) { 76 + // Not authenticated or server unreachable — local only 77 + return local; 78 + } 79 + 80 + const merged = mergeKeys(local, server); 81 + 82 + // Update local if server had keys we didn't 83 + const localChanged = Object.keys(merged).length !== Object.keys(local).length 84 + || Object.keys(merged).some(k => local[k] !== merged[k]); 85 + if (localChanged) { 86 + setLocalKeys(merged); 87 + } 88 + 89 + // Push to server if local had keys the server didn't 90 + const serverChanged = Object.keys(merged).length !== Object.keys(server).length 91 + || Object.keys(merged).some(k => server[k] !== merged[k]); 92 + if (serverChanged) { 93 + pushKeysToServer(merged); // fire-and-forget 94 + } 95 + 96 + return merged; 97 + }
+13 -6
src/sheets/main.ts
··· 8 8 9 9 import * as Y from 'yjs'; 10 10 import { importKey, encryptString, decryptString } from '../lib/crypto.js'; 11 + import { storeKey, pushKeysToServer, fetchServerKeys, getLocalKeys } from '../lib/key-sync.js'; 11 12 import { EncryptedProvider } from '../lib/provider.js'; 12 13 import { createVersionPanel } from '../version-panel.js'; 13 14 import { evaluate, extractRefs, formatCell, parseRef, colToLetter, letterToCol, cellId } from './formulas.js'; ··· 79 80 localStorage.removeItem('crypt-username'); 80 81 } 81 82 82 - const storedKeysInit = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 83 - const keyString = hash || storedKeysInit[docId]; 83 + // Resolve key: URL hash > localStorage > server (cross-device sync) 84 + const storedKeysInit = getLocalKeys(); 85 + let keyString = hash || storedKeysInit[docId]; 86 + 87 + if (!keyString) { 88 + const serverKeys = await fetchServerKeys(); 89 + if (serverKeys?.[docId]) { 90 + keyString = serverKeys[docId]; 91 + } 92 + } 84 93 85 94 if (!docId || !keyString) { 86 95 location.href = '/'; 87 96 throw new Error('No document ID or key'); 88 97 } 89 98 90 - // Store key in localStorage for return visits 91 - const storedKeys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 92 - storedKeys[docId] = keyString; 93 - localStorage.setItem('tools-keys', JSON.stringify(storedKeys)); 99 + storeKey(docId, keyString); 100 + pushKeysToServer({ [docId]: keyString }); 94 101 95 102 const cryptoKey = await importKey(keyString); 96 103
+153
tests/key-sync.test.ts
··· 1 + import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 + import { getLocalKeys, setLocalKeys, storeKey, mergeKeys } from '../src/lib/key-sync.js'; 3 + 4 + // Mock localStorage 5 + const storage = new Map<string, string>(); 6 + const localStorageMock = { 7 + getItem: (key: string) => storage.get(key) ?? null, 8 + setItem: (key: string, value: string) => { storage.set(key, value); }, 9 + removeItem: (key: string) => { storage.delete(key); }, 10 + clear: () => { storage.clear(); }, 11 + get length() { return storage.size; }, 12 + key: (i: number) => [...storage.keys()][i] ?? null, 13 + }; 14 + 15 + vi.stubGlobal('localStorage', localStorageMock); 16 + 17 + beforeEach(() => { 18 + storage.clear(); 19 + }); 20 + 21 + describe('getLocalKeys / setLocalKeys', () => { 22 + it('returns empty object when no keys stored', () => { 23 + expect(getLocalKeys()).toEqual({}); 24 + }); 25 + 26 + it('roundtrips keys through localStorage', () => { 27 + const keys = { abc123: 'keyA', def456: 'keyB' }; 28 + setLocalKeys(keys); 29 + expect(getLocalKeys()).toEqual(keys); 30 + }); 31 + 32 + it('handles corrupted localStorage gracefully', () => { 33 + storage.set('tools-keys', 'not valid json{{{'); 34 + expect(getLocalKeys()).toEqual({}); 35 + }); 36 + }); 37 + 38 + describe('storeKey', () => { 39 + it('adds a single key to an empty store', () => { 40 + storeKey('doc1', 'keyValue1'); 41 + expect(getLocalKeys()).toEqual({ doc1: 'keyValue1' }); 42 + }); 43 + 44 + it('adds to existing keys without losing them', () => { 45 + setLocalKeys({ existing: 'existingKey' }); 46 + storeKey('doc2', 'keyValue2'); 47 + expect(getLocalKeys()).toEqual({ existing: 'existingKey', doc2: 'keyValue2' }); 48 + }); 49 + 50 + it('overwrites a key for the same docId', () => { 51 + storeKey('doc1', 'oldKey'); 52 + storeKey('doc1', 'newKey'); 53 + expect(getLocalKeys()).toEqual({ doc1: 'newKey' }); 54 + }); 55 + }); 56 + 57 + describe('mergeKeys', () => { 58 + it('returns local when server is empty', () => { 59 + const local = { a: 'key1', b: 'key2' }; 60 + const server = {}; 61 + expect(mergeKeys(local, server)).toEqual(local); 62 + }); 63 + 64 + it('returns server when local is empty', () => { 65 + const local = {}; 66 + const server = { a: 'key1', b: 'key2' }; 67 + expect(mergeKeys(local, server)).toEqual(server); 68 + }); 69 + 70 + it('unions disjoint key sets', () => { 71 + const local = { a: 'key1' }; 72 + const server = { b: 'key2' }; 73 + expect(mergeKeys(local, server)).toEqual({ a: 'key1', b: 'key2' }); 74 + }); 75 + 76 + it('local wins on conflict', () => { 77 + const local = { a: 'localKey' }; 78 + const server = { a: 'serverKey' }; 79 + expect(mergeKeys(local, server)).toEqual({ a: 'localKey' }); 80 + }); 81 + 82 + it('merges with local override on overlap', () => { 83 + const local = { a: 'localA', c: 'localC' }; 84 + const server = { a: 'serverA', b: 'serverB' }; 85 + expect(mergeKeys(local, server)).toEqual({ a: 'localA', b: 'serverB', c: 'localC' }); 86 + }); 87 + }); 88 + 89 + describe('fetchServerKeys', () => { 90 + beforeEach(() => { 91 + vi.restoreAllMocks(); 92 + }); 93 + 94 + it('returns keys on 200', async () => { 95 + const { fetchServerKeys } = await import('../src/lib/key-sync.js'); 96 + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ 97 + ok: true, 98 + json: () => Promise.resolve({ keys: { doc1: 'k1' } }), 99 + })); 100 + const result = await fetchServerKeys(); 101 + expect(result).toEqual({ doc1: 'k1' }); 102 + }); 103 + 104 + it('returns null on 403', async () => { 105 + const { fetchServerKeys } = await import('../src/lib/key-sync.js'); 106 + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ 107 + ok: false, 108 + status: 403, 109 + })); 110 + const result = await fetchServerKeys(); 111 + expect(result).toBeNull(); 112 + }); 113 + 114 + it('returns null on network error', async () => { 115 + const { fetchServerKeys } = await import('../src/lib/key-sync.js'); 116 + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); 117 + const result = await fetchServerKeys(); 118 + expect(result).toBeNull(); 119 + }); 120 + }); 121 + 122 + describe('pushKeysToServer', () => { 123 + beforeEach(() => { 124 + vi.restoreAllMocks(); 125 + }); 126 + 127 + it('sends PUT with keys', async () => { 128 + const { pushKeysToServer } = await import('../src/lib/key-sync.js'); 129 + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); 130 + vi.stubGlobal('fetch', mockFetch); 131 + const result = await pushKeysToServer({ doc1: 'k1' }); 132 + expect(result).toBe(true); 133 + expect(mockFetch).toHaveBeenCalledWith('/api/keys', { 134 + method: 'PUT', 135 + headers: { 'Content-Type': 'application/json' }, 136 + body: JSON.stringify({ keys: { doc1: 'k1' } }), 137 + }); 138 + }); 139 + 140 + it('returns false on failure', async () => { 141 + const { pushKeysToServer } = await import('../src/lib/key-sync.js'); 142 + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); 143 + const result = await pushKeysToServer({ doc1: 'k1' }); 144 + expect(result).toBe(false); 145 + }); 146 + 147 + it('returns false on network error', async () => { 148 + const { pushKeysToServer } = await import('../src/lib/key-sync.js'); 149 + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); 150 + const result = await pushKeysToServer({ doc1: 'k1' }); 151 + expect(result).toBe(false); 152 + }); 153 + });