[READ-ONLY] Mirror of https://github.com/flo-bit/atproto-notify.
0

Configure Feed

Select the types of activity you want to include in your feed.

cleanup

+376 -274
+329
DEVELOPMENT.md
··· 1 + # Development 2 + 3 + Running, configuring, and deploying the `notify.atmo.tools` monorepo (relay, web 4 + dashboard, example sender). For the high-level overview see 5 + [README.md](README.md); for the sender API see https://notify.atmo.tools/docs. 6 + 7 + ## Repo layout 8 + 9 + ``` 10 + . 11 + ├── package.json # workspace root, scripts 12 + ├── pnpm-workspace.yaml # apps/* and packages/* 13 + ├── tsconfig.base.json # shared strict config 14 + ├── tsconfig.json # references for `tsc -b` 15 + ├── apps/ 16 + │ ├── relay/ 17 + │ │ ├── wrangler.toml 18 + │ │ ├── vitest.config.ts 19 + │ │ ├── migrations/ # 0001_init.sql, 0002_request_metadata.sql 20 + │ │ ├── src/ 21 + │ │ │ ├── index.ts # default export { fetch, queue, scheduled } 22 + │ │ │ ├── env.ts # Env + DispatchJob + AppContext types 23 + │ │ │ ├── router.ts # builds the XRPCRouter, wires handlers 24 + │ │ │ ├── auth/ # verifier.ts, sender.ts, user.ts 25 + │ │ │ ├── xrpc/ # one file per procedure/query 26 + │ │ │ ├── delivery/ # telegram.ts (API), dispatcher.ts (queue consumer) 27 + │ │ │ ├── telegram/ # webhook.ts, commands.ts, callbacks.ts 28 + │ │ │ ├── db/ # schema.sql, queries.ts 29 + │ │ │ ├── identity/resolve.ts 30 + │ │ │ ├── profile/fetch.ts 31 + │ │ │ ├── ratelimit.ts 32 + │ │ │ ├── well-known.ts 33 + │ │ │ └── lib/ # errors.ts, ids.ts, time.ts 34 + │ │ └── test/ # vitest (pool-workers) 35 + │ ├── web/ # SvelteKit dashboard (notify.atmo.tools) 36 + │ │ ├── wrangler.jsonc 37 + │ │ ├── svelte.config.js # adapter-cloudflare 38 + │ │ ├── vite.config.ts # workerd resolve conditions (see "Deploying the SvelteKit apps") 39 + │ │ └── src/ 40 + │ │ ├── hooks.server.ts # = atproto.handle (@svelte-atproto/oauth) 41 + │ │ ├── lib/atproto/ # OAuth client config + oauth.remote.ts (login/logout) 42 + │ │ ├── lib/server/relay.ts # calls the relay as the signed-in user 43 + │ │ └── routes/ # landing, /dashboard, /docs 44 + │ └── example-sender/ # one-page sender demo (example.notify.atmo.tools) 45 + │ ├── wrangler.jsonc 46 + │ ├── scripts/generate-keys.js # P-256 keypair for `send` (sender:keygen) 47 + │ └── src/ 48 + │ ├── lib/server/ # relay.ts, sender-auth.ts (mints sender JWTs) 49 + │ ├── lib/relay.remote.ts # requestNotifications + sendTest commands 50 + │ └── routes/ 51 + │ ├── +page.svelte 52 + │ └── .well-known/did.json/+server.ts # this app's did:web 53 + └── packages/ 54 + └── lexicons/ 55 + ├── lex.config.js # @atcute/lex-cli config 56 + ├── lexicons/tools/atmo/notifs/*.json # 13 lexicons 57 + └── src/index.ts # re-exports generated types 58 + ``` 59 + 60 + ## Local setup 61 + 62 + Requires **Node 22+** and **pnpm 11** (`corepack prepare pnpm@11 --activate`). 63 + 64 + ```sh 65 + pnpm install 66 + pnpm generate # generate lexicon types into packages/lexicons/src/lexicons/ 67 + pnpm typecheck # tsc -b across the workspace 68 + pnpm test # vitest (runs in the Workers runtime via miniflare) 69 + ``` 70 + 71 + Create the Cloudflare resources, then paste the ids into `apps/relay/wrangler.toml` 72 + (replace each `REPLACE_ME`): 73 + 74 + ```sh 75 + # from apps/relay/ 76 + pnpm exec wrangler d1 create notifs-relay # -> database_id 77 + pnpm exec wrangler kv namespace create CACHE # -> id 78 + pnpm exec wrangler queues create notifs-dispatch 79 + ``` 80 + 81 + Apply migrations (locally and/or remotely): 82 + 83 + ```sh 84 + pnpm db:migrate # remote 85 + # or, for local dev state: 86 + pnpm exec wrangler d1 migrations apply notifs-relay --local 87 + ``` 88 + 89 + Run the worker locally: 90 + 91 + ```sh 92 + pnpm dev # wrangler dev in apps/relay 93 + ``` 94 + 95 + The SvelteKit apps run on their own dev servers. Each needs local OAuth secrets 96 + in a gitignored `.env` first (`atproto:setup` generates them): 97 + 98 + ```sh 99 + pnpm --filter web atproto:setup && pnpm --filter web dev 100 + pnpm --filter example atproto:setup && pnpm --filter example dev 101 + ``` 102 + 103 + > Local dev uses a loopback OAuth client (no `ORIGIN`), so the confidential-client 104 + > path that only runs on a real domain isn't exercised. To test that locally, 105 + > point a `cloudflared` tunnel at the dev server and set `ORIGIN` to the tunnel 106 + > URL (the apps' `vite.config.ts` already allows `*.trycloudflare.com` hosts). 107 + 108 + ### Useful scripts (root) 109 + 110 + | Script | Action | 111 + | --- | --- | 112 + | `pnpm dev` | `wrangler dev` in `apps/relay` | 113 + | `pnpm build` | builds all workspaces (`pnpm -r build`) | 114 + | `pnpm test` | vitest across workspaces | 115 + | `pnpm generate` | regenerate lexicon types with `@atcute/lex-cli` | 116 + | `pnpm db:migrate` | `wrangler d1 migrations apply notifs-relay` | 117 + | `pnpm typecheck` | `tsc -b` | 118 + 119 + ## Configuration 120 + 121 + These values are baked into the code/config in several places. To rebrand or 122 + re-home the relay, change them everywhere listed below. 123 + 124 + | Constant | Value | Where it lives | 125 + | --- | --- | --- | 126 + | Relay domain | `notifs.atmo.tools` | `apps/relay/wrangler.toml` (`routes`, derived `RELAY_DID`); `apps/relay/test/helpers.ts` | 127 + | 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`) | 128 + | 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`) | 129 + | Relay service id | `#notif_relay` | `apps/relay/src/well-known.ts` (DID-doc `service[].id`); `apps/relay/src/auth/verifier.ts` (`acceptAudiences` fragment) | 130 + | 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` | 131 + | Pending request TTL | 7 days | `apps/relay/src/xrpc/requestPermission.ts` (`addDays(createdAt, 7)`) | 132 + | Link token TTL | 10 minutes | `apps/relay/src/xrpc/linkChannel.ts` (`addMinutes(now(), 10)`) | 133 + | DID-doc cache TTL (KV) | 5 minutes | `apps/relay/src/identity/resolve.ts` (`DID_DOC_CACHE_TTL_SECONDS`) | 134 + | Profile cache TTL (`senders`) | 24 hours | `apps/relay/src/profile/fetch.ts` (`PROFILE_TTL_MS`) | 135 + | `requestPermission` rate limits | 50 / hour / recipient & 100 / hour / sender | `apps/relay/src/xrpc/requestPermission.ts` (`PER_RECIPIENT_LIMIT`, `PER_SENDER_LIMIT`, `WINDOW_SECONDS`) | 136 + | `send` rate limits | 1 / sec & 100 / day / pair | `apps/relay/src/xrpc/send.ts` (`PER_SECOND_*`, `PER_DAY_*`) | 137 + | Bot username | `atmo_notify_bot` | `apps/relay/wrangler.toml` (`[vars].BOT_USERNAME`) → deep links in `linkChannel` | 138 + 139 + > **Auth model:** `requestPermission` is **user-authenticated** (the user OAuths 140 + > into the requesting app, granting the relay's `requestPermission` rpc scope); 141 + > the sender DID + display metadata are in the request body. `send` stays 142 + > **sender-authenticated** (the sender's own DID key). See "For sender developers". 143 + 144 + > **Note on `lex.config.js`** — the prompt's tree named this `lex.config.json`, 145 + > but `@atcute/lex-cli` loads its config via dynamic `import()`, which only 146 + > resolves `lex.config.js`/`.ts`. We use `.js` so `pnpm generate` works without 147 + > extra Node flags. 148 + 149 + ## Telegram bot setup 150 + 151 + 1. Message [@BotFather](https://t.me/BotFather), `/newbot`, and note the **bot 152 + token** and **username**. Put the username in `wrangler.toml` `[vars].BOT_USERNAME`. 153 + 2. (Optional) Set the command list with BotFather's `/setcommands`: 154 + ``` 155 + start - Link your account 156 + list - List authorized apps 157 + revoke - Revoke an app 158 + settings - Notification settings 159 + ``` 160 + 3. Store the bot token as a secret: 161 + ```sh 162 + pnpm exec wrangler secret put TELEGRAM_BOT_TOKEN 163 + ``` 164 + 4. Generate and store a webhook secret (it becomes the last path segment of the 165 + webhook URL, so attackers can't post fake updates): 166 + ```sh 167 + openssl rand -hex 32 # copy the output 168 + pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET 169 + ``` 170 + 5. Register the webhook with Telegram (after deploying, so the URL resolves): 171 + ```sh 172 + BOT_TOKEN="123:abc" 173 + WEBHOOK_SECRET="<the hex you generated>" 174 + curl -sS "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \ 175 + -H 'content-type: application/json' \ 176 + -d "{\"url\":\"https://notifs.atmo.tools/telegram/webhook/${WEBHOOK_SECRET}\"}" 177 + ``` 178 + 179 + ## Deploying the relay 180 + 181 + ```sh 182 + # from apps/relay/ 183 + pnpm exec wrangler deploy 184 + ``` 185 + 186 + Then: 187 + 188 + 1. In the Cloudflare dashboard, attach the **custom domain** `notifs.atmo.tools` 189 + to the Worker (Workers & Pages → the worker → Settings → Domains & Routes). 190 + The `routes` entry in `wrangler.toml` already declares it as a custom domain. 191 + 2. Verify the DID document resolves: 192 + ```sh 193 + curl -s https://notifs.atmo.tools/.well-known/did.json 194 + ``` 195 + Expected: 196 + ```json 197 + { 198 + "@context": ["https://www.w3.org/ns/did/v1"], 199 + "id": "did:web:notifs.atmo.tools", 200 + "service": [ 201 + { 202 + "id": "#notif_relay", 203 + "type": "AtprotoNotificationRelay", 204 + "serviceEndpoint": "https://notifs.atmo.tools" 205 + } 206 + ] 207 + } 208 + ``` 209 + 3. Health check: `curl -s https://notifs.atmo.tools/xrpc/_health` → `{"status":"ok"}`. 210 + 4. Lexicons are served at `https://notifs.atmo.tools/lexicons/<nsid>`. 211 + 212 + ## Deploying the SvelteKit apps (web + example sender) 213 + 214 + Both `apps/web` and `apps/example-sender` are SvelteKit apps on Cloudflare Workers 215 + (`@sveltejs/adapter-cloudflare`). They use `@svelte-atproto/oauth` for atproto 216 + OAuth and run reads via `+page.server.ts` load functions and writes via remote 217 + `command`s. 218 + 219 + ```sh 220 + # from apps/web (same shape for apps/example-sender) 221 + pnpm exec wrangler kv namespace create OAUTH_SESSIONS # paste id into wrangler.jsonc 222 + pnpm exec wrangler kv namespace create OAUTH_STATES 223 + 224 + pnpm exec atproto-oauth secret | pnpm exec wrangler secret put COOKIE_SECRET 225 + pnpm exec atproto-oauth keygen | pnpm exec wrangler secret put CLIENT_ASSERTION_KEY 226 + 227 + pnpm run deploy # vite build && wrangler deploy 228 + ``` 229 + 230 + Then attach the custom domain in the Cloudflare dashboard (`notify.atmo.tools` for 231 + web, `example.notify.atmo.tools` for the example) and make sure `ORIGIN` in 232 + `wrangler.jsonc` matches it — OAuth `client_id`/`redirect_uri` derive from it. 233 + 234 + The example sender also needs a sender keypair for `send` — see 235 + [`apps/example-sender/README.md`](apps/example-sender/README.md). 236 + 237 + > **Cloudflare/workerd build note.** `vite.config.ts` in both apps sets 238 + > `ssr.resolve.conditions: ['workerd', 'import', 'module', 'default']`. Without it 239 + > the SSR build resolves the **`node`** export condition of `@atcute/*` packages, 240 + > whose Node base64 build calls `Buffer.prototype.base64urlSlice` with no offset — 241 + > which `workerd`'s `nodejs_compat` rejects (`The "start" argument must be of type 242 + > number`), breaking OAuth sign-in **only once deployed**. The `workerd` condition 243 + > selects the Buffer-free Web builds. Don't add `browser` to the list (it makes 244 + > other deps pull browser builds that reference `window`). 245 + 246 + ## For sender developers 247 + 248 + The user-facing version of this section lives at https://notify.atmo.tools/docs, 249 + and a complete working implementation is in 250 + [`apps/example-sender`](apps/example-sender). The essentials: 251 + 252 + **Two endpoints, two different auth mechanisms:** 253 + 254 + - **`requestPermission`** proves *the user authorized this request* — it's 255 + authenticated by the **user** (via OAuth into your app, granting the 256 + `requestPermission` rpc scope; see below). The sender DID and display info are 257 + passed in the body. 258 + - **`send`** proves *the sender identity* — it's authenticated by **your app's 259 + DID** (a service-auth JWT signed with your app's key). 260 + 261 + 1. **Set up a `did:web` (or `did:plc`) for your app** with an atproto signing key 262 + (needed for `send`). For `did:web:yourapp.example`, host a DID document at 263 + `https://yourapp.example/.well-known/did.json` containing a 264 + `verificationMethod` whose id ends in `#atproto` (a `Multikey` with your public 265 + key in `publicKeyMultibase`). The relay resolves plc + web DIDs. 266 + 267 + 2. **Request permission** — the user signs into your app via atproto OAuth. Your 268 + app's scope needs only `requestPermission` (`send` uses your app's own key, not 269 + the user's session): 270 + 271 + ``` 272 + atproto rpc?lxm=tools.atmo.notifs.requestPermission&aud=* 273 + ``` 274 + 275 + Then, on the user's PDS, mint a service-auth JWT via 276 + `com.atproto.server.getServiceAuth` (`aud = did:web:notifs.atmo.tools`, 277 + `lxm = tools.atmo.notifs.requestPermission`) and call: 278 + 279 + ```ts 280 + await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.requestPermission', { 281 + method: 'POST', 282 + headers: { 283 + authorization: `Bearer ${userServiceAuthJwt}`, // issued by the USER's PDS 284 + 'content-type': 'application/json', 285 + }, 286 + body: JSON.stringify({ 287 + senderDid: 'did:web:yourapp.example', // what the user approves & what `send` uses 288 + title: 'Bookhive', // shown to the user at approval 289 + description: 'New comments on your books', 290 + iconUrl: 'https://yourapp.example/icon.png', 291 + }), 292 + }); 293 + // -> { id, status: "pending" | "alreadyGranted" } 294 + ``` 295 + 296 + The user approves it in the dashboard or Telegram. 297 + 298 + 3. **Send a notification** once granted — authenticated with **your app's own 299 + key** (`aud` = the relay, `lxm = tools.atmo.notifs.send`): 300 + 301 + ```ts 302 + import { P256PrivateKeyExportable } from '@atcute/crypto'; 303 + import { createServiceJwt } from '@atcute/xrpc-server/auth'; 304 + 305 + const keypair = await P256PrivateKeyExportable.importRaw(yourPrivateKeyBytes); 306 + const jwt = await createServiceJwt({ 307 + keypair, 308 + issuer: 'did:web:yourapp.example', 309 + audience: 'did:web:notifs.atmo.tools', 310 + lxm: 'tools.atmo.notifs.send', 311 + expiresIn: 60, 312 + }); 313 + 314 + await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.send', { 315 + method: 'POST', 316 + headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' }, 317 + body: JSON.stringify({ 318 + recipient: 'did:plc:therecipient', 319 + title: 'New reply', 320 + body: 'alice replied to your post', 321 + uri: 'https://yourapp.example/thread/123', 322 + }), 323 + }); 324 + // -> { id, delivered } (delivered = number of channels dispatched to) 325 + ``` 326 + 327 + `send` returns `403 NotAuthorized` if there is no grant, and `429 328 + RateLimitExceeded` (with `Retry-After`) when limits are hit. A muted grant is 329 + accepted silently with `delivered: 0`.
+29 -273
README.md
··· 1 - # atmo notifications relay 2 - 3 - A Cloudflare Workers service that lets any AT Protocol app send notifications to 4 - its users. A user approves which apps may notify them (via a separate SvelteKit 5 - website, built later), and delivery in v1 happens through a Telegram bot. 6 - 7 - The relay is a pure XRPC service: it verifies service-auth JWTs, stores grants / 8 - pending requests / channels in D1, rate-limits with KV, and dispatches deliveries 9 - through a Cloudflare Queue. It does **not** read the atproto firehose, hold repo 10 - records, or sign anything (it verifies inbound JWTs but never mints them). 11 - 12 - This monorepo currently contains: 13 - 14 - - **`packages/lexicons`** — the shared `tools.atmo.notifs.*` lexicons and their 15 - generated TypeScript types. 16 - - **`apps/relay`** — the Cloudflare Worker. 17 - - **`apps/web`** — placeholder for the SvelteKit dashboard (built in a follow-up). 18 - 19 - --- 20 - 21 - ## Configuration 22 - 23 - These values are baked into the code/config in several places. To rebrand or 24 - re-home the relay, change them everywhere listed below. 25 - 26 - | Constant | Value | Where it lives | 27 - | --- | --- | --- | 28 - | 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` | 29 - | 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`) | 30 - | Relay service id | `#notif_relay` | `apps/relay/src/well-known.ts` (DID-doc `service[].id`); `apps/relay/src/auth/verifier.ts` (`acceptAudiences` fragment) | 31 - | 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` | 32 - | Pending request TTL | 7 days | `apps/relay/src/xrpc/requestPermission.ts` (`addDays(createdAt, 7)`) | 33 - | Link token TTL | 10 minutes | `apps/relay/src/xrpc/linkChannel.ts` (`addMinutes(now(), 10)`) | 34 - | DID-doc cache TTL (KV) | 5 minutes | `apps/relay/src/identity/resolve.ts` (`DID_DOC_CACHE_TTL_SECONDS`) | 35 - | Profile cache TTL (`senders`) | 24 hours | `apps/relay/src/profile/fetch.ts` (`PROFILE_TTL_MS`) | 36 - | `requestPermission` rate limits | 50 / hour / recipient & 100 / hour / sender | `apps/relay/src/xrpc/requestPermission.ts` (`PER_RECIPIENT_LIMIT`, `PER_SENDER_LIMIT`, `WINDOW_SECONDS`) | 37 - | `send` rate limits | 1 / sec & 100 / day / pair | `apps/relay/src/xrpc/send.ts` (`PER_SECOND_*`, `PER_DAY_*`) | 38 - | Bot username | `REPLACE_ME` | `apps/relay/wrangler.toml` (`[vars].BOT_USERNAME`) → deep links in `linkChannel` | 39 - 40 - > **Auth model:** `requestPermission` is **user-authenticated** (the user OAuths 41 - > into the requesting app, granting the relay's `requestPermission` rpc scope); 42 - > the sender DID + display metadata are in the request body. `send` stays 43 - > **sender-authenticated** (the sender's own DID key). See "For sender developers". 44 - 45 - > **Note on `lex.config.js`** — the prompt's tree named this `lex.config.json`, 46 - > but `@atcute/lex-cli` loads its config via dynamic `import()`, which only 47 - > resolves `lex.config.js`/`.ts`. We use `.js` so `pnpm generate` works without 48 - > extra Node flags. 49 - 50 - --- 51 - 52 - ## Repo layout 53 - 54 - ``` 55 - . 56 - ├── package.json # workspace root, scripts 57 - ├── pnpm-workspace.yaml # apps/* and packages/* 58 - ├── tsconfig.base.json # shared strict config 59 - ├── tsconfig.json # references for `tsc -b` 60 - ├── apps/ 61 - │ ├── relay/ 62 - │ │ ├── wrangler.toml 63 - │ │ ├── vitest.config.ts 64 - │ │ ├── migrations/0001_init.sql 65 - │ │ ├── src/ 66 - │ │ │ ├── index.ts # default export { fetch, queue, scheduled } 67 - │ │ │ ├── env.ts # Env + DispatchJob + AppContext types 68 - │ │ │ ├── router.ts # builds the XRPCRouter, wires handlers 69 - │ │ │ ├── auth/ # verifier.ts, sender.ts, user.ts 70 - │ │ │ ├── xrpc/ # one file per procedure/query 71 - │ │ │ ├── delivery/ # telegram.ts (API), dispatcher.ts (queue consumer) 72 - │ │ │ ├── telegram/ # webhook.ts, commands.ts, callbacks.ts 73 - │ │ │ ├── db/ # schema.sql, queries.ts 74 - │ │ │ ├── identity/resolve.ts 75 - │ │ │ ├── profile/fetch.ts 76 - │ │ │ ├── ratelimit.ts 77 - │ │ │ ├── well-known.ts 78 - │ │ │ └── lib/ # errors.ts, ids.ts, time.ts 79 - │ │ └── test/ # vitest (pool-workers) 80 - │ └── web/ # placeholder 81 - └── packages/ 82 - └── lexicons/ 83 - ├── lex.config.js # @atcute/lex-cli config 84 - ├── lexicons/tools/atmo/notifs/*.json # 15 lexicons 85 - └── src/index.ts # re-exports generated types 86 - ``` 87 - 88 - --- 89 - 90 - ## Local setup 91 - 92 - Requires **Node 22+** and **pnpm 11** (`corepack prepare pnpm@11 --activate`). 93 - 94 - ```sh 95 - pnpm install 96 - pnpm generate # generate lexicon types into packages/lexicons/src/lexicons/ 97 - pnpm typecheck # tsc -b across the workspace 98 - pnpm test # vitest (runs in the Workers runtime via miniflare) 99 - ``` 100 - 101 - Create the Cloudflare resources, then paste the ids into `apps/relay/wrangler.toml` 102 - (replace each `REPLACE_ME`): 103 - 104 - ```sh 105 - # from apps/relay/ 106 - pnpm exec wrangler d1 create notifs-relay # -> database_id 107 - pnpm exec wrangler kv namespace create CACHE # -> id 108 - pnpm exec wrangler queues create notifs-dispatch 109 - ``` 110 - 111 - Apply migrations (locally and/or remotely): 112 - 113 - ```sh 114 - pnpm db:migrate # remote 115 - # or, for local dev state: 116 - pnpm exec wrangler d1 migrations apply notifs-relay --local 117 - ``` 118 - 119 - Run the worker locally: 120 - 121 - ```sh 122 - pnpm dev # wrangler dev in apps/relay 123 - ``` 124 - 125 - ### Useful scripts (root) 126 - 127 - | Script | Action | 128 - | --- | --- | 129 - | `pnpm dev` | `wrangler dev` in `apps/relay` | 130 - | `pnpm build` | builds all workspaces (`pnpm -r build`) | 131 - | `pnpm test` | vitest across workspaces | 132 - | `pnpm generate` | regenerate lexicon types with `@atcute/lex-cli` | 133 - | `pnpm db:migrate` | `wrangler d1 migrations apply notifs-relay` | 134 - | `pnpm typecheck` | `tsc -b` | 135 - 136 - --- 137 - 138 - ## Telegram bot setup 139 - 140 - 1. Message [@BotFather](https://t.me/BotFather), `/newbot`, and note the **bot 141 - token** and **username**. Put the username in `wrangler.toml` `[vars].BOT_USERNAME`. 142 - 2. (Optional) Set the command list with BotFather's `/setcommands`: 143 - ``` 144 - start - Link your account 145 - list - List authorized apps 146 - revoke - Revoke an app 147 - settings - Notification settings 148 - ``` 149 - 3. Store the bot token as a secret: 150 - ```sh 151 - pnpm exec wrangler secret put TELEGRAM_BOT_TOKEN 152 - ``` 153 - 4. Generate and store a webhook secret (it becomes the last path segment of the 154 - webhook URL, so attackers can't post fake updates): 155 - ```sh 156 - openssl rand -hex 32 # copy the output 157 - pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET 158 - ``` 159 - 5. Register the webhook with Telegram (after deploying, so the URL resolves): 160 - ```sh 161 - BOT_TOKEN="123:abc" 162 - WEBHOOK_SECRET="<the hex you generated>" 163 - curl -sS "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \ 164 - -H 'content-type: application/json' \ 165 - -d "{\"url\":\"https://notifs.atmo.tools/telegram/webhook/${WEBHOOK_SECRET}\"}" 166 - ``` 1 + # notify.atmo.tools 167 2 168 - --- 3 + Notifications for the AT Protocol. Any atproto app can ask a user for permission 4 + to notify them; the user approves which apps may notify them in a web dashboard, 5 + and deliveries go out (v1) through a Telegram bot. 169 6 170 - ## Deploying 7 + - **Dashboard** — sign in, approve apps, link Telegram: https://notify.atmo.tools 8 + - **Developer docs**: https://notify.atmo.tools/docs 9 + - **Live example sender**: https://example.notify.atmo.tools · [source](apps/example-sender) 10 + - **Relay API + `did:web`**: https://notifs.atmo.tools 171 11 172 - ```sh 173 - # from apps/relay/ 174 - pnpm exec wrangler deploy 175 - ``` 12 + > `notify.atmo.tools` is the dashboard; `notifs.atmo.tools` (with an "s") is the 13 + > relay XRPC API. User-facing links point at `notify.atmo.tools`. 176 14 177 - Then: 15 + ## Sending notifications from your app 178 16 179 - 1. In the Cloudflare dashboard, attach the **custom domain** `notifs.atmo.tools` 180 - to the Worker (Workers & Pages → the worker → Settings → Domains & Routes). 181 - The `routes` entry in `wrangler.toml` already declares it as a custom domain. 182 - 2. Verify the DID document resolves: 183 - ```sh 184 - curl -s https://notifs.atmo.tools/.well-known/did.json 185 - ``` 186 - Expected: 187 - ```json 188 - { 189 - "@context": ["https://www.w3.org/ns/did/v1"], 190 - "id": "did:web:notifs.atmo.tools", 191 - "service": [ 192 - { 193 - "id": "#notif_relay", 194 - "type": "AtprotoNotificationRelay", 195 - "serviceEndpoint": "https://notifs.atmo.tools" 196 - } 197 - ] 198 - } 199 - ``` 200 - 3. Health check: `curl -s https://notifs.atmo.tools/xrpc/_health` → `{"status":"ok"}`. 201 - 4. Lexicons are served at `https://notifs.atmo.tools/lexicons/<nsid>`. 17 + Two endpoints, two auth mechanisms: 202 18 203 - --- 19 + - **`requestPermission`** — authenticated by the **user** (atproto OAuth into your 20 + app). Names your app as the sender; the user approves it. 21 + - **`send`** — authenticated by **your app's own DID key**. Delivers to a user who 22 + has granted you permission. 204 23 205 - ## For sender developers 24 + Full walkthrough with code is at **https://notify.atmo.tools/docs**, and 25 + [`apps/example-sender`](apps/example-sender) is a complete, ~300-line reference 26 + implementation of both flows (live at https://example.notify.atmo.tools). 206 27 207 - **Two endpoints, two different auth mechanisms:** 28 + ## This repo 208 29 209 - - **`requestPermission`** proves *the user authorized this request* — it's 210 - authenticated by the **user** (via OAuth into your app with the `authSender` 211 - permission set). The sender DID and display info are passed in the body. 212 - - **`send`** proves *the sender identity* — it's authenticated by **your app's 213 - DID** (a service-auth JWT signed with your app's key). Unchanged. 214 - 215 - 1. **Set up a `did:web` (or `did:plc`) for your app** with an atproto signing key 216 - (needed for `send`). For `did:web:yourapp.example`, host a DID document at 217 - `https://yourapp.example/.well-known/did.json` containing a 218 - `verificationMethod` whose id ends in `#atproto` (a `Multikey` with your public 219 - key in `publicKeyMultibase`). The relay resolves plc + web DIDs. 220 - 221 - 2. **Request permission** — the user signs into your app via atproto OAuth. Your 222 - app's scope needs only `requestPermission` (`send` uses your app's own key, not 223 - the user's session): 224 - 225 - ``` 226 - atproto rpc?lxm=tools.atmo.notifs.requestPermission&aud=* 227 - ``` 30 + - **`apps/relay`** — the Cloudflare Worker at `notifs.atmo.tools`. Pure XRPC: it 31 + verifies inbound service-auth JWTs, stores grants / pending requests / channels 32 + in D1, rate-limits with KV, and dispatches deliveries through a Cloudflare Queue. 33 + No firehose, no repo records, no signing. 34 + - **`apps/web`** — the SvelteKit dashboard at `notify.atmo.tools` (+ `/docs`). 35 + - **`apps/example-sender`** — one-page sender demo at `example.notify.atmo.tools`. 36 + - **`packages/lexicons`** — the shared `tools.atmo.notifs.*` lexicons (13) and 37 + generated types. 228 38 229 - Then, on the user's PDS, mint a service-auth JWT via 230 - `com.atproto.server.getServiceAuth` (`aud = did:web:notifs.atmo.tools`, 231 - `lxm = tools.atmo.notifs.requestPermission`) and call: 232 - 233 - ```ts 234 - await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.requestPermission', { 235 - method: 'POST', 236 - headers: { 237 - authorization: `Bearer ${userServiceAuthJwt}`, // issued by the USER's PDS 238 - 'content-type': 'application/json', 239 - }, 240 - body: JSON.stringify({ 241 - senderDid: 'did:web:yourapp.example', // what the user approves & what `send` uses 242 - title: 'Bookhive', // shown to the user at approval 243 - description: 'New comments on your books', 244 - iconUrl: 'https://yourapp.example/icon.png', 245 - }), 246 - }); 247 - // -> { id, status: "pending" | "alreadyGranted" } 248 - ``` 249 - 250 - The user approves it in the dashboard or Telegram. 251 - 252 - 3. **Send a notification** once granted — authenticated with **your app's own 253 - key** (`aud` = the relay, `lxm = tools.atmo.notifs.send`): 254 - 255 - ```ts 256 - import { P256PrivateKeyExportable } from '@atcute/crypto'; 257 - import { createServiceJwt } from '@atcute/xrpc-server/auth'; 258 - 259 - const keypair = await P256PrivateKeyExportable.importRaw(yourPrivateKeyBytes); 260 - const jwt = await createServiceJwt({ 261 - keypair, 262 - issuer: 'did:web:yourapp.example', 263 - audience: 'did:web:notifs.atmo.tools', 264 - lxm: 'tools.atmo.notifs.send', 265 - expiresIn: 60, 266 - }); 267 - 268 - await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.send', { 269 - method: 'POST', 270 - headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' }, 271 - body: JSON.stringify({ 272 - recipient: 'did:plc:therecipient', 273 - title: 'New reply', 274 - body: 'alice replied to your post', 275 - uri: 'https://yourapp.example/thread/123', 276 - }), 277 - }); 278 - // -> { id, delivered } (delivered = number of channels dispatched to) 279 - ``` 280 - 281 - `send` returns `403 NotAuthorized` if there is no grant, and `429 282 - RateLimitExceeded` (with `Retry-After`) when limits are hit. A muted grant is 283 - accepted silently with `delivered: 0`. 39 + Running, configuring, and deploying everything: **[DEVELOPMENT.md](DEVELOPMENT.md)**.
+18 -1
apps/web/src/routes/docs/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { PROJECT_NAME } from '$lib/config'; 2 + import { GITHUB_URL, PROJECT_NAME } from '$lib/config'; 3 3 import type { PageData } from './$types'; 4 4 5 5 let { data }: { data: PageData } = $props(); ··· 54 54 <em>the user authorized this request</em> (user OAuth); 55 55 <code class="font-mono text-fg">send</code> proves 56 56 <em>the sender identity</em> (your app's own DID key). 57 + </p> 58 + <p class="mt-3 max-w-prose rounded-card border border-line bg-accent-soft p-3 text-sm text-muted"> 59 + <strong class="text-fg">Prefer a working example?</strong> A complete, ~300-line app wiring up 60 + both flows is live at 61 + <a 62 + class="text-accent hover:underline" 63 + href="https://example.notify.atmo.tools" 64 + target="_blank" 65 + rel="noreferrer">example.notify.atmo.tools</a 66 + > 67 + — try it, then read the 68 + <a 69 + class="text-accent hover:underline" 70 + href={`${GITHUB_URL}/tree/main/apps/example-sender`} 71 + target="_blank" 72 + rel="noreferrer">source ↗</a 73 + >. 57 74 </p> 58 75 </header> 59 76