-
Notifications
You must be signed in to change notification settings - Fork 10
BSKY_PUSH_NOTIFICATION_SERVICE
Written by Claude Fable 5
A reference design for a service implementing the service half of the Bluesky Push Notification Service Spec. The spec is the only coupling to clients — a conforming service can be built from the spec alone and may diverge from everything here. This design targets clients of the Bluesky appview/chat services whose primary form is a browser app (public OAuth clients with browser-held DPoP keys), and is deployable for any such client as configuration, not code changes.
Bluesky's own notification service (courier) is not reusable: it
keys device tokens by appId and holds Bluesky's APNs/FCM
credentials, with no self-serve onboarding — registering another
app id returns 200 and delivers nothing. Every third-party client
needs its own service.
The spec deliberately does not constrain the event source. This design uses per-user OAuth polling because a firehose consumer cannot see chat and would require reimplementing preference/mute/block filtering; the price is holding narrow per-user credentials.
A single Cloudflare Worker on its own origin, deployed from its own repo, with its own secrets — decoupled from any client app's deploys and signing keys.
| Concern | Piece |
|---|---|
/.well-known/did.json |
Worker fetch handler, static JSON |
/.well-known/notif-service.json |
Worker fetch handler, static JSON |
| OAuth start + callback | Worker fetch handler |
registerPush / unregisterPush XRPC |
Worker fetch handler |
| Subscriptions, devices, cursors | D1 |
| Poll scheduling + grant GC | Cron Trigger |
| Polling + sending | Queue consumer |
| VAPID key, token-encryption key, OAuth key | Worker secrets |
The cron handler is a dispatcher, not the poller: it selects due rows from D1 and enqueues them. A Queue consumer does the work, which buys concurrency, retries, and a dead-letter queue. Polling inline in the cron handler hits subrequest and wall-clock limits once there are more than a few hundred subscribers, and has no retry story.
The service is its own OAuth client, with a client_id distinct from
the client app's. This is forced, not stylistic: browser-based atproto
apps are public OAuth clients whose access tokens are DPoP-bound to a
keypair generated and held in the browser, so tokens minted by the app
are unusable server-side by construction — and widening the app's own
grant to cover the notifier's scopes would grow the app's standing
authorization for everyone, wanted or not.
Consequently the user grants a second, separate authorization through the spec's auth handoff. The scope sets by tier:
atproto
rpc:app.bsky.notification.getUnreadCount?aud=<appview-did>#bsky_appview
rpc:app.bsky.notification.listNotifications?aud=<appview-did>#bsky_appview
rpc:chat.bsky.convo.getUnreadCounts?aud=<chat-did>#bsky_chat
rpc:chat.bsky.convo.getLog?aud=<chat-did>#bsky_chat (previews tier only)
Read-only, and nothing that can post, follow, or message.
Deploy as a confidential client (private_key_jwt: client
metadata publishes a JWKS, and the token endpoint is authenticated
with a signed client assertion). This is what makes server-held grants
practical: confidential clients get a 2-year session and 3-month
refresh lifetime, against 2 weeks for public clients
(packages/oauth/oauth-provider/src/oauth-constants.ts:42-51 in the
atproto reference implementation). A public client would drop every
user fortnightly.
The code exchange happens in the Worker, with a Worker-held DPoP key.
The callback verifies the grant's sub against the login_hint and
echoes the effective chat_previews per the spec.
CREATE TABLE subscriptions (
did TEXT PRIMARY KEY,
app_id TEXT NOT NULL,
pds_url TEXT NOT NULL,
access_token BLOB NOT NULL, -- envelope-encrypted
refresh_token BLOB NOT NULL, -- envelope-encrypted
dpop_jwk BLOB NOT NULL, -- envelope-encrypted
access_expires_at INTEGER NOT NULL,
last_notif_count INTEGER NOT NULL DEFAULT 0,
last_chat_count INTEGER NOT NULL DEFAULT 0,
chat_previews INTEGER NOT NULL DEFAULT 0,
chat_log_cursor TEXT,
last_active_at INTEGER,
next_poll_at INTEGER NOT NULL,
failure_count INTEGER NOT NULL DEFAULT 0,
gc_at INTEGER -- set when device count hits 0
);
CREATE TABLE devices (
id TEXT PRIMARY KEY, -- token, or hash(endpoint)
did TEXT NOT NULL REFERENCES subscriptions(did) ON DELETE CASCADE,
app_id TEXT NOT NULL,
kind TEXT NOT NULL, -- 'webpush' | 'apns'
payload BLOB NOT NULL, -- encrypted; shape depends on kind
created_at INTEGER NOT NULL
);
CREATE INDEX devices_did ON devices(did);
CREATE INDEX subscriptions_due ON subscriptions(next_poll_at);
CREATE TABLE sent (
did TEXT NOT NULL,
uri TEXT NOT NULL, -- notification/message URI
sent_at INTEGER NOT NULL,
PRIMARY KEY (did, uri)
);kind matters because the two transports store different things: APNs
is an opaque token string, Web Push is {endpoint, p256dh, auth}.
Keying devices on the token/endpoint hash rather than a synthetic id
makes re-subscription idempotent, per the spec.
sent is the dedupe ledger. Notifications are deduped on URI, not
count delta — counts are unreliable as an identity signal, and a
count that decreases means the user read something elsewhere, which
must resync state without notifying. Prune rows older than a few days.
Tokens are envelope-encrypted with AES-GCM under a key from a Worker secret. D1 rows are otherwise plaintext to anything holding the binding.
Implements the spec's grant-lifecycle expectations:
-
Zero devices (last
unregisterPush, or last device pruned on 404/410) stops polling immediately — pushes would go nowhere — and setsgc_atto now plus the grace window. A device registration clearsgc_atand resumes polling. Whengc_atpasses, the cron dispatcher revokes the OAuth grant against the PDS and deletes the row. - An
invalid_grantduring polling (expired, or revoked by the user at their PDS) deletes the row — the user must re-authorize.
Every minute, the cron handler selects rows where
next_poll_at <= now, in bounded batches, and enqueues them. Per user,
the consumer:
- Refreshes the access token if within the expiry margin. Write back
with a conditional update (
WHERE refresh_token = <the value read>) so a lost race is a no-op retry rather than a clobber. - Calls
getUnreadCountandgetUnreadCounts. These are cheap; do not calllistNotificationson every poll. - If the notification count went up, fetch the list, take entries
newer than the stored watermark, drop any already in
sent, and enqueue sends. If a chat count went up: on the previews tier, pagechat.bsky.convo.getLogfromchat_log_cursor, take new message events, drop any already insent, and enqueue sends carrying sender and preview, advancing the cursor; on the counts tier, send a single collapsed "you have new messages" push (stabletag, per the spec). The counts path is also the degradation path whengetLogfails, so it exists regardless of tier. - If a count went down or stayed flat, update the stored counts and send nothing.
- Compute
next_poll_atfromlast_active_atand resetfailure_count; on failure, back off exponentially and drop the row after a threshold.
Adaptive cadence is a correctness requirement, not an optimization.
Poll load falls on other people's PDSes: 1,000 users at a flat 60s
cadence is 1.44M requests/day, almost all of them returning nothing.
Tighten to ~30–60s for users active in the last hour, degrade to
several minutes when idle. The whole policy lives in how next_poll_at
is computed, so it is one function to tune.
Web Push per the spec's payload format: RFC 8291 aes128gcm
against the subscription's keys, VAPID-signed. 404/410 deletes the
device row.
APNs covers native iOS wrappers (e.g. Capacitor), using
token-based auth (a .p8 key signed into a JWT). Needed because iOS
web push requires the user to install the PWA to the Home Screen
(16.4+), which most users will not do.
Badges ride each send: the unread count the poller already holds
(badge in the Web Push payload, aps.badge on APNs). Best-effort by
design — the client clears on app open, and reads on other devices
cannot clear a badge until the next push arrives.
Notification copy is the one piece likely to need real customization per deploying client; keep it in a single module rather than scattered through the sender.
Everything client-specific is configuration:
| Setting | Notes |
|---|---|
SERVICE_DID |
did:web: of the deployment's own origin |
SERVICE_NAME |
shown in client settings and consent copy |
APP_IDS |
app id(s) the deployment accepts registrations for |
OAUTH_CLIENT_ID |
URL of the deployment's client metadata |
OAUTH_PRIVATE_JWK |
secret; confidential-client signing key |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY
|
secret |
APNS_KEY_ID / APNS_TEAM_ID / APNS_PRIVATE_KEY / APNS_TOPIC
|
secret; optional |
TOKEN_ENCRYPTION_KEY |
secret |
GRACE_WINDOW_DAYS |
grant GC delay after last device unregisters |
A deployment with no APNs config serves Web Push only. app_id is
carried in the schema rather than assumed, because one service can
serve several apps.
Beyond the spec's requirements:
- Encrypt tokens at rest; rotate
TOKEN_ENCRYPTION_KEYby re-encrypting rows, not by invalidating grants. - Compromise of this service exposes notification metadata, unread counts, and (previews tier) chat message content for subscribed users, and allows sending arbitrary pushes to their devices. It does not allow acting as them — the grant cannot post, follow, or message.