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 #
- Full sync: lists all
network.cosmik.collectionrecords from the PDS, resolves linked cards, compares against the local bookmarks tree, computes a changeset. - Incremental: uses ATProto
cursorfromlistRecordsresponses to fetch only records changed since last sync. - Event-driven: bookmark
onCreated/onChanged/onMoved/onRemovedevents trigger a debounced sync (300ms window). - Periodic: an
alarmsAPI timer (configurable interval, default 5 min) provides convergence. - Conflict resolution: last-writer-wins using
updatedAt/createdAttimestamps.
Authentication #
OAuth 2.0 with PKCE + DPoP (sender-constrained tokens). No app passwords.
The flow is:
- 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 - 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) - Extension generates a DPoP ECDSA P-256 key pair + PKCE verifier
- 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) - 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 arequest_uri - Opens
{asUrl}/oauth/authorize?request_uri=...&client_id=...viabrowser.identity.launchWebAuthFlow - User approves → authorization server redirects to loopback URI with authorization code
- Extension exchanges code for tokens (DPoP-bound at the token endpoint)
- Tokens stored in
storage.local; refresh token used for silent renewal before expiry - 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-extduring 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:
-
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 athttps://bsky.social. -
Authorization server metadata (
{asUrl}/.well-known/oauth-authorization-server, RFC 8414) — returns the actualauthorization_endpoint,token_endpoint, andpushed_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
stateparameter is validated against CSRF on callback. - The extension requests minimal OAuth scopes:
atproto(base auth) andrepo?collection=network.cosmik.collection&collection=network.cosmik.card&collection=network.cosmik.collectionLink(read/write access to only the threenetwork.cosmik.*collections it uses). Norpcscope is needed — the onlyapp.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. TypeURLhascontent.url+ optionalcontent.metadata. TypeNOTEis 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 (
computeChangesetinsync.js) a pure function: given(localTree, remoteTree, syncIndex, config)it returnsChangeEntry[]. No side effects. - All XRPC communication goes through
PdsClient._fetch(). Don't callfetch()directly for PDS operations outside oflib/pds.jsandlib/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 lintbefore submitting PRs.