This repository has no description
Rust 96%
Tcl 4%
Nix <1%
Dockerfile <1%

README.md

courier #

A Bluesky Push Notification Service — the server half of real push notifications for a third-party Bluesky client.

Bluesky's own notification service is not reusable by third-party clients: 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; this is one.

Holds a narrow, read-only OAuth grant per subscriber, watches their unread counts, and sends a Web Push (or APNs) notification when something happens. The grant cannot post, follow, or message. With JETSTREAM_URL set, likes, replies, mentions, quotes, reposts and follows arrive within seconds: the network's own event stream says when to look. Chat has no event stream, so it is polled, one request per cycle when nothing changed.

Forked from courierbench's reference implementation, which is built to calibrate a conformance suite rather than to be deployed. See DESIGN.md for what changed and why, what this can carry, and what to do when it cannot carry any more.

Passes 75/75 core and 9/9 bonus cases of the courierbench conformance suite.

Running it #

nix develop          # or bring your own Rust toolchain
cargo build --release

Everything is configured from the environment; there is no config file.

Required #

Variable Notes
PORT binds 127.0.0.1, so put a TLS terminator in front
DATA_DIR holds courier.db
SERVICE_DID did:web: of this deployment's origin
SERVICE_NAME shown in client settings and consent copy
APP_IDS comma-separated app ids to accept registrations for
PLC_DIRECTORY_URL https://plc.directory. did:plc: only — a did:web: identity is fetched from its own origin and never touches this
BSKY_APPVIEW_DID did:web:api.bsky.app
BSKY_CHAT_DID did:web:api.bsky.chat
OAUTH_CLIENT_ID URL of this deployment's client metadata, on its own origin
OAUTH_PRIVATE_JWK secret; P-256 private JWK for private_key_jwt
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY secret; P-256 keypair, base64url
VAPID_SUBJECT mailto: or https: contact
TOKEN_ENCRYPTION_KEY secret; 32 random bytes, base64url
POLL_ACTIVE_MS poll cadence for recently-active accounts (e.g. 10000); with JETSTREAM_URL set this is the chat cadence
POLL_IDLE_MS cadence for everyone else (e.g. 60000)
POLL_ACTIVE_WINDOW_MS how long activity counts as recent (e.g. 300000)
GRACE_WINDOW_MS grant retention after the last device unregisters
MAX_POLL_FAILURES consecutive failures before a grant is dropped

Optional #

Variable Default Notes
POLL_CONCURRENCY 32 accounts polled at once — see DESIGN.md for sizing
POLL_LEASE_MS 60000 upper bound on how long a hung poll strands its row
SENT_RETENTION_MS 3 days how long the dedupe ledger remembers a delivery
PRUNE_INTERVAL_MS 1 hour retention sweep interval
JETSTREAM_URL — Jetstream v2 subscribeEvents endpoint, e.g. wss://jetstream.us-east.bsky.network/xrpc/network.bsky.jetstream.subscribeEvents. Set, app notifications are checked when the network says something touched a subscriber (seconds) instead of on every poll; unset, every poll checks both counts — see DESIGN.md
NOTIF_SAFETY_NET_MS 10 minutes with the lane up, the longest an account goes without a notification check
LIST_ACTIVITY_SUBSCRIPTIONS off 1 asks grants for listActivitySubscriptions (read-only), so a post by anyone a subscriber gets subscribed-post notifications for is hinted from their exact list, re-read every 10 minutes. Beyond the spec's scope set (its conformance suite's T2.07 fails with it on); existing grants are re-authorized on the client's next startUrl walk
CHAT_FRESH_WINDOW_MS 12 hours how recent a chat message must be for a previews-tier push, so a stale message from an unrelated conversation can't surface as a wrong-chat banner
APNS_BASE_URL, APNS_KEY_ID, APNS_TEAM_ID, APNS_PRIVATE_JWK, APNS_TOPIC — omit for Web Push only

None of the secrets ship with the repo. Generate your own:

# 32-byte keys, base64url, no padding
openssl rand 32 | basenc --base64url | tr -d '='

A did:web document #

SERVICE_DID must resolve to a DID document with a #bsky_notif service entry pointing at this origin — served at https://<host>/.well-known/did.json, with permissive CORS, since browsers fetch it cross-origin:

{
  "id": "did:web:notifs.example.com",
  "service": [
    {
      "id": "#bsky_notif",
      "type": "BskyNotificationService",
      "serviceEndpoint": "https://notifs.example.com"
    }
  ]
}

Docker #

cp .env.example .env    # fill in secrets; keep it chmod 600
docker compose up -d --build

ca-certificates is not optional in the image: without a trust store every outbound HTTPS call (PLC directory, appview, chat, subscribers' PDSes) fails TLS verification.

Migrating from the reference implementation #

Point DATA_DIR at the existing directory and start. An existing state.json is imported into SQLite on first run and renamed to state.json.imported; grants, devices, cursors and the dedupe ledger all carry over, so nobody has to re-authorize. The import runs only when the database is empty.

Operating it #

GET /status is unauthenticated and aggregate-only — no per-account detail. The number to watch is schedulingLagMaxMs: flat is healthy, monotonically rising means poll throughput has stopped keeping up. With the lane on, jetstreamOnline and jetstreamLagMs say whether it is up and how far behind the network it runs; safetyNetHits counts notifications no hint announced, chaseExhausted hinted records no listing showed within 2 minutes, and replyChainHints replies found only through a recorded reply chain. See DESIGN.md.

cargo run --release --bin loadgen -- --subscribers 5000 --latency-ms 200

drives the real poll loop against a stub PDS and prints scheduling lag over time, which is the way to size POLL_CONCURRENCY before a surge rather than during one.

Conformance #

cargo run -p cb-suite --bin score -- \
  --courier-cmd /path/to/courier/target/release/courier

from a courierbench checkout.

Security #

  • Credentials are envelope-encrypted at rest under TOKEN_ENCRYPTION_KEY. Rotate by re-encrypting rows, not by invalidating grants.
  • Compromise of this service exposes notification metadata, unread counts, and — on the 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.
  • /status is public by design and must stay aggregate-only.

Licence #

MIT.