···11+# Development
22+33+Running, configuring, and deploying the `notify.atmo.tools` monorepo (relay, web
44+dashboard, example sender). For the high-level overview see
55+[README.md](README.md); for the sender API see https://notify.atmo.tools/docs.
66+77+## Repo layout
88+99+```
1010+.
1111+├── package.json # workspace root, scripts
1212+├── pnpm-workspace.yaml # apps/* and packages/*
1313+├── tsconfig.base.json # shared strict config
1414+├── tsconfig.json # references for `tsc -b`
1515+├── apps/
1616+│ ├── relay/
1717+│ │ ├── wrangler.toml
1818+│ │ ├── vitest.config.ts
1919+│ │ ├── migrations/ # 0001_init.sql, 0002_request_metadata.sql
2020+│ │ ├── src/
2121+│ │ │ ├── index.ts # default export { fetch, queue, scheduled }
2222+│ │ │ ├── env.ts # Env + DispatchJob + AppContext types
2323+│ │ │ ├── router.ts # builds the XRPCRouter, wires handlers
2424+│ │ │ ├── auth/ # verifier.ts, sender.ts, user.ts
2525+│ │ │ ├── xrpc/ # one file per procedure/query
2626+│ │ │ ├── delivery/ # telegram.ts (API), dispatcher.ts (queue consumer)
2727+│ │ │ ├── telegram/ # webhook.ts, commands.ts, callbacks.ts
2828+│ │ │ ├── db/ # schema.sql, queries.ts
2929+│ │ │ ├── identity/resolve.ts
3030+│ │ │ ├── profile/fetch.ts
3131+│ │ │ ├── ratelimit.ts
3232+│ │ │ ├── well-known.ts
3333+│ │ │ └── lib/ # errors.ts, ids.ts, time.ts
3434+│ │ └── test/ # vitest (pool-workers)
3535+│ ├── web/ # SvelteKit dashboard (notify.atmo.tools)
3636+│ │ ├── wrangler.jsonc
3737+│ │ ├── svelte.config.js # adapter-cloudflare
3838+│ │ ├── vite.config.ts # workerd resolve conditions (see "Deploying the SvelteKit apps")
3939+│ │ └── src/
4040+│ │ ├── hooks.server.ts # = atproto.handle (@svelte-atproto/oauth)
4141+│ │ ├── lib/atproto/ # OAuth client config + oauth.remote.ts (login/logout)
4242+│ │ ├── lib/server/relay.ts # calls the relay as the signed-in user
4343+│ │ └── routes/ # landing, /dashboard, /docs
4444+│ └── example-sender/ # one-page sender demo (example.notify.atmo.tools)
4545+│ ├── wrangler.jsonc
4646+│ ├── scripts/generate-keys.js # P-256 keypair for `send` (sender:keygen)
4747+│ └── src/
4848+│ ├── lib/server/ # relay.ts, sender-auth.ts (mints sender JWTs)
4949+│ ├── lib/relay.remote.ts # requestNotifications + sendTest commands
5050+│ └── routes/
5151+│ ├── +page.svelte
5252+│ └── .well-known/did.json/+server.ts # this app's did:web
5353+└── packages/
5454+ └── lexicons/
5555+ ├── lex.config.js # @atcute/lex-cli config
5656+ ├── lexicons/tools/atmo/notifs/*.json # 13 lexicons
5757+ └── src/index.ts # re-exports generated types
5858+```
5959+6060+## Local setup
6161+6262+Requires **Node 22+** and **pnpm 11** (`corepack prepare pnpm@11 --activate`).
6363+6464+```sh
6565+pnpm install
6666+pnpm generate # generate lexicon types into packages/lexicons/src/lexicons/
6767+pnpm typecheck # tsc -b across the workspace
6868+pnpm test # vitest (runs in the Workers runtime via miniflare)
6969+```
7070+7171+Create the Cloudflare resources, then paste the ids into `apps/relay/wrangler.toml`
7272+(replace each `REPLACE_ME`):
7373+7474+```sh
7575+# from apps/relay/
7676+pnpm exec wrangler d1 create notifs-relay # -> database_id
7777+pnpm exec wrangler kv namespace create CACHE # -> id
7878+pnpm exec wrangler queues create notifs-dispatch
7979+```
8080+8181+Apply migrations (locally and/or remotely):
8282+8383+```sh
8484+pnpm db:migrate # remote
8585+# or, for local dev state:
8686+pnpm exec wrangler d1 migrations apply notifs-relay --local
8787+```
8888+8989+Run the worker locally:
9090+9191+```sh
9292+pnpm dev # wrangler dev in apps/relay
9393+```
9494+9595+The SvelteKit apps run on their own dev servers. Each needs local OAuth secrets
9696+in a gitignored `.env` first (`atproto:setup` generates them):
9797+9898+```sh
9999+pnpm --filter web atproto:setup && pnpm --filter web dev
100100+pnpm --filter example atproto:setup && pnpm --filter example dev
101101+```
102102+103103+> Local dev uses a loopback OAuth client (no `ORIGIN`), so the confidential-client
104104+> path that only runs on a real domain isn't exercised. To test that locally,
105105+> point a `cloudflared` tunnel at the dev server and set `ORIGIN` to the tunnel
106106+> URL (the apps' `vite.config.ts` already allows `*.trycloudflare.com` hosts).
107107+108108+### Useful scripts (root)
109109+110110+| Script | Action |
111111+| --- | --- |
112112+| `pnpm dev` | `wrangler dev` in `apps/relay` |
113113+| `pnpm build` | builds all workspaces (`pnpm -r build`) |
114114+| `pnpm test` | vitest across workspaces |
115115+| `pnpm generate` | regenerate lexicon types with `@atcute/lex-cli` |
116116+| `pnpm db:migrate` | `wrangler d1 migrations apply notifs-relay` |
117117+| `pnpm typecheck` | `tsc -b` |
118118+119119+## Configuration
120120+121121+These values are baked into the code/config in several places. To rebrand or
122122+re-home the relay, change them everywhere listed below.
123123+124124+| Constant | Value | Where it lives |
125125+| --- | --- | --- |
126126+| Relay domain | `notifs.atmo.tools` | `apps/relay/wrangler.toml` (`routes`, derived `RELAY_DID`); `apps/relay/test/helpers.ts` |
127127+| Relay DID | `did:web:notifs.atmo.tools` | `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`) |
128128+| Dashboard (web) domain | `notify.atmo.tools` | `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`) |
129129+| Relay service id | `#notif_relay` | `apps/relay/src/well-known.ts` (DID-doc `service[].id`); `apps/relay/src/auth/verifier.ts` (`acceptAudiences` fragment) |
130130+| Lexicon NSID prefix | `tools.atmo.notifs` | 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` |
131131+| Pending request TTL | 7 days | `apps/relay/src/xrpc/requestPermission.ts` (`addDays(createdAt, 7)`) |
132132+| Link token TTL | 10 minutes | `apps/relay/src/xrpc/linkChannel.ts` (`addMinutes(now(), 10)`) |
133133+| DID-doc cache TTL (KV) | 5 minutes | `apps/relay/src/identity/resolve.ts` (`DID_DOC_CACHE_TTL_SECONDS`) |
134134+| Profile cache TTL (`senders`) | 24 hours | `apps/relay/src/profile/fetch.ts` (`PROFILE_TTL_MS`) |
135135+| `requestPermission` rate limits | 50 / hour / recipient & 100 / hour / sender | `apps/relay/src/xrpc/requestPermission.ts` (`PER_RECIPIENT_LIMIT`, `PER_SENDER_LIMIT`, `WINDOW_SECONDS`) |
136136+| `send` rate limits | 1 / sec & 100 / day / pair | `apps/relay/src/xrpc/send.ts` (`PER_SECOND_*`, `PER_DAY_*`) |
137137+| Bot username | `atmo_notify_bot` | `apps/relay/wrangler.toml` (`[vars].BOT_USERNAME`) → deep links in `linkChannel` |
138138+139139+> **Auth model:** `requestPermission` is **user-authenticated** (the user OAuths
140140+> into the requesting app, granting the relay's `requestPermission` rpc scope);
141141+> the sender DID + display metadata are in the request body. `send` stays
142142+> **sender-authenticated** (the sender's own DID key). See "For sender developers".
143143+144144+> **Note on `lex.config.js`** — the prompt's tree named this `lex.config.json`,
145145+> but `@atcute/lex-cli` loads its config via dynamic `import()`, which only
146146+> resolves `lex.config.js`/`.ts`. We use `.js` so `pnpm generate` works without
147147+> extra Node flags.
148148+149149+## Telegram bot setup
150150+151151+1. Message [@BotFather](https://t.me/BotFather), `/newbot`, and note the **bot
152152+ token** and **username**. Put the username in `wrangler.toml` `[vars].BOT_USERNAME`.
153153+2. (Optional) Set the command list with BotFather's `/setcommands`:
154154+ ```
155155+ start - Link your account
156156+ list - List authorized apps
157157+ revoke - Revoke an app
158158+ settings - Notification settings
159159+ ```
160160+3. Store the bot token as a secret:
161161+ ```sh
162162+ pnpm exec wrangler secret put TELEGRAM_BOT_TOKEN
163163+ ```
164164+4. Generate and store a webhook secret (it becomes the last path segment of the
165165+ webhook URL, so attackers can't post fake updates):
166166+ ```sh
167167+ openssl rand -hex 32 # copy the output
168168+ pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET
169169+ ```
170170+5. Register the webhook with Telegram (after deploying, so the URL resolves):
171171+ ```sh
172172+ BOT_TOKEN="123:abc"
173173+ WEBHOOK_SECRET="<the hex you generated>"
174174+ curl -sS "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \
175175+ -H 'content-type: application/json' \
176176+ -d "{\"url\":\"https://notifs.atmo.tools/telegram/webhook/${WEBHOOK_SECRET}\"}"
177177+ ```
178178+179179+## Deploying the relay
180180+181181+```sh
182182+# from apps/relay/
183183+pnpm exec wrangler deploy
184184+```
185185+186186+Then:
187187+188188+1. In the Cloudflare dashboard, attach the **custom domain** `notifs.atmo.tools`
189189+ to the Worker (Workers & Pages → the worker → Settings → Domains & Routes).
190190+ The `routes` entry in `wrangler.toml` already declares it as a custom domain.
191191+2. Verify the DID document resolves:
192192+ ```sh
193193+ curl -s https://notifs.atmo.tools/.well-known/did.json
194194+ ```
195195+ Expected:
196196+ ```json
197197+ {
198198+ "@context": ["https://www.w3.org/ns/did/v1"],
199199+ "id": "did:web:notifs.atmo.tools",
200200+ "service": [
201201+ {
202202+ "id": "#notif_relay",
203203+ "type": "AtprotoNotificationRelay",
204204+ "serviceEndpoint": "https://notifs.atmo.tools"
205205+ }
206206+ ]
207207+ }
208208+ ```
209209+3. Health check: `curl -s https://notifs.atmo.tools/xrpc/_health` → `{"status":"ok"}`.
210210+4. Lexicons are served at `https://notifs.atmo.tools/lexicons/<nsid>`.
211211+212212+## Deploying the SvelteKit apps (web + example sender)
213213+214214+Both `apps/web` and `apps/example-sender` are SvelteKit apps on Cloudflare Workers
215215+(`@sveltejs/adapter-cloudflare`). They use `@svelte-atproto/oauth` for atproto
216216+OAuth and run reads via `+page.server.ts` load functions and writes via remote
217217+`command`s.
218218+219219+```sh
220220+# from apps/web (same shape for apps/example-sender)
221221+pnpm exec wrangler kv namespace create OAUTH_SESSIONS # paste id into wrangler.jsonc
222222+pnpm exec wrangler kv namespace create OAUTH_STATES
223223+224224+pnpm exec atproto-oauth secret | pnpm exec wrangler secret put COOKIE_SECRET
225225+pnpm exec atproto-oauth keygen | pnpm exec wrangler secret put CLIENT_ASSERTION_KEY
226226+227227+pnpm run deploy # vite build && wrangler deploy
228228+```
229229+230230+Then attach the custom domain in the Cloudflare dashboard (`notify.atmo.tools` for
231231+web, `example.notify.atmo.tools` for the example) and make sure `ORIGIN` in
232232+`wrangler.jsonc` matches it — OAuth `client_id`/`redirect_uri` derive from it.
233233+234234+The example sender also needs a sender keypair for `send` — see
235235+[`apps/example-sender/README.md`](apps/example-sender/README.md).
236236+237237+> **Cloudflare/workerd build note.** `vite.config.ts` in both apps sets
238238+> `ssr.resolve.conditions: ['workerd', 'import', 'module', 'default']`. Without it
239239+> the SSR build resolves the **`node`** export condition of `@atcute/*` packages,
240240+> whose Node base64 build calls `Buffer.prototype.base64urlSlice` with no offset —
241241+> which `workerd`'s `nodejs_compat` rejects (`The "start" argument must be of type
242242+> number`), breaking OAuth sign-in **only once deployed**. The `workerd` condition
243243+> selects the Buffer-free Web builds. Don't add `browser` to the list (it makes
244244+> other deps pull browser builds that reference `window`).
245245+246246+## For sender developers
247247+248248+The user-facing version of this section lives at https://notify.atmo.tools/docs,
249249+and a complete working implementation is in
250250+[`apps/example-sender`](apps/example-sender). The essentials:
251251+252252+**Two endpoints, two different auth mechanisms:**
253253+254254+- **`requestPermission`** proves *the user authorized this request* — it's
255255+ authenticated by the **user** (via OAuth into your app, granting the
256256+ `requestPermission` rpc scope; see below). The sender DID and display info are
257257+ passed in the body.
258258+- **`send`** proves *the sender identity* — it's authenticated by **your app's
259259+ DID** (a service-auth JWT signed with your app's key).
260260+261261+1. **Set up a `did:web` (or `did:plc`) for your app** with an atproto signing key
262262+ (needed for `send`). For `did:web:yourapp.example`, host a DID document at
263263+ `https://yourapp.example/.well-known/did.json` containing a
264264+ `verificationMethod` whose id ends in `#atproto` (a `Multikey` with your public
265265+ key in `publicKeyMultibase`). The relay resolves plc + web DIDs.
266266+267267+2. **Request permission** — the user signs into your app via atproto OAuth. Your
268268+ app's scope needs only `requestPermission` (`send` uses your app's own key, not
269269+ the user's session):
270270+271271+ ```
272272+ atproto rpc?lxm=tools.atmo.notifs.requestPermission&aud=*
273273+ ```
274274+275275+ Then, on the user's PDS, mint a service-auth JWT via
276276+ `com.atproto.server.getServiceAuth` (`aud = did:web:notifs.atmo.tools`,
277277+ `lxm = tools.atmo.notifs.requestPermission`) and call:
278278+279279+ ```ts
280280+ await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.requestPermission', {
281281+ method: 'POST',
282282+ headers: {
283283+ authorization: `Bearer ${userServiceAuthJwt}`, // issued by the USER's PDS
284284+ 'content-type': 'application/json',
285285+ },
286286+ body: JSON.stringify({
287287+ senderDid: 'did:web:yourapp.example', // what the user approves & what `send` uses
288288+ title: 'Bookhive', // shown to the user at approval
289289+ description: 'New comments on your books',
290290+ iconUrl: 'https://yourapp.example/icon.png',
291291+ }),
292292+ });
293293+ // -> { id, status: "pending" | "alreadyGranted" }
294294+ ```
295295+296296+ The user approves it in the dashboard or Telegram.
297297+298298+3. **Send a notification** once granted — authenticated with **your app's own
299299+ key** (`aud` = the relay, `lxm = tools.atmo.notifs.send`):
300300+301301+ ```ts
302302+ import { P256PrivateKeyExportable } from '@atcute/crypto';
303303+ import { createServiceJwt } from '@atcute/xrpc-server/auth';
304304+305305+ const keypair = await P256PrivateKeyExportable.importRaw(yourPrivateKeyBytes);
306306+ const jwt = await createServiceJwt({
307307+ keypair,
308308+ issuer: 'did:web:yourapp.example',
309309+ audience: 'did:web:notifs.atmo.tools',
310310+ lxm: 'tools.atmo.notifs.send',
311311+ expiresIn: 60,
312312+ });
313313+314314+ await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.send', {
315315+ method: 'POST',
316316+ headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' },
317317+ body: JSON.stringify({
318318+ recipient: 'did:plc:therecipient',
319319+ title: 'New reply',
320320+ body: 'alice replied to your post',
321321+ uri: 'https://yourapp.example/thread/123',
322322+ }),
323323+ });
324324+ // -> { id, delivered } (delivered = number of channels dispatched to)
325325+ ```
326326+327327+ `send` returns `403 NotAuthorized` if there is no grant, and `429
328328+ RateLimitExceeded` (with `Retry-After`) when limits are hit. A muted grant is
329329+ accepted silently with `delivered: 0`.
···11-# atmo notifications relay
22-33-A Cloudflare Workers service that lets any AT Protocol app send notifications to
44-its users. A user approves which apps may notify them (via a separate SvelteKit
55-website, built later), and delivery in v1 happens through a Telegram bot.
66-77-The relay is a pure XRPC service: it verifies service-auth JWTs, stores grants /
88-pending requests / channels in D1, rate-limits with KV, and dispatches deliveries
99-through a Cloudflare Queue. It does **not** read the atproto firehose, hold repo
1010-records, or sign anything (it verifies inbound JWTs but never mints them).
1111-1212-This monorepo currently contains:
1313-1414-- **`packages/lexicons`** — the shared `tools.atmo.notifs.*` lexicons and their
1515- generated TypeScript types.
1616-- **`apps/relay`** — the Cloudflare Worker.
1717-- **`apps/web`** — placeholder for the SvelteKit dashboard (built in a follow-up).
1818-1919----
2020-2121-## Configuration
2222-2323-These values are baked into the code/config in several places. To rebrand or
2424-re-home the relay, change them everywhere listed below.
2525-2626-| Constant | Value | Where it lives |
2727-| --- | --- | --- |
2828-| Relay domain | `notifs.atmo.tools` | `apps/relay/wrangler.toml` (`routes`, derived `RELAY_DID`); dashboard links in `apps/relay/src/telegram/commands.ts` (`DASHBOARD_URL`, `NOT_LINKED`); `apps/relay/test/helpers.ts` |
2929-| Relay DID | `did:web:notifs.atmo.tools` | `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`) |
3030-| Relay service id | `#notif_relay` | `apps/relay/src/well-known.ts` (DID-doc `service[].id`); `apps/relay/src/auth/verifier.ts` (`acceptAudiences` fragment) |
3131-| Lexicon NSID prefix | `tools.atmo.notifs` | 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` |
3232-| Pending request TTL | 7 days | `apps/relay/src/xrpc/requestPermission.ts` (`addDays(createdAt, 7)`) |
3333-| Link token TTL | 10 minutes | `apps/relay/src/xrpc/linkChannel.ts` (`addMinutes(now(), 10)`) |
3434-| DID-doc cache TTL (KV) | 5 minutes | `apps/relay/src/identity/resolve.ts` (`DID_DOC_CACHE_TTL_SECONDS`) |
3535-| Profile cache TTL (`senders`) | 24 hours | `apps/relay/src/profile/fetch.ts` (`PROFILE_TTL_MS`) |
3636-| `requestPermission` rate limits | 50 / hour / recipient & 100 / hour / sender | `apps/relay/src/xrpc/requestPermission.ts` (`PER_RECIPIENT_LIMIT`, `PER_SENDER_LIMIT`, `WINDOW_SECONDS`) |
3737-| `send` rate limits | 1 / sec & 100 / day / pair | `apps/relay/src/xrpc/send.ts` (`PER_SECOND_*`, `PER_DAY_*`) |
3838-| Bot username | `REPLACE_ME` | `apps/relay/wrangler.toml` (`[vars].BOT_USERNAME`) → deep links in `linkChannel` |
3939-4040-> **Auth model:** `requestPermission` is **user-authenticated** (the user OAuths
4141-> into the requesting app, granting the relay's `requestPermission` rpc scope);
4242-> the sender DID + display metadata are in the request body. `send` stays
4343-> **sender-authenticated** (the sender's own DID key). See "For sender developers".
4444-4545-> **Note on `lex.config.js`** — the prompt's tree named this `lex.config.json`,
4646-> but `@atcute/lex-cli` loads its config via dynamic `import()`, which only
4747-> resolves `lex.config.js`/`.ts`. We use `.js` so `pnpm generate` works without
4848-> extra Node flags.
4949-5050----
5151-5252-## Repo layout
5353-5454-```
5555-.
5656-├── package.json # workspace root, scripts
5757-├── pnpm-workspace.yaml # apps/* and packages/*
5858-├── tsconfig.base.json # shared strict config
5959-├── tsconfig.json # references for `tsc -b`
6060-├── apps/
6161-│ ├── relay/
6262-│ │ ├── wrangler.toml
6363-│ │ ├── vitest.config.ts
6464-│ │ ├── migrations/0001_init.sql
6565-│ │ ├── src/
6666-│ │ │ ├── index.ts # default export { fetch, queue, scheduled }
6767-│ │ │ ├── env.ts # Env + DispatchJob + AppContext types
6868-│ │ │ ├── router.ts # builds the XRPCRouter, wires handlers
6969-│ │ │ ├── auth/ # verifier.ts, sender.ts, user.ts
7070-│ │ │ ├── xrpc/ # one file per procedure/query
7171-│ │ │ ├── delivery/ # telegram.ts (API), dispatcher.ts (queue consumer)
7272-│ │ │ ├── telegram/ # webhook.ts, commands.ts, callbacks.ts
7373-│ │ │ ├── db/ # schema.sql, queries.ts
7474-│ │ │ ├── identity/resolve.ts
7575-│ │ │ ├── profile/fetch.ts
7676-│ │ │ ├── ratelimit.ts
7777-│ │ │ ├── well-known.ts
7878-│ │ │ └── lib/ # errors.ts, ids.ts, time.ts
7979-│ │ └── test/ # vitest (pool-workers)
8080-│ └── web/ # placeholder
8181-└── packages/
8282- └── lexicons/
8383- ├── lex.config.js # @atcute/lex-cli config
8484- ├── lexicons/tools/atmo/notifs/*.json # 15 lexicons
8585- └── src/index.ts # re-exports generated types
8686-```
8787-8888----
8989-9090-## Local setup
9191-9292-Requires **Node 22+** and **pnpm 11** (`corepack prepare pnpm@11 --activate`).
9393-9494-```sh
9595-pnpm install
9696-pnpm generate # generate lexicon types into packages/lexicons/src/lexicons/
9797-pnpm typecheck # tsc -b across the workspace
9898-pnpm test # vitest (runs in the Workers runtime via miniflare)
9999-```
100100-101101-Create the Cloudflare resources, then paste the ids into `apps/relay/wrangler.toml`
102102-(replace each `REPLACE_ME`):
103103-104104-```sh
105105-# from apps/relay/
106106-pnpm exec wrangler d1 create notifs-relay # -> database_id
107107-pnpm exec wrangler kv namespace create CACHE # -> id
108108-pnpm exec wrangler queues create notifs-dispatch
109109-```
110110-111111-Apply migrations (locally and/or remotely):
112112-113113-```sh
114114-pnpm db:migrate # remote
115115-# or, for local dev state:
116116-pnpm exec wrangler d1 migrations apply notifs-relay --local
117117-```
118118-119119-Run the worker locally:
120120-121121-```sh
122122-pnpm dev # wrangler dev in apps/relay
123123-```
124124-125125-### Useful scripts (root)
126126-127127-| Script | Action |
128128-| --- | --- |
129129-| `pnpm dev` | `wrangler dev` in `apps/relay` |
130130-| `pnpm build` | builds all workspaces (`pnpm -r build`) |
131131-| `pnpm test` | vitest across workspaces |
132132-| `pnpm generate` | regenerate lexicon types with `@atcute/lex-cli` |
133133-| `pnpm db:migrate` | `wrangler d1 migrations apply notifs-relay` |
134134-| `pnpm typecheck` | `tsc -b` |
135135-136136----
137137-138138-## Telegram bot setup
139139-140140-1. Message [@BotFather](https://t.me/BotFather), `/newbot`, and note the **bot
141141- token** and **username**. Put the username in `wrangler.toml` `[vars].BOT_USERNAME`.
142142-2. (Optional) Set the command list with BotFather's `/setcommands`:
143143- ```
144144- start - Link your account
145145- list - List authorized apps
146146- revoke - Revoke an app
147147- settings - Notification settings
148148- ```
149149-3. Store the bot token as a secret:
150150- ```sh
151151- pnpm exec wrangler secret put TELEGRAM_BOT_TOKEN
152152- ```
153153-4. Generate and store a webhook secret (it becomes the last path segment of the
154154- webhook URL, so attackers can't post fake updates):
155155- ```sh
156156- openssl rand -hex 32 # copy the output
157157- pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET
158158- ```
159159-5. Register the webhook with Telegram (after deploying, so the URL resolves):
160160- ```sh
161161- BOT_TOKEN="123:abc"
162162- WEBHOOK_SECRET="<the hex you generated>"
163163- curl -sS "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \
164164- -H 'content-type: application/json' \
165165- -d "{\"url\":\"https://notifs.atmo.tools/telegram/webhook/${WEBHOOK_SECRET}\"}"
166166- ```
11+# notify.atmo.tools
1672168168----
33+Notifications for the AT Protocol. Any atproto app can ask a user for permission
44+to notify them; the user approves which apps may notify them in a web dashboard,
55+and deliveries go out (v1) through a Telegram bot.
1696170170-## Deploying
77+- **Dashboard** — sign in, approve apps, link Telegram: https://notify.atmo.tools
88+- **Developer docs**: https://notify.atmo.tools/docs
99+- **Live example sender**: https://example.notify.atmo.tools · [source](apps/example-sender)
1010+- **Relay API + `did:web`**: https://notifs.atmo.tools
17111172172-```sh
173173-# from apps/relay/
174174-pnpm exec wrangler deploy
175175-```
1212+> `notify.atmo.tools` is the dashboard; `notifs.atmo.tools` (with an "s") is the
1313+> relay XRPC API. User-facing links point at `notify.atmo.tools`.
17614177177-Then:
1515+## Sending notifications from your app
17816179179-1. In the Cloudflare dashboard, attach the **custom domain** `notifs.atmo.tools`
180180- to the Worker (Workers & Pages → the worker → Settings → Domains & Routes).
181181- The `routes` entry in `wrangler.toml` already declares it as a custom domain.
182182-2. Verify the DID document resolves:
183183- ```sh
184184- curl -s https://notifs.atmo.tools/.well-known/did.json
185185- ```
186186- Expected:
187187- ```json
188188- {
189189- "@context": ["https://www.w3.org/ns/did/v1"],
190190- "id": "did:web:notifs.atmo.tools",
191191- "service": [
192192- {
193193- "id": "#notif_relay",
194194- "type": "AtprotoNotificationRelay",
195195- "serviceEndpoint": "https://notifs.atmo.tools"
196196- }
197197- ]
198198- }
199199- ```
200200-3. Health check: `curl -s https://notifs.atmo.tools/xrpc/_health` → `{"status":"ok"}`.
201201-4. Lexicons are served at `https://notifs.atmo.tools/lexicons/<nsid>`.
1717+Two endpoints, two auth mechanisms:
20218203203----
1919+- **`requestPermission`** — authenticated by the **user** (atproto OAuth into your
2020+ app). Names your app as the sender; the user approves it.
2121+- **`send`** — authenticated by **your app's own DID key**. Delivers to a user who
2222+ has granted you permission.
20423205205-## For sender developers
2424+Full walkthrough with code is at **https://notify.atmo.tools/docs**, and
2525+[`apps/example-sender`](apps/example-sender) is a complete, ~300-line reference
2626+implementation of both flows (live at https://example.notify.atmo.tools).
20627207207-**Two endpoints, two different auth mechanisms:**
2828+## This repo
20829209209-- **`requestPermission`** proves *the user authorized this request* — it's
210210- authenticated by the **user** (via OAuth into your app with the `authSender`
211211- permission set). The sender DID and display info are passed in the body.
212212-- **`send`** proves *the sender identity* — it's authenticated by **your app's
213213- DID** (a service-auth JWT signed with your app's key). Unchanged.
214214-215215-1. **Set up a `did:web` (or `did:plc`) for your app** with an atproto signing key
216216- (needed for `send`). For `did:web:yourapp.example`, host a DID document at
217217- `https://yourapp.example/.well-known/did.json` containing a
218218- `verificationMethod` whose id ends in `#atproto` (a `Multikey` with your public
219219- key in `publicKeyMultibase`). The relay resolves plc + web DIDs.
220220-221221-2. **Request permission** — the user signs into your app via atproto OAuth. Your
222222- app's scope needs only `requestPermission` (`send` uses your app's own key, not
223223- the user's session):
224224-225225- ```
226226- atproto rpc?lxm=tools.atmo.notifs.requestPermission&aud=*
227227- ```
3030+- **`apps/relay`** — the Cloudflare Worker at `notifs.atmo.tools`. Pure XRPC: it
3131+ verifies inbound service-auth JWTs, stores grants / pending requests / channels
3232+ in D1, rate-limits with KV, and dispatches deliveries through a Cloudflare Queue.
3333+ No firehose, no repo records, no signing.
3434+- **`apps/web`** — the SvelteKit dashboard at `notify.atmo.tools` (+ `/docs`).
3535+- **`apps/example-sender`** — one-page sender demo at `example.notify.atmo.tools`.
3636+- **`packages/lexicons`** — the shared `tools.atmo.notifs.*` lexicons (13) and
3737+ generated types.
22838229229- Then, on the user's PDS, mint a service-auth JWT via
230230- `com.atproto.server.getServiceAuth` (`aud = did:web:notifs.atmo.tools`,
231231- `lxm = tools.atmo.notifs.requestPermission`) and call:
232232-233233- ```ts
234234- await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.requestPermission', {
235235- method: 'POST',
236236- headers: {
237237- authorization: `Bearer ${userServiceAuthJwt}`, // issued by the USER's PDS
238238- 'content-type': 'application/json',
239239- },
240240- body: JSON.stringify({
241241- senderDid: 'did:web:yourapp.example', // what the user approves & what `send` uses
242242- title: 'Bookhive', // shown to the user at approval
243243- description: 'New comments on your books',
244244- iconUrl: 'https://yourapp.example/icon.png',
245245- }),
246246- });
247247- // -> { id, status: "pending" | "alreadyGranted" }
248248- ```
249249-250250- The user approves it in the dashboard or Telegram.
251251-252252-3. **Send a notification** once granted — authenticated with **your app's own
253253- key** (`aud` = the relay, `lxm = tools.atmo.notifs.send`):
254254-255255- ```ts
256256- import { P256PrivateKeyExportable } from '@atcute/crypto';
257257- import { createServiceJwt } from '@atcute/xrpc-server/auth';
258258-259259- const keypair = await P256PrivateKeyExportable.importRaw(yourPrivateKeyBytes);
260260- const jwt = await createServiceJwt({
261261- keypair,
262262- issuer: 'did:web:yourapp.example',
263263- audience: 'did:web:notifs.atmo.tools',
264264- lxm: 'tools.atmo.notifs.send',
265265- expiresIn: 60,
266266- });
267267-268268- await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.send', {
269269- method: 'POST',
270270- headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' },
271271- body: JSON.stringify({
272272- recipient: 'did:plc:therecipient',
273273- title: 'New reply',
274274- body: 'alice replied to your post',
275275- uri: 'https://yourapp.example/thread/123',
276276- }),
277277- });
278278- // -> { id, delivered } (delivered = number of channels dispatched to)
279279- ```
280280-281281- `send` returns `403 NotAuthorized` if there is no grant, and `429
282282- RateLimitExceeded` (with `Retry-After`) when limits are hit. A muted grant is
283283- accepted silently with `delivered: 0`.
3939+Running, configuring, and deploying everything: **[DEVELOPMENT.md](DEVELOPMENT.md)**.