Something went wrong. Try again.
Two-way sync Firefox bookmarks with an AT Protocol PDS using network.cosmik.* lexicons.
Something went wrong. Try again.
orbitmarks background.js
24 kB · 697 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698const 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<void>} */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<CryptoKeyPair|null>} 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<PdsClient|null>} 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<boolean>} 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<Object>} 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();});