const SYNC_ALARM_NAME = "orbitmarks-periodic-sync"; const TOKEN_REFRESH_ALARM_NAME = "orbitmarks-token-refresh"; const DEBOUNCE_DELAY = 300; const logBg = createLogger("background"); let syncState = "idle"; let lastSyncResult = null; let pdsClient = null; let dpopKeyPair = null; let bgInitialized = false; /** * Initializes the background script on install, startup, or reload. * Loads config, DPoP key pair, creates the PDS client, and sets up badge, alarms, and listeners. * @returns {Promise} */ async function initialize() { if (bgInitialized) return; bgInitialized = true; const done = logBg.timer("initialize"); try { logBg.debug("Loading config"); const config = await getConfig(); dpopKeyPair = await loadDPoPKeyPair(config); if (config.dpopNonce) setDpopNonce(config.dpopNonce); logBg.debug("Creating PDS client"); pdsClient = await createPdsClient(config, dpopKeyPair); updateBadge(config); setupAlarm(config.syncInterval); setupBookmarkListeners(); setupMessageListener(); setupTokenRefreshAlarm(config); done("background script initialized"); logBg.info("Background script initialized"); } catch (err) { logBg.error("Initialization error", { error: err.message }); done(`initialization failed: ${err.message}`); } } /** * Imports the DPoP key pair from stored JWKs in the config. * @param {Object} config - The extension config * @returns {Promise} The key pair or null if not configured */ async function loadDPoPKeyPair(config) { if (config.dpopPublicKeyJwk && config.dpopPrivateKeyJwk) { logBg.debug("loadDPoPKeyPair: importing from config"); return importDPoPKeyPair(config.dpopPublicKeyJwk, config.dpopPrivateKeyJwk); } logBg.debug("loadDPoPKeyPair: no keys in config"); return null; } /** * Creates a new PdsClient instance if config and key pair are available. * Attaches an onRefresh callback that persists refreshed tokens to storage. * @param {Object} config - The extension config * @param {CryptoKeyPair|null} keyPair - The DPoP key pair * @returns {Promise} The client or null if prerequisites are missing */ async function createPdsClient(config, keyPair) { if (!config.pdsUrl || !config.accessToken || !keyPair) { logBg.debug("createPdsClient: prerequisites missing", { hasUrl: !!config.pdsUrl, hasToken: !!config.accessToken, hasKey: !!keyPair, }); return null; } logBg.info("createPdsClient", { pdsUrl: config.pdsUrl }); return new PdsClient(config.pdsUrl, { accessToken: config.accessToken, dpopKeyPair: keyPair, onRefresh: async () => { logBg.info("Token refresh callback triggered"); const cfg = await getConfig(); if (!cfg.refreshToken || !cfg.clientId) { logBg.error("No refresh token available for onRefresh"); throw new Error("No refresh token available"); } const tokenEndpoint = cfg.oauthTokenEndpoint || `${cfg.pdsUrl}/oauth/token`; const result = await refreshAccessToken( tokenEndpoint, cfg.refreshToken, cfg.clientId, dpopKeyPair, ); await setConfig({ accessToken: result.accessToken, refreshToken: result.refreshToken, tokenExpiresAt: result.expiresIn ? Date.now() + result.expiresIn * 1000 : null, dpopNonce: getDpopNonce(), }); updateBadge(await getConfig()); return result; }, }); } /** * Updates the browser action badge and tooltip based on current config state. * Grey "!" = not configured, red "!" = no token or expired, hidden = connected. * @param {Object} config - The extension config * @returns {void} */ function updateBadge(config) { if (!config.handle || !config.clientId) { browser.browserAction.setBadgeText({ text: "!" }); browser.browserAction.setBadgeBackgroundColor({ color: "#888888" }); browser.browserAction.setTitle({ title: "OrbitMarks — Not configured" }); logBg.debug("Badge: not configured"); return; } if (!config.accessToken) { browser.browserAction.setBadgeText({ text: "!" }); browser.browserAction.setBadgeBackgroundColor({ color: "#e22850" }); browser.browserAction.setTitle({ title: "OrbitMarks — Not authenticated" }); logBg.debug("Badge: not authenticated"); return; } if (config.tokenExpiresAt && Date.now() > config.tokenExpiresAt) { browser.browserAction.setBadgeText({ text: "!" }); browser.browserAction.setBadgeBackgroundColor({ color: "#e22850" }); browser.browserAction.setTitle({ title: "OrbitMarks — Token expired" }); logBg.debug("Badge: token expired"); return; } browser.browserAction.setBadgeText({ text: "" }); browser.browserAction.setTitle({ title: "OrbitMarks — Connected" }); logBg.debug("Badge: connected"); } /** * Sets the badge to a blue "~" to indicate an active sync operation. * @returns {void} */ function setSyncBadge() { browser.browserAction.setBadgeText({ text: "~" }); browser.browserAction.setBadgeBackgroundColor({ color: "#0060df" }); } /** * Restores the badge to its normal state (based on config) after a sync completes. * @returns {void} */ function clearSyncBadge() { getConfig().then(updateBadge); } /** * Creates or clears the periodic sync alarm based on the configured interval. * @param {number} intervalMinutes - Sync interval in minutes (0 disables) * @returns {void} */ function setupAlarm(intervalMinutes) { browser.alarms.clear(SYNC_ALARM_NAME); if (intervalMinutes > 0) { logBg.debug("Setting up periodic sync alarm", { intervalMinutes }); browser.alarms.create(SYNC_ALARM_NAME, { periodInMinutes: intervalMinutes, }); } else { logBg.debug("Periodic sync disabled"); } } /** * Schedules a one-time alarm to refresh the access token before it expires (5 min buffer). * @param {Object} config - The extension config with tokenExpiresAt * @returns {void} */ function setupTokenRefreshAlarm(config) { browser.alarms.clear(TOKEN_REFRESH_ALARM_NAME); if (config.tokenExpiresAt) { const msUntilExpiry = config.tokenExpiresAt - Date.now(); const refreshInMs = Math.max(60000, msUntilExpiry - 300000); if (refreshInMs > 0) { logBg.debug("Setting up token refresh alarm", { delayMinutes: Math.round(refreshInMs / 60000), }); browser.alarms.create(TOKEN_REFRESH_ALARM_NAME, { delayInMinutes: refreshInMs / 60000, }); } } } /** * Registers bookmark event listeners (onCreated, onChanged, onMoved, onRemoved) * with debouncing to trigger sync on changes within the sync root. * @returns {void} */ function setupBookmarkListeners() { const handleChange = debounce(async () => { await triggerSync("bookmark_event"); }, DEBOUNCE_DELAY); browser.bookmarks.onCreated.addListener(async (id, bookmark) => { if (!bookmark.parentId) return; const config = await getConfig(); if (!config.syncRootId) return; if (await isInSyncRoot(id, config.syncRootId)) { logBg.debug("Bookmark created, triggering sync", { id, title: bookmark.title }); handleChange(); } }); browser.bookmarks.onChanged.addListener(async (id, changeInfo) => { const config = await getConfig(); if (!config.syncRootId) return; const node = await browser.bookmarks.get(id).catch(() => null); if (node && node[0] && (await isInSyncRoot(node[0].parentId, config.syncRootId))) { if (syncState !== "syncing" && changeInfo.title) { logBg.debug("Tracking local modification time", { id }); await updateSyncIndexEntry(id, { localModifiedAt: Date.now() }); } logBg.debug("Bookmark changed, triggering sync", { id, title: node[0].title }); handleChange(); } }); browser.bookmarks.onMoved.addListener(async (id, moveInfo) => { const config = await getConfig(); if (!config.syncRootId) return; if ( (await isInSyncRoot(id, config.syncRootId)) || (await isInSyncRoot(moveInfo.parentId, config.syncRootId)) ) { logBg.debug("Bookmark moved, triggering sync", { id }); handleChange(); } }); browser.bookmarks.onRemoved.addListener(async (id, removeInfo) => { const config = await getConfig(); if (!config.syncRootId) return; if ( (await isInSyncRoot(id, config.syncRootId)) || (await isInSyncRoot(removeInfo.parentId, config.syncRootId)) ) { logBg.debug("Bookmark removed, triggering sync", { id }); handleChange(); } }); } /** * Walks up the bookmark tree from a given ID to check if it descends from the sync root. * @param {string} bookmarkId - The bookmark/folder ID to check * @param {string} syncRootId - The sync root folder ID * @returns {Promise} True if the node is inside the sync root */ async function isInSyncRoot(bookmarkId, syncRootId) { try { let node = await browser.bookmarks .get(bookmarkId) .then((n) => n[0]) .catch(() => null); if (!node) { logBg.debug("isInSyncRoot: node not found", { bookmarkId }); return false; } while (node && node.id !== syncRootId) { if (!node.parentId) return false; try { node = await browser.bookmarks.get(node.parentId).then((n) => n[0]); } catch { logBg.debug("isInSyncRoot: parent not found", { parentId: node.parentId }); return false; } } return node && node.id === syncRootId; } catch (err) { logBg.debug("isInSyncRoot error", { bookmarkId, error: err.message }); return false; } } /** * Triggers a sync cycle if not already syncing. Sets badge to "~" and clears it on completion. * @param {string} source - Reason for sync ("bookmark_event", "manual", "periodic") * @returns {Promise} The sync result from runSync */ async function triggerSync(source) { if (syncState === "syncing") { logBg.debug("triggerSync: already syncing, skipping", { source }); return; } logBg.info("triggerSync", { source }); syncState = "syncing"; setSyncBadge(); try { const config = await getConfig(); if (!pdsClient) { throw new Error("PDS client not initialized"); } lastSyncResult = await runSync(config, pdsClient, config.did); logBg.info("Sync completed", { source, status: lastSyncResult.status }); return lastSyncResult; } catch (err) { lastSyncResult = { status: "error", message: err.message }; logBg.error("Sync error", { source, error: err.message }); return lastSyncResult; } finally { syncState = "idle"; clearSyncBadge(); } } /** * Runs the full OAuth flow: discovers endpoints, augments client_id with redirect_uri and scope, * generates PKCE params + DPoP key pair, pushes PAR, launches browser auth popup, * exchanges the code for tokens, persists everything to config, and initializes the PDS client. * @param {Object} config - The extension config (needs pdsUrl and clientId) * @returns {Promise<{status: string, message?: string}>} */ async function authenticate(config, endpoints) { const done = logBg.timer("authenticate"); logBg.info("Starting OAuth authentication"); const redirectUri = (() => { const url = new URL(browser.identity.getRedirectURL()); const subdomain = url.hostname.split(".")[0]; return `http://127.0.0.1/mozoauth2/${subdomain}`; })(); const clientIdUrl = new URL(config.clientId); const clientIdSearch = clientIdUrl.searchParams; clientIdSearch.set("redirect_uri", redirectUri); clientIdSearch.set("scope", "atproto repo?collection=network.cosmik.collection&collection=network.cosmik.card&collection=network.cosmik.collectionLink"); config.clientId = new URL( clientIdUrl.pathname + "?" + clientIdSearch, clientIdUrl.origin, ).toString(); let authEndpoint, tokenEndpoint, parEndpoint, authServer; if (endpoints) { ({ authEndpoint, tokenEndpoint, parEndpoint, authServer } = endpoints); } else { ({ authEndpoint, tokenEndpoint, parEndpoint, authServer } = await discoverOAuthEndpoints( config.pdsUrl, )); } await setConfig({ oauthAuthEndpoint: authEndpoint, oauthTokenEndpoint: tokenEndpoint, oauthParEndpoint: parEndpoint, oauthAuthServer: authServer, }); config.oauthAuthEndpoint = authEndpoint; config.oauthTokenEndpoint = tokenEndpoint; const verifier = generateCodeVerifier(); const challenge = await computeCodeChallenge(verifier); const state = generateState(); const keyPair = await generateDPoPKeyPair(); logBg.debug("DPoP key pair generated"); let authUrl; try { const requestUri = await pushAuthorizationRequest( parEndpoint, config.clientId, redirectUri, challenge, state, keyPair, ); authUrl = `${authEndpoint}?request_uri=${encodeURIComponent(requestUri)}&client_id=${encodeURIComponent(config.clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}`; logBg.debug("PAR succeeded, using request_uri"); } catch (parErr) { logBg.warn("PAR not available, falling back to direct authorize", { error: parErr.message }); authUrl = buildAuthorizationUrl(authEndpoint, config.clientId, redirectUri, challenge, state); } try { logBg.debug("Launching web auth flow"); const responseUrl = await browser.identity.launchWebAuthFlow({ url: authUrl, interactive: true, }); const parsed = new URL(responseUrl); const code = parsed.searchParams.get("code"); const returnedState = parsed.searchParams.get("state"); if (!code) throw new Error("No authorization code in response"); if (returnedState !== state) throw new Error("State mismatch — possible CSRF"); logBg.debug("Authorization code received, exchanging for tokens"); const tokens = await exchangeCode( tokenEndpoint, code, redirectUri, config.clientId, verifier, keyPair, ); await setConfig({ dpopNonce: getDpopNonce() }); const publicJwk = await exportPublicJWK(keyPair); const privateJwk = await exportPrivateJWK(keyPair); const tokenExpiresAt = tokens.expiresIn ? Date.now() + tokens.expiresIn * 1000 : null; const newConfig = { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, tokenExpiresAt, dpopPublicKeyJwk: publicJwk, dpopPrivateKeyJwk: privateJwk, clientId: config.clientId, }; await setConfig(newConfig); updateBadge(await getConfig()); dpopKeyPair = keyPair; pdsClient = new PdsClient(config.pdsUrl, { accessToken: tokens.accessToken, dpopKeyPair: keyPair, onRefresh: async () => { const cfg = await getConfig(); const tokenEndpoint = cfg.oauthTokenEndpoint || `${cfg.pdsUrl}/oauth/token`; const result = await refreshAccessToken( tokenEndpoint, cfg.refreshToken, cfg.clientId, dpopKeyPair, ); await setConfig({ accessToken: result.accessToken, refreshToken: result.refreshToken, tokenExpiresAt: result.expiresIn ? Date.now() + result.expiresIn * 1000 : null, dpopNonce: getDpopNonce(), }); updateBadge(await getConfig()); return result; }, }); setupTokenRefreshAlarm(await getConfig()); done("authentication succeeded"); return { status: "ok" }; } catch (err) { logBg.error("Authentication failed", { error: err.message }); done(`failed: ${err.message}`); return { status: "error", message: err.message }; } } /** * Registers the runtime message listener for options/popup communication. * Handles: resolve-handle, authenticate, disconnect, sync-now, get-status, get-config, * save-config, set-sync-root, clear-sync-root, clear-config. * @returns {void} */ function setupMessageListener() { browser.runtime.onMessage.addListener(async (msg) => { logBg.debug("Message received", { action: msg.action }); switch (msg.action) { case "resolve-handle": { if (!msg.handle) { logBg.warn("resolve-handle: no handle provided"); return { status: "error", message: "Handle required" }; } try { const cleanHandle = msg.handle.replace(/^https?:\/\//, "").replace(/\/$/, ""); const dnsDid = await resolveDIDviaDNS(cleanHandle); let did, pdsUrl; if (dnsDid) { did = dnsDid; if (did.startsWith("did:web:")) { const domain = did.replace(/^did:web:/, ""); return { status: "needs_permission", origin: `https://${domain}/*`, handle: msg.handle, }; } const doc = await resolveDIDDocument(did); pdsUrl = extractPdsUrl(doc, did); } else { return { status: "needs_permission", origin: `https://${cleanHandle}/*`, handle: msg.handle, }; } logBg.info("Handle resolved", { handle: msg.handle, did }); return { status: "ok", did, pdsUrl }; } catch (err) { logBg.error("Handle resolution failed", { handle: msg.handle, error: err.message }); return { status: "error", message: err.message }; } } case "authenticate": { const config = await getConfig(); if (!config.pdsUrl || !config.clientId) { logBg.warn("authenticate: prerequisites missing", { hasPdsUrl: !!config.pdsUrl, hasClientId: !!config.clientId, }); return { status: "error", message: "PDS URL and Client ID required" }; } const pdsOrigin = new URL(config.pdsUrl).origin + "/*"; // Check if the PDS advertises a different authorization server. // We must check this BEFORE discoverOAuthEndpoints, because that function // silently falls back to PDS endpoints when the auth server metadata fetch // fails (e.g. due to missing host permission). let authServerUrl = null; try { const base = config.pdsUrl.replace(/\/+$/, ""); const protectedResp = await fetch(`${base}/.well-known/oauth-protected-resource`); if (protectedResp.ok) { const meta = await protectedResp.json(); if (meta.authorization_servers && meta.authorization_servers.length > 0) { authServerUrl = meta.authorization_servers[0]; } } } catch (err) { logBg.debug("OAuth protected resource fetch failed", { error: err.message }); } if (authServerUrl) { const authOrigin = authServerUrl.replace(/\/+$/, "") + "/*"; if (authOrigin !== pdsOrigin) { const hasAuthPerm = await browser.permissions.contains({ origins: [authOrigin] }); if (!hasAuthPerm) { return { status: "needs_permission", origin: authOrigin, message: `Grant access to the authorization server (${authServerUrl}) to connect.`, }; } } } const endpoints = await discoverOAuthEndpoints(config.pdsUrl); return authenticate(config, endpoints); } case "disconnect": { logBg.info("Disconnecting"); await setConfig({ accessToken: "", refreshToken: "", tokenExpiresAt: null, dpopPublicKeyJwk: null, dpopPrivateKeyJwk: null, }); dpopKeyPair = null; pdsClient = null; browser.alarms.clear(TOKEN_REFRESH_ALARM_NAME); updateBadge(await getConfig()); return { status: "ok" }; } case "sync-now": logBg.info("Manual sync requested"); return triggerSync("manual"); case "get-status": { const config = await getConfig(); return { syncState, lastSyncResult, configured: !!(config.pdsUrl && config.did && config.clientId), authenticated: !!config.accessToken, connected: !!(pdsClient && config.accessToken), tokenExpired: config.tokenExpiresAt ? Date.now() > config.tokenExpiresAt : false, }; } case "get-config": return getConfig(); case "save-config": { logBg.info("save-config", { keys: Object.keys(msg.config) }); await setConfig(msg.config); setupAlarm(msg.config.syncInterval || 5); pdsClient = await createPdsClient(await getConfig(), dpopKeyPair); updateBadge(await getConfig()); return { status: "ok" }; } case "set-sync-root": { const existing = (await getConfig()).syncRootId; if (existing) { logBg.warn("set-sync-root: already set", { existingRootId: existing }); return { status: "error", message: "Sync root already set. Clear it first in options." }; } let folder; if (msg.folderId) { folder = await browser.bookmarks.get(msg.folderId).then((n) => n[0]); logBg.info("Using existing folder as sync root", { id: folder.id, title: folder.title }); } else { folder = await browser.bookmarks.create({ title: msg.title || "OrbitMarks", parentId: msg.parentId || undefined, }); logBg.info("Created new sync root folder", { id: folder.id, title: folder.title }); } await setConfig({ syncRootId: folder.id }); return { status: "ok", folderId: folder.id, folderTitle: folder.title }; } case "clear-sync-root": { logBg.info("Clearing sync root"); await setConfig({ syncRootId: null }); return { status: "ok" }; } case "clear-config": { logBg.warn("Clearing all config"); await clearConfig(); dpopKeyPair = null; pdsClient = null; browser.alarms.clear(TOKEN_REFRESH_ALARM_NAME); updateBadge(await getConfig()); return { status: "ok" }; } case "get-logs": { return { logs: getLogsAsText() }; } case "clear-logs": { clearLogs(); return { status: "ok" }; } default: logBg.warn("Unknown action", { action: msg.action }); return { status: "error", message: `Unknown action: ${msg.action}` }; } }); } browser.alarms.onAlarm.addListener(async (alarm) => { logBg.info("Alarm fired", { name: alarm.name }); if (alarm.name === SYNC_ALARM_NAME) { await triggerSync("periodic"); } else if (alarm.name === TOKEN_REFRESH_ALARM_NAME) { logBg.info("Token refresh alarm fired"); const config = await getConfig(); if (config.refreshToken && config.clientId && dpopKeyPair) { try { const tokenEndpoint = config.oauthTokenEndpoint || `${config.pdsUrl}/oauth/token`; const result = await refreshAccessToken( tokenEndpoint, config.refreshToken, config.clientId, dpopKeyPair, ); const newConfig = { accessToken: result.accessToken, refreshToken: result.refreshToken, tokenExpiresAt: result.expiresIn ? Date.now() + result.expiresIn * 1000 : null, dpopNonce: getDpopNonce(), }; await setConfig(newConfig); pdsClient = await createPdsClient(await getConfig(), dpopKeyPair); setupTokenRefreshAlarm(await getConfig()); updateBadge(await getConfig()); logBg.info("Token refreshed successfully"); } catch (err) { logBg.error("Token refresh failed", { error: err.message }); await setConfig({ accessToken: "", tokenExpiresAt: null }); updateBadge(await getConfig()); } } else { logBg.warn("Token refresh prerequisites missing", { hasRefreshToken: !!config.refreshToken, hasClientId: !!config.clientId, hasKeyPair: !!dpopKeyPair, }); } } }); browser.runtime.onInstalled.addListener(async () => { await initialize(); }); browser.runtime.onStartup.addListener(async () => { await initialize(); });