[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.

fedöration

+886 -47
+2
README.md
··· 37 37 generated types. 38 38 39 39 Running, configuring, and deploying everything: **[DEVELOPMENT.md](DEVELOPMENT.md)**. 40 + Want notifications for your own app without depending on atmo.pub? Run the relay 41 + yourself: **[SELF-HOSTING.md](SELF-HOSTING.md)**.
+105
SELF-HOSTING.md
··· 1 + # Run your own relay 2 + 3 + Want notifications for your own app(s) without depending on atmo.pub? Run the 4 + relay yourself. You get web push, Telegram, an inbox, and per-app/category 5 + routing — for whatever apps you register. **It runs on Cloudflare Workers** (D1 + 6 + KV + Queues); that's the only supported target for now. 7 + 8 + For deeper details see [DEVELOPMENT.md](DEVELOPMENT.md). 9 + 10 + ## Prerequisites 11 + 12 + - A Cloudflare account (Workers paid plan — Queues require it). 13 + - A domain in that account for the relay, e.g. `relay.yourapp.example`. 14 + - Node 20+ and `pnpm`. 15 + 16 + ## Steps 17 + 18 + 1. **Clone & install** 19 + ```bash 20 + git clone <this repo> && cd atproto-notifs && pnpm install 21 + cd apps/relay 22 + ``` 23 + 24 + 2. **Create the Cloudflare resources**, then paste the IDs into `wrangler.toml`: 25 + ```bash 26 + pnpm exec wrangler d1 create notifs-relay # → database_id 27 + pnpm exec wrangler kv namespace create CACHE # → kv id 28 + pnpm exec wrangler queues create notifs-dispatch 29 + ``` 30 + 31 + 3. **Set your domain** in `wrangler.toml`: 32 + ```toml 33 + routes = [{ pattern = "relay.yourapp.example", custom_domain = true }] 34 + [vars] 35 + RELAY_DID = "did:web:relay.yourapp.example" # must match the domain 36 + ``` 37 + The relay serves its `did:web` doc at `/.well-known/did.json`, derived from 38 + `RELAY_DID`. 39 + 40 + 4. **Generate keys** 41 + ```bash 42 + pnpm relay:keygen # prints a private + public multikey for signing callbacks 43 + pnpm vapid:keygen # prints VAPID keys for web push 44 + ``` 45 + - Put the relay **public** multikey into `RELAY_PUBLIC_KEY_MULTIBASE` in 46 + `src/well-known.ts` (it must match the private key from the same run). 47 + - Put `VAPID_PUBLIC_KEY` + `VAPID_SUBJECT` (a `mailto:`) in `wrangler.toml [vars]`. 48 + 49 + 5. **Set secrets** 50 + ```bash 51 + pnpm exec wrangler secret put RELAY_PRIVATE_KEY # from relay:keygen 52 + pnpm exec wrangler secret put VAPID_PRIVATE_JWK # from vapid:keygen 53 + # Telegram (optional — skip if you only want web push): 54 + pnpm exec wrangler secret put TELEGRAM_BOT_TOKEN 55 + pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET 56 + ``` 57 + If you use Telegram, also set `BOT_USERNAME` in `[vars]`. 58 + 59 + 6. **Apply migrations & deploy** 60 + ```bash 61 + pnpm db:migrate # applies migrations to the remote D1 62 + pnpm run deploy 63 + ``` 64 + 65 + 7. **Verify** 66 + ```bash 67 + curl https://relay.yourapp.example/.well-known/did.json # your did:web doc 68 + curl https://relay.yourapp.example/xrpc/_health # {"status":"ok"} 69 + ``` 70 + 71 + ## Register your app(s) 72 + 73 + Edit `src/lib/apps.ts` — list each app that integrates with your relay: 74 + 75 + ```ts 76 + export const APPS: readonly RegisteredApp[] = [ 77 + { 78 + did: 'did:web:yourapp.example', 79 + title: 'Your App', 80 + callbackUrl: 'https://yourapp.example', // for subscriberChanged (optional) 81 + trusted: true, // auto-approve permission requests (skip the pending step) 82 + manage: 'full', // relay-wide management designation (optional) 83 + }, 84 + ]; 85 + ``` 86 + 87 + Then redeploy. For a single-app relay this is usually just your one app. 88 + 89 + ## Sending from your app 90 + 91 + Your sender authenticates with its own DID key, addressed to **your** relay: 92 + 93 + - `aud` = your `RELAY_DID`, endpoint = `https://relay.yourapp.example` 94 + - call `pub.atmo.notify.requestPermission` then `pub.atmo.notify.send` 95 + 96 + `apps/example-sender` is a complete reference — copy its `src/lib/server/` auth + 97 + relay code and point `RELAY_ORIGIN`/`RELAY_DID` at your relay. 98 + 99 + ## Notes 100 + 101 + - The relay serves **any** app a user grants. To hard-restrict it to only your 102 + DID, gate `requestPermission`/`send` on your sender DID (a few lines in 103 + `src/xrpc/`). 104 + - A user can still *also* use atmo.pub (or another relay) for other channels — 105 + your app just sends to each relay the user uses. Relays are independent.
+6
apps/relay/migrations/0008_manage.sql
··· 1 + -- Per-grant management capability the user designates for an app: 2 + -- 'none' (default) → app may only send / self-read per relay policy 3 + -- 'self' → app may manage its own slice (routing, inbox) 4 + -- 'full' → app may manage the user's whole notification account 5 + -- See MANAGEMENT-AUTH.md. 6 + ALTER TABLE grants ADD COLUMN manage TEXT NOT NULL DEFAULT 'none';
+104
apps/relay/src/auth/management.ts
··· 1 + // Authentication + authorization for notification *management* calls. 2 + // See MANAGEMENT-AUTH.md for the full model. In short: 3 + // - The app is always authenticated by its own service-auth bearer (`iss`). 4 + // - The user is identified by a body `userToken` (dual-auth) OR, for an app 5 + // with a standing designation, a vouched body `did` (no user token). 6 + // - Authorization = the (user, app) capability (relay-wide or per-grant) plus 7 + // the relay's self-policy for the undesignated open end. 8 + import type { Did, Nsid } from '@atcute/lexicons'; 9 + 10 + import * as q from '../db/queries'; 11 + import type { AppContext, Env } from '../env'; 12 + import { relayManageFor } from '../lib/apps'; 13 + import { notAuthorized } from '../lib/errors'; 14 + 15 + import { verifySenderRequest, verifyServiceToken } from './sender'; 16 + 17 + export type Capability = 'none' | 'self' | 'full'; 18 + export type SelfPolicy = 'off' | 'relay-allowlist' | 'user-allowlist' | 'open'; 19 + 20 + const RANK: Record<Capability, number> = { none: 0, self: 1, full: 2 }; 21 + 22 + function readPolicy(env: Env): SelfPolicy { 23 + return (env.MANAGEMENT_SELF_READ_POLICY as SelfPolicy | undefined) ?? 'open'; 24 + } 25 + function writePolicy(env: Env): SelfPolicy { 26 + return (env.MANAGEMENT_SELF_WRITE_POLICY as SelfPolicy | undefined) ?? 'user-allowlist'; 27 + } 28 + 29 + /** 30 + * The app's standing capability for a user = max(relay-wide designation, 31 + * per-grant designation). `granted` is true if a grant exists or the app is 32 + * relay-wide designated (relay managers don't need a per-user grant row). 33 + */ 34 + async function resolveCapability( 35 + env: Env, 36 + userDid: Did, 37 + appDid: Did, 38 + ): Promise<{ cap: Capability; granted: boolean }> { 39 + const relayCap = relayManageFor(appDid); 40 + const grant = await q.getGrant(env.DB, userDid, appDid); 41 + const grantCap = (grant?.manage as Capability | undefined) ?? 'none'; 42 + const cap = RANK[relayCap ?? 'none'] >= RANK[grantCap] ? (relayCap ?? 'none') : grantCap; 43 + return { cap, granted: grant !== null || relayCap !== undefined }; 44 + } 45 + 46 + export interface ManagementNeed { 47 + scope: 'self' | 'full'; 48 + write: boolean; 49 + lxm: string; 50 + } 51 + 52 + export interface ManagementCall { 53 + appDid: Did; 54 + userDid: Did; 55 + } 56 + 57 + /** 58 + * Verify + authorize a management call, returning the authenticated app DID and 59 + * the resolved user DID. Throws `NotAuthorized` (403) on any failure. 60 + */ 61 + export async function verifyManagementCall( 62 + app: AppContext, 63 + request: Request, 64 + input: { userToken?: string; did?: string }, 65 + need: ManagementNeed, 66 + ): Promise<ManagementCall> { 67 + const lxm = need.lxm as Nsid; 68 + const { senderDid: appDid } = await verifySenderRequest(app.verifier, request, lxm); 69 + 70 + // User identity: dual-auth (a user token) or vouch (a body DID, designation-gated). 71 + let userDid: Did; 72 + let vouched: boolean; 73 + if (input.userToken !== undefined) { 74 + ({ did: userDid } = await verifyServiceToken(app.verifier, input.userToken, lxm)); 75 + vouched = false; 76 + } else if (input.did !== undefined) { 77 + userDid = input.did as Did; 78 + vouched = true; 79 + } else { 80 + throw notAuthorized(); 81 + } 82 + if (userDid === appDid) throw notAuthorized(); 83 + 84 + const { cap, granted } = await resolveCapability(app.env, userDid, appDid); 85 + if (!granted) throw notAuthorized(); 86 + 87 + const designated = (scope: 'self' | 'full') => RANK[cap] >= RANK[scope]; 88 + 89 + if (need.scope === 'full') { 90 + // Whole-account always needs a manager designation; vouch or dual both fine. 91 + if (!designated('full')) throw notAuthorized(); 92 + return { appDid, userDid }; 93 + } 94 + 95 + // self scope 96 + if (designated('self')) return { appDid, userDid }; // designated → vouch or dual ok 97 + 98 + // Undesignated self: vouch is not allowed (no standing consent), and the 99 + // relay's open-end policy must admit it. 100 + if (vouched) throw notAuthorized(); 101 + const policy = need.write ? writePolicy(app.env) : readPolicy(app.env); 102 + if (policy !== 'open') throw notAuthorized(); // relay/user allowlists are covered by `designated` 103 + return { appDid, userDid }; 104 + }
+16
apps/relay/src/db/queries.ts
··· 55 55 title: string | null; 56 56 description: string | null; 57 57 icon_url: string | null; 58 + /** Management capability the user designated for this app: 'none'|'self'|'full'. */ 59 + manage: string; 58 60 } 59 61 60 62 /** A grant joined with the (optional) cached Bluesky profile (`s.*`). */ ··· 383 385 const result = await db 384 386 .prepare('UPDATE grants SET muted = ? WHERE recipient_did = ? AND sender_did = ?') 385 387 .bind(toInt(muted), recipientDid, senderDid) 388 + .run(); 389 + return changed(result); 390 + } 391 + 392 + /** Set a grant's management capability ('none'|'self'|'full'). See MANAGEMENT-AUTH.md. */ 393 + export async function setGrantManage( 394 + db: D1Database, 395 + recipientDid: Did, 396 + senderDid: Did, 397 + manage: 'none' | 'self' | 'full', 398 + ): Promise<boolean> { 399 + const result = await db 400 + .prepare('UPDATE grants SET manage = ? WHERE recipient_did = ? AND sender_did = ?') 401 + .bind(manage, recipientDid, senderDid) 386 402 .run(); 387 403 return changed(result); 388 404 }
+6
apps/relay/src/env.ts
··· 65 65 /** Telegram bot username, used to build deep links (var). */ 66 66 BOT_USERNAME: string; 67 67 68 + /** Self-management admission policy for *undesignated* granted apps (var). 69 + * 'off'|'relay-allowlist'|'user-allowlist'|'open'. See MANAGEMENT-AUTH.md. 70 + * Defaults in code: reads → 'open', writes → 'user-allowlist'. */ 71 + MANAGEMENT_SELF_READ_POLICY?: string; 72 + MANAGEMENT_SELF_WRITE_POLICY?: string; 73 + 68 74 /** Relay's P-256 signing key as a private multikey (secret) — for outbound 69 75 * service-auth JWTs (e.g. the subscriberChanged callback). `relay:keygen`. */ 70 76 RELAY_PRIVATE_KEY: string;
+15
apps/relay/src/lib/apps.ts
··· 19 19 callbackUrl?: string; 20 20 /** Auto-grant at `requestPermission` (skips the pending step). */ 21 21 trusted?: boolean; 22 + /** 23 + * Relay-wide management designation (see MANAGEMENT-AUTH.md). 'full' = a 24 + * first-party manager that may manage any user's whole account; 'self' = 25 + * relay-allowlisted to manage its own slice for any user. Omit → none 26 + * (users may still designate per-grant). 27 + */ 28 + manage?: 'self' | 'full'; 22 29 } 23 30 24 31 export const APPS: readonly RegisteredApp[] = [ ··· 28 35 description: 'Demo app showing the atmo.pub integration.', 29 36 // For local end-to-end testing, point this at your local example-sender. 30 37 callbackUrl: 'https://example.atmo.pub', 38 + // Relay-allowlisted for self-management so the example's "Step 3" demo works 39 + // without a per-user designation UI (which lands in a later slice). 40 + manage: 'self', 31 41 }, 32 42 ]; 33 43 34 44 /** Auto-grant senders (the former TRUSTED_SENDERS). */ 35 45 export function isTrustedSender(did: Did): boolean { 36 46 return APPS.some((app) => app.did === did && app.trusted === true); 47 + } 48 + 49 + /** Relay-wide management designation for `did`, if any ('self' | 'full'). */ 50 + export function relayManageFor(did: string): 'self' | 'full' | undefined { 51 + return APPS.find((app) => app.did === did)?.manage; 37 52 } 38 53 39 54 /** The registered app for `did`, if it has a callback URL configured. */
+5
apps/relay/src/lib/errors.ts
··· 20 20 return new XRPCError({ status: 403, error: 'NotAuthorized', message }); 21 21 } 22 22 23 + /** 400 `InvalidRequest` — e.g. an unknown management method in the envelope. */ 24 + export function invalidRequest(message = 'Invalid request'): XRPCError { 25 + return new XRPCError({ status: 400, error: 'InvalidRequest', message }); 26 + } 27 + 23 28 /** 24 29 * 429 `RateLimitExceeded` with a `Retry-After` header (whole seconds, min 1). 25 30 */
+14
apps/relay/src/router.ts
··· 1 1 import { 2 2 PubAtmoNotifyGetRouting, 3 3 PubAtmoNotifyListNotifications, 4 + PubAtmoNotifyManage, 4 5 PubAtmoNotifyMarkRead, 6 + PubAtmoNotifyMuteSelf, 5 7 PubAtmoNotifyRequestPermission, 8 + PubAtmoNotifyRevokeSelf, 6 9 PubAtmoNotifySend, 7 10 PubAtmoNotifySetRouting, 8 11 } from '@atmo/notifs-lexicons'; ··· 12 15 import type { AppContext, Env } from './env'; 13 16 import { makeGetRouting } from './xrpc/getRouting'; 14 17 import { makeListNotifications } from './xrpc/listNotifications'; 18 + import { makeManage } from './xrpc/manage'; 15 19 import { makeMarkRead } from './xrpc/markRead'; 20 + import { makeMuteSelf } from './xrpc/muteSelf'; 16 21 import { makeRequestPermission } from './xrpc/requestPermission'; 22 + import { makeRevokeSelf } from './xrpc/revokeSelf'; 17 23 import { makeSend } from './xrpc/send'; 18 24 import { makeSetRouting } from './xrpc/setRouting'; 19 25 ··· 26 32 * app reads/writes how *its own* notifications are routed for a user. 27 33 * - `listNotifications` / `markRead` (same dual-auth): an app reads/acks the 28 34 * notifications *it* sent to a user (with read state + delivery counts). 35 + * - `revokeSelf` / `muteSelf` (same dual-auth): an app turns itself off / mutes 36 + * itself for a user. All five self-scoped methods go through 37 + * `verifyManagementCall` (capability + self-policy; see MANAGEMENT-AUTH.md). 38 + * - `manage` (vouch or dual-auth, `full` only): whole-account management for a 39 + * designated manager app — the portable counterpart to the service binding. 29 40 * 30 41 * Every first-party user-management method (grant, revoke, the list/get 31 42 * queries, settings, and so on) lives behind the `RelayRpc` service-binding ··· 51 62 router.addProcedure(PubAtmoNotifyGetRouting.mainSchema, makeGetRouting(app)); 52 63 router.addProcedure(PubAtmoNotifyListNotifications.mainSchema, makeListNotifications(app)); 53 64 router.addProcedure(PubAtmoNotifyMarkRead.mainSchema, makeMarkRead(app)); 65 + router.addProcedure(PubAtmoNotifyRevokeSelf.mainSchema, makeRevokeSelf(app)); 66 + router.addProcedure(PubAtmoNotifyMuteSelf.mainSchema, makeMuteSelf(app)); 67 + router.addProcedure(PubAtmoNotifyManage.mainSchema, makeManage(app)); 54 68 55 69 return router; 56 70 }
+4
apps/relay/src/rpc/entrypoint.ts
··· 5 5 AlertRoute, 6 6 AppInfo, 7 7 AppRoute, 8 + Capability, 8 9 CategoryRoute, 9 10 DeviceView, 10 11 ListNotificationsResult, ··· 101 102 } 102 103 setDefaultRoute(did: Did, route: AlertRoute) { 103 104 return ops.setDefaultRoute(this.env, did, route); 105 + } 106 + setGrantManage(did: Did, sender: Did, manage: Capability) { 107 + return ops.setGrantManage(this.env, did, sender, manage); 104 108 } 105 109 verifyAppLogin(token: string): Promise<{ did: Did }> { 106 110 return ops.verifyAppLogin(this.env, token);
+13
apps/relay/src/rpc/ops.ts
··· 9 9 AlertRoute, 10 10 AppInfo, 11 11 AppRoute, 12 + Capability, 12 13 CategoryRoute, 13 14 DeviceView, 14 15 ListNotificationsResult, ··· 372 373 sender: g.sender_did, 373 374 title: g.title ?? g.display_name ?? g.handle ?? g.sender_did, 374 375 route: (appRouteBy.get(g.sender_did) ?? 'default') as AppRoute, 376 + manage: g.manage as Capability, 375 377 categories: (catsBySender.get(g.sender_did) ?? []).map((c) => ({ 376 378 category: c.category, 377 379 description: c.description ?? undefined, ··· 418 420 ): Promise<{ ok: boolean }> { 419 421 await q.ensureUser(env.DB, did, now()); 420 422 await q.setDefaultRoute(env.DB, did, route); 423 + return { ok: true }; 424 + } 425 + 426 + /** Designate an app's management capability for this user. See MANAGEMENT-AUTH.md. */ 427 + export async function setGrantManage( 428 + env: Env, 429 + did: Did, 430 + sender: Did, 431 + manage: Capability, 432 + ): Promise<{ ok: boolean }> { 433 + await q.setGrantManage(env.DB, did, sender, manage); 421 434 return { ok: true }; 422 435 } 423 436
+7 -1
apps/relay/src/well-known.ts
··· 1 1 import getRouting from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/getRouting.json'; 2 2 import listNotifications from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/listNotifications.json'; 3 + import manage from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/manage.json'; 3 4 import markRead from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/markRead.json'; 5 + import muteSelf from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/muteSelf.json'; 4 6 import requestPermission from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/requestPermission.json'; 7 + import revokeSelf from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/revokeSelf.json'; 5 8 import send from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/send.json'; 6 9 import setRouting from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/setRouting.json'; 7 10 import subscriberChanged from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/subscriberChanged.json'; ··· 19 22 'pub.atmo.notify.setRouting': setRouting, 20 23 'pub.atmo.notify.getRouting': getRouting, 21 24 'pub.atmo.notify.listNotifications': listNotifications, 25 + 'pub.atmo.notify.manage': manage, 22 26 'pub.atmo.notify.markRead': markRead, 27 + 'pub.atmo.notify.revokeSelf': revokeSelf, 28 + 'pub.atmo.notify.muteSelf': muteSelf, 23 29 'pub.atmo.notify.subscriberChanged': subscriberChanged, 24 30 }; 25 31 ··· 27 33 // service-auth JWTs the relay issues (e.g. the `subscriberChanged` callback, 28 34 // ENABLE-FROM-WEB.md). Its private half is the RELAY_PRIVATE_KEY secret. Rotate 29 35 // both together via `relay:keygen`. 30 - const RELAY_PUBLIC_KEY_MULTIBASE = 'zDnaeZB4zYA9upEh4bXkxXjsQJJhdq9zuNVtmk9QXhrno5yhd'; 36 + const RELAY_PUBLIC_KEY_MULTIBASE = 'zDnaebFbH5Q6PhQE8g7ZryvijrstP3oFARmivjPHpneFd8W9t'; 31 37 32 38 /** 33 39 * `GET /.well-known/did.json` — the relay's `did:web` document. Publishes an
+8 -9
apps/relay/src/xrpc/getRouting.ts
··· 1 1 import { PubAtmoNotifyGetRouting } from '@atmo/notifs-lexicons'; 2 2 import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 3 4 - import { verifySenderRequest, verifyServiceToken } from '../auth/sender'; 4 + import { verifyManagementCall } from '../auth/management'; 5 5 import * as q from '../db/queries'; 6 6 import type { AppContext } from '../env'; 7 - import { notAuthorized } from '../lib/errors'; 8 7 9 8 const LXM = 'pub.atmo.notify.getRouting'; 10 9 11 10 /** 12 11 * Read how the calling app's own notifications are currently routed for a user, 13 - * so the app can render an accurate in-app settings UI. Dual-authenticated and 14 - * grant-gated exactly like setRouting; returns only this app's slice plus the 12 + * so the app can render an accurate in-app settings UI. A self-scoped management 13 + * read (see MANAGEMENT-AUTH.md); returns only this app's slice plus the 15 14 * account-default value (so 'default'/'app' can be labelled by the caller). 16 15 */ 17 16 export function makeGetRouting( ··· 19 18 ): ProcedureConfig<PubAtmoNotifyGetRouting.mainSchema> { 20 19 return { 21 20 handler: async ({ request, input }) => { 22 - const { senderDid } = await verifySenderRequest(app.verifier, request, LXM); 23 - const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM); 24 - 25 - if (userDid === senderDid) throw notAuthorized(); 26 - if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized(); 21 + const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, { 22 + scope: 'self', 23 + write: false, 24 + lxm: LXM, 25 + }); 27 26 28 27 const [user, appRoute, cats, routes] = await Promise.all([ 29 28 q.getUser(app.env.DB, userDid),
+9 -10
apps/relay/src/xrpc/listNotifications.ts
··· 1 1 import { PubAtmoNotifyListNotifications } from '@atmo/notifs-lexicons'; 2 2 import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 3 4 - import { verifySenderRequest, verifyServiceToken } from '../auth/sender'; 4 + import { verifyManagementCall } from '../auth/management'; 5 5 import * as q from '../db/queries'; 6 6 import type { AppContext } from '../env'; 7 - import { notAuthorized } from '../lib/errors'; 8 7 9 8 const LXM = 'pub.atmo.notify.listNotifications'; 10 9 const DEFAULT_LIMIT = 50; 11 10 12 11 /** 13 12 * Let an app page the notifications *it* sent to a user, with read state and 14 - * the per-notification delivery count. Dual-authenticated and grant-gated like 15 - * setRouting; results are scoped to (userDid, senderDid) so an app only ever 16 - * sees its own notifications. Cursor is the `created_at` of the last row. 13 + * the per-notification delivery count. A self-scoped management read (see 14 + * MANAGEMENT-AUTH.md); results are scoped to (userDid, senderDid) so an app only 15 + * ever sees its own notifications. Cursor is the `created_at` of the last row. 17 16 */ 18 17 export function makeListNotifications( 19 18 app: AppContext, 20 19 ): ProcedureConfig<PubAtmoNotifyListNotifications.mainSchema> { 21 20 return { 22 21 handler: async ({ request, input }) => { 23 - const { senderDid } = await verifySenderRequest(app.verifier, request, LXM); 24 - const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM); 25 - 26 - if (userDid === senderDid) throw notAuthorized(); 27 - if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized(); 22 + const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, { 23 + scope: 'self', 24 + write: false, 25 + lxm: LXM, 26 + }); 28 27 29 28 const limit = input.limit ?? DEFAULT_LIMIT; 30 29 let before: number | undefined;
+96
apps/relay/src/xrpc/manage.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { 3 + PubAtmoNotifyManage, 4 + type AlertRoute, 5 + type AppRoute, 6 + type CategoryRoute, 7 + type MarkReadInput, 8 + type PubAtmoNotifyDenyPending, 9 + type PubAtmoNotifyGrant, 10 + type PubAtmoNotifyLinkChannel, 11 + type PubAtmoNotifyMuteGrant, 12 + type PubAtmoNotifyRevoke, 13 + type PubAtmoNotifyUnlinkChannel, 14 + type PubAtmoNotifyUpdateSettings, 15 + type PushSubscriptionInput, 16 + } from '@atmo/notifs-lexicons'; 17 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 18 + 19 + import { verifyManagementCall } from '../auth/management'; 20 + import type { AppContext, Env } from '../env'; 21 + import { invalidRequest } from '../lib/errors'; 22 + import * as ops from '../rpc/ops'; 23 + 24 + const LXM = 'pub.atmo.notify.manage'; 25 + 26 + // One envelope over the whole-account (`full`) management surface. Every entry 27 + // delegates to the same `ops.*` the service binding uses, so there's a single 28 + // implementation. `params` is the lexicon's `unknown` payload; we cast it to the 29 + // op's input at the boundary (callers are designated `full` managers). The auth 30 + // gate (`verifyManagementCall` at `scope: 'full'`) runs before any op. 31 + type OpFn = (env: Env, did: Did, params: unknown) => Promise<unknown>; 32 + 33 + const OPS: Record<string, OpFn> = { 34 + // reads 35 + listGrants: (env, did) => ops.listGrants(env, did), 36 + listPending: (env, did) => ops.listPending(env, did), 37 + listChannels: (env, did) => ops.listChannels(env, did), 38 + getSettings: (env, did) => ops.getSettings(env, did), 39 + listDevices: (env, did) => ops.listDevices(env, did), 40 + getRouting: (env, did) => ops.getRouting(env, did), 41 + listNotifications: (env, did, p) => 42 + ops.listNotifications(env, did, (p as { cursor?: string } | undefined)?.cursor), 43 + // writes 44 + grant: (env, did, p) => ops.grant(env, did, p as PubAtmoNotifyGrant.$input), 45 + revoke: (env, did, p) => ops.revoke(env, did, p as PubAtmoNotifyRevoke.$input), 46 + denyPending: (env, did, p) => ops.denyPending(env, did, p as PubAtmoNotifyDenyPending.$input), 47 + muteGrant: (env, did, p) => ops.muteGrant(env, did, p as PubAtmoNotifyMuteGrant.$input), 48 + linkChannel: (env, did, p) => ops.linkChannel(env, did, p as PubAtmoNotifyLinkChannel.$input), 49 + unlinkChannel: (env, did, p) => 50 + ops.unlinkChannel(env, did, p as PubAtmoNotifyUnlinkChannel.$input), 51 + updateSettings: (env, did, p) => 52 + ops.updateSettings(env, did, p as PubAtmoNotifyUpdateSettings.$input), 53 + registerWebPush: (env, did, p) => ops.registerWebPush(env, did, p as PushSubscriptionInput), 54 + unregisterWebPush: (env, did, p) => 55 + ops.unregisterWebPush(env, did, (p as { endpoint: string }).endpoint), 56 + renameDevice: (env, did, p) => { 57 + const { endpoint, label } = p as { endpoint: string; label: string }; 58 + return ops.renameDevice(env, did, endpoint, label); 59 + }, 60 + markRead: (env, did, p) => ops.markRead(env, did, p as MarkReadInput), 61 + setRouting: (env, did, p) => { 62 + const { sender, category, route } = p as { sender: Did; category: string; route: CategoryRoute }; 63 + return ops.setRouting(env, did, sender, category, route); 64 + }, 65 + setAppRouting: (env, did, p) => { 66 + const { sender, route } = p as { sender: Did; route: AppRoute }; 67 + return ops.setAppRouting(env, did, sender, route); 68 + }, 69 + setDefaultRoute: (env, did, p) => 70 + ops.setDefaultRoute(env, did, (p as { route: AlertRoute }).route), 71 + }; 72 + 73 + /** 74 + * `pub.atmo.notify.manage` — whole-account management for `full` managers (see 75 + * MANAGEMENT-AUTH.md). The portable counterpart to the service binding; both 76 + * call `ops.*`. Vouch or dual-auth; `full` is always designation-gated. 77 + */ 78 + export function makeManage(app: AppContext): ProcedureConfig<PubAtmoNotifyManage.mainSchema> { 79 + return { 80 + handler: async ({ request, input }) => { 81 + const op = OPS[input.method]; 82 + if (op === undefined) throw invalidRequest(`Unknown management method: ${input.method}`); 83 + 84 + const { userDid } = await verifyManagementCall(app, request, input, { 85 + scope: 'full', 86 + write: true, // unused at `full` scope (no read/write split there) 87 + lxm: LXM, 88 + }); 89 + 90 + const result = await op(app.env, userDid, input.params); 91 + // `result` is the lexicon's freeform `unknown`; cast at the boundary (it may 92 + // be an array/object/void depending on the op — JSON-serialized as-is). 93 + return json(result === undefined ? {} : { result: result as Record<string, unknown> }); 94 + }, 95 + }; 96 + }
+7 -8
apps/relay/src/xrpc/markRead.ts
··· 1 1 import { PubAtmoNotifyMarkRead } from '@atmo/notifs-lexicons'; 2 2 import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 3 4 - import { verifySenderRequest, verifyServiceToken } from '../auth/sender'; 4 + import { verifyManagementCall } from '../auth/management'; 5 5 import * as q from '../db/queries'; 6 6 import type { AppContext } from '../env'; 7 - import { notAuthorized } from '../lib/errors'; 8 7 import { now } from '../lib/time'; 9 8 10 9 const LXM = 'pub.atmo.notify.markRead'; 11 10 12 11 /** 13 12 * Let an app mark the notifications *it* sent to a user as read — all of them, 14 - * or a specific `ids` set. Dual-authenticated and grant-gated like setRouting; 13 + * or a specific `ids` set. A self-scoped management write (see MANAGEMENT-AUTH.md); 15 14 * the update is scoped to (userDid, senderDid), so ids belonging to other apps 16 15 * are silently ignored. 17 16 */ 18 17 export function makeMarkRead(app: AppContext): ProcedureConfig<PubAtmoNotifyMarkRead.mainSchema> { 19 18 return { 20 19 handler: async ({ request, input }) => { 21 - const { senderDid } = await verifySenderRequest(app.verifier, request, LXM); 22 - const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM); 23 - 24 - if (userDid === senderDid) throw notAuthorized(); 25 - if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized(); 20 + const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, { 21 + scope: 'self', 22 + write: true, 23 + lxm: LXM, 24 + }); 26 25 27 26 const marked = await q.markNotificationsReadFromSender( 28 27 app.env.DB,
+23
apps/relay/src/xrpc/muteSelf.ts
··· 1 + import { PubAtmoNotifyMuteSelf } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyManagementCall } from '../auth/management'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + 8 + const LXM = 'pub.atmo.notify.muteSelf'; 9 + 10 + /** An app mutes/unmutes its own notifications for a user. Self-scoped. */ 11 + export function makeMuteSelf(app: AppContext): ProcedureConfig<PubAtmoNotifyMuteSelf.mainSchema> { 12 + return { 13 + handler: async ({ request, input }) => { 14 + const { appDid, userDid } = await verifyManagementCall(app, request, input, { 15 + scope: 'self', 16 + write: true, 17 + lxm: LXM, 18 + }); 19 + await q.setGrantMuted(app.env.DB, userDid, appDid, input.muted); 20 + return json({ ok: true }); 21 + }, 22 + }; 23 + }
+25
apps/relay/src/xrpc/revokeSelf.ts
··· 1 + import { PubAtmoNotifyRevokeSelf } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyManagementCall } from '../auth/management'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + 8 + const LXM = 'pub.atmo.notify.revokeSelf'; 9 + 10 + /** An app removes its own grant for a user (turns itself off). Self-scoped. */ 11 + export function makeRevokeSelf( 12 + app: AppContext, 13 + ): ProcedureConfig<PubAtmoNotifyRevokeSelf.mainSchema> { 14 + return { 15 + handler: async ({ request, input }) => { 16 + const { appDid, userDid } = await verifyManagementCall(app, request, input, { 17 + scope: 'self', 18 + write: true, 19 + lxm: LXM, 20 + }); 21 + await q.deleteGrant(app.env.DB, userDid, appDid); 22 + return json({ ok: true }); 23 + }, 24 + }; 25 + }
+11 -15
apps/relay/src/xrpc/setRouting.ts
··· 1 1 import { PubAtmoNotifySetRouting } from '@atmo/notifs-lexicons'; 2 2 import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 3 4 - import { verifySenderRequest, verifyServiceToken } from '../auth/sender'; 4 + import { verifyManagementCall } from '../auth/management'; 5 5 import * as q from '../db/queries'; 6 6 import type { AppContext } from '../env'; 7 - import { notAuthorized } from '../lib/errors'; 8 7 9 8 const LXM = 'pub.atmo.notify.setRouting'; 10 9 11 10 /** 12 11 * Let an app change how *its own* notifications are routed for a user. 13 12 * 14 - * Dual-authenticated: the app proves its identity with its service-auth JWT in 15 - * the Authorization header (→ `senderDid`), and the user proves consent with a 16 - * fresh user-issued service-auth JWT in `userToken` (→ `userDid`). Both are 17 - * scoped to this method. We then require an active grant from the user to the 18 - * app, and only ever touch routing rows keyed by (userDid, senderDid) — so an 19 - * app can never reach the account default, other apps, or channels. 13 + * A self-scoped management write (see MANAGEMENT-AUTH.md): the app is 14 + * authenticated by its service-auth bearer and the user by the body `userToken`; 15 + * `verifyManagementCall` enforces the (user, app) capability + self-write policy. 16 + * Only ever touches routing rows keyed by (userDid, senderDid) — never the 17 + * account default, other apps, or channels. 20 18 */ 21 19 export function makeSetRouting( 22 20 app: AppContext, 23 21 ): ProcedureConfig<PubAtmoNotifySetRouting.mainSchema> { 24 22 return { 25 23 handler: async ({ request, input }) => { 26 - const { senderDid } = await verifySenderRequest(app.verifier, request, LXM); 27 - const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM); 28 - 29 - // The two tokens must be distinct identities, and the user must have an 30 - // active grant for this app (i.e. the app is approved to notify them). 31 - if (userDid === senderDid) throw notAuthorized(); 32 - if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized(); 24 + const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, { 25 + scope: 'self', 26 + write: true, 27 + lxm: LXM, 28 + }); 33 29 34 30 if (input.route !== undefined) { 35 31 if (input.route === 'default') {
+6 -1
apps/relay/test/app-routing-inbox.test.ts
··· 46 46 }); 47 47 } 48 48 49 - /** A mock app + user identity pair (both DID docs resolvable), with the app granted. */ 49 + /** 50 + * A mock app + user identity pair (both DID docs resolvable), granted and 51 + * designated `self` — so self-writes are admitted under the default policy. 52 + * (The capability gate itself is exercised in management-auth.test.ts.) 53 + */ 50 54 async function granted(tag: string): Promise<{ app: TestIdentity; user: TestIdentity }> { 51 55 const app = await makeIdentity(`did:plc:${tag}-app`); 52 56 const user = await makeIdentity(`did:plc:${tag}-user`); 53 57 mockPlc(app); 54 58 mockPlc(user); 55 59 await grant(user.did, app.did); 60 + await q.setGrantManage(env.DB, user.did, app.did, 'self'); 56 61 return { app, user }; 57 62 } 58 63
+119
apps/relay/test/management-auth.test.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; 3 + import { beforeAll, expect, it } from 'vitest'; 4 + 5 + import * as q from '../src/db/queries'; 6 + import worker from '../src/index'; 7 + 8 + import { installFetchMock, makeIdentity, makeJwt, mockPlc, xrpcPost, type TestIdentity } from './helpers'; 9 + 10 + beforeAll(() => { 11 + installFetchMock(); 12 + }); 13 + 14 + const SETROUTING = 'pub.atmo.notify.setRouting'; // self-write 15 + const GETROUTING = 'pub.atmo.notify.getRouting'; // self-read 16 + const REVOKESELF = 'pub.atmo.notify.revokeSelf'; // self-write 17 + const MUTESELF = 'pub.atmo.notify.muteSelf'; // self-write 18 + 19 + async function call(req: Request): Promise<Response> { 20 + const ctx = createExecutionContext(); 21 + const res = await worker.fetch(req, env, ctx); 22 + await waitOnExecutionContext(ctx); 23 + return res; 24 + } 25 + 26 + /** Dual-auth call: app bearer + user consent token, both scoped to `lxm`. */ 27 + async function dualCall( 28 + lxm: string, 29 + app: TestIdentity, 30 + user: TestIdentity, 31 + body: Record<string, unknown> = {}, 32 + ): Promise<Response> { 33 + const appJwt = await makeJwt(app, { lxm }); 34 + const userToken = await makeJwt(user, { lxm }); 35 + return call(xrpcPost(lxm, appJwt, { userToken, ...body })); 36 + } 37 + 38 + /** Granted but NOT designated (manage='none'). */ 39 + async function plain(tag: string): Promise<{ app: TestIdentity; user: TestIdentity }> { 40 + const app = await makeIdentity(`did:plc:${tag}-app`); 41 + const user = await makeIdentity(`did:plc:${tag}-user`); 42 + mockPlc(app); 43 + mockPlc(user); 44 + await q.upsertGrant(env.DB, { 45 + recipientDid: user.did, 46 + senderDid: app.did, 47 + grantedAt: Date.now(), 48 + title: null, 49 + description: null, 50 + iconUrl: null, 51 + }); 52 + return { app, user }; 53 + } 54 + 55 + async function designate(user: Did, app: Did, level: 'self' | 'full'): Promise<void> { 56 + await q.setGrantManage(env.DB, user, app, level); 57 + } 58 + 59 + // --- the default self policies: read=open, write=user-allowlist ------------- 60 + 61 + it('undesignated app: self-READ is allowed (read policy defaults to open)', async () => { 62 + const { app, user } = await plain('mgmt-read'); 63 + const res = await dualCall(GETROUTING, app, user); 64 + expect(res.status).toBe(200); 65 + }); 66 + 67 + it('undesignated app: self-WRITE is denied (write policy defaults to user-allowlist)', async () => { 68 + const { app, user } = await plain('mgmt-write'); 69 + const res = await dualCall(SETROUTING, app, user, { route: 'telegram' }); 70 + expect(res.status).toBe(403); 71 + // …and nothing was written. 72 + expect(await q.getAppRoute(env.DB, user.did, app.did)).toBeNull(); 73 + }); 74 + 75 + it('designated `self`: self-WRITE is allowed', async () => { 76 + const { app, user } = await plain('mgmt-self-ok'); 77 + await designate(user.did, app.did, 'self'); 78 + const res = await dualCall(SETROUTING, app, user, { route: 'telegram' }); 79 + expect(res.status).toBe(200); 80 + expect((await q.getAppRoute(env.DB, user.did, app.did))?.route).toBe('telegram'); 81 + }); 82 + 83 + it('no grant at all: denied even for reads', async () => { 84 + const app = await makeIdentity('did:plc:mgmt-nogrant-app'); 85 + const user = await makeIdentity('did:plc:mgmt-nogrant-user'); 86 + mockPlc(app); 87 + mockPlc(user); 88 + const res = await dualCall(GETROUTING, app, user); 89 + expect(res.status).toBe(403); 90 + }); 91 + 92 + // --- revokeSelf / muteSelf (self-write) ------------------------------------ 93 + 94 + it('revokeSelf: denied when undesignated, deletes the grant when designated', async () => { 95 + const { app, user } = await plain('mgmt-revoke'); 96 + const denied = await dualCall(REVOKESELF, app, user); 97 + expect(denied.status).toBe(403); 98 + expect(await q.getGrant(env.DB, user.did, app.did)).not.toBeNull(); 99 + 100 + await designate(user.did, app.did, 'self'); 101 + const ok = await dualCall(REVOKESELF, app, user); 102 + expect(ok.status).toBe(200); 103 + expect(await q.getGrant(env.DB, user.did, app.did)).toBeNull(); 104 + }); 105 + 106 + it('muteSelf: a designated app can mute itself', async () => { 107 + const { app, user } = await plain('mgmt-mute'); 108 + await designate(user.did, app.did, 'self'); 109 + const res = await dualCall(MUTESELF, app, user, { muted: true }); 110 + expect(res.status).toBe(200); 111 + expect((await q.getGrant(env.DB, user.did, app.did))?.muted).toBe(1); 112 + }); 113 + 114 + it('a `full` designation also satisfies self-scoped writes', async () => { 115 + const { app, user } = await plain('mgmt-full'); 116 + await designate(user.did, app.did, 'full'); 117 + const res = await dualCall(SETROUTING, app, user, { route: 'push' }); 118 + expect(res.status).toBe(200); 119 + });
+103
apps/relay/test/management-full.test.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; 3 + import { beforeAll, expect, it } from 'vitest'; 4 + 5 + import * as q from '../src/db/queries'; 6 + import worker from '../src/index'; 7 + 8 + import { installFetchMock, makeIdentity, makeJwt, mockPlc, xrpcPost, type TestIdentity } from './helpers'; 9 + 10 + beforeAll(() => { 11 + installFetchMock(); 12 + }); 13 + 14 + const MANAGE = 'pub.atmo.notify.manage'; 15 + 16 + async function call(req: Request): Promise<Response> { 17 + const ctx = createExecutionContext(); 18 + const res = await worker.fetch(req, env, ctx); 19 + await waitOnExecutionContext(ctx); 20 + return res; 21 + } 22 + 23 + /** Dual-auth manage call: app bearer + user token (both lxm=manage). */ 24 + async function manage( 25 + app: TestIdentity, 26 + user: TestIdentity, 27 + body: { method: string; params?: unknown }, 28 + ): Promise<Response> { 29 + const appJwt = await makeJwt(app, { lxm: MANAGE }); 30 + const userToken = await makeJwt(user, { lxm: MANAGE }); 31 + return call(xrpcPost(MANAGE, appJwt, { userToken, ...body })); 32 + } 33 + 34 + /** Make an app + user, grant, and designate the app at `level`. */ 35 + async function setup( 36 + tag: string, 37 + level: 'none' | 'self' | 'full', 38 + ): Promise<{ app: TestIdentity; user: TestIdentity }> { 39 + const app = await makeIdentity(`did:plc:${tag}-app`); 40 + const user = await makeIdentity(`did:plc:${tag}-user`); 41 + mockPlc(app); 42 + mockPlc(user); 43 + await q.upsertGrant(env.DB, { 44 + recipientDid: user.did, 45 + senderDid: app.did, 46 + grantedAt: Date.now(), 47 + title: null, 48 + description: null, 49 + iconUrl: null, 50 + }); 51 + if (level !== 'none') await q.setGrantManage(env.DB, user.did, app.did, level); 52 + return { app, user }; 53 + } 54 + 55 + it('full manager: listGrants returns the account’s grants', async () => { 56 + const { app, user } = await setup('mf-list', 'full'); 57 + const res = await manage(app, user, { method: 'listGrants' }); 58 + expect(res.status).toBe(200); 59 + const data = (await res.json()) as { result: { grants: { sender: string }[] } }; 60 + expect(Array.isArray(data.result.grants)).toBe(true); 61 + expect(data.result.grants.some((g) => g.sender === app.did)).toBe(true); 62 + }); 63 + 64 + it('full manager: can grant another app on the user’s behalf', async () => { 65 + const { app, user } = await setup('mf-grant', 'full'); 66 + const target: Did = 'did:plc:mf-target-app'; 67 + const res = await manage(app, user, { method: 'grant', params: { sender: target } }); 68 + expect(res.status).toBe(200); 69 + expect(await q.getGrant(env.DB, user.did, target)).not.toBeNull(); 70 + }); 71 + 72 + it('self designation is NOT enough for the full surface', async () => { 73 + const { app, user } = await setup('mf-self', 'self'); 74 + const res = await manage(app, user, { method: 'listGrants' }); 75 + expect(res.status).toBe(403); 76 + }); 77 + 78 + it('undesignated (granted but manage=none) is denied', async () => { 79 + const { app, user } = await setup('mf-none', 'none'); 80 + const res = await manage(app, user, { method: 'listGrants' }); 81 + expect(res.status).toBe(403); 82 + }); 83 + 84 + it('vouch (no user token) works for a designated full manager', async () => { 85 + const { app, user } = await setup('mf-vouch', 'full'); 86 + const appJwt = await makeJwt(app, { lxm: MANAGE }); 87 + // No userToken — vouch via body `did`. 88 + const res = await call(xrpcPost(MANAGE, appJwt, { method: 'getSettings', did: user.did })); 89 + expect(res.status).toBe(200); 90 + }); 91 + 92 + it('vouch is rejected without a designation', async () => { 93 + const { app, user } = await setup('mf-vouch-none', 'none'); 94 + const appJwt = await makeJwt(app, { lxm: MANAGE }); 95 + const res = await call(xrpcPost(MANAGE, appJwt, { method: 'getSettings', did: user.did })); 96 + expect(res.status).toBe(403); 97 + }); 98 + 99 + it('unknown method → 400', async () => { 100 + const { app, user } = await setup('mf-unknown', 'full'); 101 + const res = await manage(app, user, { method: 'deleteEverything' }); 102 + expect(res.status).toBe(400); 103 + });
+7
apps/web/src/lib/remote/notifs.remote.ts
··· 113 113 } 114 114 ); 115 115 116 + export const setManage = command( 117 + v.object({ sender: didSchema, manage: v.picklist(['none', 'self', 'full']) }), 118 + async ({ sender, manage }) => { 119 + await requireRelay().setGrantManage(sender as Did, manage); 120 + } 121 + ); 122 + 116 123 export const setAutoAllow = command( 117 124 v.object({ autoAllow: v.picklist(['all', 'trusted', 'none']) }), 118 125 async ({ autoAllow }) => {
+3 -1
apps/web/src/lib/server/relay.ts
··· 12 12 import type { 13 13 AlertRoute, 14 14 AppRoute, 15 + Capability, 15 16 CategoryRoute, 16 17 MarkReadInput, 17 18 PushSubscriptionInput, ··· 64 65 setRouting: (sender: Did, category: string, route: CategoryRoute) => 65 66 svc.setRouting(did, sender, category, route), 66 67 setAppRouting: (sender: Did, route: AppRoute) => svc.setAppRouting(did, sender, route), 67 - setDefaultRoute: (route: AlertRoute) => svc.setDefaultRoute(did, route) 68 + setDefaultRoute: (route: AlertRoute) => svc.setDefaultRoute(did, route), 69 + setGrantManage: (sender: Did, manage: Capability) => svc.setGrantManage(did, sender, manage) 68 70 }; 69 71 }
+44 -2
apps/web/src/routes/(app)/apps/[sender]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import type { AppRoute, CategoryRoute } from '@atmo/notifs-lexicons'; 2 + import type { AppRoute, Capability, CategoryRoute } from '@atmo/notifs-lexicons'; 3 3 import { invalidateAll } from '$app/navigation'; 4 4 import AppMark from '$lib/components/AppMark.svelte'; 5 - import { setAppRouting, setRouting } from '$lib/remote/notifs.remote'; 5 + import { setAppRouting, setManage, setRouting } from '$lib/remote/notifs.remote'; 6 6 import { APP_ROUTES, CATEGORY_ROUTES, ROUTE_LABELS } from '$lib/routes'; 7 7 import type { PageData } from './$types'; 8 8 ··· 33 33 const sender = data.app?.sender; 34 34 if (sender) run(category, () => setRouting({ sender, category, route })); 35 35 } 36 + 37 + function changeManage(manage: Capability) { 38 + const sender = data.app?.sender; 39 + if (sender) run('__manage', () => setManage({ sender, manage })); 40 + } 41 + 42 + const CAPABILITIES: Capability[] = ['none', 'self', 'full']; 43 + const CAP_LABELS: Record<Capability, string> = { 44 + none: 'No access', 45 + self: 'Manage its own settings', 46 + full: 'Manage your whole account' 47 + }; 36 48 37 49 const selectClass = 38 50 'shrink-0 rounded-md border border-line bg-surface-2 px-2 py-1.5 text-sm text-fg disabled:opacity-50'; ··· 133 145 {/each} 134 146 </ul> 135 147 {/if} 148 + </section> 149 + 150 + <!-- Management access (capability designation; see MANAGEMENT-AUTH.md) --> 151 + <section class="mt-8 max-w-2xl"> 152 + <h2 class="mb-3 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase"> 153 + Management access 154 + </h2> 155 + <div class="rounded-card border border-line bg-surface p-4"> 156 + <div class="flex items-center justify-between gap-4"> 157 + <div class="min-w-0"> 158 + <div class="text-sm font-medium text-fg">Let this app change your settings</div> 159 + <p class="mt-1 text-xs text-muted"> 160 + <span class="font-medium text-fg">Manage its own settings</span> lets the app adjust how 161 + <em>its</em> notifications reach you, from inside the app. 162 + <span class="font-medium text-fg">Manage your whole account</span> lets it act as a full 163 + dashboard — every app, channel and setting. Grant full access only to apps you trust. 164 + </p> 165 + </div> 166 + <select 167 + class={selectClass} 168 + value={data.app.manage} 169 + disabled={busy['__manage']} 170 + onchange={(e) => changeManage(e.currentTarget.value as Capability)} 171 + > 172 + {#each CAPABILITIES as c (c)} 173 + <option value={c}>{CAP_LABELS[c]}</option> 174 + {/each} 175 + </select> 176 + </div> 177 + </div> 136 178 </section> 137 179 {/if} 138 180 </div>
+52
packages/lexicons/lexicons/pub/atmo/notify/manage.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.atmo.notify.manage", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Whole-account notification management for a designated *manager* app (see MANAGEMENT-AUTH.md). One envelope over every account-management operation `web/` performs — grants, pending, channels, devices, settings, routing, inbox. Requires `full` capability for (user, app): the app must be a relay-wide manager or per-user designated. The app authenticates with its service-auth bearer; the user is identified by a body `userToken` (dual-auth) or, for a standing designation, a vouched `did` (e.g. lite-session users). `method` selects the operation and `params` carries its arguments; `result` is that operation's output.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["method"], 13 + "properties": { 14 + "method": { 15 + "type": "string", 16 + "description": "Operation name, e.g. 'listGrants', 'grant', 'revoke', 'getSettings', 'updateSettings', 'setDefaultRoute'. See MANAGEMENT-AUTH.md's per-method table." 17 + }, 18 + "userToken": { 19 + "type": "string", 20 + "description": "A user-issued service-auth JWT (aud = relay, lxm = pub.atmo.notify.manage) — dual-auth. Omit to vouch via `did` (only honored for a designated manager)." 21 + }, 22 + "did": { 23 + "type": "string", 24 + "format": "did", 25 + "description": "The user being managed, when vouching (no userToken). Ignored if userToken is present (the user is taken from the token)." 26 + }, 27 + "params": { 28 + "type": "unknown", 29 + "description": "Arguments for `method` (shape depends on the operation)." 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "properties": { 39 + "result": { 40 + "type": "unknown", 41 + "description": "The selected operation's return value (absent for void operations)." 42 + } 43 + } 44 + } 45 + }, 46 + "errors": [ 47 + { "name": "NotAuthorized" }, 48 + { "name": "InvalidRequest", "description": "Unknown or malformed method." } 49 + ] 50 + } 51 + } 52 + }
+36
packages/lexicons/lexicons/pub/atmo/notify/muteSelf.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.atmo.notify.muteSelf", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Let an app mute or unmute its *own* notifications for a user from inside the app. Muted = recorded to the inbox but no alert channels fire. Dual-authenticated like setRouting (app JWT + a user-issued `userToken`). Self-scoped: only ever affects the calling app's own grant.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["userToken", "muted"], 13 + "properties": { 14 + "userToken": { 15 + "type": "string", 16 + "description": "A user-issued atproto service-auth JWT (com.atproto.server.getServiceAuth) with aud = the relay's DID and lxm = pub.atmo.notify.muteSelf." 17 + }, 18 + "muted": { 19 + "type": "boolean", 20 + "description": "true = mute this app's notifications; false = unmute." 21 + } 22 + } 23 + } 24 + }, 25 + "output": { 26 + "encoding": "application/json", 27 + "schema": { 28 + "type": "object", 29 + "required": ["ok"], 30 + "properties": { "ok": { "type": "boolean" } } 31 + } 32 + }, 33 + "errors": [{ "name": "NotAuthorized" }] 34 + } 35 + } 36 + }
+32
packages/lexicons/lexicons/pub/atmo/notify/revokeSelf.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.atmo.notify.revokeSelf", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Let an app revoke its *own* notification grant for a user — i.e. turn itself off from inside the app. Dual-authenticated like setRouting (app JWT in the Authorization header + a user-issued `userToken`). Self-scoped: only ever removes the calling app's own grant.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["userToken"], 13 + "properties": { 14 + "userToken": { 15 + "type": "string", 16 + "description": "A user-issued atproto service-auth JWT (com.atproto.server.getServiceAuth) with aud = the relay's DID and lxm = pub.atmo.notify.revokeSelf." 17 + } 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": ["ok"], 26 + "properties": { "ok": { "type": "boolean" } } 27 + } 28 + }, 29 + "errors": [{ "name": "NotAuthorized" }] 30 + } 31 + } 32 + }
+8
packages/lexicons/src/rpc.ts
··· 75 75 /** Per-category route: a concrete route, or 'app' (inherit the app-wide route). */ 76 76 export type CategoryRoute = AlertRoute | 'app'; 77 77 78 + /** Management capability the user designated for an app. See MANAGEMENT-AUTH.md. */ 79 + export type Capability = 'none' | 'self' | 'full'; 80 + 78 81 export interface RoutingCategory { 79 82 category: string; 80 83 description?: string; ··· 85 88 title: string; 86 89 /** App-wide route applied to everything from this app (and to categories set to 'app'). */ 87 90 route: AppRoute; 91 + /** What this app may manage on the user's behalf ('none'|'self'|'full'). */ 92 + manage: Capability; 88 93 categories: RoutingCategory[]; 89 94 } 90 95 export interface RoutingConfig { ··· 148 153 ): Promise<{ ok: boolean }>; 149 154 setAppRouting(did: Did, sender: Did, route: AppRoute): Promise<{ ok: boolean }>; 150 155 setDefaultRoute(did: Did, route: AlertRoute): Promise<{ ok: boolean }>; 156 + 157 + /** Designate an app's management capability for this user ('none'|'self'|'full'). */ 158 + setGrantManage(did: Did, sender: Did, manage: Capability): Promise<{ ok: boolean }>; 151 159 152 160 /** 153 161 * Cross-app login: verify a `pub.atmo.auth` service-auth JWT (issued by the