From 6ddcbd4623aa7779e95acb916c4d1870612c3d7e Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sat, 23 May 2026 23:08:34 +0200 Subject: [PATCH] move docs --- README.md | 4 +- docs/CROSS-APP-AUTH.md | 162 ++++++++++++++++ DEVELOPMENT.md => docs/DEVELOPMENT.md | 54 +++--- docs/ENABLE-FROM-WEB.md | 196 +++++++++++++++++++ docs/MANAGEMENT-AUTH.md | 245 ++++++++++++++++++++++++ REDESIGN.md => docs/REDESIGN.md | 0 SELF-HOSTING.md => docs/SELF-HOSTING.md | 0 7 files changed, 632 insertions(+), 29 deletions(-) create mode 100644 docs/CROSS-APP-AUTH.md rename DEVELOPMENT.md => docs/DEVELOPMENT.md (83%) create mode 100644 docs/ENABLE-FROM-WEB.md create mode 100644 docs/MANAGEMENT-AUTH.md rename REDESIGN.md => docs/REDESIGN.md (100%) rename SELF-HOSTING.md => docs/SELF-HOSTING.md (100%) diff --git a/README.md b/README.md index 9ed9699..e9bf440 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,6 @@ implementation of both flows (live at https://example.atmo.pub). - **`packages/lexicons`** — the shared `pub.atmo.notify.*` lexicons (13) and generated types. -Running, configuring, and deploying everything: **[DEVELOPMENT.md](DEVELOPMENT.md)**. +Running, configuring, and deploying everything: **[DEVELOPMENT.md](docs/DEVELOPMENT.md)**. Want notifications for your own app without depending on atmo.pub? Run the relay -yourself: **[SELF-HOSTING.md](SELF-HOSTING.md)**. +yourself: **[SELF-HOSTING.md](docs/SELF-HOSTING.md)**. diff --git a/docs/CROSS-APP-AUTH.md b/docs/CROSS-APP-AUTH.md new file mode 100644 index 0000000..2de8960 --- /dev/null +++ b/docs/CROSS-APP-AUTH.md @@ -0,0 +1,162 @@ +# Cross-app login (`pub.atmo.auth`) — implementation plan + +Let another atproto app show a link that drops the user into **atmo.pub already +signed in**, with no OAuth round-trip. Borrowed from Roomy ↔ OpenMeet's +service-auth pattern, simplified for our architecture. + +The key simplification: **atmo.pub only ever needs `locals.did`.** Every action +goes through the trusted `RELAY` service binding (`relayFor(platform, did)`), and +the web app's own `OAUTH_SCOPE` is just `'atproto'` (identity). So a session +minted from a service-auth identity proof is *functionally identical* to a full +OAuth session — there is no OpenMeet-style "degraded new-user" mode. atmo.pub +never writes to the user's PDS, so it needs nothing more than the verified DID. + +## How it works + +``` +Sender app (holds the user's OAuth session) + │ 1. mint PDS service-auth JWT: + │ iss = user DID, aud = did:web:relay.atmo.pub, + │ lxm = pub.atmo.auth, exp ≈ 60s + │ (com.atproto.server.getServiceAuth — needs scope rpc?lxm=pub.atmo.auth&aud=*) + │ 2. window.open → https://atmo.pub/applogin?token=&redirect=/apps/ + ▼ +atmo.pub /applogin (+server.ts GET) + │ 3. platform.env.RELAY.verifyAppLogin(token) ── binding, not public XRPC + │ relay: resolve DID doc, verify sig, check aud + lxm + exp, + │ single-use via SHA-256(token) in KV → { did } + │ 4. set signed cookie (DID + exp, 30d), Referrer-Policy: no-referrer + │ 5. 303 → validated redirect (strips the token from the URL) + ▼ +hooks.server.ts honors the cookie → locals.did + locals.authVia='link' +``` + +## Locked decisions + +1. **Token in the link, single-use.** The PDS-signed JWT goes straight in + `?token=`. It is itself single-use (KV token-hash) + ~60s, so it has the same + security properties as an opaque code; the DID it reveals isn't secret. The + `/applogin` response 303-redirects immediately (no HTML/subresources) and sets + `Referrer-Policy: no-referrer`, so the token doesn't leak to third parties; it + is inert after first use even if it lingers in history/logs. + *Hardening option (not now): put the token in the URL `#fragment` and POST it + from client JS, so it never reaches server access logs at all.* +2. **lxm = `pub.atmo.auth`.** A generic "sign in to an atmo app" primitive (a + convention NSID string, no published lexicon — same as OpenMeet's + `net.openmeet.auth`). It is part of the public integration contract: senders + put `rpc?lxm=pub.atmo.auth&aud=*` in their OAuth scope. +3. **`aud = did:web:relay.atmo.pub`.** The relay is the verifying authority and + the verifier already accepts this DID, so no new did:web is needed. +4. **Verification on the private binding.** New `verifyAppLogin(token)` on + `RelayRpc` / `NotifsRpc`. Public XRPC stays locked to `requestPermission` + + `send` — third parties only *mint* tokens; only atmo.pub's server *consumes* + them, and it has the binding. +5. **Separate 30-day lite-session cookie.** A short-lived identity proof becomes + a longer-lived bearer cookie — the one real tradeoff (OpenMeet's headline + caveat). We cap it at 30 days (vs. the OAuth cookie's 180) to shrink the blast + radius of a leaked link. The cookie carries only `{ did, exp }`, HMAC-signed + with `COOKIE_SECRET`; `exp` is re-checked server-side. +6. **`locals.authVia: 'oauth' | 'link' | null`.** Cheap to add now; lets any + future PDS-writing feature require a real OAuth upgrade. Today both paths are + equivalent because atmo.pub does no PDS writes. +7. **Deep-link to the app's own settings.** Senders default `redirect` to + `/apps/` so "Configure notifications for X" lands exactly there, + pre-authed. General SSO (`redirect=/inbox`) is just a different target. + +## The contract (what a third-party sender implements) + +``` +Scope: atproto rpc?lxm=pub.atmo.auth&aud=* (alongside requestPermission) +Mint: com.atproto.server.getServiceAuth({ aud: 'did:web:relay.atmo.pub', + lxm: 'pub.atmo.auth' }) +Link: https://atmo.pub/applogin?token=&redirect= +``` + +`redirect` must be a relative path (`/…`, not `//`, no scheme). atmo.pub +re-validates and falls back to `/apps` on anything invalid. + +## Changes by package + +### `apps/relay` +- **`src/auth/appLogin.ts`** (new) — `verifyAppLogin(env, token) → { did }`: + - Build a synthetic `Request` with `Authorization: Bearer ` and call the + existing `getVerifier(env).verifyRequest(req, { lxm: 'pub.atmo.auth' })` + (audiences already include `did:web:relay.atmo.pub`). + - Single-use: `key = 'applogin:' + base64url(sha256(token))`; if + `CACHE.get(key)` exists → reject (replay); else `CACHE.put(key, '1', { expirationTtl: 300 })`. + Keyed on the token hash, not `jti`, so it works whether or not the PDS emits a + unique nonce (the shared verifier has no `replayStore`, so non-jti + `requestPermission` tokens keep working). + - Throw a typed error on any failure; the web route maps it to a clean fallback. +- **`src/rpc/ops.ts`** — `export async function verifyAppLogin(env, token)` + wrapping the above (optionally `ensureUser` — the first management call already + does, so optional). +- **`src/rpc/entrypoint.ts`** — `verifyAppLogin(token: string)` method on + `RelayRpc`. +- **`packages/lexicons/src/rpc.ts`** — add to `NotifsRpc`: + `verifyAppLogin(token: string): Promise<{ did: Did }>` (the one method with no + leading `did` — it's pre-auth; documented as such). +- No migration (replay state is KV, not D1). +- Tests: `test/appLogin.test.ts` — valid token → did; wrong lxm; wrong aud; + expired; replay (second use rejected). Mint test tokens with `createServiceJwt` + + a throwaway P-256 key whose DID doc the resolver is stubbed to return (mirror + existing auth tests). + +### `apps/web` +- **`src/lib/server/liteSession.ts`** (new) — `sign(did)` / `verify(cookieValue)` + using WebCrypto HMAC-SHA256 over `COOKIE_SECRET`; payload `{ did, exp }`, + format `b64url(json).b64url(mac)`; `verify` checks the MAC and `exp`. +- **`src/routes/applogin/+server.ts`** (new) — `GET`: read `token` + `redirect`; + `platform.env.RELAY.verifyAppLogin(token)`; on success `cookies.set('atmo_session', + sign(did), { httpOnly, secure, sameSite:'lax', path:'/', maxAge: 60*60*24*30 })` + and `303` to the validated redirect; on failure `303 → /?login=expired`. Set + `Referrer-Policy: no-referrer`. +- **`src/hooks.server.ts`** — wrap `atproto.handle`: run the OAuth handle; if it + didn't set `locals.did`, read+verify `atmo_session` → set `locals.did` + + `locals.authVia='link'`. Set `locals.authVia='oauth'` when OAuth populated it. +- **`src/app.d.ts`** — add `authVia: 'oauth' | 'link' | null` to `App.Locals`; + add `verifyAppLogin` to the `RELAY` binding type (comes free via `NotifsRpc`). +- **`src/lib/atproto/oauth.remote.ts`** — `oauthLogout` also clears `atmo_session`. +- (Optional UI) Settings shows "Signed in via a link from another app — sign in + fully" when `authVia==='link'`. Not required for function. + +### `apps/example-sender` (reference integration) +- **`src/lib/config.ts`** — add `rpc?lxm=pub.atmo.auth&aud=*` to `OAUTH_SCOPE`. +- **`src/lib/server/relay.ts`** — `mintAppLoginUrl(client)`: + `getServiceAuth({ aud: RELAY_DID, lxm: 'pub.atmo.auth' })` → return + `${DASHBOARD_ORIGIN}/applogin?token=${token}&redirect=${encodeURIComponent('/apps/' + SENDER_DID)}`. +- **`src/lib/relay.remote.ts`** — `command openInAtmo()` → `mintAppLoginUrl(locals.client)`. +- **`src/routes/+page.svelte`** — "Open in atmo.pub" button: + `const w = window.open('about:blank'); const { url } = await openInAtmo(); w.location.href = url;` + (the `about:blank` first preserves the user gesture / dodges popup blockers). + +### `apps/homepage` (`/docs`) +- New "Cross-app login" section documenting the contract above + the security + notes below. + +## Security notes (carry into docs) + +- **Short token → 30-day session.** Single-use + ~60s + 30-day cap shrink the + window and blast radius, but a link captured before first use within its TTL + yields a session. Don't log the links. +- **aud binding** — a token for `did:web:relay.atmo.pub` is useless at any other + service. **lxm binding** — `pub.atmo.auth` tokens can't invoke anything else. +- **`aud=*` wildcard** in the sender scope lets a compromised sender mint tokens + for other services that also accept `pub.atmo.auth`; low risk (only we use it). + Senders may pin `aud=did:web:relay.atmo.pub` for max safety. +- **Trust identity, not metadata** — only the DID + verified signature are + trusted; we store nothing the PDS volunteers. +- **PDS compromise / availability** — same federation tradeoffs as + `requestPermission`: a compromised PDS can mint valid tokens; a down PDS means + no link. + +## Build checklist (when greenlit) + +- [ ] relay: `verifyAppLogin` (auth/ops/entrypoint) + `NotifsRpc` method + tests +- [ ] web: `liteSession` helper, `/applogin` route, `hooks` wrap, `app.d.ts`, + logout clears cookie +- [ ] example-sender: scope, `mintAppLoginUrl`, `openInAtmo`, button +- [ ] homepage `/docs`: cross-app login section +- [ ] verify monorepo green (relay tests + typecheck; web/homepage/example + svelte-check; builds) +``` diff --git a/DEVELOPMENT.md b/docs/DEVELOPMENT.md similarity index 83% rename from DEVELOPMENT.md rename to docs/DEVELOPMENT.md index 7c844f0..6da96fa 100644 --- a/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,8 +1,8 @@ # Development -Running, configuring, and deploying the `notify.atmo.tools` monorepo (relay, web +Running, configuring, and deploying the `atmo.pub` monorepo (relay, web dashboard, example sender). For the high-level overview see -[README.md](README.md); for the sender API see https://notify.atmo.tools/docs. +[README.md](README.md); for the sender API see https://atmo.pub/docs. ## Repo layout @@ -32,7 +32,7 @@ dashboard, example sender). For the high-level overview see │ │ │ ├── well-known.ts │ │ │ └── lib/ # errors.ts, ids.ts, time.ts │ │ └── test/ # vitest (pool-workers) -│ ├── web/ # SvelteKit dashboard (notify.atmo.tools) +│ ├── web/ # SvelteKit dashboard (atmo.pub) │ │ ├── wrangler.jsonc │ │ ├── svelte.config.js # adapter-cloudflare │ │ ├── vite.config.ts # workerd resolve conditions (see "Deploying the SvelteKit apps") @@ -41,7 +41,7 @@ dashboard, example sender). For the high-level overview see │ │ ├── lib/atproto/ # OAuth client config + oauth.remote.ts (login/logout) │ │ ├── lib/server/relay.ts # calls the relay as the signed-in user │ │ └── routes/ # landing, /dashboard, /docs -│ └── example-sender/ # one-page sender demo (example.notify.atmo.tools) +│ └── example-sender/ # one-page sender demo (example.atmo.pub) │ ├── wrangler.jsonc │ ├── scripts/generate-keys.js # P-256 keypair for `send` (sender:keygen) │ └── src/ @@ -53,7 +53,7 @@ dashboard, example sender). For the high-level overview see └── packages/ └── lexicons/ ├── lex.config.js # @atcute/lex-cli config - ├── lexicons/tools/atmo/notifs/*.json # 13 lexicons + ├── lexicons/pub/atmo/notify/*.json # 13 lexicons └── src/index.ts # re-exports generated types ``` @@ -123,11 +123,11 @@ re-home the relay, change them everywhere listed below. | Constant | Value | Where it lives | | --- | --- | --- | -| Relay domain | `notifs.atmo.tools` | `apps/relay/wrangler.toml` (`routes`, derived `RELAY_DID`); `apps/relay/test/helpers.ts` | -| Relay DID | `did:web:notifs.atmo.tools` | `apps/relay/wrangler.toml` (`[vars].RELAY_DID`); consumed by `apps/relay/src/auth/verifier.ts` and `apps/relay/src/well-known.ts` (service endpoint derived from it); `apps/relay/test/helpers.ts` (`RELAY_DID`) | -| Dashboard (web) domain | `notify.atmo.tools` | `apps/web/wrangler.jsonc` (`ORIGIN`); the relay's Telegram messages in `apps/relay/src/telegram/commands.ts` (`DASHBOARD_URL`, `NOT_LINKED`); `apps/example-sender/src/lib/config.ts` (`DASHBOARD_ORIGIN`) | +| Relay domain | `relay.atmo.pub` | `apps/relay/wrangler.toml` (`routes`, derived `RELAY_DID`); `apps/relay/test/helpers.ts` | +| Relay DID | `did:web:relay.atmo.pub` | `apps/relay/wrangler.toml` (`[vars].RELAY_DID`); consumed by `apps/relay/src/auth/verifier.ts` and `apps/relay/src/well-known.ts` (service endpoint derived from it); `apps/relay/test/helpers.ts` (`RELAY_DID`) | +| Dashboard (web) domain | `atmo.pub` | `apps/web/wrangler.jsonc` (`ORIGIN`); the relay's Telegram messages in `apps/relay/src/telegram/commands.ts` (`DASHBOARD_URL`, `NOT_LINKED`); `apps/example-sender/src/lib/config.ts` (`DASHBOARD_ORIGIN`) | | Relay service id | `#notif_relay` | `apps/relay/src/well-known.ts` (DID-doc `service[].id`); `apps/relay/src/auth/verifier.ts` (`acceptAudiences` fragment) | -| Lexicon NSID prefix | `tools.atmo.notifs` | Every file under `packages/lexicons/lexicons/` (each `id`); regenerated types under `packages/lexicons/src/lexicons/`; the `LXM` constant in every `apps/relay/src/xrpc/*.ts`; the map + imports in `apps/relay/src/well-known.ts` | +| Lexicon NSID prefix | `pub.atmo.notify` | Every file under `packages/lexicons/lexicons/` (each `id`); regenerated types under `packages/lexicons/src/lexicons/`; the `LXM` constant in every `apps/relay/src/xrpc/*.ts`; the map + imports in `apps/relay/src/well-known.ts` | | Pending request TTL | 7 days | `apps/relay/src/xrpc/requestPermission.ts` (`addDays(createdAt, 7)`) | | Link token TTL | 10 minutes | `apps/relay/src/xrpc/linkChannel.ts` (`addMinutes(now(), 10)`) | | DID-doc cache TTL (KV) | 5 minutes | `apps/relay/src/identity/resolve.ts` (`DID_DOC_CACHE_TTL_SECONDS`) | @@ -173,7 +173,7 @@ re-home the relay, change them everywhere listed below. WEBHOOK_SECRET="" curl -sS "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \ -H 'content-type: application/json' \ - -d "{\"url\":\"https://notifs.atmo.tools/telegram/webhook/${WEBHOOK_SECRET}\"}" + -d "{\"url\":\"https://relay.atmo.pub/telegram/webhook/${WEBHOOK_SECRET}\"}" ``` ## Deploying the relay @@ -185,29 +185,29 @@ pnpm exec wrangler deploy Then: -1. In the Cloudflare dashboard, attach the **custom domain** `notifs.atmo.tools` +1. In the Cloudflare dashboard, attach the **custom domain** `relay.atmo.pub` to the Worker (Workers & Pages → the worker → Settings → Domains & Routes). The `routes` entry in `wrangler.toml` already declares it as a custom domain. 2. Verify the DID document resolves: ```sh - curl -s https://notifs.atmo.tools/.well-known/did.json + curl -s https://relay.atmo.pub/.well-known/did.json ``` Expected: ```json { "@context": ["https://www.w3.org/ns/did/v1"], - "id": "did:web:notifs.atmo.tools", + "id": "did:web:relay.atmo.pub", "service": [ { "id": "#notif_relay", "type": "AtprotoNotificationRelay", - "serviceEndpoint": "https://notifs.atmo.tools" + "serviceEndpoint": "https://relay.atmo.pub" } ] } ``` -3. Health check: `curl -s https://notifs.atmo.tools/xrpc/_health` → `{"status":"ok"}`. -4. Lexicons are served at `https://notifs.atmo.tools/lexicons/`. +3. Health check: `curl -s https://relay.atmo.pub/xrpc/_health` → `{"status":"ok"}`. +4. Lexicons are served at `https://relay.atmo.pub/lexicons/`. ## Deploying the SvelteKit apps (web + example sender) @@ -227,8 +227,8 @@ pnpm exec atproto-oauth keygen | pnpm exec wrangler secret put CLIENT_ASSERTION_ pnpm run deploy # vite build && wrangler deploy ``` -Then attach the custom domain in the Cloudflare dashboard (`notify.atmo.tools` for -web, `example.notify.atmo.tools` for the example) and make sure `ORIGIN` in +Then attach the custom domain in the Cloudflare dashboard (`atmo.pub` for +web, `example.atmo.pub` for the example) and make sure `ORIGIN` in `wrangler.jsonc` matches it — OAuth `client_id`/`redirect_uri` derive from it. The example sender also needs a sender keypair for `send` — see @@ -245,7 +245,7 @@ The example sender also needs a sender keypair for `send` — see ## For sender developers -The user-facing version of this section lives at https://notify.atmo.tools/docs, +The user-facing version of this section lives at https://atmo.pub/docs, and a complete working implementation is in [`apps/example-sender`](apps/example-sender). The essentials: @@ -269,15 +269,15 @@ and a complete working implementation is in the user's session): ``` - atproto rpc?lxm=tools.atmo.notifs.requestPermission&aud=* + atproto rpc?lxm=pub.atmo.notify.requestPermission&aud=* ``` Then, on the user's PDS, mint a service-auth JWT via - `com.atproto.server.getServiceAuth` (`aud = did:web:notifs.atmo.tools`, - `lxm = tools.atmo.notifs.requestPermission`) and call: + `com.atproto.server.getServiceAuth` (`aud = did:web:relay.atmo.pub`, + `lxm = pub.atmo.notify.requestPermission`) and call: ```ts - await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.requestPermission', { + await fetch('https://relay.atmo.pub/xrpc/pub.atmo.notify.requestPermission', { method: 'POST', headers: { authorization: `Bearer ${userServiceAuthJwt}`, // issued by the USER's PDS @@ -296,7 +296,7 @@ and a complete working implementation is in The user approves it in the dashboard or Telegram. 3. **Send a notification** once granted — authenticated with **your app's own - key** (`aud` = the relay, `lxm = tools.atmo.notifs.send`): + key** (`aud` = the relay, `lxm = pub.atmo.notify.send`): ```ts import { P256PrivateKeyExportable } from '@atcute/crypto'; @@ -306,12 +306,12 @@ and a complete working implementation is in const jwt = await createServiceJwt({ keypair, issuer: 'did:web:yourapp.example', - audience: 'did:web:notifs.atmo.tools', - lxm: 'tools.atmo.notifs.send', + audience: 'did:web:relay.atmo.pub', + lxm: 'pub.atmo.notify.send', expiresIn: 60, }); - await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.send', { + await fetch('https://relay.atmo.pub/xrpc/pub.atmo.notify.send', { method: 'POST', headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' }, body: JSON.stringify({ diff --git a/docs/ENABLE-FROM-WEB.md b/docs/ENABLE-FROM-WEB.md new file mode 100644 index 0000000..42fc729 --- /dev/null +++ b/docs/ENABLE-FROM-WEB.md @@ -0,0 +1,196 @@ +# Enable apps from inside atmo.pub (relay-signed callback) — spec + +Let a user turn on notifications for a (hardcoded, for now) third-party app from +inside atmo.pub, with no trip through that app's own login. The app finds out via +a **relay-signed callback** — the relay vouches for the opt-in, which the app +already trusts since it sends everything through the relay. Companion to +`CROSS-APP-AUTH.md`; this is the *outbound* direction. + +## Why this shape + +`send` is gated solely by a grant (`send.ts:29`), and atmo.pub can create grants +for any signed-in user over the `RELAY` binding (works for **lite-session** users +too — it's just a DID). So "enable App X" decomposes into: + +1. **Create the grant** — already possible via `ops.grant`. +2. **Tell App X** — the new part. + +For #2 we make the **relay a JWT issuer**: on grant create/revoke it mints a +service-auth JWT (`iss = did:web:relay.atmo.pub`) addressed to App X and POSTs a +`subscriberChanged` event. App X verifies the relay's signature exactly like it +verifies any service-auth, confirms `iss` is the relay, and updates its +subscriber list. No polling, no web-side service-auth, no shared secrets, works +for lite sessions. + +``` +atmo.pub ── user toggles "Enable App X" ──▶ RELAY binding ops.grant(user, {sender: X}) + │ upsert grant + │ enqueue { kind:'subscriberChanged', sender:X, recipient:user, enabled:true } + ▼ +relay queue/dispatcher ── mint relay JWT (iss=did:web:relay.atmo.pub, aud=X, + lxm=pub.atmo.notify.subscriberChanged) ──▶ POST X/xrpc/…subscriberChanged + ▼ +App X ── verify sig + aud=self + lxm + iss==relayDID ──▶ record subscriber ──▶ send() from now on +``` + +## Trust & its one tradeoff + +App X trusts the relay's *assertion* of consent, not a fresh user-PDS signature. +That's the **same** trust App X already places in the relay (it gates and +delivers all of App X's notifications) and the same trust atmo.pub's binding-based +management runs on. Reach for a user-JWT instead only if an app does something +privileged on opt-in (provisions an account, links identity) — not this case. + +Replay is a non-issue: the callback sets a **state** (`enabled: true|false`), not +an increment, so it's idempotent — no `jti`/single-use needed. + +## The relay becomes an issuer (the one real prerequisite) + +Today the relay only verifies (`well-known.ts:26`, DID doc has no +`verificationMethod`). Mirror exactly what `apps/example-sender` already does as a +sender: + +- **`scripts/generate-relay-key.js`** (copy of the sender's `generate-keys.js`) → + prints a P-256 private+public multikey. Add `"relay:keygen"` to relay scripts. +- **`RELAY_PRIVATE_KEY`** secret (multikey); add `RELAY_PRIVATE_KEY: string` to + `Env` (env.ts) and to `.dev.vars` for tests. +- **`src/auth/relay-signer.ts`** (copy of `sender-auth.ts`): cache the keypair, + `mintRelayJwt(env, aud, lxm)` via `createServiceJwt({ keypair, issuer: + env.RELAY_DID, audience: aud, lxm, expiresIn: 60 })`. +- **`handleWellKnownDid`** (well-known.ts): add the public key as a + `verificationMethod` (`id: '#atproto'`, `type: 'Multikey'`, controller = + RELAY_DID). App X resolves this via `did:web` to verify. + +## The app registry (single source for trusted + catalog + callback) + +Replace `src/lib/trusted.ts` with `src/lib/apps.ts`: + +```ts +export interface RegisteredApp { + did: Did; + title: string; + description?: string; + iconUrl?: string; + /** Where the relay POSTs subscriber callbacks. Omit → no callback for this app. */ + callbackUrl?: string; // e.g. 'https://example.atmo.pub' (→ /xrpc/… appended) + /** Auto-grant at requestPermission (the old TRUSTED_SENDERS behaviour). */ + trusted?: boolean; +} +export const APPS: readonly RegisteredApp[] = [/* … */]; + +export const isTrustedSender = (did: Did) => APPS.some(a => a.did === did && a.trusted); +export const callbackAppFor = (did: Did) => APPS.find(a => a.did === did && a.callbackUrl); +export const appCatalog = () => APPS.map(({ did, title, description, iconUrl }) => + ({ did, title, description, iconUrl })); +``` + +(`requestPermission.ts` already imports `isTrustedSender` — unchanged.) +For v1 the `callbackUrl` is hardcoded; later it could be resolved from App X's +DID-doc `serviceEndpoint` so only the DID is config. + +## The callback contract + +New **published** lexicon `packages/lexicons/lexicons/pub/atmo/notify/subscriberChanged.json` +(a procedure App X implements; served via `well-known.ts` like send/requestPermission): + +```jsonc +{ + "lexicon": 1, + "id": "pub.atmo.notify.subscriberChanged", + "defs": { "main": { + "type": "procedure", + "description": "Relay-authenticated. The relay tells an app that a user's notification subscription to it changed (enabled/disabled). iss is the relay DID.", + "input": { "encoding": "application/json", "schema": { + "type": "object", + "required": ["recipient", "enabled"], + "properties": { + "recipient": { "type": "string", "format": "did" }, + "enabled": { "type": "boolean" }, + "changedAt": { "type": "string", "format": "datetime", + "description": "When the grant changed; lets the app resolve out-of-order callbacks." } + } + }}, + "output": { "encoding": "application/json", "schema": { + "type": "object", "properties": { "ok": { "type": "boolean" } } } } + }} +} +``` + +Run `pnpm --filter @atmo/notifs-lexicons generate` after adding it; add it to the +`LEXICONS` map in `well-known.ts`. + +## Changes by package + +### `apps/relay` +- `src/lib/apps.ts` (replaces `trusted.ts`, above). +- `src/auth/relay-signer.ts` + `scripts/generate-relay-key.js` + `RELAY_PRIVATE_KEY` + in `Env`; `verificationMethod` in `handleWellKnownDid`. +- `src/env.ts` `DispatchJob` += variant (note: **no `channel`** field, unlike the + others): + ```ts + | { kind: 'subscriberChanged'; sender: string; recipient: string; + enabled: boolean; changedAt: string } + ``` +- `src/delivery/dispatcher.ts`: + - `dispatch()`: `if (job.kind === 'subscriberChanged') return sendSubscriberCallback(env, job);` + - `reapIfDead()`: guard `job.kind` first (it dereferences `job.channel`). + Treat a `4xx` from the app as permanent (ack/drop); everything else retries + via Queues. + - `sendSubscriberCallback`: look up `callbackAppFor(job.sender)`; `mintRelayJwt(env, + job.sender, 'pub.atmo.notify.subscriberChanged')`; `POST ${callbackUrl}/xrpc/pub.atmo.notify.subscriberChanged` + with `Authorization: Bearer …` and body `{ recipient, enabled, changedAt }`; + throw on non-2xx. +- `src/rpc/ops.ts`: in `grant` (after upsert) and `revoke` (only when `revoked`), + if `callbackAppFor(input.sender)`, `await env.DISPATCH_QUEUE.send({ kind: + 'subscriberChanged', … enabled })`. Add binding op `listApps()` → `appCatalog()`. + *(Optional: also fire from the requestPermission auto-allow + Telegram approve + paths for completeness; not required since those apps already know the user.)* +- `src/rpc/entrypoint.ts` + `packages/lexicons/src/rpc.ts`: add + `listApps(): Promise` to `NotifsRpc` (+ an `AppInfo` interface: + `{ did; title; description?; iconUrl? }`). Second binding method with no leading + `did` (catalog is static), alongside `verifyAppLogin`. +- `test/subscriberChanged.test.ts`: grant→queues a job for a registered app (and + *not* for an unregistered one); dispatcher mints a relay JWT and POSTs the + expected bearer + body (mock fetch); revoke→`enabled:false`. + +### `apps/web` +- `src/lib/server/relay.ts` `relayFor`: expose `listApps: () => svc.listApps()`. +- An **"Apps you can enable"** view (on the Apps screen, or a Discover tab): + render `listApps()` × `listGrants()` → a toggle per app. On → existing `grant({ + sender })`; off → existing `revoke({ sender })`. Works for lite + OAuth sessions. + +### `apps/example-sender` (reference receiver) +- **Inbound endpoint** `src/routes/xrpc/[method]/+server.ts` handling + `pub.atmo.notify.subscriberChanged`: + - Verify with a `ServiceJwtVerifier` (`acceptAudiences: [SENDER_DID]`, web+plc + resolver, `lxm: 'pub.atmo.notify.subscriberChanged'`). + - **Assert `issuer === RELAY_DID`** — accept callbacks *only* from the relay. + - Record `{ recipient, enabled }` (the demo can store in KV / log; a real app + persists it and starts/stops sending). +- No new OAuth scope (this is inbound, app-key-verified — not user OAuth). + +## Security recap (carry to docs) +- App X must verify **iss == relay DID** in addition to sig/aud/lxm — otherwise + anyone could mint an aud=AppX token. This is the critical check. +- Idempotent state callbacks → safe to retry; no replay store needed. Include + `changedAt` so rapid on/off/on toggles can be ordered by the receiver. +- Relay key compromise = an attacker can forge enrollments — bounded, since a + compromised relay already controls all grants/data. Rotate via the DID doc. +- Delivery is best-effort with Queue retries; for a hard guarantee add a pull + reconcile method (`listGrantedRecipients`, app-DID-authed) later so apps can + catch missed callbacks. Out of scope for v1. + +## Build checklist — DONE +- [x] relay: keypair (`relay:keygen` + `RELAY_PRIVATE_KEY` + Env), `relay-signer.ts`, did.json key +- [x] lexicon `subscriberChanged.json` + generated + served in well-known +- [x] `apps.ts` registry (replaced trusted.ts); `listApps` on binding + contract +- [x] `DispatchJob` variant + dispatcher `sendSubscriberCallback` + reap guard (4xx drop / 5xx retry) +- [x] `ops.grant`/`ops.revoke` enqueue callback for registered apps +- [x] web: `listApps` in relayFor + "Apps you can enable" UI (Enable = grant) +- [x] example-sender: inbound `routes/xrpc/[method]` verify-and-record endpoint (asserts iss==relay) + page list +- [x] tests (`test/subscriberChanged.test.ts`, relay 60); monorepo `pnpm -r build` green + +**Before prod:** run `relay:keygen`, set the `RELAY_PRIVATE_KEY` secret, paste the +public multikey into `well-known.ts` (a dev key is committed there now), and set +each app's real `callbackUrl` in `apps.ts`. +``` diff --git a/docs/MANAGEMENT-AUTH.md b/docs/MANAGEMENT-AUTH.md new file mode 100644 index 0000000..e72ffba --- /dev/null +++ b/docs/MANAGEMENT-AUTH.md @@ -0,0 +1,245 @@ +# Notification management auth — spec + +How an app (first-party *or* third-party) reads and changes a user's notification +configuration on a relay. Companion to `CROSS-APP-AUTH.md` (inbound user login) +and `ENABLE-FROM-WEB.md` (outbound subscriber callbacks). + +## Scope — what this is and isn't + +**Out of scope (unchanged, deliberately open):** `requestPermission` and `send`. +Any atproto app can ask a user for permission and, once granted, send. That +federated layer is gated only by the per-(user, sender) **grant**. Nothing here +changes it. + +**In scope:** everything `apps/web` (atmo.pub) does today over the Cloudflare +**service binding** — read/modify a user's grants, pending requests, channels, +devices, routing, account defaults, inbox. Two pressures force this off the +binding: + +1. A relay's management UI (atmo.pub's `web/`, or an app's own dashboard) may not + run on Cloudflare, so it can't use a service binding. +2. We want **third-party dashboards** — alternative clients that manage a user's + whole notification account, opted into by the user. + +The relay itself can stay Cloudflare-only for now; the binding remains as a +fast path. This spec adds a **portable, app-authenticated XRPC surface** beside +it, both delegating to the same `apps/relay/src/rpc/ops.ts`. + +## Two facts that constrain everything + +1. **A user service-auth JWT carries no app identity.** `com.atproto.server.getServiceAuth` + mints `{ iss = user DID, aud, lxm, exp, jti }` — there is no `client_id`. The + relay's verifier surfaces exactly `{ issuer, audience, lxm }`. So you **cannot** + tell *which app* asked from a user token; to authenticate an app you need a + credential the app controls (its own DID-signed service-auth — standard atproto + inter-service auth, `iss = app DID`). +2. **A service-auth token consents to a *method*, not its *parameters*.** A user + token for `lxm = …revoke` says "the user authorized *a* revoke," not "revoke + app Y." An untrusted intermediary could retarget it. This is *the* reason + whole-account power can't rest on per-call user tokens alone. + +## The model + +Two orthogonal axes. + +### Axis 1 — authorization: what may the caller touch? + +A capability per `(user, app)`: + +- **`send`** — base grant only (the open federated layer). No management. +- **`self`** — *only this app's own slice*: its routing, its inbox, its app-wide + route (the dual-auth federated methods that already exist). **Read and write + split here:** *reading* the own slice (`getRouting`-self, `listNotifications`-self) + is open to any granted app by default — it's their own relationship data — + while *writing* (`setRouting`, `setAppRouting`, `markRead`, `revokeSelf`, + `muteSelf`) needs admission via the self-write policy below. +- **`full` (manager)** — whole account: all notifications, all apps, all channels, + devices, account default, auto-allow, pending. Everything `web/` does. **No + read/write split** — reading the full account exposes every channel (email, + phone, telegram) and the complete app graph, which is as sensitive as writing, + so there is no "read-only full" tier. + +**Invariant (never toggleable): `self` is always own-slice.** Sender is taken +from the authenticated app token, never the request body. No policy, allowlist, +or flag may widen a `self` app beyond its own slice. The knobs below govern +*admission*, never *scope*. + +### Axis 2 — authentication: how is the user established? + +- **Vouch** — app token (`iss = app DID`) + the user DID in the body, **no user + token**. The app asserts "I act for this user." Only honored when the app has a + *standing designation* for that user (below) — the designation **is** the + consent. Works for **lite sessions** (magic-link), since no user token is needed. +- **Dual-auth** — app token + a fresh user token (`iss = user DID`, same `lxm`). + Proves live user presence per call. Needed when there's no standing designation. + The user DID is taken from the user token's `iss` (never trusted from the body). + **Cannot** ride a lite session (there's no token to present). +- **Binding** — the Cloudflare service binding is a transport-level vouch for the + first-party relay UI. Equivalent trust to a relay-wide manager; kept as a fast + path. + +### The gate — who reaches `self` / `full`? + +Designation comes from two places: + +- **Relay-wide** (operator-controlled): a small allowlist of first-party apps that + may vouch for *any* user. This is atmo.pub itself (the binding today). +- **Per-user** (user-controlled): the user designates "app Z may manage my + notifications," at `self` or `full`. This is the per-grant capability. + +And relay-level **policies** for the *open* end of `self` (granted apps with no +per-user designation, acting with dual-auth). Reads and writes are governed +separately; each takes `off | relay-allowlist | user-allowlist | open`: + +- **`MANAGEMENT_SELF_READ_POLICY`** — default **`open`** (reading your own slice is + low-risk; bounded by self-scope, a user token, and an existing grant). +- **`MANAGEMENT_SELF_WRITE_POLICY`** — default **`user-allowlist`** (writes can + escalate routing, so opt-in). `off` disables undesignated self-writes; `open` + allows any granted app with a user token. + +A per-user `self`/`full` designation always satisfies both (a designated app may +read and write its slice, and may vouch). `full` is **always** designation-gated +(relay-wide or per-user) — there is no "open full management"; OAuth scope alone +never unlocks it. + +## Decision table + +| Caller for `(user, app)` | May touch | Vouch (lite-session OK) | Dual-auth | +|---|---|---|---| +| First-party (relay-wide manager) / binding | whole account | ✅ | ✅ | +| Per-user `full` manager | whole account | ✅ | ✅ | +| Per-user `self` app | own slice | ✅ | ✅ | +| Granted, undesignated, policy=`open`/allowed | own slice | ❌ (no standing consent) | ✅ | +| Granted, undesignated, policy=`off`/excluded | nothing (mgmt) | ❌ | ❌ | + +Rule of thumb: **vouch needs standing designation; dual-auth needs a real +session; whole-account needs manager status.** + +## Per-method capability + +Each `ops.*` declares the capability it needs. `self` methods take their sender +from the app token (never the body); `full` methods accept any target. + +| Capability | Methods | +|---|---| +| **`self`-read** | `getRouting` (own slice), `listNotifications` (own) | +| **`self`-write** | `setRouting`, `setAppRouting`, `markRead` (own), `revokeSelf`, `muteSelf` | +| **`full`-read** | `listGrants`, `listPending`, `listChannels`, `getSettings`, `listDevices`, `getRouting` (full config), `listNotifications` (all) | +| **`full`-write** | `grant`, `revoke` (any), `denyPending`, `muteGrant` (any), `linkChannel`, `unlinkChannel`, `updateSettings`, `registerWebPush`, `unregisterWebPush`, `renameDevice`, `setDefaultRoute` | +| **infrastructure** (not user-data-scoped) | `verifyAppLogin` (token is self-authorizing), `listApps` (static catalog) | + +Notes: +- `getRouting` / `listNotifications` exist at **both** scopes — they're distinct + lexicons/handlers: the `self` ones return only the calling app's slice (already + built); the `full` ones return the whole account. +- `revokeSelf` / `muteSelf` are **new** `self`-write methods so an app can let the + user turn it off / mute it from inside the app (target is implicitly the caller — + safe under fact #2). Whole-account `revoke`/`muteGrant` (any target) stay `full`. +- `registerWebPush` is `full`: a device belongs to the user, not to one app, so + registering it is a dashboard action. + +## Why third-party `full` is safe despite fact #2 + +The param-consent gap only bites when you rely on *per-call consent* to constrain +an app you didn't broadly trust. A `full` manager is a **deliberate broad +designation** — the same trust the user already places in atmo.pub, or that you'd +place in an email client. A malicious designated manager is "you picked a bad +manager," not a protocol hole. Undesignated apps never get whole-account, so the +gap can't be exploited there. + +## Setting designations (UX) + +No new machinery — reuse what exists: + +- **In the dashboard** (implemented) — atmo.pub's app detail page has a + "Management access" selector (`none → self → full`). Removing a manager revokes + standing vouch immediately. +- **Magic-link bounce** — an app deep-links the user into atmo.pub (lite session, + first-party), they flip the selector, and the app manages from then on. Reuses + `CROSS-APP-AUTH.md`. + +(A permission-time `requestPermission.requestsManagement` flag was considered and +**dropped** — the dashboard toggle + magic-link bounce cover it without +complicating the grant flow.) + +## Data & config + +- **Per-grant capability:** add `manage TEXT NOT NULL DEFAULT 'none'` + (`'none' | 'self' | 'full'`) to the grants row — sits beside `muted`, mirrors + the account-level `auto_allow` pattern. This is the relay-local source of truth. +- **Relay-wide managers:** the app registry from `ENABLE-FROM-WEB.md` + (`src/lib/apps.ts`) gains `manager?: boolean` (relay-wide `full`). +- **Self policies:** `MANAGEMENT_SELF_READ_POLICY` (default `open`) and + `MANAGEMENT_SELF_WRITE_POLICY` (default `user-allowlist`) env vars. + +## Portability & audit (manager records) — v1 vs later + +**v1: relay-local.** Designations live in the `manage` column. Simple, private +(nobody can see who your managers are), and needs no repo-write scope. + +**Later (opt-in): repo records.** Mirror designations as +`pub.atmo.notify.manager` records (`{ app, level, createdAt }`) in the user's PDS +repo, so they're **portable** (travel if the user changes relays) and +**auditable** (one repo-native list of who can manage you). Tradeoffs that keep +this out of v1: repo records are world-readable (they leak which manager apps you +use), and writing them needs repo-write OAuth at designation time. When added, the +relay treats the repo as an additional source the user opts into — same +relay-local-now / repo-portable-later shape as the subscriber sync in +`ENABLE-FROM-WEB.md`. + +## Wire protocol + +- **`full` is a single envelope, `pub.atmo.notify.manage`** (chosen over ~20 + per-method lexicons — `full` has no read/write split and a manager mints one + token, so an envelope is the right grain). Input `{ method, params?, userToken?, + did? }`; a dispatch map routes `method` to the same `ops.*` the binding uses. + `self` methods stay as their own lexicons (they need precise input schemas and + the read/write split). +- A `verifyManagementCall(env, request, input, { lxm, need: 'self'|'full' })` helper: + 1. `verifySenderRequest` → app DID (always present). + 2. If `input.userToken` present → `verifyServiceToken` → user DID (must match any + body DID); else expect a vouched body user DID. + 3. Resolve capability for `(userDID, appDID)` from relay-wide managers + + per-grant `manage` + self policy. + 4. Admit only if capability ≥ `need`, and vouch only with standing designation. + Then call the same `ops.*(env, userDID, input)`. +- **Binding stays** as the first-party fast path (same `ops`); the XRPC surface is + the portable path for off-Cloudflare and third-party dashboards. +- **Optional shared-secret fallback** (`MANAGEMENT_SHARED_SECRET` + DID) for a + non-atproto management backend that can't mint a DID-signed token. Strictly + weaker than DID-auth (symmetric, unrotatable, no audit); don't build until + needed. + +## Security recap + +- Authenticate the **app** by its DID-signed service-auth (`iss`), gate by DID — + never by `client_id` (absent from the token) and never inferred from a user token. +- `full` requires **manager designation**; never reachable by scope or per-call + consent alone. +- `self` is **own-slice forever** — sender from the app token, not the body. +- **Vouch** (no user token) demands standing designation; that's also the *only* + management path that works for lite sessions. Undesignated apps need a real + session (dual-auth). +- Manager-key compromise = an attacker manages that manager's users — bounded and + rotatable via the DID doc; the same exposure atmo.pub already carries. + +## Build checklist + +- [x] grants: `manage` column (`0008`); `apps.ts` `manage?: 'self'|'full'` + `relayManageFor`; + `MANAGEMENT_SELF_READ_POLICY` + `MANAGEMENT_SELF_WRITE_POLICY` env *(Slice 1)* +- [x] `verifyManagementCall` helper (app-auth + user-token/vouch + capability resolution) *(Slice 1)* +- [x] new `self`-write ops `revokeSelf` + `muteSelf`; retrofit the 4 self methods onto the helper *(Slice 1)* +- [x] `full` surface: the `pub.atmo.notify.manage` envelope → `ops.*` (binding kept as fast path) *(Slice 2)* +- [x] tests: capability matrix (self read/write/designated; full vouch + dual-auth + unknown method) *(Slices 1–2)* +- [x] dashboard: per-app capability control on the app detail page (`manage` on + `RoutingApp` + `setGrantManage` binding/command + selector UI) *(Slice 3)* +- [~] dashboard: dedicated "your managers" view (list apps where `manage` != none) — optional; per-app control above already covers designation +- [—] `requestPermission` optional `requestsManagement` — **dropped** (dashboard toggle + magic-link bounce suffice) +- [ ] off-Cloudflare `verifyAppLogin` over XRPC (manager-app-authed) for cross-app login *(follow-up)* + +## Deferred to v2 + +- **Repo-record manager designations** (portability + audit) — see *Portability & + audit* above; v1 is relay-local. +- **Shared-secret management fallback** for non-atproto backends — DID-auth covers + every atproto app; add only if such a backend appears. diff --git a/REDESIGN.md b/docs/REDESIGN.md similarity index 100% rename from REDESIGN.md rename to docs/REDESIGN.md diff --git a/SELF-HOSTING.md b/docs/SELF-HOSTING.md similarity index 100% rename from SELF-HOSTING.md rename to docs/SELF-HOSTING.md -- 2.51.2