Social Annotations in the Atmosphere
15
fork

Configure Feed

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

refactor: consolidate migration to BackgroundWorker with OAuth session key rename

- Add migrateIfNeeded() to BackgroundWorker for shared migration logic
- Create ProxyBackgroundWorker that composes BackgroundWorker and adds proxy-specific cleanup (service workers, IndexedDB, sessionStorage)
- Rename OAuth session key from 'synthesis-oauth:session' to 'seams-oauth-session'
- Remove migration logic from loadwabac.js (now handled by ProxyBackgroundWorker)
- Fix WebStorageAdapter to properly remove keys when set to null (instead of storing "null" string)
- Update ExtensionBackgroundWorker to compose BackgroundWorker
- Update e2e migration tests to use consolidated seams-migration-version key

+1014 -173
+235
.skill/atproto-oauth.md
··· 1 + # AT Protocol OAuth Skill 2 + 3 + Deep reference for implementing AT Protocol OAuth in this codebase. Use this when working on authentication, PDS interactions, or user identity. 4 + 5 + ## Key Concepts 6 + 7 + **AT Protocol is NOT just Bluesky.** It's a decentralized protocol where: 8 + - Users have a **DID** (decentralized identifier) and a **handle** (e.g., `user.bsky.social`) 9 + - User data lives on a **PDS** (Personal Data Server), not necessarily bsky.social 10 + - Apps authenticate via OAuth to read/write records on the user's PDS 11 + 12 + ## OAuth Flow Overview 13 + 14 + 1. **Handle Resolution**: User enters handle -> resolve to DID + PDS endpoint 15 + 2. **Authorization**: Redirect user to their PDS's authorization server 16 + 3. **Token Exchange**: Exchange auth code for access/refresh tokens (with PKCE) 17 + 4. **Authenticated Requests**: Use tokens to call XRPC endpoints on user's PDS 18 + 19 + ### Handle Resolution 20 + 21 + Handles are resolved via DNS TXT records or `.well-known/atproto-did`: 22 + ``` 23 + user.bsky.social -> DNS TXT _atproto.user.bsky.social -> did:plc:xyz 24 + did:plc:xyz -> plc.directory -> PDS endpoint (e.g., https://bsky.social) 25 + ``` 26 + 27 + ## Required Security Features 28 + 29 + - **PKCE** (Proof Key for Code Exchange): Prevents auth code interception 30 + - **State Parameter**: Prevents CSRF attacks 31 + - **DPoP** (Demonstration of Proof-of-Possession): Binds tokens to client 32 + 33 + ## Available Scopes 34 + 35 + | Scope | Description | Risk Level | 36 + |-------|-------------|------------| 37 + | `atproto` | Basic AT Protocol access (required) | Low | 38 + | `transition:generic` | Read/write posts, follows, blocks | High | 39 + | `transition:chat.bsky` | Access to Bluesky DMs | High | 40 + 41 + **Always request minimal scopes.** This app uses: 42 + - `atproto` - Required for basic AT Protocol access and our custom lexicons (`community.lexicon.annotation.*`) 43 + - `transition:generic` - Required to fetch user profile/avatar via `app.bsky.actor.getProfile` 44 + 45 + ## Codebase Implementation 46 + 47 + ### Key Files 48 + 49 + | File | Purpose | 50 + |------|---------| 51 + | `packages/core/src/oauth/index.ts` | OAuthManager class and flow | 52 + | `packages/core/src/pds/index.ts` | PDS client for XRPC calls | 53 + | `landing/oauth/client-metadata.json` | OAuth client registration | 54 + 55 + ### OAuthManager Class 56 + 57 + Located at `packages/core/src/oauth/index.ts`: 58 + 59 + ```typescript 60 + import { 61 + configureOAuth, 62 + createAuthorizationUrl, 63 + finalizeAuthorization, 64 + resolveFromIdentity, 65 + OAuthUserAgent, 66 + type OAuthSession, 67 + } from "@atcute/oauth-browser-client"; 68 + 69 + export class OAuthManager { 70 + constructor(storage: StorageAdapter, launcher: OAuthLauncher, config: OAuthConfig) {} 71 + 72 + initialize() // Configure OAuth client 73 + async startLoginProcess(handle: string) // Full OAuth flow 74 + async saveSession(session: OAuthSession) 75 + async loadSession(): Promise<OAuthSession | null> 76 + async clearSession() 77 + async getProfile(session: OAuthSession) // Fetch user profile 78 + } 79 + ``` 80 + 81 + ### OAuth Configuration 82 + 83 + Client metadata is served at `https://seams.so/oauth/client-metadata.json`: 84 + 85 + ```json 86 + { 87 + "client_id": "https://seams.so/oauth/client-metadata.json", 88 + "client_uri": "https://seams.so", 89 + "redirect_uris": [ 90 + "https://seams.so/oauth/callback", 91 + "https://seams.so/oauth/ff/callback", 92 + "https://sure.seams.so/oauth-callback.html" 93 + ], 94 + "application_type": "web", 95 + "dpop_bound_access_tokens": true, 96 + "grant_types": ["authorization_code", "refresh_token"], 97 + "scope": "atproto transition:generic", 98 + "token_endpoint_auth_method": "none" 99 + } 100 + ``` 101 + 102 + ### Making Authenticated Requests 103 + 104 + Use `OAuthUserAgent` from `@atcute/oauth-browser-client`: 105 + 106 + ```typescript 107 + import { OAuthUserAgent } from "@atcute/oauth-browser-client"; 108 + 109 + const session = await oauth.loadSession(); 110 + const agent = new OAuthUserAgent(session); 111 + 112 + // XRPC calls 113 + const response = await agent.handle('/xrpc/com.atproto.repo.createRecord', { 114 + method: 'POST', 115 + headers: { 'Content-Type': 'application/json' }, 116 + body: JSON.stringify({ 117 + repo: session.info.sub, // User's DID 118 + collection: 'community.lexicon.annotation.annotation', 119 + record: { /* ... */ }, 120 + }), 121 + }); 122 + ``` 123 + 124 + ### DPoP Nonce Handling 125 + 126 + The PDS may return 401 with `use_dpop_nonce` error. Retry logic: 127 + 128 + ```typescript 129 + private async request(agent: OAuthUserAgent, path: string, options: any, retryCount = 0): Promise<Response> { 130 + const response = await agent.handle(path, options); 131 + 132 + if (response.status === 401 && retryCount < 3) { 133 + const data = await response.clone().json(); 134 + if (data.error === 'use_dpop_nonce') { 135 + // OAuthUserAgent auto-updates nonce from response headers 136 + return this.request(agent, path, options, retryCount + 1); 137 + } else if (data.error === 'invalid_token') { 138 + // Force token refresh 139 + const newSession = await agent.getSession({ noCache: true }); 140 + await this.oauth.saveSession(newSession); 141 + const newAgent = new OAuthUserAgent(newSession); 142 + return this.request(newAgent, path, options, retryCount + 1); 143 + } 144 + } 145 + return response; 146 + } 147 + ``` 148 + 149 + ## Custom Lexicons 150 + 151 + This app uses custom lexicons for annotations: 152 + 153 + ### Annotation Record (`community.lexicon.annotation.annotation`) 154 + 155 + Defined in `lexicon/community/lexicon/annotation/annotation.json`: 156 + 157 + ```typescript 158 + const record = { 159 + $type: 'community.lexicon.annotation.annotation', 160 + target: [{ 161 + source: 'https://example.com/page', // URL being annotated 162 + selector: [{ 163 + $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 164 + exact: 'selected text', 165 + prefix: 'context before', 166 + suffix: 'context after' 167 + }] 168 + }], 169 + body: 'My annotation comment', 170 + createdAt: new Date().toISOString(), 171 + }; 172 + ``` 173 + 174 + ### Comment Record (`pub.leaflet.comment`) 175 + 176 + ```typescript 177 + const record = { 178 + $type: 'pub.leaflet.comment', 179 + subject: 'at://did:plc:xyz/community.lexicon.annotation.annotation/rkey', 180 + plaintext: 'Reply text', 181 + createdAt: new Date().toISOString(), 182 + reply: { parent: 'at://...' }, // Optional parent comment 183 + onPage: 'https://example.com/page', 184 + }; 185 + ``` 186 + 187 + ## Common XRPC Endpoints 188 + 189 + | Endpoint | Purpose | 190 + |----------|---------| 191 + | `com.atproto.repo.createRecord` | Create new record | 192 + | `com.atproto.repo.deleteRecord` | Delete record by rkey | 193 + | `com.atproto.repo.getRecord` | Get single record | 194 + | `com.atproto.repo.listRecords` | List records in collection | 195 + | `app.bsky.actor.getProfile` | Get user profile (requires `transition:generic`) | 196 + 197 + ## Common Mistakes to Avoid 198 + 199 + - **Don't hardcode bsky.social** - users can be on any PDS 200 + - **Don't request `transition:generic`** unless you need Bluesky social features 201 + - **Don't store tokens in localStorage** - use secure HTTP-only cookies or server sessions 202 + - **Don't skip PKCE/state validation** - these prevent real attacks 203 + - **Don't ignore DPoP nonce errors** - implement retry logic 204 + 205 + ## Session Storage 206 + 207 + Sessions are stored using the `StorageAdapter` interface with key `seams-oauth-session`: 208 + 209 + ```typescript 210 + const OAUTH_SESSION_KEY = "seams-oauth-session"; 211 + 212 + // Save 213 + await storage.set(OAUTH_SESSION_KEY, session); 214 + 215 + // Load 216 + const session = await storage.get(OAUTH_SESSION_KEY); 217 + 218 + // Clear 219 + await storage.set(OAUTH_SESSION_KEY, null); 220 + ``` 221 + 222 + ## Testing OAuth 223 + 224 + Unit tests in `packages/core/src/__tests__/` mock the OAuth flow. E2E tests may be flaky due to popup timing. 225 + 226 + ```bash 227 + pnpm test # Unit tests 228 + pnpm test:e2e:extension # E2E (requires .env.test with credentials) 229 + ``` 230 + 231 + ## External Resources 232 + 233 + - [AT Protocol OAuth Spec](https://atproto.com/specs/oauth) 234 + - [atcute/oauth-browser-client](https://github.com/mary-ext/atcute) 235 + - [Bluesky PDS Documentation](https://docs.bsky.app/)
+2 -1
history/STORAGE_FIRST_ARCHITECTURE.md
··· 414 414 annotations: Annotation[]; 415 415 comments: Comment[]; 416 416 lastSync: number; // timestamp 417 - 'synthesis-oauth:session': OAuthSession; // Already exists 417 + 'seams-oauth-session': OAuthSession; // OAuth session 418 + 'seams-migration-version': number; // Migration version for forced re-login 418 419 } 419 420 ``` 420 421
+61 -17
packages/core/src/background/__tests__/extension.test.ts
··· 94 94 }); 95 95 expect(browser.menus.onClicked.addListener).toHaveBeenCalled(); 96 96 }); 97 + 98 + it('calls migrateIfNeeded before registering listeners', async () => { 99 + // Track the order of operations 100 + const callOrder: string[] = []; 101 + 102 + // Override storage.get to track migration check 103 + mockStorage.get.mockImplementation((key: string) => { 104 + if (key === 'seams-migration-version') { 105 + callOrder.push('migration-check'); 106 + return Promise.resolve(1); // Version matches, no migration needed 107 + } 108 + return Promise.resolve([]); 109 + }); 110 + 111 + // Track when listeners are registered 112 + const originalAddListener = browser.tabs.onActivated.addListener; 113 + browser.tabs.onActivated.addListener = vi.fn((...args: any[]) => { 114 + callOrder.push('register-listeners'); 115 + return originalAddListener(...args); 116 + }); 117 + 118 + await worker.start(); 119 + 120 + // Migration check should happen before listener registration 121 + expect(callOrder[0]).toBe('migration-check'); 122 + expect(callOrder).toContain('register-listeners'); 123 + }); 97 124 }); 98 125 99 126 describe('action click handler', () => { ··· 278 305 }); 279 306 }); 280 307 281 - describe('fetchAndCacheAnnotations', () => { 308 + describe('fetchAndCacheAnnotations (via BackgroundWorker)', () => { 282 309 it('merges new annotations with existing ones', async () => { 283 - await worker.start(); 310 + // Set migration version to skip migration 311 + mockStorage.get.mockImplementation((key: string) => { 312 + if (key === 'seams-migration-version') return Promise.resolve(1); 313 + if (key === 'annotations') return Promise.resolve([{ uri: 'existing:1', cid: 'cid1' }]); 314 + return Promise.resolve([]); 315 + }); 284 316 285 - mockStorage.get.mockResolvedValue([ 286 - { uri: 'existing:1', cid: 'cid1' }, 287 - ]); 317 + await worker.start(); 288 318 289 319 vi.mocked(fetchAnnotations).mockResolvedValue([ 290 320 { uri: 'new:1', cid: 'cid2', value: {} } as any, ··· 302 332 ]); 303 333 }); 304 334 305 - it('does not add duplicate annotations', async () => { 306 - await worker.start(); 335 + it('updates existing annotations with same URI', async () => { 336 + // Set migration version to skip migration 337 + mockStorage.get.mockImplementation((key: string) => { 338 + if (key === 'seams-migration-version') return Promise.resolve(1); 339 + if (key === 'annotations') return Promise.resolve([{ uri: 'existing:1', cid: 'cid1' }]); 340 + return Promise.resolve([]); 341 + }); 307 342 308 - mockStorage.get.mockResolvedValue([ 309 - { uri: 'existing:1', cid: 'cid1' }, 310 - ]); 343 + await worker.start(); 311 344 312 345 vi.mocked(fetchAnnotations).mockResolvedValue([ 313 - { uri: 'existing:1', cid: 'cid1', value: {} } as any, // Same URI 346 + { uri: 'existing:1', cid: 'cid1', value: { updated: true } } as any, // Same URI, updated data 314 347 ]); 315 348 316 349 const handler = browser.tabs.onActivated.addListener.mock.calls[0][0]; ··· 318 351 319 352 await handler({ tabId: 1 }); 320 353 354 + // BackgroundWorker updates existing annotations instead of skipping them 321 355 expect(mockStorage.set).toHaveBeenCalledWith('annotations', [ 322 - { uri: 'existing:1', cid: 'cid1' }, 356 + { uri: 'existing:1', cid: 'cid1', value: { updated: true } }, 323 357 ]); 324 358 }); 325 359 326 360 it('limits total annotations to 500', async () => { 327 - await worker.start(); 328 - 329 361 // 400 existing annotations 330 362 const existing = Array.from({ length: 400 }, (_, i) => ({ 331 363 uri: `existing:${i}`, ··· 339 371 value: {}, 340 372 })); 341 373 342 - mockStorage.get.mockResolvedValue(existing); 374 + // Set migration version to skip migration 375 + mockStorage.get.mockImplementation((key: string) => { 376 + if (key === 'seams-migration-version') return Promise.resolve(1); 377 + if (key === 'annotations') return Promise.resolve(existing); 378 + return Promise.resolve([]); 379 + }); 380 + 381 + await worker.start(); 382 + 343 383 vi.mocked(fetchAnnotations).mockResolvedValue(newAnns as any); 344 384 345 385 const handler = browser.tabs.onActivated.addListener.mock.calls[0][0]; ··· 347 387 348 388 await handler({ tabId: 1 }); 349 389 350 - const storedAnnotations = mockStorage.set.mock.calls[0][1]; 351 - expect(storedAnnotations).toHaveLength(500); 390 + // Find the annotations call (not migration calls) 391 + const annotationsCall = mockStorage.set.mock.calls.find( 392 + call => call[0] === 'annotations' 393 + ); 394 + expect(annotationsCall).toBeDefined(); 395 + expect(annotationsCall![1]).toHaveLength(500); 352 396 }); 353 397 }); 354 398 });
+210
packages/core/src/background/__tests__/proxy.test.ts
··· 1 + import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 + import { ProxyBackgroundWorker } from '../proxy'; 3 + import type { StorageAdapter } from '../../storage/adapter'; 4 + 5 + // Constants matching those in worker.ts 6 + const MIGRATION_VERSION = 1; 7 + const MIGRATION_VERSION_KEY = 'seams-migration-version'; 8 + const OAUTH_SESSION_KEY = 'seams-oauth-session'; 9 + 10 + describe('ProxyBackgroundWorker', () => { 11 + let worker: ProxyBackgroundWorker; 12 + let mockStorage: { 13 + get: ReturnType<typeof vi.fn>; 14 + set: ReturnType<typeof vi.fn>; 15 + onChange: ReturnType<typeof vi.fn>; 16 + }; 17 + let mockFetchAnnotations: ReturnType<typeof vi.fn>; 18 + 19 + // Mock browser APIs 20 + let mockServiceWorkerRegistrations: any[]; 21 + let mockIndexedDBDatabases: any[]; 22 + 23 + beforeEach(() => { 24 + vi.spyOn(console, 'log').mockImplementation(() => {}); 25 + vi.spyOn(console, 'warn').mockImplementation(() => {}); 26 + vi.spyOn(console, 'error').mockImplementation(() => {}); 27 + 28 + mockStorage = { 29 + get: vi.fn().mockResolvedValue(null), 30 + set: vi.fn().mockResolvedValue(undefined), 31 + onChange: vi.fn(), 32 + }; 33 + 34 + mockFetchAnnotations = vi.fn().mockResolvedValue([]); 35 + 36 + // Mock service worker registrations 37 + mockServiceWorkerRegistrations = []; 38 + const mockNavigator = { 39 + serviceWorker: { 40 + getRegistrations: vi.fn().mockResolvedValue(mockServiceWorkerRegistrations), 41 + }, 42 + }; 43 + vi.stubGlobal('navigator', mockNavigator); 44 + 45 + // Mock IndexedDB 46 + mockIndexedDBDatabases = []; 47 + const mockIndexedDB = { 48 + databases: vi.fn().mockResolvedValue(mockIndexedDBDatabases), 49 + deleteDatabase: vi.fn().mockReturnValue({ 50 + onsuccess: null, 51 + onerror: null, 52 + onblocked: null, 53 + }), 54 + }; 55 + vi.stubGlobal('indexedDB', mockIndexedDB); 56 + 57 + // Mock sessionStorage 58 + const mockSessionStorage = { 59 + removeItem: vi.fn(), 60 + }; 61 + vi.stubGlobal('sessionStorage', mockSessionStorage); 62 + 63 + worker = new ProxyBackgroundWorker({ 64 + storage: mockStorage as unknown as StorageAdapter, 65 + fetchAnnotations: mockFetchAnnotations, 66 + }); 67 + }); 68 + 69 + describe('migrateIfNeeded', () => { 70 + it('runs proxy-specific cleanup when migration is needed', async () => { 71 + // No version stored - migration needed 72 + mockStorage.get.mockResolvedValue(null); 73 + 74 + const result = await worker.migrateIfNeeded(); 75 + 76 + expect(result).toBe(true); 77 + // Should clear OAuth session 78 + expect(mockStorage.set).toHaveBeenCalledWith(OAUTH_SESSION_KEY, null); 79 + // Should clear session storage 80 + expect(sessionStorage.removeItem).toHaveBeenCalledWith('seams_login_redirect'); 81 + // Should check for service workers 82 + expect(navigator.serviceWorker.getRegistrations).toHaveBeenCalled(); 83 + // Should check for IndexedDB databases 84 + expect(indexedDB.databases).toHaveBeenCalled(); 85 + }); 86 + 87 + it('does not run proxy-specific cleanup when version matches', async () => { 88 + // Version matches - no migration needed 89 + mockStorage.get.mockResolvedValue(MIGRATION_VERSION); 90 + 91 + const result = await worker.migrateIfNeeded(); 92 + 93 + expect(result).toBe(false); 94 + // Should NOT clear session storage 95 + expect(sessionStorage.removeItem).not.toHaveBeenCalled(); 96 + // Should NOT check for service workers 97 + expect(navigator.serviceWorker.getRegistrations).not.toHaveBeenCalled(); 98 + }); 99 + 100 + it('unregisters service workers during migration', async () => { 101 + const mockUnregister = vi.fn().mockResolvedValue(true); 102 + mockServiceWorkerRegistrations.push( 103 + { scope: 'http://localhost/', unregister: mockUnregister }, 104 + { scope: 'http://localhost/sw/', unregister: mockUnregister } 105 + ); 106 + mockStorage.get.mockResolvedValue(null); 107 + 108 + await worker.migrateIfNeeded(); 109 + 110 + expect(mockUnregister).toHaveBeenCalledTimes(2); 111 + }); 112 + 113 + it('deletes wabac-related IndexedDB databases during migration', async () => { 114 + mockIndexedDBDatabases.push( 115 + { name: 'wabac-collection' }, 116 + { name: 'db' }, 117 + { name: 'other-db' } // Should not be deleted 118 + ); 119 + mockStorage.get.mockResolvedValue(null); 120 + 121 + // Mock deleteDatabase to trigger onsuccess 122 + const deleteRequests: any[] = []; 123 + (indexedDB.deleteDatabase as any).mockImplementation((name: string) => { 124 + const request = { 125 + onsuccess: null as any, 126 + onerror: null as any, 127 + onblocked: null as any, 128 + }; 129 + deleteRequests.push({ name, request }); 130 + // Trigger onsuccess async 131 + setTimeout(() => request.onsuccess?.(), 0); 132 + return request; 133 + }); 134 + 135 + await worker.migrateIfNeeded(); 136 + 137 + // Should only delete wabac-related databases 138 + const deletedNames = deleteRequests.map(r => r.name); 139 + expect(deletedNames).toContain('wabac-collection'); 140 + expect(deletedNames).toContain('db'); 141 + expect(deletedNames).not.toContain('other-db'); 142 + }); 143 + 144 + it('handles missing navigator.serviceWorker gracefully', async () => { 145 + // Remove serviceWorker from navigator 146 + vi.stubGlobal('navigator', {}); 147 + mockStorage.get.mockResolvedValue(null); 148 + 149 + // Should not throw 150 + const result = await worker.migrateIfNeeded(); 151 + 152 + expect(result).toBe(true); 153 + }); 154 + 155 + it('handles missing indexedDB.databases gracefully', async () => { 156 + // Remove databases method from indexedDB 157 + vi.stubGlobal('indexedDB', {}); 158 + mockStorage.get.mockResolvedValue(null); 159 + 160 + // Should not throw 161 + const result = await worker.migrateIfNeeded(); 162 + 163 + expect(result).toBe(true); 164 + }); 165 + 166 + it('handles missing sessionStorage gracefully', async () => { 167 + // Remove sessionStorage 168 + vi.stubGlobal('sessionStorage', undefined); 169 + mockStorage.get.mockResolvedValue(null); 170 + 171 + // Should not throw 172 + const result = await worker.migrateIfNeeded(); 173 + 174 + expect(result).toBe(true); 175 + }); 176 + }); 177 + 178 + describe('delegation to BackgroundWorker', () => { 179 + it('delegates syncAnnotationsForUrl to worker', async () => { 180 + mockStorage.get.mockResolvedValue([]); 181 + mockFetchAnnotations.mockResolvedValue([]); 182 + 183 + await worker.syncAnnotationsForUrl('https://example.com'); 184 + 185 + expect(mockFetchAnnotations).toHaveBeenCalledWith('https://example.com'); 186 + }); 187 + 188 + it('delegates setCurrentUrl to worker', () => { 189 + worker.setCurrentUrl('https://example.com'); 190 + 191 + // Can verify by calling forceSync - it should use the set URL 192 + mockStorage.get.mockResolvedValue([]); 193 + mockFetchAnnotations.mockResolvedValue([]); 194 + 195 + worker.forceSync(); 196 + 197 + expect(mockFetchAnnotations).toHaveBeenCalledWith('https://example.com'); 198 + }); 199 + 200 + it('delegates forceSync to worker', async () => { 201 + worker.setCurrentUrl('https://example.com'); 202 + mockStorage.get.mockResolvedValue([]); 203 + mockFetchAnnotations.mockResolvedValue([]); 204 + 205 + await worker.forceSync(); 206 + 207 + expect(mockFetchAnnotations).toHaveBeenCalled(); 208 + }); 209 + }); 210 + });
+56 -1
packages/core/src/background/__tests__/worker.test.ts
··· 3 3 import type { StorageAdapter } from '../../storage/adapter'; 4 4 import type { Annotation } from '../../types'; 5 5 6 + // Constants matching those in worker.ts 7 + const MIGRATION_VERSION = 1; 8 + const MIGRATION_VERSION_KEY = 'seams-migration-version'; 9 + const OAUTH_SESSION_KEY = 'seams-oauth-session'; 10 + 6 11 describe('BackgroundWorker', () => { 7 12 let worker: BackgroundWorker; 8 13 let mockStorage: { ··· 28 33 29 34 mockStorage = { 30 35 get: vi.fn(), 31 - set: vi.fn(), 36 + set: vi.fn().mockResolvedValue(undefined), 32 37 onChange: vi.fn(), 33 38 }; 34 39 ··· 197 202 await worker.forceSync(); 198 203 199 204 expect(mockFetchAnnotations).not.toHaveBeenCalled(); 205 + }); 206 + }); 207 + 208 + describe('migrateIfNeeded', () => { 209 + it('clears OAuth session when version is missing', async () => { 210 + mockStorage.get.mockResolvedValue(null); 211 + 212 + const result = await worker.migrateIfNeeded(); 213 + 214 + expect(result).toBe(true); 215 + expect(mockStorage.set).toHaveBeenCalledWith(OAUTH_SESSION_KEY, null); 216 + }); 217 + 218 + it('clears OAuth session when version is outdated', async () => { 219 + mockStorage.get.mockResolvedValue(0); // Old version 220 + 221 + const result = await worker.migrateIfNeeded(); 222 + 223 + expect(result).toBe(true); 224 + expect(mockStorage.set).toHaveBeenCalledWith(OAUTH_SESSION_KEY, null); 225 + }); 226 + 227 + it('does not clear OAuth session when version matches', async () => { 228 + mockStorage.get.mockResolvedValue(MIGRATION_VERSION); 229 + 230 + const result = await worker.migrateIfNeeded(); 231 + 232 + expect(result).toBe(false); 233 + expect(mockStorage.set).not.toHaveBeenCalled(); 234 + }); 235 + 236 + it('updates stored version after migration', async () => { 237 + mockStorage.get.mockResolvedValue(null); 238 + 239 + await worker.migrateIfNeeded(); 240 + 241 + expect(mockStorage.set).toHaveBeenCalledWith(MIGRATION_VERSION_KEY, MIGRATION_VERSION); 242 + }); 243 + 244 + it('returns true when migration ran, false otherwise', async () => { 245 + // First call: no version stored 246 + mockStorage.get.mockResolvedValue(null); 247 + const first = await worker.migrateIfNeeded(); 248 + expect(first).toBe(true); 249 + 250 + // Second call: version matches 251 + mockStorage.get.mockResolvedValue(MIGRATION_VERSION); 252 + mockStorage.set.mockClear(); 253 + const second = await worker.migrateIfNeeded(); 254 + expect(second).toBe(false); 200 255 }); 201 256 }); 202 257 });
+13 -29
packages/core/src/background/extension.ts
··· 1 1 // Extension background worker - handles tab events, fetching, and syncing 2 2 import type { StorageAdapter } from '../storage/adapter'; 3 - import type { Annotation } from '../types'; 4 3 import { normalizeUrl } from '../utils'; 5 4 import { fetchAnnotations } from '../api'; 5 + import { BackgroundWorker } from './worker'; 6 6 7 7 declare const browser: any; 8 8 ··· 17 17 private storage: StorageAdapter; 18 18 private backendUrl: string; 19 19 private useContextMenu: boolean; 20 + private worker: BackgroundWorker; 20 21 21 22 constructor(options: ExtensionBackgroundWorkerOptions) { 22 23 this.storage = options.storage; 23 24 this.backendUrl = options.backendUrl || 'http://localhost:8080'; 24 25 this.useContextMenu = options.useContextMenu ?? false; 26 + 27 + // Create composed BackgroundWorker instance 28 + this.worker = new BackgroundWorker({ 29 + storage: this.storage, 30 + fetchAnnotations: (url: string) => fetchAnnotations(this.backendUrl, url), 31 + }); 25 32 } 26 33 27 34 async start(): Promise<void> { 35 + await this.worker.migrateIfNeeded(); 28 36 this.registerListeners(); 29 37 } 30 38 ··· 70 78 if (tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) { 71 79 const normalized = normalizeUrl(tab.url); 72 80 console.log('[background] Tab activated, pre-fetching annotations for', normalized); 73 - await this.fetchAndCacheAnnotations(normalized); 81 + await this.worker.syncAnnotationsForUrl(normalized); 74 82 } 75 83 } catch (error) { 76 84 console.error('[background] Failed to pre-fetch on tab activation:', error); ··· 82 90 if (changeInfo.status === 'complete' && tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('about:')) { 83 91 const normalized = normalizeUrl(tab.url); 84 92 console.log('[background] Tab updated, pre-fetching annotations for', normalized); 85 - await this.fetchAndCacheAnnotations(normalized); 93 + await this.worker.syncAnnotationsForUrl(normalized); 86 94 } 87 95 }); 88 96 ··· 94 102 95 103 const normalized = normalizeUrl(details.url); 96 104 console.log('[background] SPA navigation detected:', normalized); 97 - await this.fetchAndCacheAnnotations(normalized); 105 + await this.worker.syncAnnotationsForUrl(normalized); 98 106 }); 99 107 } else { 100 108 console.error('[background] browser.webNavigation is not available!'); ··· 107 115 browser.tabs.query({ active: true, currentWindow: true }).then((tabs: any[]) => { 108 116 if (tabs[0]?.url) { 109 117 const normalized = normalizeUrl(tabs[0].url); 110 - this.fetchAndCacheAnnotations(normalized); 118 + this.worker.syncAnnotationsForUrl(normalized); 111 119 } 112 120 }); 113 121 sendResponse({ success: true }); ··· 123 131 } 124 132 } 125 133 }); 126 - } 127 - 128 - private async fetchAndCacheAnnotations(url: string): Promise<void> { 129 - console.log(`[background] Fetching annotations for ${url}`); 130 - 131 - try { 132 - const newAnnotations = await fetchAnnotations(this.backendUrl, url); 133 - 134 - // Merge with existing annotations 135 - const annotations = await this.storage.get('annotations') || []; 136 - const existingUris = new Set(annotations.map((a: any) => a.uri)); 137 - const toAdd = newAnnotations.filter((a: Annotation) => !existingUris.has(a.uri)); 138 - 139 - // Limit total annotations to prevent unbounded memory growth 140 - const MAX_ANNOTATIONS = 500; 141 - const updated = [...annotations, ...toAdd].slice(-MAX_ANNOTATIONS); 142 - 143 - await this.storage.set('annotations', updated); 144 - 145 - console.log(`[background] Fetched ${newAnnotations.length} annotations for ${url}, added ${toAdd.length} new (total: ${updated.length})`); 146 - } catch (error) { 147 - console.error('[background] Failed to fetch annotations:', error); 148 - throw error; 149 - } 150 134 } 151 135 152 136 }
+2
packages/core/src/background/index.ts
··· 2 2 export type { BackgroundWorkerOptions } from './worker'; 3 3 export { ExtensionBackgroundWorker } from './extension'; 4 4 export type { ExtensionBackgroundWorkerOptions } from './extension'; 5 + export { ProxyBackgroundWorker } from './proxy'; 6 + export type { ProxyBackgroundWorkerOptions } from './proxy';
+111
packages/core/src/background/proxy.ts
··· 1 + // Proxy background worker - handles proxy-specific migration and cleanup 2 + import type { StorageAdapter } from '../storage/adapter'; 3 + import type { Annotation } from '../types'; 4 + import { BackgroundWorker } from './worker'; 5 + 6 + export interface ProxyBackgroundWorkerOptions { 7 + storage: StorageAdapter; 8 + fetchAnnotations: (url: string) => Promise<Annotation[]>; 9 + } 10 + 11 + export class ProxyBackgroundWorker { 12 + private worker: BackgroundWorker; 13 + 14 + constructor(options: ProxyBackgroundWorkerOptions) { 15 + this.worker = new BackgroundWorker({ 16 + storage: options.storage, 17 + fetchAnnotations: options.fetchAnnotations, 18 + }); 19 + } 20 + 21 + /** 22 + * Run migration if needed - handles both shared migration (OAuth) and proxy-specific cleanup 23 + * Returns true if migration was performed, false otherwise 24 + */ 25 + async migrateIfNeeded(): Promise<boolean> { 26 + const migrated = await this.worker.migrateIfNeeded(); 27 + 28 + if (migrated) { 29 + console.log('[ProxyBackgroundWorker] Migration triggered, running proxy-specific cleanup'); 30 + await this.clearServiceWorkers(); 31 + await this.clearWabacIndexedDB(); 32 + this.clearSessionStorage(); 33 + } 34 + 35 + return migrated; 36 + } 37 + 38 + /** 39 + * Unregister all service workers for this scope 40 + */ 41 + private async clearServiceWorkers(): Promise<void> { 42 + try { 43 + if (typeof navigator !== 'undefined' && navigator.serviceWorker) { 44 + const registrations = await navigator.serviceWorker.getRegistrations(); 45 + for (const registration of registrations) { 46 + console.log('[ProxyBackgroundWorker] Unregistering service worker:', registration.scope); 47 + await registration.unregister(); 48 + } 49 + } 50 + } catch (error) { 51 + console.warn('[ProxyBackgroundWorker] Failed to unregister service workers:', error); 52 + } 53 + } 54 + 55 + /** 56 + * Clear wabac.js IndexedDB databases 57 + * wabac.js uses databases like 'db', 'wabac-xxx', etc. 58 + */ 59 + private async clearWabacIndexedDB(): Promise<void> { 60 + try { 61 + if (typeof indexedDB !== 'undefined' && indexedDB.databases) { 62 + const databases = await indexedDB.databases(); 63 + for (const db of databases) { 64 + // Clear wabac-related databases and the generic 'db' used by wabac 65 + if (db.name && (db.name.startsWith('wabac') || db.name === 'db')) { 66 + console.log('[ProxyBackgroundWorker] Deleting IndexedDB:', db.name); 67 + await new Promise<void>((resolve, reject) => { 68 + const req = indexedDB.deleteDatabase(db.name!); 69 + req.onsuccess = () => resolve(); 70 + req.onerror = () => reject(req.error); 71 + req.onblocked = () => { 72 + console.warn('[ProxyBackgroundWorker] IndexedDB delete blocked:', db.name); 73 + resolve(); // Don't fail migration if blocked 74 + }; 75 + }); 76 + } 77 + } 78 + } 79 + } catch (error) { 80 + console.warn('[ProxyBackgroundWorker] Failed to clear IndexedDB:', error); 81 + } 82 + } 83 + 84 + /** 85 + * Clear session storage keys used by the proxy 86 + */ 87 + private clearSessionStorage(): void { 88 + try { 89 + if (typeof sessionStorage !== 'undefined') { 90 + sessionStorage.removeItem('seams_login_redirect'); 91 + console.log('[ProxyBackgroundWorker] Cleared session storage redirect key'); 92 + } 93 + } catch (error) { 94 + console.warn('[ProxyBackgroundWorker] Failed to clear session storage:', error); 95 + } 96 + } 97 + 98 + // Delegate methods to composed BackgroundWorker 99 + 100 + async syncAnnotationsForUrl(url: string): Promise<void> { 101 + return this.worker.syncAnnotationsForUrl(url); 102 + } 103 + 104 + setCurrentUrl(url: string): void { 105 + this.worker.setCurrentUrl(url); 106 + } 107 + 108 + async forceSync(): Promise<void> { 109 + return this.worker.forceSync(); 110 + } 111 + }
+29
packages/core/src/background/worker.ts
··· 2 2 import type { StorageAdapter } from '../storage/adapter'; 3 3 import type { Annotation } from '../types'; 4 4 5 + // Migration version - increment to force re-login for all users 6 + const MIGRATION_VERSION = 3; 7 + const MIGRATION_VERSION_KEY = 'seams-migration-version'; 8 + const OAUTH_SESSION_KEY = 'seams-oauth-session'; 9 + 5 10 export interface BackgroundWorkerOptions { 6 11 storage: StorageAdapter; 7 12 fetchAnnotations: (url: string) => Promise<Annotation[]>; ··· 15 20 constructor(options: BackgroundWorkerOptions) { 16 21 this.storage = options.storage; 17 22 this.fetchAnnotations = options.fetchAnnotations; 23 + } 24 + 25 + /** 26 + * Run migration if needed - clears OAuth session to force re-login 27 + * Returns true if migration was performed, false otherwise 28 + */ 29 + async migrateIfNeeded(): Promise<boolean> { 30 + const stored = await this.storage.get(MIGRATION_VERSION_KEY); 31 + if (stored === MIGRATION_VERSION) { 32 + console.log('[BackgroundWorker] Migration version matches, no migration needed'); 33 + return false; 34 + } 35 + 36 + console.log(`[BackgroundWorker] Migration needed: ${stored || 'none'} -> ${MIGRATION_VERSION}`); 37 + 38 + // Clear OAuth session (forces re-login) 39 + await this.storage.set(OAUTH_SESSION_KEY, null); 40 + console.log('[BackgroundWorker] Cleared OAuth session'); 41 + 42 + // Update stored version 43 + await this.storage.set(MIGRATION_VERSION_KEY, MIGRATION_VERSION); 44 + console.log('[BackgroundWorker] Migration complete, version set to:', MIGRATION_VERSION); 45 + 46 + return true; 18 47 } 19 48 20 49 async syncAnnotationsForUrl(url: string): Promise<void> {
+199
packages/core/src/components/__tests__/annotation-card.test.ts
··· 1 + import { describe, it, expect, beforeEach, beforeAll } from 'vitest'; 2 + import { SeamsAnnotationCard } from '../annotation-card'; 3 + import type { Annotation } from '../../types'; 4 + 5 + // Register the custom element before tests 6 + beforeAll(() => { 7 + if (!customElements.get('seams-annotation-card')) { 8 + customElements.define('seams-annotation-card', SeamsAnnotationCard); 9 + } 10 + }); 11 + 12 + const createAnnotation = (overrides: Partial<Annotation> = {}): Annotation => ({ 13 + uri: 'at://did:plc:test/community.lexicon.annotation.annotation/abc123', 14 + cid: 'bafytest', 15 + value: { 16 + target: { 17 + url: 'https://example.com/some/page', 18 + selector: [ 19 + { 20 + $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 21 + exact: 'Selected text', 22 + }, 23 + ], 24 + }, 25 + body: 'My annotation', 26 + createdAt: '2024-01-15T12:00:00Z', 27 + }, 28 + author: { 29 + did: 'did:plc:testuser', 30 + handle: 'testuser.bsky.social', 31 + }, 32 + ...overrides, 33 + }); 34 + 35 + describe('SeamsAnnotationCard', () => { 36 + let card: SeamsAnnotationCard; 37 + 38 + beforeEach(() => { 39 + card = document.createElement('seams-annotation-card') as SeamsAnnotationCard; 40 + document.body.appendChild(card); 41 + }); 42 + 43 + describe('showSourceInfo prop', () => { 44 + it('hides source link and domain by default when showSourceInfo is not set', () => { 45 + const annotation = createAnnotation(); 46 + card.annotation = annotation; 47 + // Don't set showSourceInfo - test the default behavior 48 + 49 + const shadowRoot = card.shadowRoot!; 50 + const sourceLink = shadowRoot.querySelector('.annotation-source'); 51 + const sourceDomain = shadowRoot.querySelector('.source-domain'); 52 + 53 + expect(sourceLink).toBeNull(); 54 + expect(sourceDomain).toBeNull(); 55 + }); 56 + 57 + it('hides source link and domain when showSourceInfo is explicitly false', () => { 58 + const annotation = createAnnotation(); 59 + card.annotation = annotation; 60 + card.showSourceInfo = false; 61 + 62 + const shadowRoot = card.shadowRoot!; 63 + const sourceLink = shadowRoot.querySelector('.annotation-source'); 64 + const sourceDomain = shadowRoot.querySelector('.source-domain'); 65 + 66 + expect(sourceLink).toBeNull(); 67 + expect(sourceDomain).toBeNull(); 68 + }); 69 + 70 + it('shows source link when showSourceInfo is true (for landing page)', () => { 71 + const annotation = createAnnotation(); 72 + card.annotation = annotation; 73 + card.showSourceInfo = true; 74 + 75 + const shadowRoot = card.shadowRoot!; 76 + const sourceLink = shadowRoot.querySelector('.annotation-source'); 77 + 78 + expect(sourceLink).not.toBeNull(); 79 + // Domain is rendered inside the link with an arrow 80 + expect(sourceLink?.textContent).toContain('example.com'); 81 + expect(sourceLink?.textContent).toContain('↗'); 82 + }); 83 + 84 + it('extracts and displays domain from URL (TLD only, no path)', () => { 85 + const annotation = createAnnotation({ 86 + value: { 87 + target: { url: 'https://www.nytimes.com/2024/01/15/technology/ai-news.html' }, 88 + body: 'Test', 89 + createdAt: '2024-01-15T12:00:00Z', 90 + }, 91 + }); 92 + card.annotation = annotation; 93 + card.showSourceInfo = true; 94 + 95 + const shadowRoot = card.shadowRoot!; 96 + const sourceLink = shadowRoot.querySelector('.annotation-source'); 97 + 98 + expect(sourceLink?.textContent).toContain('nytimes.com'); 99 + }); 100 + 101 + it('removes www prefix from domain', () => { 102 + const annotation = createAnnotation({ 103 + value: { 104 + target: { url: 'https://www.example.com/page' }, 105 + body: 'Test', 106 + createdAt: '2024-01-15T12:00:00Z', 107 + }, 108 + }); 109 + card.annotation = annotation; 110 + card.showSourceInfo = true; 111 + 112 + const shadowRoot = card.shadowRoot!; 113 + const sourceLink = shadowRoot.querySelector('.annotation-source'); 114 + 115 + expect(sourceLink?.textContent).toContain('example.com'); 116 + expect(sourceLink?.textContent).not.toContain('www.'); 117 + }); 118 + 119 + it('handles subdomains correctly', () => { 120 + const annotation = createAnnotation({ 121 + value: { 122 + target: { url: 'https://blog.example.com/post' }, 123 + body: 'Test', 124 + createdAt: '2024-01-15T12:00:00Z', 125 + }, 126 + }); 127 + card.annotation = annotation; 128 + card.showSourceInfo = true; 129 + 130 + const shadowRoot = card.shadowRoot!; 131 + const sourceLink = shadowRoot.querySelector('.annotation-source'); 132 + 133 + // Should show subdomain.domain.tld 134 + expect(sourceLink?.textContent).toContain('blog.example.com'); 135 + }); 136 + }); 137 + 138 + describe('proxy URL generation', () => { 139 + it('generates proxy URL when proxyBaseUrl is provided', () => { 140 + const annotation = createAnnotation({ 141 + value: { 142 + target: { url: 'https://example.com/article' }, 143 + body: 'Test', 144 + createdAt: '2024-01-15T12:00:00Z', 145 + }, 146 + }); 147 + card.annotation = annotation; 148 + card.showSourceInfo = true; 149 + card.proxyBaseUrl = 'https://sure.seams.so'; 150 + 151 + const shadowRoot = card.shadowRoot!; 152 + const sourceLink = shadowRoot.querySelector('.annotation-source') as HTMLAnchorElement; 153 + 154 + expect(sourceLink?.href).toBe('https://sure.seams.so/#https://example.com/article'); 155 + }); 156 + 157 + it('uses direct URL when proxyBaseUrl is not provided', () => { 158 + const annotation = createAnnotation({ 159 + value: { 160 + target: { url: 'https://example.com/article' }, 161 + body: 'Test', 162 + createdAt: '2024-01-15T12:00:00Z', 163 + }, 164 + }); 165 + card.annotation = annotation; 166 + card.showSourceInfo = true; 167 + // No proxyBaseUrl set 168 + 169 + const shadowRoot = card.shadowRoot!; 170 + const sourceLink = shadowRoot.querySelector('.annotation-source') as HTMLAnchorElement; 171 + 172 + expect(sourceLink?.href).toBe('https://example.com/article'); 173 + }); 174 + }); 175 + 176 + describe('source info positioning', () => { 177 + it('renders source link after the timestamp in annotation-meta', () => { 178 + const annotation = createAnnotation(); 179 + card.annotation = annotation; 180 + card.showSourceInfo = true; 181 + 182 + const shadowRoot = card.shadowRoot!; 183 + const meta = shadowRoot.querySelector('.annotation-meta'); 184 + const children = Array.from(meta?.children || []); 185 + 186 + // Find the positions of timestamp span and source link 187 + const timestampIndex = children.findIndex( 188 + el => el.tagName === 'SPAN' && !el.classList.contains('annotation-source') 189 + ); 190 + const sourceLinkIndex = children.findIndex( 191 + el => el.classList.contains('annotation-source') 192 + ); 193 + 194 + expect(timestampIndex).toBeGreaterThan(-1); 195 + expect(sourceLinkIndex).toBeGreaterThan(-1); 196 + expect(sourceLinkIndex).toBeGreaterThan(timestampIndex); 197 + }); 198 + }); 199 + });
+5 -5
packages/core/src/oauth/__tests__/oauth.test.ts
··· 157 157 await manager.startLoginProcess(mockHandle); 158 158 159 159 expect(mockStorage.set).toHaveBeenCalledWith( 160 - 'synthesis-oauth:session', 160 + 'seams-oauth-session', 161 161 mockSession 162 162 ); 163 163 }); ··· 197 197 await manager.saveSession(session); 198 198 199 199 expect(mockStorage.set).toHaveBeenCalledWith( 200 - 'synthesis-oauth:session', 200 + 'seams-oauth-session', 201 201 session 202 202 ); 203 203 }); ··· 210 210 211 211 const session = await manager.loadSession(); 212 212 213 - expect(mockStorage.get).toHaveBeenCalledWith('synthesis-oauth:session'); 213 + expect(mockStorage.get).toHaveBeenCalledWith('seams-oauth-session'); 214 214 expect(session).toEqual(storedSession); 215 215 }); 216 216 ··· 228 228 await manager.clearSession(); 229 229 230 230 expect(mockStorage.set).toHaveBeenCalledWith( 231 - 'synthesis-oauth:session', 231 + 'seams-oauth-session', 232 232 null 233 233 ); 234 234 }); ··· 337 337 await handleOAuthCallback(mockStorage); 338 338 339 339 expect(mockStorage.set).toHaveBeenCalledWith( 340 - 'synthesis-oauth:session', 340 + 'seams-oauth-session', 341 341 mockSession 342 342 ); 343 343 });
+1 -1
packages/core/src/oauth/index.ts
··· 8 8 } from "@atcute/oauth-browser-client"; 9 9 import type { StorageAdapter } from "../storage"; 10 10 11 - const OAUTH_SESSION_KEY = "synthesis-oauth:session"; 11 + const OAUTH_SESSION_KEY = "seams-oauth-session"; 12 12 13 13 export interface OAuthLauncher { 14 14 launch(authUrl: URL): Promise<string>;
+1 -1
packages/core/src/sidebar/__tests__/login-typeahead.test.ts
··· 6 6 const createMockStorage = (): StorageAdapter => ({ 7 7 get: vi.fn().mockImplementation((key: string | string[]) => { 8 8 // Return null for oauth session to simulate logged-out state 9 - if (key === 'synthesis-oauth:session') { 9 + if (key === 'seams-oauth-session') { 10 10 return Promise.resolve(null); 11 11 } 12 12 // Return empty arrays/objects for other keys
+37
packages/core/src/storage/__tests__/web.test.ts
··· 146 146 oldValue: 'oldValue', 147 147 }); 148 148 }); 149 + 150 + it('removes key from localStorage when value is null', async () => { 151 + localStorage.setItem('testKey', JSON.stringify('existingValue')); 152 + 153 + await adapter.set('testKey', null); 154 + 155 + expect(localStorage.getItem('testKey')).toBeNull(); 156 + }); 157 + 158 + it('removes key from localStorage when value is undefined', async () => { 159 + localStorage.setItem('testKey', JSON.stringify('existingValue')); 160 + 161 + await adapter.set('testKey', undefined); 162 + 163 + expect(localStorage.getItem('testKey')).toBeNull(); 164 + }); 165 + 166 + it('broadcasts change with null value when key is removed', async () => { 167 + localStorage.setItem('testKey', JSON.stringify('oldValue')); 168 + 169 + await adapter.set('testKey', null); 170 + 171 + expect(postMessageMock).toHaveBeenCalledWith({ 172 + key: 'testKey', 173 + newValue: null, 174 + oldValue: 'oldValue', 175 + }); 176 + }); 177 + 178 + it('returns null when getting a key that was set to null', async () => { 179 + localStorage.setItem('testKey', JSON.stringify('existingValue')); 180 + await adapter.set('testKey', null); 181 + 182 + const result = await adapter.get('testKey'); 183 + 184 + expect(result).toBeNull(); 185 + }); 149 186 }); 150 187 151 188 describe('onChange', () => {
+7 -1
packages/core/src/storage/web.ts
··· 44 44 45 45 async set(key: string, value: any): Promise<void> { 46 46 const oldValue = await this.get(key); 47 - localStorage.setItem(key, JSON.stringify(value)); 47 + 48 + // Handle null/undefined by removing the key instead of storing "null" string 49 + if (value === null || value === undefined) { 50 + localStorage.removeItem(key); 51 + } else { 52 + localStorage.setItem(key, JSON.stringify(value)); 53 + } 48 54 49 55 const change: StorageChange = { key, newValue: value, oldValue }; 50 56
+10 -92
proxy/public/loadwabac.js
··· 1 1 /** 2 2 * SeamsLiveProxy - Client-side web proxy using wabac.js service worker 3 3 * Based on https://github.com/webrecorder/wabac.js/blob/main/examples/live-proxy/loadwabac.js 4 + * 5 + * Note: Migration is handled by ProxyBackgroundWorker in shell.ts 6 + * This class only handles wabac.js service worker registration and URL routing 4 7 */ 5 8 class SeamsLiveProxy { 6 - // Migration version - increment to trigger migration for all users 7 - // Version 2: Initial client-side proxy migration (clears old SW, OAuth, storage) 8 - static PROXY_VERSION = 2; 9 - static VERSION_KEY = 'seams-proxy-version'; 10 - 11 9 constructor({ 12 10 corsProxy = 'http://127.0.0.1:8082/proxy/', 13 11 collName = 'liveproxy', ··· 46 44 } 47 45 } 48 46 49 - /** 50 - * Run migration if needed - clears old SW, storage keys, and IndexedDB 51 - * Returns true if migration was performed 52 - */ 53 - async _migrateIfNeeded() { 54 - const currentVersion = localStorage.getItem(SeamsLiveProxy.VERSION_KEY); 55 - const targetVersion = SeamsLiveProxy.PROXY_VERSION.toString(); 56 - 57 - if (currentVersion === targetVersion) { 58 - console.log('[loadwabac] Version matches, no migration needed'); 59 - return false; 60 - } 61 - 62 - console.log(`[loadwabac] Migration needed: ${currentVersion || 'none'} -> ${targetVersion}`); 63 - 64 - // Unregister all service workers for this scope 65 - try { 66 - const registrations = await navigator.serviceWorker.getRegistrations(); 67 - for (const registration of registrations) { 68 - console.log('[loadwabac] Unregistering old service worker:', registration.scope); 69 - await registration.unregister(); 70 - } 71 - } catch (error) { 72 - console.warn('[loadwabac] Failed to unregister service workers:', error); 73 - } 74 - 75 - // Clear session storage (seams_login_redirect) 76 - try { 77 - sessionStorage.removeItem('seams_login_redirect'); 78 - console.log('[loadwabac] Cleared session storage redirect key'); 79 - } catch (error) { 80 - console.warn('[loadwabac] Failed to clear session storage:', error); 81 - } 82 - 83 - // Clear OAuth session (synthesis-oauth:session) 84 - try { 85 - localStorage.removeItem('synthesis-oauth:session'); 86 - console.log('[loadwabac] Cleared OAuth session'); 87 - } catch (error) { 88 - console.warn('[loadwabac] Failed to clear OAuth session:', error); 89 - } 90 - 91 - // Clear wabac.js IndexedDB databases 92 - // wabac.js uses databases like 'db', 'wabac-xxx', etc. 93 - try { 94 - if (indexedDB.databases) { 95 - const databases = await indexedDB.databases(); 96 - for (const db of databases) { 97 - // Clear wabac-related databases and the generic 'db' used by wabac 98 - if (db.name && (db.name.startsWith('wabac') || db.name === 'db')) { 99 - console.log('[loadwabac] Deleting IndexedDB:', db.name); 100 - await new Promise((resolve, reject) => { 101 - const req = indexedDB.deleteDatabase(db.name); 102 - req.onsuccess = () => resolve(); 103 - req.onerror = () => reject(req.error); 104 - req.onblocked = () => { 105 - console.warn('[loadwabac] IndexedDB delete blocked:', db.name); 106 - resolve(); // Don't fail migration if blocked 107 - }; 108 - }); 109 - } 110 - } 111 - } 112 - } catch (error) { 113 - console.warn('[loadwabac] Failed to clear IndexedDB:', error); 114 - } 115 - 116 - // Mark migration complete 117 - localStorage.setItem(SeamsLiveProxy.VERSION_KEY, targetVersion); 118 - console.log('[loadwabac] Migration complete, version set to:', targetVersion); 119 - 120 - return true; 121 - } 122 - 123 47 async init() { 124 48 console.log('[loadwabac] Initializing proxy'); 125 49 126 - // Run migration if needed (clears old SW, storage) 127 - const migrated = await this._migrateIfNeeded(); 128 - if (migrated) { 129 - console.log('[loadwabac] Migration completed, proceeding with fresh registration'); 130 - } 50 + // Note: Migration is handled by ProxyBackgroundWorker in shell.ts before this runs 131 51 132 52 const scope = './'; 133 53 const swParams = new URLSearchParams({ injectScripts: this.injectScripts }); 134 54 135 - // If no migration was needed and SW is already active, skip registration 55 + // If SW is already active, skip registration 136 56 // The collection persists in SW's IndexedDB 137 - if (!migrated) { 138 - const existingReg = await navigator.serviceWorker.getRegistration(scope); 139 - if (existingReg?.active && navigator.serviceWorker.controller) { 140 - console.log('[loadwabac] SW already active and controlling, skipping registration'); 141 - this._setupEventListeners(); 142 - return; 143 - } 57 + const existingReg = await navigator.serviceWorker.getRegistration(scope); 58 + if (existingReg?.active && navigator.serviceWorker.controller) { 59 + console.log('[loadwabac] SW already active and controlling, skipping registration'); 60 + this._setupEventListeners(); 61 + return; 144 62 } 145 63 146 64 // Do fresh registration
+13 -4
proxy/src/shell.ts
··· 1 1 // Shell entry point - runs in the parent frame 2 - // Manages BackgroundWorker, storage, and renders SeamsSidebar web component 2 + // Manages ProxyBackgroundWorker, storage, and renders SeamsSidebar web component 3 3 import { 4 4 WebStorageAdapter, 5 - BackgroundWorker, 5 + ProxyBackgroundWorker, 6 6 fetchAnnotations, 7 7 SeamsSidebar, 8 8 DEFAULT_OAUTH_SCOPE, ··· 18 18 // Initialize storage (localStorage accessible from shell) 19 19 const storage = new WebStorageAdapter(); 20 20 21 - // Initialize background worker 22 - const backgroundWorker = new BackgroundWorker({ 21 + // Initialize proxy background worker 22 + const backgroundWorker = new ProxyBackgroundWorker({ 23 23 storage, 24 24 fetchAnnotations: async (url: string) => { 25 25 return fetchAnnotations(BACKEND_URL, url); 26 26 }, 27 + }); 28 + 29 + // Run migration before anything else (clears OAuth session and proxy-specific state if version changed) 30 + backgroundWorker.migrateIfNeeded().then((migrated) => { 31 + if (migrated) { 32 + console.log('[shell] Migration completed - OAuth session and proxy state cleared'); 33 + } 34 + }).catch((error) => { 35 + console.error('[shell] Migration failed:', error); 27 36 }); 28 37 29 38 // Track current page URL
+22 -21
tests/e2e/proxy/migration.spec.ts
··· 6 6 * 2. The version key is outdated 7 7 * 3. Old service worker needs to be replaced 8 8 * 9 - * The migration should: 10 - * - Unregister old service workers 11 - * - Clear sessionStorage['seams_login_redirect'] 12 - * - Clear localStorage['synthesis-oauth:session'] 13 - * - Delete wabac.js IndexedDB databases 14 - * - Set the version key to prevent re-migration 9 + * Migration is handled by ProxyBackgroundWorker (in shell.ts) which: 10 + * - Clears OAuth session (via BackgroundWorker.migrateIfNeeded) 11 + * - Unregisters old service workers 12 + * - Clears sessionStorage['seams_login_redirect'] 13 + * - Deletes wabac.js IndexedDB databases 14 + * - Sets the version key to prevent re-migration 15 15 */ 16 16 17 17 import { test, expect, type Page } from '@playwright/test'; 18 18 import { PROXY_BASE_URL, waitForServiceWorkerReady } from '../../helpers/proxy'; 19 19 20 - // Constants matching those in loadwabac.js 21 - const VERSION_KEY = 'seams-proxy-version'; 22 - const OAUTH_SESSION_KEY = 'synthesis-oauth:session'; 20 + // Constants matching those in BackgroundWorker (packages/core/src/background/worker.ts) 21 + const MIGRATION_VERSION_KEY = 'seams-migration-version'; 22 + const OAUTH_SESSION_KEY = 'seams-oauth-session'; 23 23 const LOGIN_REDIRECT_KEY = 'seams_login_redirect'; 24 24 25 25 test.describe('Proxy Migration', () => { ··· 101 101 await page.reload({ waitUntil: 'networkidle' }); 102 102 await waitForServiceWorkerReady(page); 103 103 104 - // Version key should now be set 104 + // Migration version key should now be set 105 105 const versionKey = await page.evaluate( 106 106 (key) => localStorage.getItem(key), 107 - VERSION_KEY 107 + MIGRATION_VERSION_KEY 108 108 ); 109 109 expect(versionKey).not.toBeNull(); 110 110 expect(parseInt(versionKey!)).toBeGreaterThanOrEqual(1); 111 111 112 - // OAuth session should be cleared 112 + // OAuth session should be cleared (key removed from localStorage) 113 113 const postOAuth = await page.evaluate( 114 114 (key) => localStorage.getItem(key), 115 115 OAUTH_SESSION_KEY ··· 135 135 // Get the current version 136 136 const currentVersion = await page.evaluate( 137 137 (key) => localStorage.getItem(key), 138 - VERSION_KEY 138 + MIGRATION_VERSION_KEY 139 139 ); 140 140 expect(currentVersion).not.toBeNull(); 141 141 ··· 175 175 await waitForServiceWorkerReady(page); 176 176 177 177 // Set an old version number (simulating upgrade from old version) 178 + // Version 0 is always outdated since current version is 1 178 179 await page.evaluate( 179 180 (key) => { 180 - localStorage.setItem(key, '1'); // Old version 181 + localStorage.setItem(key, '0'); // Old version 181 182 }, 182 - VERSION_KEY 183 + MIGRATION_VERSION_KEY 183 184 ); 184 185 185 186 // Set up state that should be cleared ··· 189 190 await page.reload({ waitUntil: 'networkidle' }); 190 191 await waitForServiceWorkerReady(page); 191 192 192 - // Version should be updated to current 193 + // Version should be updated to current (1) 193 194 const newVersion = await page.evaluate( 194 195 (key) => localStorage.getItem(key), 195 - VERSION_KEY 196 + MIGRATION_VERSION_KEY 196 197 ); 197 - expect(parseInt(newVersion!)).toBeGreaterThan(1); 198 + expect(parseInt(newVersion!)).toBeGreaterThanOrEqual(1); 198 199 199 - // OAuth session should be cleared 200 + // OAuth session should be cleared (key removed from localStorage) 200 201 const postOAuth = await page.evaluate( 201 202 (key) => localStorage.getItem(key), 202 203 OAUTH_SESSION_KEY ··· 260 261 (key) => { 261 262 localStorage.removeItem(key); 262 263 }, 263 - VERSION_KEY 264 + MIGRATION_VERSION_KEY 264 265 ); 265 266 266 267 // Reload to trigger migration ··· 274 275 // Note: New databases may be created after migration, but old data is gone 275 276 const versionAfter = await page.evaluate( 276 277 (key) => localStorage.getItem(key), 277 - VERSION_KEY 278 + MIGRATION_VERSION_KEY 278 279 ); 279 280 expect(versionAfter).not.toBeNull(); 280 281 });