Development#
Running, configuring, and deploying the atmo.pub monorepo (relay, web
dashboard, example sender). For the high-level overview see
README.md; for the sender API see https://atmo.pub/docs.
Repo layout#
.
├── package.json # workspace root, scripts
├── pnpm-workspace.yaml # apps/* and packages/*
├── tsconfig.base.json # shared strict config
├── tsconfig.json # references for `tsc -b`
├── apps/
│ ├── relay/
│ │ ├── wrangler.toml
│ │ ├── vitest.config.ts
│ │ ├── migrations/ # 0001_init.sql (consolidated canonical schema)
│ │ ├── src/
│ │ │ ├── index.ts # default export { fetch, queue, scheduled }
│ │ │ ├── env.ts # Env + DispatchJob + AppContext types
│ │ │ ├── router.ts # builds the XRPCRouter, wires handlers
│ │ │ ├── auth/ # verifier.ts, sender.ts, user.ts
│ │ │ ├── xrpc/ # one file per procedure/query
│ │ │ ├── delivery/ # dispatcher.ts (queue consumer) + one module per channel
│ │ │ │ # (webpush, telegram, email, bluesky-dm, webhook) + channel/limits
│ │ │ ├── telegram/ # webhook.ts, commands.ts, callbacks.ts
│ │ │ ├── db/ # queries.ts (schema: migrations/0001_init.sql)
│ │ │ ├── identity/resolve.ts
│ │ │ ├── profile/fetch.ts
│ │ │ ├── ratelimit.ts
│ │ │ ├── well-known.ts
│ │ │ └── lib/ # errors.ts, ids.ts, time.ts
│ │ └── test/ # vitest (pool-workers)
│ ├── web/ # SvelteKit dashboard (atmo.pub)
│ │ ├── wrangler.jsonc
│ │ ├── svelte.config.js # adapter-cloudflare
│ │ ├── vite.config.ts # workerd resolve conditions (see "Deploying the SvelteKit apps")
│ │ └── src/
│ │ ├── hooks.server.ts # = atproto.handle (@svelte-atproto/oauth)
│ │ ├── lib/atproto/ # OAuth client config + oauth.remote.ts (login/logout)
│ │ ├── lib/server/relay.ts # calls the relay as the signed-in user
│ │ └── routes/ # (app)/ dashboard: apps, settings, inbox
│ ├── homepage/ # marketing landing + developer docs (docs.atmo.pub)
│ │ └── static/llms.txt # LLM-oriented integration guide
│ └── example-sender/ # one-page sender demo (example.atmo.pub)
│ ├── wrangler.jsonc
│ ├── scripts/generate-keys.js # P-256 keypair for `send` (sender:keygen)
│ └── src/
│ ├── lib/server/ # relay.ts, sender-auth.ts (mints sender JWTs)
│ ├── lib/relay.remote.ts # requestNotifications + sendTest commands
│ └── routes/
│ ├── +page.svelte
│ └── .well-known/did.json/+server.ts # this app's did:web
└── packages/
└── lexicons/
├── lex.config.js # @atcute/lex-cli config
├── lexicons/pub/atmo/notify/*.json # 13 lexicons
└── src/index.ts # re-exports generated types
Local setup#
Requires Node 22+ and pnpm 11 (corepack prepare pnpm@11 --activate).
pnpm install
pnpm generate # generate lexicon types into packages/lexicons/src/lexicons/
pnpm typecheck # tsc -b across the workspace
pnpm test # vitest (runs in the Workers runtime via miniflare)
Create the Cloudflare resources, then paste the ids into apps/relay/wrangler.toml
(replace each REPLACE_ME):
# from apps/relay/
pnpm exec wrangler d1 create notifs-relay # -> database_id
pnpm exec wrangler kv namespace create CACHE # -> id
pnpm exec wrangler queues create notifs-dispatch
Apply migrations (locally and/or remotely):
pnpm db:migrate # remote
# or, for local dev state:
pnpm exec wrangler d1 migrations apply notifs-relay --local
Run the worker locally:
pnpm dev # wrangler dev in apps/relay
The SvelteKit apps run on their own dev servers. Each needs local OAuth secrets
in a gitignored .env first (atproto:setup generates them):
pnpm --filter web atproto:setup && pnpm --filter web dev
pnpm --filter example atproto:setup && pnpm --filter example dev
Local dev uses a loopback OAuth client (no
ORIGIN), so the confidential-client path that only runs on a real domain isn't exercised. To test that locally, point acloudflaredtunnel at the dev server and setORIGINto the tunnel URL (the apps'vite.config.tsalready allows*.trycloudflare.comhosts).
Useful scripts (root)#
| Script | Action |
|---|---|
pnpm dev |
wrangler dev in apps/relay |
pnpm build |
builds all workspaces (pnpm -r build) |
pnpm test |
vitest across workspaces |
pnpm generate |
regenerate lexicon types with @atcute/lex-cli |
pnpm db:migrate |
wrangler d1 migrations apply notifs-relay |
pnpm typecheck |
tsc -b |
Configuration#
These values are baked into the code/config in several places. To rebrand or re-home the relay, change them everywhere listed below.
| Constant | Value | Where it lives |
|---|---|---|
| Relay domain | relay.atmo.pub |
apps/relay/wrangler.toml (routes, derived RELAY_DID); apps/relay/test/helpers.ts |
| Relay DID | did:web:relay.atmo.pub |
apps/relay/wrangler.toml ([vars].RELAY_DID); consumed by apps/relay/src/auth/verifier.ts and apps/relay/src/well-known.ts (service endpoint derived from it); apps/relay/test/helpers.ts (RELAY_DID) |
| Dashboard (web) domain | atmo.pub |
apps/web/wrangler.jsonc (ORIGIN); the relay's Telegram messages in apps/relay/src/telegram/commands.ts (DASHBOARD_URL, NOT_LINKED); apps/example-sender/src/lib/config.ts (DASHBOARD_ORIGIN) |
| Relay service id | #notif_relay |
apps/relay/src/well-known.ts (DID-doc service[].id); apps/relay/src/auth/verifier.ts (acceptAudiences fragment) |
| Lexicon NSID prefix | pub.atmo.notify |
Every file under packages/lexicons/lexicons/ (each id); regenerated types under packages/lexicons/src/lexicons/; the LXM constant in every apps/relay/src/xrpc/*.ts; the map + imports in apps/relay/src/well-known.ts |
| Pending request TTL | 7 days | apps/relay/src/xrpc/requestPermission.ts (addDays(createdAt, 7)) |
| Link token TTL | 10 minutes | apps/relay/src/xrpc/linkChannel.ts (addMinutes(now(), 10)) |
| DID-doc cache TTL (KV) | 5 minutes | apps/relay/src/identity/resolve.ts (DID_DOC_CACHE_TTL_SECONDS) |
Profile cache TTL (senders) |
24 hours | apps/relay/src/profile/fetch.ts (PROFILE_TTL_MS) |
requestPermission rate limits |
50 / hour / recipient & 100 / hour / sender | apps/relay/src/xrpc/requestPermission.ts (PER_RECIPIENT_LIMIT, PER_SENDER_LIMIT, WINDOW_SECONDS) |
send rate limits |
1 / sec & 100 / day / pair | apps/relay/src/xrpc/send.ts (PER_SECOND_*, PER_DAY_*) |
| Bot username | atmo_notify_bot |
apps/relay/wrangler.toml ([vars].BOT_USERNAME) → deep links in linkChannel |
Auth model:
requestPermissionis user-authenticated (the user OAuths into the requesting app, granting the relay'srequestPermissionrpc scope); the sender DID + display metadata are in the request body.sendstays sender-authenticated (the sender's own DID key). See "For sender developers".
Note on
lex.config.js— the prompt's tree named thislex.config.json, but@atcute/lex-cliloads its config via dynamicimport(), which only resolveslex.config.js/.ts. We use.jssopnpm generateworks without extra Node flags.
Telegram bot setup#
- Message @BotFather,
/newbot, and note the bot token and username. Put the username inwrangler.toml[vars].BOT_USERNAME. - (Optional) Set the command list with BotFather's
/setcommands:start - Link your account list - List authorized apps revoke - Revoke an app settings - Notification settings - Store the bot token as a secret:
pnpm exec wrangler secret put TELEGRAM_BOT_TOKEN - Generate and store a webhook secret (it becomes the last path segment of the
webhook URL, so attackers can't post fake updates):
openssl rand -hex 32 # copy the output pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET - Register the webhook with Telegram (after deploying, so the URL resolves):
BOT_TOKEN="123:abc" WEBHOOK_SECRET="<the hex you generated>" curl -sS "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \ -H 'content-type: application/json' \ -d "{\"url\":\"https://relay.atmo.pub/telegram/webhook/${WEBHOOK_SECRET}\"}"
Deploying the relay#
# from apps/relay/
pnpm exec wrangler deploy
Then:
- In the Cloudflare dashboard, attach the custom domain
relay.atmo.pubto the Worker (Workers & Pages → the worker → Settings → Domains & Routes). Theroutesentry inwrangler.tomlalready declares it as a custom domain. - Verify the DID document resolves:
Expected:curl -s https://relay.atmo.pub/.well-known/did.json{ "@context": ["https://www.w3.org/ns/did/v1"], "id": "did:web:relay.atmo.pub", "service": [ { "id": "#notif_relay", "type": "AtprotoNotificationRelay", "serviceEndpoint": "https://relay.atmo.pub" } ] } - Health check:
curl -s https://relay.atmo.pub/xrpc/_health→{"status":"ok"}. - Lexicons are served at
https://relay.atmo.pub/lexicons/<nsid>.
Deploying the SvelteKit apps (web + example sender)#
Both apps/web and apps/example-sender are SvelteKit apps on Cloudflare Workers
(@sveltejs/adapter-cloudflare). They use @svelte-atproto/oauth for atproto
OAuth and run reads via +page.server.ts load functions and writes via remote
commands.
# from apps/web (same shape for apps/example-sender)
pnpm exec wrangler kv namespace create OAUTH_SESSIONS # paste id into wrangler.jsonc
pnpm exec wrangler kv namespace create OAUTH_STATES
pnpm exec atproto-oauth secret | pnpm exec wrangler secret put COOKIE_SECRET
pnpm exec atproto-oauth keygen | pnpm exec wrangler secret put CLIENT_ASSERTION_KEY
pnpm run deploy # vite build && wrangler deploy
Then attach the custom domain in the Cloudflare dashboard (atmo.pub for
web, example.atmo.pub for the example) and make sure ORIGIN in
wrangler.jsonc matches it — OAuth client_id/redirect_uri derive from it.
The example sender also needs a sender keypair for send — see
apps/example-sender/README.md.
Cloudflare/workerd build note.
vite.config.tsin both apps setsssr.resolve.conditions: ['workerd', 'import', 'module', 'default']. Without it the SSR build resolves thenodeexport condition of@atcute/*packages, whose Node base64 build callsBuffer.prototype.base64urlSlicewith no offset — whichworkerd'snodejs_compatrejects (The "start" argument must be of type number), breaking OAuth sign-in only once deployed. Theworkerdcondition selects the Buffer-free Web builds. Don't addbrowserto the list (it makes other deps pull browser builds that referencewindow).
For sender developers#
The user-facing version of this section lives at https://atmo.pub/docs,
and a complete working implementation is in
apps/example-sender. The essentials:
Two endpoints, two different auth mechanisms:
requestPermissionproves the user authorized this request — it's authenticated by the user (via OAuth into your app, granting therequestPermissionrpc scope; see below). The sender DID and display info are passed in the body.sendproves the sender identity — it's authenticated by your app's DID (a service-auth JWT signed with your app's key).
-
Set up a
did:web(ordid:plc) for your app with an atproto signing key (needed forsend). Fordid:web:yourapp.example, host a DID document athttps://yourapp.example/.well-known/did.jsoncontaining averificationMethodwhose id ends in#atproto(aMultikeywith your public key inpublicKeyMultibase). The relay resolves plc + web DIDs. -
Request permission — the user signs into your app via atproto OAuth. Your app's scope needs only
requestPermission(senduses your app's own key, not the user's session):atproto rpc?lxm=pub.atmo.notify.requestPermission&aud=*Then, on the user's PDS, mint a service-auth JWT via
com.atproto.server.getServiceAuth(aud = did:web:relay.atmo.pub,lxm = pub.atmo.notify.requestPermission) and call:await fetch('https://relay.atmo.pub/xrpc/pub.atmo.notify.requestPermission', { method: 'POST', headers: { authorization: `Bearer ${userServiceAuthJwt}`, // issued by the USER's PDS 'content-type': 'application/json', }, body: JSON.stringify({ senderDid: 'did:web:yourapp.example', // what the user approves & what `send` uses title: 'Bookhive', // shown to the user at approval description: 'New comments on your books', iconUrl: 'https://yourapp.example/icon.png', }), }); // -> { id, status: "pending" | "alreadyGranted" }The user approves it in the dashboard or Telegram.
-
Send a notification once granted — authenticated with your app's own key (
aud= the relay,lxm = pub.atmo.notify.send):import { P256PrivateKeyExportable } from '@atcute/crypto'; import { createServiceJwt } from '@atcute/xrpc-server/auth'; const keypair = await P256PrivateKeyExportable.importRaw(yourPrivateKeyBytes); const jwt = await createServiceJwt({ keypair, issuer: 'did:web:yourapp.example', audience: 'did:web:relay.atmo.pub', lxm: 'pub.atmo.notify.send', expiresIn: 60, }); await fetch('https://relay.atmo.pub/xrpc/pub.atmo.notify.send', { method: 'POST', headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' }, body: JSON.stringify({ recipient: 'did:plc:therecipient', title: 'New reply', body: 'alice replied to your post', uri: 'https://yourapp.example/thread/123', }), }); // -> { id, delivered } (delivered = number of channels dispatched to)sendreturns403 NotAuthorizedif there is no grant, and429 RateLimitExceeded(withRetry-After) when limits are hit. A muted grant is accepted silently withdelivered: 0.