···3737 generated types.
38383939Running, configuring, and deploying everything: **[DEVELOPMENT.md](DEVELOPMENT.md)**.
4040+Want notifications for your own app without depending on atmo.pub? Run the relay
4141+yourself: **[SELF-HOSTING.md](SELF-HOSTING.md)**.
···11+# Run your own relay
22+33+Want notifications for your own app(s) without depending on atmo.pub? Run the
44+relay yourself. You get web push, Telegram, an inbox, and per-app/category
55+routing — for whatever apps you register. **It runs on Cloudflare Workers** (D1 +
66+KV + Queues); that's the only supported target for now.
77+88+For deeper details see [DEVELOPMENT.md](DEVELOPMENT.md).
99+1010+## Prerequisites
1111+1212+- A Cloudflare account (Workers paid plan — Queues require it).
1313+- A domain in that account for the relay, e.g. `relay.yourapp.example`.
1414+- Node 20+ and `pnpm`.
1515+1616+## Steps
1717+1818+1. **Clone & install**
1919+ ```bash
2020+ git clone <this repo> && cd atproto-notifs && pnpm install
2121+ cd apps/relay
2222+ ```
2323+2424+2. **Create the Cloudflare resources**, then paste the IDs into `wrangler.toml`:
2525+ ```bash
2626+ pnpm exec wrangler d1 create notifs-relay # → database_id
2727+ pnpm exec wrangler kv namespace create CACHE # → kv id
2828+ pnpm exec wrangler queues create notifs-dispatch
2929+ ```
3030+3131+3. **Set your domain** in `wrangler.toml`:
3232+ ```toml
3333+ routes = [{ pattern = "relay.yourapp.example", custom_domain = true }]
3434+ [vars]
3535+ RELAY_DID = "did:web:relay.yourapp.example" # must match the domain
3636+ ```
3737+ The relay serves its `did:web` doc at `/.well-known/did.json`, derived from
3838+ `RELAY_DID`.
3939+4040+4. **Generate keys**
4141+ ```bash
4242+ pnpm relay:keygen # prints a private + public multikey for signing callbacks
4343+ pnpm vapid:keygen # prints VAPID keys for web push
4444+ ```
4545+ - Put the relay **public** multikey into `RELAY_PUBLIC_KEY_MULTIBASE` in
4646+ `src/well-known.ts` (it must match the private key from the same run).
4747+ - Put `VAPID_PUBLIC_KEY` + `VAPID_SUBJECT` (a `mailto:`) in `wrangler.toml [vars]`.
4848+4949+5. **Set secrets**
5050+ ```bash
5151+ pnpm exec wrangler secret put RELAY_PRIVATE_KEY # from relay:keygen
5252+ pnpm exec wrangler secret put VAPID_PRIVATE_JWK # from vapid:keygen
5353+ # Telegram (optional — skip if you only want web push):
5454+ pnpm exec wrangler secret put TELEGRAM_BOT_TOKEN
5555+ pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET
5656+ ```
5757+ If you use Telegram, also set `BOT_USERNAME` in `[vars]`.
5858+5959+6. **Apply migrations & deploy**
6060+ ```bash
6161+ pnpm db:migrate # applies migrations to the remote D1
6262+ pnpm run deploy
6363+ ```
6464+6565+7. **Verify**
6666+ ```bash
6767+ curl https://relay.yourapp.example/.well-known/did.json # your did:web doc
6868+ curl https://relay.yourapp.example/xrpc/_health # {"status":"ok"}
6969+ ```
7070+7171+## Register your app(s)
7272+7373+Edit `src/lib/apps.ts` — list each app that integrates with your relay:
7474+7575+```ts
7676+export const APPS: readonly RegisteredApp[] = [
7777+ {
7878+ did: 'did:web:yourapp.example',
7979+ title: 'Your App',
8080+ callbackUrl: 'https://yourapp.example', // for subscriberChanged (optional)
8181+ trusted: true, // auto-approve permission requests (skip the pending step)
8282+ manage: 'full', // relay-wide management designation (optional)
8383+ },
8484+];
8585+```
8686+8787+Then redeploy. For a single-app relay this is usually just your one app.
8888+8989+## Sending from your app
9090+9191+Your sender authenticates with its own DID key, addressed to **your** relay:
9292+9393+- `aud` = your `RELAY_DID`, endpoint = `https://relay.yourapp.example`
9494+- call `pub.atmo.notify.requestPermission` then `pub.atmo.notify.send`
9595+9696+`apps/example-sender` is a complete reference — copy its `src/lib/server/` auth +
9797+relay code and point `RELAY_ORIGIN`/`RELAY_DID` at your relay.
9898+9999+## Notes
100100+101101+- The relay serves **any** app a user grants. To hard-restrict it to only your
102102+ DID, gate `requestPermission`/`send` on your sender DID (a few lines in
103103+ `src/xrpc/`).
104104+- A user can still *also* use atmo.pub (or another relay) for other channels —
105105+ your app just sends to each relay the user uses. Relays are independent.
···11+-- Per-grant management capability the user designates for an app:
22+-- 'none' (default) → app may only send / self-read per relay policy
33+-- 'self' → app may manage its own slice (routing, inbox)
44+-- 'full' → app may manage the user's whole notification account
55+-- See MANAGEMENT-AUTH.md.
66+ALTER TABLE grants ADD COLUMN manage TEXT NOT NULL DEFAULT 'none';
···1919 callbackUrl?: string;
2020 /** Auto-grant at `requestPermission` (skips the pending step). */
2121 trusted?: boolean;
2222+ /**
2323+ * Relay-wide management designation (see MANAGEMENT-AUTH.md). 'full' = a
2424+ * first-party manager that may manage any user's whole account; 'self' =
2525+ * relay-allowlisted to manage its own slice for any user. Omit → none
2626+ * (users may still designate per-grant).
2727+ */
2828+ manage?: 'self' | 'full';
2229}
23302431export const APPS: readonly RegisteredApp[] = [
···2835 description: 'Demo app showing the atmo.pub integration.',
2936 // For local end-to-end testing, point this at your local example-sender.
3037 callbackUrl: 'https://example.atmo.pub',
3838+ // Relay-allowlisted for self-management so the example's "Step 3" demo works
3939+ // without a per-user designation UI (which lands in a later slice).
4040+ manage: 'self',
3141 },
3242];
33433444/** Auto-grant senders (the former TRUSTED_SENDERS). */
3545export function isTrustedSender(did: Did): boolean {
3646 return APPS.some((app) => app.did === did && app.trusted === true);
4747+}
4848+4949+/** Relay-wide management designation for `did`, if any ('self' | 'full'). */
5050+export function relayManageFor(did: string): 'self' | 'full' | undefined {
5151+ return APPS.find((app) => app.did === did)?.manage;
3752}
38533954/** The registered app for `did`, if it has a callback URL configured. */
···11import { PubAtmoNotifyGetRouting } from '@atmo/notifs-lexicons';
22import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3344-import { verifySenderRequest, verifyServiceToken } from '../auth/sender';
44+import { verifyManagementCall } from '../auth/management';
55import * as q from '../db/queries';
66import type { AppContext } from '../env';
77-import { notAuthorized } from '../lib/errors';
8798const LXM = 'pub.atmo.notify.getRouting';
1091110/**
1211 * Read how the calling app's own notifications are currently routed for a user,
1313- * so the app can render an accurate in-app settings UI. Dual-authenticated and
1414- * grant-gated exactly like setRouting; returns only this app's slice plus the
1212+ * so the app can render an accurate in-app settings UI. A self-scoped management
1313+ * read (see MANAGEMENT-AUTH.md); returns only this app's slice plus the
1514 * account-default value (so 'default'/'app' can be labelled by the caller).
1615 */
1716export function makeGetRouting(
···1918): ProcedureConfig<PubAtmoNotifyGetRouting.mainSchema> {
2019 return {
2120 handler: async ({ request, input }) => {
2222- const { senderDid } = await verifySenderRequest(app.verifier, request, LXM);
2323- const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM);
2424-2525- if (userDid === senderDid) throw notAuthorized();
2626- if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized();
2121+ const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, {
2222+ scope: 'self',
2323+ write: false,
2424+ lxm: LXM,
2525+ });
27262827 const [user, appRoute, cats, routes] = await Promise.all([
2928 q.getUser(app.env.DB, userDid),
···11import { PubAtmoNotifyListNotifications } from '@atmo/notifs-lexicons';
22import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3344-import { verifySenderRequest, verifyServiceToken } from '../auth/sender';
44+import { verifyManagementCall } from '../auth/management';
55import * as q from '../db/queries';
66import type { AppContext } from '../env';
77-import { notAuthorized } from '../lib/errors';
8798const LXM = 'pub.atmo.notify.listNotifications';
109const DEFAULT_LIMIT = 50;
11101211/**
1312 * Let an app page the notifications *it* sent to a user, with read state and
1414- * the per-notification delivery count. Dual-authenticated and grant-gated like
1515- * setRouting; results are scoped to (userDid, senderDid) so an app only ever
1616- * sees its own notifications. Cursor is the `created_at` of the last row.
1313+ * the per-notification delivery count. A self-scoped management read (see
1414+ * MANAGEMENT-AUTH.md); results are scoped to (userDid, senderDid) so an app only
1515+ * ever sees its own notifications. Cursor is the `created_at` of the last row.
1716 */
1817export function makeListNotifications(
1918 app: AppContext,
2019): ProcedureConfig<PubAtmoNotifyListNotifications.mainSchema> {
2120 return {
2221 handler: async ({ request, input }) => {
2323- const { senderDid } = await verifySenderRequest(app.verifier, request, LXM);
2424- const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM);
2525-2626- if (userDid === senderDid) throw notAuthorized();
2727- if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized();
2222+ const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, {
2323+ scope: 'self',
2424+ write: false,
2525+ lxm: LXM,
2626+ });
28272928 const limit = input.limit ?? DEFAULT_LIMIT;
3029 let before: number | undefined;
···11import { PubAtmoNotifyMarkRead } from '@atmo/notifs-lexicons';
22import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3344-import { verifySenderRequest, verifyServiceToken } from '../auth/sender';
44+import { verifyManagementCall } from '../auth/management';
55import * as q from '../db/queries';
66import type { AppContext } from '../env';
77-import { notAuthorized } from '../lib/errors';
87import { now } from '../lib/time';
98109const LXM = 'pub.atmo.notify.markRead';
11101211/**
1312 * Let an app mark the notifications *it* sent to a user as read — all of them,
1414- * or a specific `ids` set. Dual-authenticated and grant-gated like setRouting;
1313+ * or a specific `ids` set. A self-scoped management write (see MANAGEMENT-AUTH.md);
1514 * the update is scoped to (userDid, senderDid), so ids belonging to other apps
1615 * are silently ignored.
1716 */
1817export function makeMarkRead(app: AppContext): ProcedureConfig<PubAtmoNotifyMarkRead.mainSchema> {
1918 return {
2019 handler: async ({ request, input }) => {
2121- const { senderDid } = await verifySenderRequest(app.verifier, request, LXM);
2222- const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM);
2323-2424- if (userDid === senderDid) throw notAuthorized();
2525- if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized();
2020+ const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, {
2121+ scope: 'self',
2222+ write: true,
2323+ lxm: LXM,
2424+ });
26252726 const marked = await q.markNotificationsReadFromSender(
2827 app.env.DB,
···11import { PubAtmoNotifySetRouting } from '@atmo/notifs-lexicons';
22import { json, type ProcedureConfig } from '@atcute/xrpc-server';
3344-import { verifySenderRequest, verifyServiceToken } from '../auth/sender';
44+import { verifyManagementCall } from '../auth/management';
55import * as q from '../db/queries';
66import type { AppContext } from '../env';
77-import { notAuthorized } from '../lib/errors';
8798const LXM = 'pub.atmo.notify.setRouting';
1091110/**
1211 * Let an app change how *its own* notifications are routed for a user.
1312 *
1414- * Dual-authenticated: the app proves its identity with its service-auth JWT in
1515- * the Authorization header (→ `senderDid`), and the user proves consent with a
1616- * fresh user-issued service-auth JWT in `userToken` (→ `userDid`). Both are
1717- * scoped to this method. We then require an active grant from the user to the
1818- * app, and only ever touch routing rows keyed by (userDid, senderDid) — so an
1919- * app can never reach the account default, other apps, or channels.
1313+ * A self-scoped management write (see MANAGEMENT-AUTH.md): the app is
1414+ * authenticated by its service-auth bearer and the user by the body `userToken`;
1515+ * `verifyManagementCall` enforces the (user, app) capability + self-write policy.
1616+ * Only ever touches routing rows keyed by (userDid, senderDid) — never the
1717+ * account default, other apps, or channels.
2018 */
2119export function makeSetRouting(
2220 app: AppContext,
2321): ProcedureConfig<PubAtmoNotifySetRouting.mainSchema> {
2422 return {
2523 handler: async ({ request, input }) => {
2626- const { senderDid } = await verifySenderRequest(app.verifier, request, LXM);
2727- const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM);
2828-2929- // The two tokens must be distinct identities, and the user must have an
3030- // active grant for this app (i.e. the app is approved to notify them).
3131- if (userDid === senderDid) throw notAuthorized();
3232- if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized();
2424+ const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, {
2525+ scope: 'self',
2626+ write: true,
2727+ lxm: LXM,
2828+ });
33293430 if (input.route !== undefined) {
3531 if (input.route === 'default') {