Two-way sync Firefox bookmarks with an AT Protocol PDS using network.cosmik.* lexicons.
README.md

OrbitMarks #

A Firefox WebExtension that two-way syncs a dedicated bookmarks subtree with records on an AT Protocol PDS using the network.cosmik.* lexicon namespace.

Entity Bookmarks representation ATProto record
Collection Subfolder network.cosmik.collection
Card (URL type) Bookmark network.cosmik.card
Membership Bookmark inside folder network.cosmik.collectionLink

Architecture #

flowchart LR
    %% External Systems
    Firefox[🦊 Firefox Bookmarks]
    PDS[☁️ AT Protocol PDS]

    %% Extension Core Box
    subgraph Extension ["🧩 WebExtension Core"]
        Listener[Event Listener\n& 300ms Debounce]
        Engine[Sync Engine\n(Orchestrator)]
        Calculator[ChangeSet\nCalculator]
        Client[PDS Client\n(OAuth/DPoP)]
        Index[(Sync Index)]
    end

    %% Data Flow Pipeline
    Firefox -->|Local Changes| Listener
    Listener -->|Trigger Sync| Engine
    
    Engine <-->|1. Map IDs| Index
    Engine <-->|2. Diff Trees| Calculator
    Engine <-->|3. Sync Data| Client
    
    Client <-->|HTTPS XRPC| PDS
    Engine -.->|Apply Changes| Firefox

    %% Styling
    classDef external fill:#f5f5f5,stroke:#333,stroke-width:1px;
    class Firefox,PDS external;

Sync protocol #

  1. Full sync: lists all network.cosmik.collection records from the PDS, resolves linked cards, compares against the local bookmarks tree, computes a changeset.
  2. Incremental: uses ATProto cursor from listRecords responses to fetch only records changed since last sync.
  3. Event-driven: bookmark onCreated/onChanged/onMoved/onRemoved events trigger a debounced sync (300ms window).
  4. Periodic: an alarms API timer (configurable interval, default 5 min) provides convergence.
  5. Conflict resolution: last-writer-wins using updatedAt / createdAt timestamps.

Authentication #

OAuth 2.0 with PKCE + DPoP (sender-constrained tokens). No app passwords.

The flow is:

  1. User enters their ATProto handle — extension resolves it to a DID + PDS URL via DNS-over-HTTPS (Cloudflare), falling back to /.well-known/atproto-did.json
  2. Extension discovers the OAuth authorization server from the PDS via {pdsUrl}/.well-known/oauth-protected-resource (RFC 8707), then fetches server metadata from {asUrl}/.well-known/oauth-authorization-server (RFC 8414)
  3. Extension generates a DPoP ECDSA P-256 key pair + PKCE verifier
  4. Augments the loopback client_id (http://localhost) with ?redirect_uri= and ?scope= parameters — the auth server derives client metadata from these (loopback client per RFC 8252)
  5. Pushes the authorization request (with granular scopes atproto repo?collection=network.cosmik.collection&collection=network.cosmik.card&collection=network.cosmik.collectionLink) to {asUrl}/oauth/par (Pushed Authorization Request, RFC 9126), receives a request_uri
  6. Opens {asUrl}/oauth/authorize?request_uri=...&client_id=... via browser.identity.launchWebAuthFlow
  7. User approves → authorization server redirects to loopback URI with authorization code
  8. Extension exchanges code for tokens (DPoP-bound at the token endpoint)
  9. Tokens stored in storage.local; refresh token used for silent renewal before expiry
  10. All subsequent XRPC requests include a DPoP proof JWT + Authorization: DPoP <token>

XRPC calls use the correct HTTP method per AT Protocol lexicon: queries (describeRepo, listRecords, getRecord) use GET with URL query parameters, procedures (createRecord, putRecord, deleteRecord) use POST with JSON body.

Prerequisites #

  • Firefox 110+ (or Firefox ESR)
  • Node.js 18+ (for web-ext during development)
  • An AT Protocol PDS that supports OAuth and the network.cosmik.* lexicons

OAuth Client Metadata #

The extension uses http://localhost as the OAuth client_id and http://127.0.0.1/mozoauth2/{ext_subdomain}/ as the redirect_uri (per Firefox's loopback redirect format from MDN, based on RFC 8252 section 7.3). The {ext_subdomain} is derived from browser.identity.getRedirectURL() — Firefox uses this to route the redirect back to the correct extension. This is a special case defined in OAuth 2.0 for Native Apps (RFC 8252) and supported by AT Protocol PDSes — the PDS treats it as a native public client with implicit metadata, so no publicly hosted client-metadata.json is needed.

If you need to use a custom client (e.g. for a hosted integration), you can overwrite the clientId field in the extension's Options page with your own client-metadata.json URL.

OAuth endpoint discovery #

Before initiating the authorization flow, the extension performs two-step OAuth endpoint discovery:

  1. Resource server metadata ({pdsUrl}/.well-known/oauth-protected-resource, RFC 8707) — tells the client which authorization server to use. For Bluesky users, this points from the individual PDS host (e.g. puffball.us-east.host.bsky.network) to the central authorization server at https://bsky.social.

  2. Authorization server metadata ({asUrl}/.well-known/oauth-authorization-server, RFC 8414) — returns the actual authorization_endpoint, token_endpoint, and pushed_authorization_request_endpoint.

If either well-known endpoint is unavailable, the extension falls back to constructing URLs from the PDS URL directly.

Pushed Authorization Requests (PAR) #

Bluesky's authorization server requires PAR (require_pushed_authorization_requests: true). Instead of passing authorization parameters as query strings in the authorization URL, the extension POSTs them to {asUrl}/oauth/par first, receives a request_uri, and then only passes request_uri and client_id to the authorization endpoint. This is handled automatically with a fallback to direct authorization for PDSes that don't support PAR.

Development #

# Install web-ext for running the extension in Firefox
npm install --global web-ext

# Run the extension in a temporary Firefox profile
web-ext run

# Run with a specific Firefox binary
web-ext run --firefox=/usr/bin/firefox

# Lint the extension
web-ext lint

The extension loads from source — no build step required. The manifest.json and all scripts are loaded directly.

Project structure #

├── manifest.json          # Firefox WebExtension manifest (V2)
├── background.js          # Main entry: message bus, OAuth, badge, alarms
├── options.html           # Configuration page (PDS, client_id, sync root)
├── options.js
├── popup.html             # Toolbar popup
├── popup.js
├── lib/
│   ├── util.js            # TID generation, debounce, sleep
│   ├── config.js          # storage.local wrapper for config + sync index
│   ├── resolve.js         # Handle → DID → PDS URL resolution (DNS DoH + .well-known)
│   ├── oauth.js           # PKCE, DPoP key management, token exchange/refresh
│   ├── pds.js             # PdsClient — DPoP-bound XRPC calls with auto-refresh
│   └── sync.js            # Changeset calculator + sync orchestration
├── icons/
│   └── icon.svg
├── lexicons/              # The AT Protocol lexicon schemas
│   ├── card.json
│   ├── collection.json
│   └── collectionLink.json
└── .design/spec/          # Specification documents

Key modules #

Module Responsibility
resolve.js resolvePDS(handle) — DNS-over-HTTPS + .well-known → DID + PDS URL
oauth.js generateCodeVerifier(), computeCodeChallenge(), generateDPoPKeyPair(), createDPoPProof(), exchangeCode(), refreshAccessToken() — all stateless, Web Crypto API
pds.js PdsClient class — wraps every XRPC call with DPoP proof, retries on 5xx, auto-refreshes on 401
sync.js buildLocalTree() / buildRemoteTree() to snapshot both sides, computeChangeset() (pure function diffing), applyChanges() (ordered application in dependency-safe sequence)
config.js Thin persistence layer over storage.local — config, sync index, OAuth tokens, DPoP key JWKs

Security notes #

  • DPoP private key is stored as JWK in storage.local (encrypted at rest by Firefox). It is extractable by design — the extension needs to use it for signing proofs after reload.
  • Access tokens are short-lived. Refresh tokens stored alongside. Token refresh happens automatically before expiry.
  • OAuth state parameter is validated against CSRF on callback.
  • The extension requests minimal OAuth scopes: atproto (base auth) and repo?collection=network.cosmik.collection&collection=network.cosmik.card&collection=network.cosmik.collectionLink (read/write access to only the three network.cosmik.* collections it uses). No rpc scope is needed — the only app.bsky.* call is unauthenticated.
  • Bookmarks API access is limited to the sync root subtree via parent-chain traversal checks.

Lexicon namespaces #

The extension uses three record types under network.cosmik.*:

  • network.cosmik.collection — a folder/category. Fields: name, accessType (OPEN/CLOSED), description, createdAt, updatedAt.
  • network.cosmik.card — a content item. Type URL has content.url + optional content.metadata. Type NOTE is ignored for bookmark representation.
  • network.cosmik.collectionLink — many-to-many link with strong refs to both card and collection. Fields: card (strongRef), collection (strongRef), addedBy (DID), addedAt.

Packaging #

web-ext build

Produces a .zip in web-ext-artifacts/ ready for self-hosting or submission to Firefox Add-ons.

Contributing #

  • Code should not depend on npm packages — the extension uses only browser-native APIs and the Web Crypto API.
  • Keep the changeset calculator (computeChangeset in sync.js) a pure function: given (localTree, remoteTree, syncIndex, config) it returns ChangeEntry[]. No side effects.
  • All XRPC communication goes through PdsClient._fetch(). Don't call fetch() directly for PDS operations outside of lib/pds.js and lib/oauth.js.
  • DPoP key pairs are generated once per authentication session and stored as JWKs. If you need to rotate keys, the user must re-authenticate.
  • Test with web-ext lint before submitting PRs.