diff --git a/README.md b/README.md index bcee756..9ed9699 100644 --- a/README.md +++ b/README.md @@ -37,3 +37,5 @@ implementation of both flows (live at https://example.atmo.pub). generated types. Running, configuring, and deploying everything: **[DEVELOPMENT.md](DEVELOPMENT.md)**. +Want notifications for your own app without depending on atmo.pub? Run the relay +yourself: **[SELF-HOSTING.md](SELF-HOSTING.md)**. diff --git a/SELF-HOSTING.md b/SELF-HOSTING.md new file mode 100644 index 0000000..f85b995 --- /dev/null +++ b/SELF-HOSTING.md @@ -0,0 +1,105 @@ +# Run your own relay + +Want notifications for your own app(s) without depending on atmo.pub? Run the +relay yourself. You get web push, Telegram, an inbox, and per-app/category +routing — for whatever apps you register. **It runs on Cloudflare Workers** (D1 + +KV + Queues); that's the only supported target for now. + +For deeper details see [DEVELOPMENT.md](DEVELOPMENT.md). + +## Prerequisites + +- A Cloudflare account (Workers paid plan — Queues require it). +- A domain in that account for the relay, e.g. `relay.yourapp.example`. +- Node 20+ and `pnpm`. + +## Steps + +1. **Clone & install** + ```bash + git clone && cd atproto-notifs && pnpm install + cd apps/relay + ``` + +2. **Create the Cloudflare resources**, then paste the IDs into `wrangler.toml`: + ```bash + pnpm exec wrangler d1 create notifs-relay # → database_id + pnpm exec wrangler kv namespace create CACHE # → kv id + pnpm exec wrangler queues create notifs-dispatch + ``` + +3. **Set your domain** in `wrangler.toml`: + ```toml + routes = [{ pattern = "relay.yourapp.example", custom_domain = true }] + [vars] + RELAY_DID = "did:web:relay.yourapp.example" # must match the domain + ``` + The relay serves its `did:web` doc at `/.well-known/did.json`, derived from + `RELAY_DID`. + +4. **Generate keys** + ```bash + pnpm relay:keygen # prints a private + public multikey for signing callbacks + pnpm vapid:keygen # prints VAPID keys for web push + ``` + - Put the relay **public** multikey into `RELAY_PUBLIC_KEY_MULTIBASE` in + `src/well-known.ts` (it must match the private key from the same run). + - Put `VAPID_PUBLIC_KEY` + `VAPID_SUBJECT` (a `mailto:`) in `wrangler.toml [vars]`. + +5. **Set secrets** + ```bash + pnpm exec wrangler secret put RELAY_PRIVATE_KEY # from relay:keygen + pnpm exec wrangler secret put VAPID_PRIVATE_JWK # from vapid:keygen + # Telegram (optional — skip if you only want web push): + pnpm exec wrangler secret put TELEGRAM_BOT_TOKEN + pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET + ``` + If you use Telegram, also set `BOT_USERNAME` in `[vars]`. + +6. **Apply migrations & deploy** + ```bash + pnpm db:migrate # applies migrations to the remote D1 + pnpm run deploy + ``` + +7. **Verify** + ```bash + curl https://relay.yourapp.example/.well-known/did.json # your did:web doc + curl https://relay.yourapp.example/xrpc/_health # {"status":"ok"} + ``` + +## Register your app(s) + +Edit `src/lib/apps.ts` — list each app that integrates with your relay: + +```ts +export const APPS: readonly RegisteredApp[] = [ + { + did: 'did:web:yourapp.example', + title: 'Your App', + callbackUrl: 'https://yourapp.example', // for subscriberChanged (optional) + trusted: true, // auto-approve permission requests (skip the pending step) + manage: 'full', // relay-wide management designation (optional) + }, +]; +``` + +Then redeploy. For a single-app relay this is usually just your one app. + +## Sending from your app + +Your sender authenticates with its own DID key, addressed to **your** relay: + +- `aud` = your `RELAY_DID`, endpoint = `https://relay.yourapp.example` +- call `pub.atmo.notify.requestPermission` then `pub.atmo.notify.send` + +`apps/example-sender` is a complete reference — copy its `src/lib/server/` auth + +relay code and point `RELAY_ORIGIN`/`RELAY_DID` at your relay. + +## Notes + +- The relay serves **any** app a user grants. To hard-restrict it to only your + DID, gate `requestPermission`/`send` on your sender DID (a few lines in + `src/xrpc/`). +- A user can still *also* use atmo.pub (or another relay) for other channels — + your app just sends to each relay the user uses. Relays are independent. diff --git a/apps/relay/migrations/0008_manage.sql b/apps/relay/migrations/0008_manage.sql new file mode 100644 index 0000000..c7984b9 --- /dev/null +++ b/apps/relay/migrations/0008_manage.sql @@ -0,0 +1,6 @@ +-- Per-grant management capability the user designates for an app: +-- 'none' (default) → app may only send / self-read per relay policy +-- 'self' → app may manage its own slice (routing, inbox) +-- 'full' → app may manage the user's whole notification account +-- See MANAGEMENT-AUTH.md. +ALTER TABLE grants ADD COLUMN manage TEXT NOT NULL DEFAULT 'none'; diff --git a/apps/relay/src/auth/management.ts b/apps/relay/src/auth/management.ts new file mode 100644 index 0000000..d7063b3 --- /dev/null +++ b/apps/relay/src/auth/management.ts @@ -0,0 +1,104 @@ +// Authentication + authorization for notification *management* calls. +// See MANAGEMENT-AUTH.md for the full model. In short: +// - The app is always authenticated by its own service-auth bearer (`iss`). +// - The user is identified by a body `userToken` (dual-auth) OR, for an app +// with a standing designation, a vouched body `did` (no user token). +// - Authorization = the (user, app) capability (relay-wide or per-grant) plus +// the relay's self-policy for the undesignated open end. +import type { Did, Nsid } from '@atcute/lexicons'; + +import * as q from '../db/queries'; +import type { AppContext, Env } from '../env'; +import { relayManageFor } from '../lib/apps'; +import { notAuthorized } from '../lib/errors'; + +import { verifySenderRequest, verifyServiceToken } from './sender'; + +export type Capability = 'none' | 'self' | 'full'; +export type SelfPolicy = 'off' | 'relay-allowlist' | 'user-allowlist' | 'open'; + +const RANK: Record = { none: 0, self: 1, full: 2 }; + +function readPolicy(env: Env): SelfPolicy { + return (env.MANAGEMENT_SELF_READ_POLICY as SelfPolicy | undefined) ?? 'open'; +} +function writePolicy(env: Env): SelfPolicy { + return (env.MANAGEMENT_SELF_WRITE_POLICY as SelfPolicy | undefined) ?? 'user-allowlist'; +} + +/** + * The app's standing capability for a user = max(relay-wide designation, + * per-grant designation). `granted` is true if a grant exists or the app is + * relay-wide designated (relay managers don't need a per-user grant row). + */ +async function resolveCapability( + env: Env, + userDid: Did, + appDid: Did, +): Promise<{ cap: Capability; granted: boolean }> { + const relayCap = relayManageFor(appDid); + const grant = await q.getGrant(env.DB, userDid, appDid); + const grantCap = (grant?.manage as Capability | undefined) ?? 'none'; + const cap = RANK[relayCap ?? 'none'] >= RANK[grantCap] ? (relayCap ?? 'none') : grantCap; + return { cap, granted: grant !== null || relayCap !== undefined }; +} + +export interface ManagementNeed { + scope: 'self' | 'full'; + write: boolean; + lxm: string; +} + +export interface ManagementCall { + appDid: Did; + userDid: Did; +} + +/** + * Verify + authorize a management call, returning the authenticated app DID and + * the resolved user DID. Throws `NotAuthorized` (403) on any failure. + */ +export async function verifyManagementCall( + app: AppContext, + request: Request, + input: { userToken?: string; did?: string }, + need: ManagementNeed, +): Promise { + const lxm = need.lxm as Nsid; + const { senderDid: appDid } = await verifySenderRequest(app.verifier, request, lxm); + + // User identity: dual-auth (a user token) or vouch (a body DID, designation-gated). + let userDid: Did; + let vouched: boolean; + if (input.userToken !== undefined) { + ({ did: userDid } = await verifyServiceToken(app.verifier, input.userToken, lxm)); + vouched = false; + } else if (input.did !== undefined) { + userDid = input.did as Did; + vouched = true; + } else { + throw notAuthorized(); + } + if (userDid === appDid) throw notAuthorized(); + + const { cap, granted } = await resolveCapability(app.env, userDid, appDid); + if (!granted) throw notAuthorized(); + + const designated = (scope: 'self' | 'full') => RANK[cap] >= RANK[scope]; + + if (need.scope === 'full') { + // Whole-account always needs a manager designation; vouch or dual both fine. + if (!designated('full')) throw notAuthorized(); + return { appDid, userDid }; + } + + // self scope + if (designated('self')) return { appDid, userDid }; // designated → vouch or dual ok + + // Undesignated self: vouch is not allowed (no standing consent), and the + // relay's open-end policy must admit it. + if (vouched) throw notAuthorized(); + const policy = need.write ? writePolicy(app.env) : readPolicy(app.env); + if (policy !== 'open') throw notAuthorized(); // relay/user allowlists are covered by `designated` + return { appDid, userDid }; +} diff --git a/apps/relay/src/db/queries.ts b/apps/relay/src/db/queries.ts index 0bdbfd5..3cc0c63 100644 --- a/apps/relay/src/db/queries.ts +++ b/apps/relay/src/db/queries.ts @@ -55,6 +55,8 @@ export interface GrantRow { title: string | null; description: string | null; icon_url: string | null; + /** Management capability the user designated for this app: 'none'|'self'|'full'. */ + manage: string; } /** A grant joined with the (optional) cached Bluesky profile (`s.*`). */ @@ -387,6 +389,20 @@ export async function setGrantMuted( return changed(result); } +/** Set a grant's management capability ('none'|'self'|'full'). See MANAGEMENT-AUTH.md. */ +export async function setGrantManage( + db: D1Database, + recipientDid: Did, + senderDid: Did, + manage: 'none' | 'self' | 'full', +): Promise { + const result = await db + .prepare('UPDATE grants SET manage = ? WHERE recipient_did = ? AND sender_did = ?') + .bind(manage, recipientDid, senderDid) + .run(); + return changed(result); +} + export async function deleteGrant(db: D1Database, recipientDid: Did, senderDid: Did): Promise { const result = await db .prepare('DELETE FROM grants WHERE recipient_did = ? AND sender_did = ?') diff --git a/apps/relay/src/env.ts b/apps/relay/src/env.ts index 1e80f36..06d6dbb 100644 --- a/apps/relay/src/env.ts +++ b/apps/relay/src/env.ts @@ -65,6 +65,12 @@ export interface Env { /** Telegram bot username, used to build deep links (var). */ BOT_USERNAME: string; + /** Self-management admission policy for *undesignated* granted apps (var). + * 'off'|'relay-allowlist'|'user-allowlist'|'open'. See MANAGEMENT-AUTH.md. + * Defaults in code: reads → 'open', writes → 'user-allowlist'. */ + MANAGEMENT_SELF_READ_POLICY?: string; + MANAGEMENT_SELF_WRITE_POLICY?: string; + /** Relay's P-256 signing key as a private multikey (secret) — for outbound * service-auth JWTs (e.g. the subscriberChanged callback). `relay:keygen`. */ RELAY_PRIVATE_KEY: string; diff --git a/apps/relay/src/lib/apps.ts b/apps/relay/src/lib/apps.ts index dd5add9..4a3afd9 100644 --- a/apps/relay/src/lib/apps.ts +++ b/apps/relay/src/lib/apps.ts @@ -19,6 +19,13 @@ export interface RegisteredApp { callbackUrl?: string; /** Auto-grant at `requestPermission` (skips the pending step). */ trusted?: boolean; + /** + * Relay-wide management designation (see MANAGEMENT-AUTH.md). 'full' = a + * first-party manager that may manage any user's whole account; 'self' = + * relay-allowlisted to manage its own slice for any user. Omit → none + * (users may still designate per-grant). + */ + manage?: 'self' | 'full'; } export const APPS: readonly RegisteredApp[] = [ @@ -28,6 +35,9 @@ export const APPS: readonly RegisteredApp[] = [ description: 'Demo app showing the atmo.pub integration.', // For local end-to-end testing, point this at your local example-sender. callbackUrl: 'https://example.atmo.pub', + // Relay-allowlisted for self-management so the example's "Step 3" demo works + // without a per-user designation UI (which lands in a later slice). + manage: 'self', }, ]; @@ -36,6 +46,11 @@ export function isTrustedSender(did: Did): boolean { return APPS.some((app) => app.did === did && app.trusted === true); } +/** Relay-wide management designation for `did`, if any ('self' | 'full'). */ +export function relayManageFor(did: string): 'self' | 'full' | undefined { + return APPS.find((app) => app.did === did)?.manage; +} + /** The registered app for `did`, if it has a callback URL configured. */ export function callbackAppFor(did: string): RegisteredApp | undefined { return APPS.find((app) => app.did === did && app.callbackUrl !== undefined); diff --git a/apps/relay/src/lib/errors.ts b/apps/relay/src/lib/errors.ts index c2d5f71..5faaab9 100644 --- a/apps/relay/src/lib/errors.ts +++ b/apps/relay/src/lib/errors.ts @@ -20,6 +20,11 @@ export function notAuthorized(message = 'Not authorized to notify this recipient return new XRPCError({ status: 403, error: 'NotAuthorized', message }); } +/** 400 `InvalidRequest` — e.g. an unknown management method in the envelope. */ +export function invalidRequest(message = 'Invalid request'): XRPCError { + return new XRPCError({ status: 400, error: 'InvalidRequest', message }); +} + /** * 429 `RateLimitExceeded` with a `Retry-After` header (whole seconds, min 1). */ diff --git a/apps/relay/src/router.ts b/apps/relay/src/router.ts index 05604ad..a1652b4 100644 --- a/apps/relay/src/router.ts +++ b/apps/relay/src/router.ts @@ -1,8 +1,11 @@ import { PubAtmoNotifyGetRouting, PubAtmoNotifyListNotifications, + PubAtmoNotifyManage, PubAtmoNotifyMarkRead, + PubAtmoNotifyMuteSelf, PubAtmoNotifyRequestPermission, + PubAtmoNotifyRevokeSelf, PubAtmoNotifySend, PubAtmoNotifySetRouting, } from '@atmo/notifs-lexicons'; @@ -12,8 +15,11 @@ import { getVerifier } from './auth/verifier'; import type { AppContext, Env } from './env'; import { makeGetRouting } from './xrpc/getRouting'; import { makeListNotifications } from './xrpc/listNotifications'; +import { makeManage } from './xrpc/manage'; import { makeMarkRead } from './xrpc/markRead'; +import { makeMuteSelf } from './xrpc/muteSelf'; import { makeRequestPermission } from './xrpc/requestPermission'; +import { makeRevokeSelf } from './xrpc/revokeSelf'; import { makeSend } from './xrpc/send'; import { makeSetRouting } from './xrpc/setRouting'; @@ -26,6 +32,11 @@ import { makeSetRouting } from './xrpc/setRouting'; * app reads/writes how *its own* notifications are routed for a user. * - `listNotifications` / `markRead` (same dual-auth): an app reads/acks the * notifications *it* sent to a user (with read state + delivery counts). + * - `revokeSelf` / `muteSelf` (same dual-auth): an app turns itself off / mutes + * itself for a user. All five self-scoped methods go through + * `verifyManagementCall` (capability + self-policy; see MANAGEMENT-AUTH.md). + * - `manage` (vouch or dual-auth, `full` only): whole-account management for a + * designated manager app — the portable counterpart to the service binding. * * Every first-party user-management method (grant, revoke, the list/get * queries, settings, and so on) lives behind the `RelayRpc` service-binding @@ -51,6 +62,9 @@ export function buildRouter(env: Env, ctx: ExecutionContext): XRPCRouter { router.addProcedure(PubAtmoNotifyGetRouting.mainSchema, makeGetRouting(app)); router.addProcedure(PubAtmoNotifyListNotifications.mainSchema, makeListNotifications(app)); router.addProcedure(PubAtmoNotifyMarkRead.mainSchema, makeMarkRead(app)); + router.addProcedure(PubAtmoNotifyRevokeSelf.mainSchema, makeRevokeSelf(app)); + router.addProcedure(PubAtmoNotifyMuteSelf.mainSchema, makeMuteSelf(app)); + router.addProcedure(PubAtmoNotifyManage.mainSchema, makeManage(app)); return router; } diff --git a/apps/relay/src/rpc/entrypoint.ts b/apps/relay/src/rpc/entrypoint.ts index a43669d..e368722 100644 --- a/apps/relay/src/rpc/entrypoint.ts +++ b/apps/relay/src/rpc/entrypoint.ts @@ -5,6 +5,7 @@ import type { AlertRoute, AppInfo, AppRoute, + Capability, CategoryRoute, DeviceView, ListNotificationsResult, @@ -102,6 +103,9 @@ export class RelayRpc extends WorkerEntrypoint implements NotifsRpc { setDefaultRoute(did: Did, route: AlertRoute) { return ops.setDefaultRoute(this.env, did, route); } + setGrantManage(did: Did, sender: Did, manage: Capability) { + return ops.setGrantManage(this.env, did, sender, manage); + } verifyAppLogin(token: string): Promise<{ did: Did }> { return ops.verifyAppLogin(this.env, token); } diff --git a/apps/relay/src/rpc/ops.ts b/apps/relay/src/rpc/ops.ts index 4a2ad4f..216836f 100644 --- a/apps/relay/src/rpc/ops.ts +++ b/apps/relay/src/rpc/ops.ts @@ -9,6 +9,7 @@ import type { AlertRoute, AppInfo, AppRoute, + Capability, CategoryRoute, DeviceView, ListNotificationsResult, @@ -372,6 +373,7 @@ export async function getRouting(env: Env, did: Did): Promise { sender: g.sender_did, title: g.title ?? g.display_name ?? g.handle ?? g.sender_did, route: (appRouteBy.get(g.sender_did) ?? 'default') as AppRoute, + manage: g.manage as Capability, categories: (catsBySender.get(g.sender_did) ?? []).map((c) => ({ category: c.category, description: c.description ?? undefined, @@ -421,6 +423,17 @@ export async function setDefaultRoute( return { ok: true }; } +/** Designate an app's management capability for this user. See MANAGEMENT-AUTH.md. */ +export async function setGrantManage( + env: Env, + did: Did, + sender: Did, + manage: Capability, +): Promise<{ ok: boolean }> { + await q.setGrantManage(env.DB, did, sender, manage); + return { ok: true }; +} + /** * Cross-app login: verify a `pub.atmo.auth` service-auth token and return the * issuer DID, ensuring the user row exists. Pre-auth — no leading `did`, the DID diff --git a/apps/relay/src/well-known.ts b/apps/relay/src/well-known.ts index 849cf43..61a52e0 100644 --- a/apps/relay/src/well-known.ts +++ b/apps/relay/src/well-known.ts @@ -1,7 +1,10 @@ import getRouting from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/getRouting.json'; import listNotifications from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/listNotifications.json'; +import manage from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/manage.json'; import markRead from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/markRead.json'; +import muteSelf from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/muteSelf.json'; import requestPermission from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/requestPermission.json'; +import revokeSelf from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/revokeSelf.json'; import send from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/send.json'; import setRouting from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/setRouting.json'; import subscriberChanged from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/subscriberChanged.json'; @@ -19,7 +22,10 @@ const LEXICONS: Record = { 'pub.atmo.notify.setRouting': setRouting, 'pub.atmo.notify.getRouting': getRouting, 'pub.atmo.notify.listNotifications': listNotifications, + 'pub.atmo.notify.manage': manage, 'pub.atmo.notify.markRead': markRead, + 'pub.atmo.notify.revokeSelf': revokeSelf, + 'pub.atmo.notify.muteSelf': muteSelf, 'pub.atmo.notify.subscriberChanged': subscriberChanged, }; @@ -27,7 +33,7 @@ const LEXICONS: Record = { // service-auth JWTs the relay issues (e.g. the `subscriberChanged` callback, // ENABLE-FROM-WEB.md). Its private half is the RELAY_PRIVATE_KEY secret. Rotate // both together via `relay:keygen`. -const RELAY_PUBLIC_KEY_MULTIBASE = 'zDnaeZB4zYA9upEh4bXkxXjsQJJhdq9zuNVtmk9QXhrno5yhd'; +const RELAY_PUBLIC_KEY_MULTIBASE = 'zDnaebFbH5Q6PhQE8g7ZryvijrstP3oFARmivjPHpneFd8W9t'; /** * `GET /.well-known/did.json` — the relay's `did:web` document. Publishes an diff --git a/apps/relay/src/xrpc/getRouting.ts b/apps/relay/src/xrpc/getRouting.ts index 1b2a8a9..bd08f68 100644 --- a/apps/relay/src/xrpc/getRouting.ts +++ b/apps/relay/src/xrpc/getRouting.ts @@ -1,17 +1,16 @@ import { PubAtmoNotifyGetRouting } from '@atmo/notifs-lexicons'; import { json, type ProcedureConfig } from '@atcute/xrpc-server'; -import { verifySenderRequest, verifyServiceToken } from '../auth/sender'; +import { verifyManagementCall } from '../auth/management'; import * as q from '../db/queries'; import type { AppContext } from '../env'; -import { notAuthorized } from '../lib/errors'; const LXM = 'pub.atmo.notify.getRouting'; /** * Read how the calling app's own notifications are currently routed for a user, - * so the app can render an accurate in-app settings UI. Dual-authenticated and - * grant-gated exactly like setRouting; returns only this app's slice plus the + * so the app can render an accurate in-app settings UI. A self-scoped management + * read (see MANAGEMENT-AUTH.md); returns only this app's slice plus the * account-default value (so 'default'/'app' can be labelled by the caller). */ export function makeGetRouting( @@ -19,11 +18,11 @@ export function makeGetRouting( ): ProcedureConfig { return { handler: async ({ request, input }) => { - const { senderDid } = await verifySenderRequest(app.verifier, request, LXM); - const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM); - - if (userDid === senderDid) throw notAuthorized(); - if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized(); + const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, { + scope: 'self', + write: false, + lxm: LXM, + }); const [user, appRoute, cats, routes] = await Promise.all([ q.getUser(app.env.DB, userDid), diff --git a/apps/relay/src/xrpc/listNotifications.ts b/apps/relay/src/xrpc/listNotifications.ts index 1c26db5..2888bd0 100644 --- a/apps/relay/src/xrpc/listNotifications.ts +++ b/apps/relay/src/xrpc/listNotifications.ts @@ -1,30 +1,29 @@ import { PubAtmoNotifyListNotifications } from '@atmo/notifs-lexicons'; import { json, type ProcedureConfig } from '@atcute/xrpc-server'; -import { verifySenderRequest, verifyServiceToken } from '../auth/sender'; +import { verifyManagementCall } from '../auth/management'; import * as q from '../db/queries'; import type { AppContext } from '../env'; -import { notAuthorized } from '../lib/errors'; const LXM = 'pub.atmo.notify.listNotifications'; const DEFAULT_LIMIT = 50; /** * Let an app page the notifications *it* sent to a user, with read state and - * the per-notification delivery count. Dual-authenticated and grant-gated like - * setRouting; results are scoped to (userDid, senderDid) so an app only ever - * sees its own notifications. Cursor is the `created_at` of the last row. + * the per-notification delivery count. A self-scoped management read (see + * MANAGEMENT-AUTH.md); results are scoped to (userDid, senderDid) so an app only + * ever sees its own notifications. Cursor is the `created_at` of the last row. */ export function makeListNotifications( app: AppContext, ): ProcedureConfig { return { handler: async ({ request, input }) => { - const { senderDid } = await verifySenderRequest(app.verifier, request, LXM); - const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM); - - if (userDid === senderDid) throw notAuthorized(); - if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized(); + const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, { + scope: 'self', + write: false, + lxm: LXM, + }); const limit = input.limit ?? DEFAULT_LIMIT; let before: number | undefined; diff --git a/apps/relay/src/xrpc/manage.ts b/apps/relay/src/xrpc/manage.ts new file mode 100644 index 0000000..6c87c83 --- /dev/null +++ b/apps/relay/src/xrpc/manage.ts @@ -0,0 +1,96 @@ +import type { Did } from '@atcute/lexicons'; +import { + PubAtmoNotifyManage, + type AlertRoute, + type AppRoute, + type CategoryRoute, + type MarkReadInput, + type PubAtmoNotifyDenyPending, + type PubAtmoNotifyGrant, + type PubAtmoNotifyLinkChannel, + type PubAtmoNotifyMuteGrant, + type PubAtmoNotifyRevoke, + type PubAtmoNotifyUnlinkChannel, + type PubAtmoNotifyUpdateSettings, + type PushSubscriptionInput, +} from '@atmo/notifs-lexicons'; +import { json, type ProcedureConfig } from '@atcute/xrpc-server'; + +import { verifyManagementCall } from '../auth/management'; +import type { AppContext, Env } from '../env'; +import { invalidRequest } from '../lib/errors'; +import * as ops from '../rpc/ops'; + +const LXM = 'pub.atmo.notify.manage'; + +// One envelope over the whole-account (`full`) management surface. Every entry +// delegates to the same `ops.*` the service binding uses, so there's a single +// implementation. `params` is the lexicon's `unknown` payload; we cast it to the +// op's input at the boundary (callers are designated `full` managers). The auth +// gate (`verifyManagementCall` at `scope: 'full'`) runs before any op. +type OpFn = (env: Env, did: Did, params: unknown) => Promise; + +const OPS: Record = { + // reads + listGrants: (env, did) => ops.listGrants(env, did), + listPending: (env, did) => ops.listPending(env, did), + listChannels: (env, did) => ops.listChannels(env, did), + getSettings: (env, did) => ops.getSettings(env, did), + listDevices: (env, did) => ops.listDevices(env, did), + getRouting: (env, did) => ops.getRouting(env, did), + listNotifications: (env, did, p) => + ops.listNotifications(env, did, (p as { cursor?: string } | undefined)?.cursor), + // writes + grant: (env, did, p) => ops.grant(env, did, p as PubAtmoNotifyGrant.$input), + revoke: (env, did, p) => ops.revoke(env, did, p as PubAtmoNotifyRevoke.$input), + denyPending: (env, did, p) => ops.denyPending(env, did, p as PubAtmoNotifyDenyPending.$input), + muteGrant: (env, did, p) => ops.muteGrant(env, did, p as PubAtmoNotifyMuteGrant.$input), + linkChannel: (env, did, p) => ops.linkChannel(env, did, p as PubAtmoNotifyLinkChannel.$input), + unlinkChannel: (env, did, p) => + ops.unlinkChannel(env, did, p as PubAtmoNotifyUnlinkChannel.$input), + updateSettings: (env, did, p) => + ops.updateSettings(env, did, p as PubAtmoNotifyUpdateSettings.$input), + registerWebPush: (env, did, p) => ops.registerWebPush(env, did, p as PushSubscriptionInput), + unregisterWebPush: (env, did, p) => + ops.unregisterWebPush(env, did, (p as { endpoint: string }).endpoint), + renameDevice: (env, did, p) => { + const { endpoint, label } = p as { endpoint: string; label: string }; + return ops.renameDevice(env, did, endpoint, label); + }, + markRead: (env, did, p) => ops.markRead(env, did, p as MarkReadInput), + setRouting: (env, did, p) => { + const { sender, category, route } = p as { sender: Did; category: string; route: CategoryRoute }; + return ops.setRouting(env, did, sender, category, route); + }, + setAppRouting: (env, did, p) => { + const { sender, route } = p as { sender: Did; route: AppRoute }; + return ops.setAppRouting(env, did, sender, route); + }, + setDefaultRoute: (env, did, p) => + ops.setDefaultRoute(env, did, (p as { route: AlertRoute }).route), +}; + +/** + * `pub.atmo.notify.manage` — whole-account management for `full` managers (see + * MANAGEMENT-AUTH.md). The portable counterpart to the service binding; both + * call `ops.*`. Vouch or dual-auth; `full` is always designation-gated. + */ +export function makeManage(app: AppContext): ProcedureConfig { + return { + handler: async ({ request, input }) => { + const op = OPS[input.method]; + if (op === undefined) throw invalidRequest(`Unknown management method: ${input.method}`); + + const { userDid } = await verifyManagementCall(app, request, input, { + scope: 'full', + write: true, // unused at `full` scope (no read/write split there) + lxm: LXM, + }); + + const result = await op(app.env, userDid, input.params); + // `result` is the lexicon's freeform `unknown`; cast at the boundary (it may + // be an array/object/void depending on the op — JSON-serialized as-is). + return json(result === undefined ? {} : { result: result as Record }); + }, + }; +} diff --git a/apps/relay/src/xrpc/markRead.ts b/apps/relay/src/xrpc/markRead.ts index 5fdd576..f65d061 100644 --- a/apps/relay/src/xrpc/markRead.ts +++ b/apps/relay/src/xrpc/markRead.ts @@ -1,28 +1,27 @@ import { PubAtmoNotifyMarkRead } from '@atmo/notifs-lexicons'; import { json, type ProcedureConfig } from '@atcute/xrpc-server'; -import { verifySenderRequest, verifyServiceToken } from '../auth/sender'; +import { verifyManagementCall } from '../auth/management'; import * as q from '../db/queries'; import type { AppContext } from '../env'; -import { notAuthorized } from '../lib/errors'; import { now } from '../lib/time'; const LXM = 'pub.atmo.notify.markRead'; /** * Let an app mark the notifications *it* sent to a user as read — all of them, - * or a specific `ids` set. Dual-authenticated and grant-gated like setRouting; + * or a specific `ids` set. A self-scoped management write (see MANAGEMENT-AUTH.md); * the update is scoped to (userDid, senderDid), so ids belonging to other apps * are silently ignored. */ export function makeMarkRead(app: AppContext): ProcedureConfig { return { handler: async ({ request, input }) => { - const { senderDid } = await verifySenderRequest(app.verifier, request, LXM); - const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM); - - if (userDid === senderDid) throw notAuthorized(); - if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized(); + const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, { + scope: 'self', + write: true, + lxm: LXM, + }); const marked = await q.markNotificationsReadFromSender( app.env.DB, diff --git a/apps/relay/src/xrpc/muteSelf.ts b/apps/relay/src/xrpc/muteSelf.ts new file mode 100644 index 0000000..1b4317e --- /dev/null +++ b/apps/relay/src/xrpc/muteSelf.ts @@ -0,0 +1,23 @@ +import { PubAtmoNotifyMuteSelf } from '@atmo/notifs-lexicons'; +import { json, type ProcedureConfig } from '@atcute/xrpc-server'; + +import { verifyManagementCall } from '../auth/management'; +import * as q from '../db/queries'; +import type { AppContext } from '../env'; + +const LXM = 'pub.atmo.notify.muteSelf'; + +/** An app mutes/unmutes its own notifications for a user. Self-scoped. */ +export function makeMuteSelf(app: AppContext): ProcedureConfig { + return { + handler: async ({ request, input }) => { + const { appDid, userDid } = await verifyManagementCall(app, request, input, { + scope: 'self', + write: true, + lxm: LXM, + }); + await q.setGrantMuted(app.env.DB, userDid, appDid, input.muted); + return json({ ok: true }); + }, + }; +} diff --git a/apps/relay/src/xrpc/revokeSelf.ts b/apps/relay/src/xrpc/revokeSelf.ts new file mode 100644 index 0000000..14bfd1e --- /dev/null +++ b/apps/relay/src/xrpc/revokeSelf.ts @@ -0,0 +1,25 @@ +import { PubAtmoNotifyRevokeSelf } from '@atmo/notifs-lexicons'; +import { json, type ProcedureConfig } from '@atcute/xrpc-server'; + +import { verifyManagementCall } from '../auth/management'; +import * as q from '../db/queries'; +import type { AppContext } from '../env'; + +const LXM = 'pub.atmo.notify.revokeSelf'; + +/** An app removes its own grant for a user (turns itself off). Self-scoped. */ +export function makeRevokeSelf( + app: AppContext, +): ProcedureConfig { + return { + handler: async ({ request, input }) => { + const { appDid, userDid } = await verifyManagementCall(app, request, input, { + scope: 'self', + write: true, + lxm: LXM, + }); + await q.deleteGrant(app.env.DB, userDid, appDid); + return json({ ok: true }); + }, + }; +} diff --git a/apps/relay/src/xrpc/setRouting.ts b/apps/relay/src/xrpc/setRouting.ts index 2fb8585..0ab60c2 100644 --- a/apps/relay/src/xrpc/setRouting.ts +++ b/apps/relay/src/xrpc/setRouting.ts @@ -1,35 +1,31 @@ import { PubAtmoNotifySetRouting } from '@atmo/notifs-lexicons'; import { json, type ProcedureConfig } from '@atcute/xrpc-server'; -import { verifySenderRequest, verifyServiceToken } from '../auth/sender'; +import { verifyManagementCall } from '../auth/management'; import * as q from '../db/queries'; import type { AppContext } from '../env'; -import { notAuthorized } from '../lib/errors'; const LXM = 'pub.atmo.notify.setRouting'; /** * Let an app change how *its own* notifications are routed for a user. * - * Dual-authenticated: the app proves its identity with its service-auth JWT in - * the Authorization header (→ `senderDid`), and the user proves consent with a - * fresh user-issued service-auth JWT in `userToken` (→ `userDid`). Both are - * scoped to this method. We then require an active grant from the user to the - * app, and only ever touch routing rows keyed by (userDid, senderDid) — so an - * app can never reach the account default, other apps, or channels. + * A self-scoped management write (see MANAGEMENT-AUTH.md): the app is + * authenticated by its service-auth bearer and the user by the body `userToken`; + * `verifyManagementCall` enforces the (user, app) capability + self-write policy. + * Only ever touches routing rows keyed by (userDid, senderDid) — never the + * account default, other apps, or channels. */ export function makeSetRouting( app: AppContext, ): ProcedureConfig { return { handler: async ({ request, input }) => { - const { senderDid } = await verifySenderRequest(app.verifier, request, LXM); - const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, LXM); - - // The two tokens must be distinct identities, and the user must have an - // active grant for this app (i.e. the app is approved to notify them). - if (userDid === senderDid) throw notAuthorized(); - if ((await q.getGrant(app.env.DB, userDid, senderDid)) === null) throw notAuthorized(); + const { appDid: senderDid, userDid } = await verifyManagementCall(app, request, input, { + scope: 'self', + write: true, + lxm: LXM, + }); if (input.route !== undefined) { if (input.route === 'default') { diff --git a/apps/relay/test/app-routing-inbox.test.ts b/apps/relay/test/app-routing-inbox.test.ts index 361c25a..5382a47 100644 --- a/apps/relay/test/app-routing-inbox.test.ts +++ b/apps/relay/test/app-routing-inbox.test.ts @@ -46,13 +46,18 @@ function grant(recipient: Did, sender: Did): Promise { }); } -/** A mock app + user identity pair (both DID docs resolvable), with the app granted. */ +/** + * A mock app + user identity pair (both DID docs resolvable), granted and + * designated `self` — so self-writes are admitted under the default policy. + * (The capability gate itself is exercised in management-auth.test.ts.) + */ async function granted(tag: string): Promise<{ app: TestIdentity; user: TestIdentity }> { const app = await makeIdentity(`did:plc:${tag}-app`); const user = await makeIdentity(`did:plc:${tag}-user`); mockPlc(app); mockPlc(user); await grant(user.did, app.did); + await q.setGrantManage(env.DB, user.did, app.did, 'self'); return { app, user }; } diff --git a/apps/relay/test/management-auth.test.ts b/apps/relay/test/management-auth.test.ts new file mode 100644 index 0000000..26db3bd --- /dev/null +++ b/apps/relay/test/management-auth.test.ts @@ -0,0 +1,119 @@ +import type { Did } from '@atcute/lexicons'; +import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; +import { beforeAll, expect, it } from 'vitest'; + +import * as q from '../src/db/queries'; +import worker from '../src/index'; + +import { installFetchMock, makeIdentity, makeJwt, mockPlc, xrpcPost, type TestIdentity } from './helpers'; + +beforeAll(() => { + installFetchMock(); +}); + +const SETROUTING = 'pub.atmo.notify.setRouting'; // self-write +const GETROUTING = 'pub.atmo.notify.getRouting'; // self-read +const REVOKESELF = 'pub.atmo.notify.revokeSelf'; // self-write +const MUTESELF = 'pub.atmo.notify.muteSelf'; // self-write + +async function call(req: Request): Promise { + const ctx = createExecutionContext(); + const res = await worker.fetch(req, env, ctx); + await waitOnExecutionContext(ctx); + return res; +} + +/** Dual-auth call: app bearer + user consent token, both scoped to `lxm`. */ +async function dualCall( + lxm: string, + app: TestIdentity, + user: TestIdentity, + body: Record = {}, +): Promise { + const appJwt = await makeJwt(app, { lxm }); + const userToken = await makeJwt(user, { lxm }); + return call(xrpcPost(lxm, appJwt, { userToken, ...body })); +} + +/** Granted but NOT designated (manage='none'). */ +async function plain(tag: string): Promise<{ app: TestIdentity; user: TestIdentity }> { + const app = await makeIdentity(`did:plc:${tag}-app`); + const user = await makeIdentity(`did:plc:${tag}-user`); + mockPlc(app); + mockPlc(user); + await q.upsertGrant(env.DB, { + recipientDid: user.did, + senderDid: app.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null, + }); + return { app, user }; +} + +async function designate(user: Did, app: Did, level: 'self' | 'full'): Promise { + await q.setGrantManage(env.DB, user, app, level); +} + +// --- the default self policies: read=open, write=user-allowlist ------------- + +it('undesignated app: self-READ is allowed (read policy defaults to open)', async () => { + const { app, user } = await plain('mgmt-read'); + const res = await dualCall(GETROUTING, app, user); + expect(res.status).toBe(200); +}); + +it('undesignated app: self-WRITE is denied (write policy defaults to user-allowlist)', async () => { + const { app, user } = await plain('mgmt-write'); + const res = await dualCall(SETROUTING, app, user, { route: 'telegram' }); + expect(res.status).toBe(403); + // …and nothing was written. + expect(await q.getAppRoute(env.DB, user.did, app.did)).toBeNull(); +}); + +it('designated `self`: self-WRITE is allowed', async () => { + const { app, user } = await plain('mgmt-self-ok'); + await designate(user.did, app.did, 'self'); + const res = await dualCall(SETROUTING, app, user, { route: 'telegram' }); + expect(res.status).toBe(200); + expect((await q.getAppRoute(env.DB, user.did, app.did))?.route).toBe('telegram'); +}); + +it('no grant at all: denied even for reads', async () => { + const app = await makeIdentity('did:plc:mgmt-nogrant-app'); + const user = await makeIdentity('did:plc:mgmt-nogrant-user'); + mockPlc(app); + mockPlc(user); + const res = await dualCall(GETROUTING, app, user); + expect(res.status).toBe(403); +}); + +// --- revokeSelf / muteSelf (self-write) ------------------------------------ + +it('revokeSelf: denied when undesignated, deletes the grant when designated', async () => { + const { app, user } = await plain('mgmt-revoke'); + const denied = await dualCall(REVOKESELF, app, user); + expect(denied.status).toBe(403); + expect(await q.getGrant(env.DB, user.did, app.did)).not.toBeNull(); + + await designate(user.did, app.did, 'self'); + const ok = await dualCall(REVOKESELF, app, user); + expect(ok.status).toBe(200); + expect(await q.getGrant(env.DB, user.did, app.did)).toBeNull(); +}); + +it('muteSelf: a designated app can mute itself', async () => { + const { app, user } = await plain('mgmt-mute'); + await designate(user.did, app.did, 'self'); + const res = await dualCall(MUTESELF, app, user, { muted: true }); + expect(res.status).toBe(200); + expect((await q.getGrant(env.DB, user.did, app.did))?.muted).toBe(1); +}); + +it('a `full` designation also satisfies self-scoped writes', async () => { + const { app, user } = await plain('mgmt-full'); + await designate(user.did, app.did, 'full'); + const res = await dualCall(SETROUTING, app, user, { route: 'push' }); + expect(res.status).toBe(200); +}); diff --git a/apps/relay/test/management-full.test.ts b/apps/relay/test/management-full.test.ts new file mode 100644 index 0000000..c959ba2 --- /dev/null +++ b/apps/relay/test/management-full.test.ts @@ -0,0 +1,103 @@ +import type { Did } from '@atcute/lexicons'; +import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; +import { beforeAll, expect, it } from 'vitest'; + +import * as q from '../src/db/queries'; +import worker from '../src/index'; + +import { installFetchMock, makeIdentity, makeJwt, mockPlc, xrpcPost, type TestIdentity } from './helpers'; + +beforeAll(() => { + installFetchMock(); +}); + +const MANAGE = 'pub.atmo.notify.manage'; + +async function call(req: Request): Promise { + const ctx = createExecutionContext(); + const res = await worker.fetch(req, env, ctx); + await waitOnExecutionContext(ctx); + return res; +} + +/** Dual-auth manage call: app bearer + user token (both lxm=manage). */ +async function manage( + app: TestIdentity, + user: TestIdentity, + body: { method: string; params?: unknown }, +): Promise { + const appJwt = await makeJwt(app, { lxm: MANAGE }); + const userToken = await makeJwt(user, { lxm: MANAGE }); + return call(xrpcPost(MANAGE, appJwt, { userToken, ...body })); +} + +/** Make an app + user, grant, and designate the app at `level`. */ +async function setup( + tag: string, + level: 'none' | 'self' | 'full', +): Promise<{ app: TestIdentity; user: TestIdentity }> { + const app = await makeIdentity(`did:plc:${tag}-app`); + const user = await makeIdentity(`did:plc:${tag}-user`); + mockPlc(app); + mockPlc(user); + await q.upsertGrant(env.DB, { + recipientDid: user.did, + senderDid: app.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null, + }); + if (level !== 'none') await q.setGrantManage(env.DB, user.did, app.did, level); + return { app, user }; +} + +it('full manager: listGrants returns the account’s grants', async () => { + const { app, user } = await setup('mf-list', 'full'); + const res = await manage(app, user, { method: 'listGrants' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { result: { grants: { sender: string }[] } }; + expect(Array.isArray(data.result.grants)).toBe(true); + expect(data.result.grants.some((g) => g.sender === app.did)).toBe(true); +}); + +it('full manager: can grant another app on the user’s behalf', async () => { + const { app, user } = await setup('mf-grant', 'full'); + const target: Did = 'did:plc:mf-target-app'; + const res = await manage(app, user, { method: 'grant', params: { sender: target } }); + expect(res.status).toBe(200); + expect(await q.getGrant(env.DB, user.did, target)).not.toBeNull(); +}); + +it('self designation is NOT enough for the full surface', async () => { + const { app, user } = await setup('mf-self', 'self'); + const res = await manage(app, user, { method: 'listGrants' }); + expect(res.status).toBe(403); +}); + +it('undesignated (granted but manage=none) is denied', async () => { + const { app, user } = await setup('mf-none', 'none'); + const res = await manage(app, user, { method: 'listGrants' }); + expect(res.status).toBe(403); +}); + +it('vouch (no user token) works for a designated full manager', async () => { + const { app, user } = await setup('mf-vouch', 'full'); + const appJwt = await makeJwt(app, { lxm: MANAGE }); + // No userToken — vouch via body `did`. + const res = await call(xrpcPost(MANAGE, appJwt, { method: 'getSettings', did: user.did })); + expect(res.status).toBe(200); +}); + +it('vouch is rejected without a designation', async () => { + const { app, user } = await setup('mf-vouch-none', 'none'); + const appJwt = await makeJwt(app, { lxm: MANAGE }); + const res = await call(xrpcPost(MANAGE, appJwt, { method: 'getSettings', did: user.did })); + expect(res.status).toBe(403); +}); + +it('unknown method → 400', async () => { + const { app, user } = await setup('mf-unknown', 'full'); + const res = await manage(app, user, { method: 'deleteEverything' }); + expect(res.status).toBe(400); +}); diff --git a/apps/web/src/lib/remote/notifs.remote.ts b/apps/web/src/lib/remote/notifs.remote.ts index 7233f9d..b255421 100644 --- a/apps/web/src/lib/remote/notifs.remote.ts +++ b/apps/web/src/lib/remote/notifs.remote.ts @@ -113,6 +113,13 @@ export const setAppRouting = command( } ); +export const setManage = command( + v.object({ sender: didSchema, manage: v.picklist(['none', 'self', 'full']) }), + async ({ sender, manage }) => { + await requireRelay().setGrantManage(sender as Did, manage); + } +); + export const setAutoAllow = command( v.object({ autoAllow: v.picklist(['all', 'trusted', 'none']) }), async ({ autoAllow }) => { diff --git a/apps/web/src/lib/server/relay.ts b/apps/web/src/lib/server/relay.ts index cce44e6..ab41e1f 100644 --- a/apps/web/src/lib/server/relay.ts +++ b/apps/web/src/lib/server/relay.ts @@ -12,6 +12,7 @@ import type { Did } from '@atcute/lexicons'; import type { AlertRoute, AppRoute, + Capability, CategoryRoute, MarkReadInput, PushSubscriptionInput, @@ -64,6 +65,7 @@ export function relayFor(platform: App.Platform | undefined, did: Did | null) { setRouting: (sender: Did, category: string, route: CategoryRoute) => svc.setRouting(did, sender, category, route), setAppRouting: (sender: Did, route: AppRoute) => svc.setAppRouting(did, sender, route), - setDefaultRoute: (route: AlertRoute) => svc.setDefaultRoute(did, route) + setDefaultRoute: (route: AlertRoute) => svc.setDefaultRoute(did, route), + setGrantManage: (sender: Did, manage: Capability) => svc.setGrantManage(did, sender, manage) }; } diff --git a/apps/web/src/routes/(app)/apps/[sender]/+page.svelte b/apps/web/src/routes/(app)/apps/[sender]/+page.svelte index dcf9f27..799ef89 100644 --- a/apps/web/src/routes/(app)/apps/[sender]/+page.svelte +++ b/apps/web/src/routes/(app)/apps/[sender]/+page.svelte @@ -1,8 +1,8 @@ @@ -134,5 +146,35 @@ {/if} + + +
+

+ Management access +

+
+
+
+
Let this app change your settings
+

+ Manage its own settings lets the app adjust how + its notifications reach you, from inside the app. + Manage your whole account lets it act as a full + dashboard — every app, channel and setting. Grant full access only to apps you trust. +

+
+ +
+
+
{/if} diff --git a/packages/lexicons/lexicons/pub/atmo/notify/manage.json b/packages/lexicons/lexicons/pub/atmo/notify/manage.json new file mode 100644 index 0000000..59bbd91 --- /dev/null +++ b/packages/lexicons/lexicons/pub/atmo/notify/manage.json @@ -0,0 +1,52 @@ +{ + "lexicon": 1, + "id": "pub.atmo.notify.manage", + "defs": { + "main": { + "type": "procedure", + "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.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["method"], + "properties": { + "method": { + "type": "string", + "description": "Operation name, e.g. 'listGrants', 'grant', 'revoke', 'getSettings', 'updateSettings', 'setDefaultRoute'. See MANAGEMENT-AUTH.md's per-method table." + }, + "userToken": { + "type": "string", + "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)." + }, + "did": { + "type": "string", + "format": "did", + "description": "The user being managed, when vouching (no userToken). Ignored if userToken is present (the user is taken from the token)." + }, + "params": { + "type": "unknown", + "description": "Arguments for `method` (shape depends on the operation)." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "result": { + "type": "unknown", + "description": "The selected operation's return value (absent for void operations)." + } + } + } + }, + "errors": [ + { "name": "NotAuthorized" }, + { "name": "InvalidRequest", "description": "Unknown or malformed method." } + ] + } + } +} diff --git a/packages/lexicons/lexicons/pub/atmo/notify/muteSelf.json b/packages/lexicons/lexicons/pub/atmo/notify/muteSelf.json new file mode 100644 index 0000000..d8b09a5 --- /dev/null +++ b/packages/lexicons/lexicons/pub/atmo/notify/muteSelf.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "pub.atmo.notify.muteSelf", + "defs": { + "main": { + "type": "procedure", + "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.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["userToken", "muted"], + "properties": { + "userToken": { + "type": "string", + "description": "A user-issued atproto service-auth JWT (com.atproto.server.getServiceAuth) with aud = the relay's DID and lxm = pub.atmo.notify.muteSelf." + }, + "muted": { + "type": "boolean", + "description": "true = mute this app's notifications; false = unmute." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + "errors": [{ "name": "NotAuthorized" }] + } + } +} diff --git a/packages/lexicons/lexicons/pub/atmo/notify/revokeSelf.json b/packages/lexicons/lexicons/pub/atmo/notify/revokeSelf.json new file mode 100644 index 0000000..3d74737 --- /dev/null +++ b/packages/lexicons/lexicons/pub/atmo/notify/revokeSelf.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "pub.atmo.notify.revokeSelf", + "defs": { + "main": { + "type": "procedure", + "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.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["userToken"], + "properties": { + "userToken": { + "type": "string", + "description": "A user-issued atproto service-auth JWT (com.atproto.server.getServiceAuth) with aud = the relay's DID and lxm = pub.atmo.notify.revokeSelf." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["ok"], + "properties": { "ok": { "type": "boolean" } } + } + }, + "errors": [{ "name": "NotAuthorized" }] + } + } +} diff --git a/packages/lexicons/src/rpc.ts b/packages/lexicons/src/rpc.ts index 3801c68..c7b6877 100644 --- a/packages/lexicons/src/rpc.ts +++ b/packages/lexicons/src/rpc.ts @@ -75,6 +75,9 @@ export type AppRoute = AlertRoute | 'default'; /** Per-category route: a concrete route, or 'app' (inherit the app-wide route). */ export type CategoryRoute = AlertRoute | 'app'; +/** Management capability the user designated for an app. See MANAGEMENT-AUTH.md. */ +export type Capability = 'none' | 'self' | 'full'; + export interface RoutingCategory { category: string; description?: string; @@ -85,6 +88,8 @@ export interface RoutingApp { title: string; /** App-wide route applied to everything from this app (and to categories set to 'app'). */ route: AppRoute; + /** What this app may manage on the user's behalf ('none'|'self'|'full'). */ + manage: Capability; categories: RoutingCategory[]; } export interface RoutingConfig { @@ -149,6 +154,9 @@ export interface NotifsRpc { setAppRouting(did: Did, sender: Did, route: AppRoute): Promise<{ ok: boolean }>; setDefaultRoute(did: Did, route: AlertRoute): Promise<{ ok: boolean }>; + /** Designate an app's management capability for this user ('none'|'self'|'full'). */ + setGrantManage(did: Did, sender: Did, manage: Capability): Promise<{ ok: boolean }>; + /** * Cross-app login: verify a `pub.atmo.auth` service-auth JWT (issued by the * user's PDS, addressed to the relay DID, single-use) and return the issuer