From b242c10f24349de274476c5645233bd9edbefd9c Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sun, 24 May 2026 22:06:33 +0200 Subject: [PATCH] add bluesky dms, add emails, refactor, lots of other changes --- apps/homepage/static/llms.txt | 20 +- apps/relay/migrations/0001_init.sql | 112 +++- .../migrations/0002_request_metadata.sql | 12 - apps/relay/migrations/0003_push.sql | 13 - apps/relay/migrations/0004_inbox.sql | 17 - apps/relay/migrations/0005_routing.sql | 28 - .../migrations/0006_app_routing_autoallow.sql | 18 - apps/relay/migrations/0007_push_labels.sql | 3 - apps/relay/migrations/0008_manage.sql | 6 - apps/relay/migrations/0009_email.sql | 10 - apps/relay/src/auth/management.ts | 35 +- apps/relay/src/db/queries.ts | 432 ++++++------ apps/relay/src/delivery/bluesky-dm.ts | 122 ++++ apps/relay/src/delivery/dispatcher.ts | 50 +- apps/relay/src/delivery/webhook.ts | 49 ++ apps/relay/src/env.ts | 27 +- apps/relay/src/lib/ids.ts | 9 + apps/relay/src/rpc/entrypoint.ts | 45 +- apps/relay/src/rpc/ops.ts | 275 ++++++-- apps/relay/src/telegram/callbacks.ts | 2 +- apps/relay/src/telegram/commands.ts | 27 +- apps/relay/src/xrpc/getRouting.ts | 37 +- apps/relay/src/xrpc/manage.ts | 19 +- apps/relay/src/xrpc/requestPermission.ts | 7 +- apps/relay/src/xrpc/send.ts | 174 ++--- apps/relay/test/dm-channel.test.ts | 57 ++ apps/relay/test/email-channel.test.ts | 70 +- apps/relay/test/helpers.ts | 100 +++ apps/relay/test/inbox.test.ts | 23 + apps/relay/test/management-auth.test.ts | 59 +- apps/relay/test/management-full.test.ts | 15 +- apps/relay/test/send.test.ts | 203 ++++-- apps/relay/test/telegram-link.test.ts | 36 +- apps/relay/test/webhook-channel.test.ts | 89 +++ apps/relay/test/webhook.test.ts | 64 ++ apps/relay/test/webpush.test.ts | 33 +- .../lib/components/ChannelRoutePicker.svelte | 246 ++++++- apps/web/src/lib/components/Icon.svelte | 5 + apps/web/src/lib/components/RouteChip.svelte | 2 + apps/web/src/lib/push.ts | 32 +- apps/web/src/lib/remote/notifs.remote.ts | 65 +- apps/web/src/lib/routes.ts | 28 +- apps/web/src/lib/server/relay.ts | 18 +- apps/web/src/routes/(app)/apps/+page.svelte | 126 +--- .../(app)/apps/[sender]/+page.server.ts | 8 +- .../routes/(app)/apps/[sender]/+page.svelte | 174 ++++- .../src/routes/(app)/settings/+page.server.ts | 21 +- .../src/routes/(app)/settings/+page.svelte | 616 +++++++++++++----- docs/MANAGEMENT-AUTH.md | 38 +- .../lexicons/pub/atmo/notify/getRouting.json | 28 +- .../lexicons/pub/atmo/notify/setRouting.json | 4 +- packages/lexicons/src/rpc.ts | 232 +++++-- 52 files changed, 2877 insertions(+), 1064 deletions(-) delete mode 100644 apps/relay/migrations/0002_request_metadata.sql delete mode 100644 apps/relay/migrations/0003_push.sql delete mode 100644 apps/relay/migrations/0004_inbox.sql delete mode 100644 apps/relay/migrations/0005_routing.sql delete mode 100644 apps/relay/migrations/0006_app_routing_autoallow.sql delete mode 100644 apps/relay/migrations/0007_push_labels.sql delete mode 100644 apps/relay/migrations/0008_manage.sql delete mode 100644 apps/relay/migrations/0009_email.sql create mode 100644 apps/relay/src/delivery/bluesky-dm.ts create mode 100644 apps/relay/src/delivery/webhook.ts create mode 100644 apps/relay/test/dm-channel.test.ts create mode 100644 apps/relay/test/webhook-channel.test.ts create mode 100644 apps/relay/test/webhook.test.ts diff --git a/apps/homepage/static/llms.txt b/apps/homepage/static/llms.txt index 768b873..62aaca3 100644 --- a/apps/homepage/static/llms.txt +++ b/apps/homepage/static/llms.txt @@ -147,17 +147,23 @@ and writes additionally require the relay to permit it (atmo.pub default: the us must have granted your app "manage its own settings" in the dashboard; reads are open). On a relay you run yourself, you can allow your own DID by default. - A **route** is a channel set, encoded as a `+`-joined string of `push`/`telegram`/`email` - (e.g. `"push+email"`), `"off"` for none, plus the inherit sentinels `"default"` - (app-wide → account default) and `"app"` (category → app-wide). + A **route** is a `+`-joined set of channel tokens, e.g. `"push+email"`, `"off"` for + none, plus the inherit sentinels `"default"` (app-wide → account default) and `"app"` + (category → app-wide). A token is either a bare channel — `push`/`telegram`/`email`, + meaning all of that channel's instances — or a channel narrowed to one delivery + instance as `channel:` (e.g. `"push:a1b2c3"` = one specific device). Users pick + instance ids in the dashboard; an unknown id simply delivers to nothing. - `setRouting` — body `{ userToken, route?, categories? }` - - `route`: app-wide route — a `+`-joined channel set, `"off"`, or `"default"`. Omit to leave unchanged. - - `categories`: `[{ "id": "", "route": "" | "off" | "app" }]` + - `route`: app-wide route — a `+`-joined token set, `"off"`, or `"default"`. Omit to leave unchanged. + - `categories`: `[{ "id": "", "route": "" | "off" | "app" }]` - → `{ "ok": true }` - `getRouting` — body `{ userToken }` → `{ "route": "", "defaultRoute": "", - "categories": [{ "id", "description?", "route" }] }` - (each route is a `+`-joined channel set / `off` / sentinel as above) + "categories": [{ "id", "description?", "route" }], + "targets": [{ "type", "id", "label" }] }` + (each route is a `+`-joined token set / `off` / sentinel as above. `targets` is the + user's deliverable instances — render a picker and use a target's `id` in a + `channel:` token; labels are privacy-safe, never a raw email/handle.) - `listNotifications` — body `{ userToken, limit?(1–100, default 50), cursor? }` → `{ "notifications": [{ "id","title","body","uri?","category?", "createdAt"(ISO datetime),"read"(bool),"delivered?"(int) }], "cursor?" }` diff --git a/apps/relay/migrations/0001_init.sql b/apps/relay/migrations/0001_init.sql index de92075..55797e1 100644 --- a/apps/relay/migrations/0001_init.sql +++ b/apps/relay/migrations/0001_init.sql @@ -1,31 +1,58 @@ --- Initial schema for the atproto notification relay. --- All timestamps are unix milliseconds (Date.now()). +-- Canonical schema for the atproto notification relay. +-- All timestamps are unix milliseconds (Date.now()); booleans are 0/1 integers. CREATE TABLE users ( did TEXT PRIMARY KEY, created_at INTEGER NOT NULL, - notify_pending_via_telegram INTEGER NOT NULL DEFAULT 0 + notify_pending_via_telegram INTEGER NOT NULL DEFAULT 0, + -- Account-default alert route: a '+'-joined token set (see lexicons/rpc.ts). + default_route TEXT NOT NULL DEFAULT 'push', + -- Incoming-request policy: 'all' | 'trusted' (TRUSTED_SENDERS) | 'none'. + auto_allow TEXT NOT NULL DEFAULT 'trusted' ); -CREATE TABLE channels ( +-- Unified delivery targets. Every place a notification can be delivered — a web +-- push device, a Telegram chat, or a verified email — is one row, discriminated +-- by `channel` ('push' | 'telegram' | 'email'). +-- * `ref` the channel's natural dedup key (push endpoint / chat id / email +-- address). Globally unique per channel. +-- * `id` a stable, opaque token a route references to target this one +-- instance (e.g. one of several push devices). Survives re-link. +-- * `label` display name (device name / Telegram username / email address). +-- * `named` 1 once the user renames it — so the auto label (which for email/ +-- telegram is PII) is never exposed to apps unless user-chosen. +-- * `verified` gates delivery: email starts 0 until a code is confirmed; +-- push/telegram are created already verified (1). +-- * `config` channel-specific JSON: push {p256dh, auth}; email {code, expires} +-- while unverified (cleared once verified); telegram {}. +CREATE TABLE delivery_targets ( + id TEXT PRIMARY KEY, did TEXT NOT NULL, - platform TEXT NOT NULL, - platform_user_id TEXT NOT NULL, - display_name TEXT, - linked_at INTEGER NOT NULL, - PRIMARY KEY (did, platform) + channel TEXT NOT NULL, + ref TEXT NOT NULL, + label TEXT, + named INTEGER NOT NULL DEFAULT 0, + verified INTEGER NOT NULL DEFAULT 1, + config TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + UNIQUE (channel, ref) ); -CREATE UNIQUE INDEX channels_by_platform_user ON channels (platform, platform_user_id); +CREATE INDEX delivery_targets_by_did ON delivery_targets (did); +-- Short-lived tokens for the Telegram linking handshake (deep-link → /start). +-- `label` is an optional user-chosen name carried from the web form through the +-- round-trip, applied to the delivery target on completion (named = 1). CREATE TABLE link_tokens ( token TEXT PRIMARY KEY, did TEXT NOT NULL, platform TEXT NOT NULL, + label TEXT, expires_at INTEGER NOT NULL ); CREATE INDEX link_tokens_by_did ON link_tokens (did); CREATE INDEX link_tokens_by_expires ON link_tokens (expires_at); +-- Cached Bluesky profiles for senders (best-effort display metadata). CREATE TABLE senders ( did TEXT PRIMARY KEY, handle TEXT, @@ -34,11 +61,16 @@ CREATE TABLE senders ( profile_fetched_at INTEGER ); +-- A sender's request to notify a recipient, awaiting approval. The requester +-- supplies user-facing display metadata (title/description/icon), copied onto the +-- grant on approval. CREATE TABLE pending_requests ( id TEXT PRIMARY KEY, recipient_did TEXT NOT NULL, sender_did TEXT NOT NULL, - reason TEXT, + title TEXT, + description TEXT, + icon_url TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, UNIQUE (recipient_did, sender_did) @@ -46,15 +78,24 @@ CREATE TABLE pending_requests ( CREATE INDEX pending_by_recipient ON pending_requests (recipient_did); CREATE INDEX pending_by_expires ON pending_requests (expires_at); +-- An approved (recipient, sender) pair. `manage` is the management capability the +-- user designates for the app: 'none' | 'self' | 'full' (see MANAGEMENT-AUTH.md). +-- New grants default to 'self' (an app may manage its own routing/inbox); the user +-- can downgrade to 'none' or upgrade to 'full' per app. CREATE TABLE grants ( recipient_did TEXT NOT NULL, sender_did TEXT NOT NULL, granted_at INTEGER NOT NULL, muted INTEGER NOT NULL DEFAULT 0, + title TEXT, + description TEXT, + icon_url TEXT, + manage TEXT NOT NULL DEFAULT 'self', PRIMARY KEY (recipient_did, sender_did) ); CREATE INDEX grants_by_recipient ON grants (recipient_did); +-- One row per accepted `send`: how many alert channels it fanned out to. CREATE TABLE delivery_log ( id TEXT PRIMARY KEY, recipient_did TEXT NOT NULL, @@ -64,3 +105,52 @@ CREATE TABLE delivery_log ( created_at INTEGER NOT NULL ); CREATE INDEX delivery_by_recipient ON delivery_log (recipient_did, created_at DESC); + +-- Inbox: the canonical history of every accepted `send`. Routing only decides +-- which alert channels also fire; everything lands here regardless. `read_at` +-- null = unread; `actors` is a JSON array of handles/DIDs. +CREATE TABLE notifications ( + id TEXT PRIMARY KEY, + recipient_did TEXT NOT NULL, + sender_did TEXT NOT NULL, + category TEXT, + title TEXT NOT NULL, + body TEXT NOT NULL, + uri TEXT, + actors TEXT, + created_at INTEGER NOT NULL, + read_at INTEGER +); +CREATE INDEX notifications_by_recipient ON notifications (recipient_did, created_at DESC); + +-- Categories discovered from `send` (per recipient+sender), so the routing UI can +-- list them with a description. +CREATE TABLE app_categories ( + recipient_did TEXT NOT NULL, + sender_did TEXT NOT NULL, + category TEXT NOT NULL, + description TEXT, + last_seen INTEGER NOT NULL, + PRIMARY KEY (recipient_did, sender_did, category) +); +CREATE INDEX app_categories_by_pair ON app_categories (recipient_did, sender_did); + +-- Routing, resolved bottom-up per notification: +-- category (routing) → app (app_routing) → account default (users.default_route) +-- A missing row at a level means "inherit the next level up". Route values are +-- '+'-joined token sets / 'off' (see lexicons/rpc.ts). +CREATE TABLE routing ( + recipient_did TEXT NOT NULL, + sender_did TEXT NOT NULL, + category TEXT NOT NULL, + route TEXT NOT NULL, + PRIMARY KEY (recipient_did, sender_did, category) +); +CREATE INDEX routing_by_recipient ON routing (recipient_did); + +CREATE TABLE app_routing ( + recipient_did TEXT NOT NULL, + sender_did TEXT NOT NULL, + route TEXT NOT NULL, + PRIMARY KEY (recipient_did, sender_did) +); diff --git a/apps/relay/migrations/0002_request_metadata.sql b/apps/relay/migrations/0002_request_metadata.sql deleted file mode 100644 index f8247e7..0000000 --- a/apps/relay/migrations/0002_request_metadata.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Revised requestPermission: the requester supplies user-facing display metadata --- (title/description/icon) for the sender, stored per pending request and copied --- onto the grant on approval. The legacy `pending_requests.reason` column stays --- (nullable, no longer written/read) to avoid a risky DROP COLUMN. - -ALTER TABLE pending_requests ADD COLUMN title TEXT; -ALTER TABLE pending_requests ADD COLUMN description TEXT; -ALTER TABLE pending_requests ADD COLUMN icon_url TEXT; - -ALTER TABLE grants ADD COLUMN title TEXT; -ALTER TABLE grants ADD COLUMN description TEXT; -ALTER TABLE grants ADD COLUMN icon_url TEXT; diff --git a/apps/relay/migrations/0003_push.sql b/apps/relay/migrations/0003_push.sql deleted file mode 100644 index 86d0095..0000000 --- a/apps/relay/migrations/0003_push.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Web push subscriptions (Phase 2). One row per browser PushSubscription; a --- user can have several (multiple devices/browsers). Telegram channels stay in --- the `channels` table; web push lives here because keys + endpoint don't fit --- the (did, platform) shape and there are many per user. - -CREATE TABLE push_subscriptions ( - endpoint TEXT PRIMARY KEY, -- unique per subscription; the push service URL - did TEXT NOT NULL, - p256dh TEXT NOT NULL, -- client public key (base64url, uncompressed point) - auth TEXT NOT NULL, -- client auth secret (base64url, 16 bytes) - created_at INTEGER NOT NULL -); -CREATE INDEX push_subs_by_did ON push_subscriptions (did); diff --git a/apps/relay/migrations/0004_inbox.sql b/apps/relay/migrations/0004_inbox.sql deleted file mode 100644 index ccbd018..0000000 --- a/apps/relay/migrations/0004_inbox.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Inbox (Phase 3). Every accepted `send` is recorded here as the canonical --- history; per-category routing (Phase 4) only decides which alert channels also --- fire. `read_at` null = unread. `actors` is a JSON array of handles/DIDs. - -CREATE TABLE notifications ( - id TEXT PRIMARY KEY, - recipient_did TEXT NOT NULL, - sender_did TEXT NOT NULL, - category TEXT, - title TEXT NOT NULL, - body TEXT NOT NULL, - uri TEXT, - actors TEXT, - created_at INTEGER NOT NULL, - read_at INTEGER -); -CREATE INDEX notifications_by_recipient ON notifications (recipient_did, created_at DESC); diff --git a/apps/relay/migrations/0005_routing.sql b/apps/relay/migrations/0005_routing.sql deleted file mode 100644 index 7e9b276..0000000 --- a/apps/relay/migrations/0005_routing.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Per-category routing (Phase 4). --- --- app_categories: categories discovered from `send` (per recipient+sender), so --- the routing UI can list them with a description. routing: per-category alert --- override; absence means "inherit the user's default_route". Route tokens: --- 'push' | 'telegram' | 'push+telegram' | 'off' (everything is in the inbox --- regardless; the token only gates alert channels). - -CREATE TABLE app_categories ( - recipient_did TEXT NOT NULL, - sender_did TEXT NOT NULL, - category TEXT NOT NULL, - description TEXT, - last_seen INTEGER NOT NULL, - PRIMARY KEY (recipient_did, sender_did, category) -); -CREATE INDEX app_categories_by_pair ON app_categories (recipient_did, sender_did); - -CREATE TABLE routing ( - recipient_did TEXT NOT NULL, - sender_did TEXT NOT NULL, - category TEXT NOT NULL, - route TEXT NOT NULL, - PRIMARY KEY (recipient_did, sender_did, category) -); -CREATE INDEX routing_by_recipient ON routing (recipient_did); - -ALTER TABLE users ADD COLUMN default_route TEXT NOT NULL DEFAULT 'push'; diff --git a/apps/relay/migrations/0006_app_routing_autoallow.sql b/apps/relay/migrations/0006_app_routing_autoallow.sql deleted file mode 100644 index a644d1f..0000000 --- a/apps/relay/migrations/0006_app_routing_autoallow.sql +++ /dev/null @@ -1,18 +0,0 @@ --- App-wide routing + auto-allow policy. --- --- Routing is now 3 levels, resolved bottom-up for each notification: --- category (routing table) → app (app_routing) → user default (users.default_route) --- A missing row at a level means "inherit the next level up". So a category with --- no row inherits the app; an app with no row inherits the user default. Tokens --- are concrete alert routes only: 'push' | 'telegram' | 'push+telegram' | 'off'. -CREATE TABLE app_routing ( - recipient_did TEXT NOT NULL, - sender_did TEXT NOT NULL, - route TEXT NOT NULL, - PRIMARY KEY (recipient_did, sender_did) -); - --- Per-user policy for incoming permission requests (gates the requestPermission --- auto-grant): 'all' (auto-grant anyone) | 'trusted' (only TRUSTED_SENDERS) | --- 'none' (always require approval). -ALTER TABLE users ADD COLUMN auto_allow TEXT NOT NULL DEFAULT 'trusted'; diff --git a/apps/relay/migrations/0007_push_labels.sql b/apps/relay/migrations/0007_push_labels.sql deleted file mode 100644 index 0457748..0000000 --- a/apps/relay/migrations/0007_push_labels.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Device labels for web push subscriptions (auto-detected from the User-Agent at --- registration, user-renameable). Null = unnamed (UI shows a fallback). -ALTER TABLE push_subscriptions ADD COLUMN label TEXT; diff --git a/apps/relay/migrations/0008_manage.sql b/apps/relay/migrations/0008_manage.sql deleted file mode 100644 index c7984b9..0000000 --- a/apps/relay/migrations/0008_manage.sql +++ /dev/null @@ -1,6 +0,0 @@ --- 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/migrations/0009_email.sql b/apps/relay/migrations/0009_email.sql deleted file mode 100644 index 43043a6..0000000 --- a/apps/relay/migrations/0009_email.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Email delivery channel: one address per user, verified by a short code emailed --- via comail before the relay will deliver to it. See delivery/email.ts. -CREATE TABLE email_channels ( - recipient_did TEXT PRIMARY KEY, - address TEXT NOT NULL, - verified INTEGER NOT NULL DEFAULT 0, - verify_code TEXT, - verify_expires INTEGER, - created_at INTEGER NOT NULL -); diff --git a/apps/relay/src/auth/management.ts b/apps/relay/src/auth/management.ts index d7063b3..3eb48f9 100644 --- a/apps/relay/src/auth/management.ts +++ b/apps/relay/src/auth/management.ts @@ -1,8 +1,8 @@ // 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). +// - The user is ALWAYS identified by a fresh body `userToken` (dual-auth) — there +// is no standing "vouch" path; every call needs per-call user consent. // - 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'; @@ -61,24 +61,16 @@ export interface ManagementCall { export async function verifyManagementCall( app: AppContext, request: Request, - input: { userToken?: string; did?: string }, + input: { userToken?: 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(); - } + // User identity is ALWAYS proven by a fresh user-issued token (no vouch path), + // so every management call carries per-call user consent. + if (input.userToken === undefined) throw notAuthorized(); + const { did: userDid } = await verifyServiceToken(app.verifier, input.userToken, lxm); if (userDid === appDid) throw notAuthorized(); const { cap, granted } = await resolveCapability(app.env, userDid, appDid); @@ -87,18 +79,15 @@ export async function verifyManagementCall( 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. + // Whole-account always needs a manager designation. 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(); + // self scope: a designation passes; otherwise the relay's open-end policy must + // admit the undesignated app (reads default open, writes default user-allowlist). + if (designated('self')) return { appDid, userDid }; const policy = need.write ? writePolicy(app.env) : readPolicy(app.env); - if (policy !== 'open') throw notAuthorized(); // relay/user allowlists are covered by `designated` + if (policy !== 'open') throw notAuthorized(); return { appDid, userDid }; } diff --git a/apps/relay/src/db/queries.ts b/apps/relay/src/db/queries.ts index cdbb935..e75e4ad 100644 --- a/apps/relay/src/db/queries.ts +++ b/apps/relay/src/db/queries.ts @@ -12,93 +12,15 @@ export interface UserRow { auto_allow: string; } -export interface ChannelRow { - did: Did; - platform: string; - platform_user_id: string; - display_name: string | null; - linked_at: number; -} - export interface LinkTokenRow { token: string; did: Did; platform: string; + /** Optional user-chosen name carried through the linking round-trip. */ + label: string | null; expires_at: number; } -// --- email channel (one verified address per user) ------------------------- - -export interface EmailChannelRow { - recipient_did: Did; - address: string; - verified: number; - verify_code: string | null; - verify_expires: number | null; - created_at: number; -} - -export function getEmailChannel(db: D1Database, did: Did): Promise { - return db - .prepare('SELECT * FROM email_channels WHERE recipient_did = ?') - .bind(did) - .first(); -} - -/** Set (or replace) a user's pending email + verification code (resets verified). */ -export async function upsertEmailChannel( - db: D1Database, - input: { did: Did; address: string; verifyCode: string; verifyExpires: number; createdAt: number }, -): Promise { - await db - .prepare( - `INSERT INTO email_channels (recipient_did, address, verified, verify_code, verify_expires, created_at) - VALUES (?, ?, 0, ?, ?, ?) - ON CONFLICT(recipient_did) DO UPDATE SET - address = excluded.address, - verified = 0, - verify_code = excluded.verify_code, - verify_expires = excluded.verify_expires, - created_at = excluded.created_at`, - ) - .bind(input.did, input.address, input.verifyCode, input.verifyExpires, input.createdAt) - .run(); -} - -/** Mark verified iff the code matches and hasn't expired. Returns true on success. */ -export async function verifyEmailChannel( - db: D1Database, - did: Did, - code: string, - nowMs: number, -): Promise { - const result = await db - .prepare( - `UPDATE email_channels SET verified = 1, verify_code = NULL, verify_expires = NULL - WHERE recipient_did = ? AND verified = 0 AND verify_code = ? AND verify_expires > ?`, - ) - .bind(did, code, nowMs) - .run(); - return changed(result); -} - -export async function deleteEmailChannel(db: D1Database, did: Did): Promise { - const result = await db - .prepare('DELETE FROM email_channels WHERE recipient_did = ?') - .bind(did) - .run(); - return changed(result); -} - -/** The user's verified email address, or null. Used by delivery. */ -export async function getVerifiedEmail(db: D1Database, did: Did): Promise { - const row = await db - .prepare('SELECT address FROM email_channels WHERE recipient_did = ? AND verified = 1') - .bind(did) - .first<{ address: string }>(); - return row?.address ?? null; -} - export interface SenderRow { did: Did; handle: string | null; @@ -172,67 +94,228 @@ export async function setNotifyPending(db: D1Database, did: Did, value: boolean) } // --------------------------------------------------------------------------- -// channels +// delivery_targets (unified push / telegram / email) // --------------------------------------------------------------------------- +// +// One table for every delivery destination, discriminated by `channel`. `ref` is +// the channel's natural dedup key (push endpoint / telegram chat id / email +// address); `id` is the stable token a route uses to target one instance. See +// migrations/0001_init.sql. -export async function listChannelsForDid(db: D1Database, did: Did): Promise { - const { results } = await db - .prepare('SELECT * FROM channels WHERE did = ? ORDER BY linked_at DESC') - .bind(did) - .all(); - return results; +export type DeliveryChannelKind = 'push' | 'telegram' | 'email' | 'dm' | 'webhook'; + +export interface DeliveryTargetRow { + id: string; + did: Did; + channel: string; + ref: string; + label: string | null; + /** 1 once the user renamed it (so the auto label isn't exposed to apps). */ + named: number; + verified: number; + config: string; // channel-specific JSON + created_at: number; } -export function getChannelByPlatformUser( - db: D1Database, - platform: string, - platformUserId: string, -): Promise { - return db - .prepare('SELECT * FROM channels WHERE platform = ? AND platform_user_id = ?') - .bind(platform, platformUserId) - .first(); +/** A delivery target with its `config` JSON parsed into channel-specific fields. */ +export type DeliveryInstance = + | { id: string; channel: 'push'; label: string; verified: boolean; endpoint: string; p256dh: string; auth: string } + | { id: string; channel: 'telegram'; label: string; verified: boolean; chatId: string } + | { id: string; channel: 'email'; label: string; verified: boolean; address: string } + | { id: string; channel: 'dm'; label: string; verified: boolean; recipientDid: string } + | { id: string; channel: 'webhook'; label: string; verified: boolean; url: string }; + +const DEFAULT_LABEL: Record = { + push: 'Unknown device', + telegram: 'Telegram', + email: 'Email', + dm: 'Bluesky DM', + webhook: 'Webhook', +}; + +/** Parse a row into a typed instance, or null for an unknown channel. */ +export function toDeliveryInstance(row: DeliveryTargetRow): DeliveryInstance | null { + const verified = row.verified === 1; + if (row.channel === 'push') { + const cfg = JSON.parse(row.config || '{}') as { p256dh?: string; auth?: string }; + return { + id: row.id, + channel: 'push', + label: row.label ?? DEFAULT_LABEL.push, + verified, + endpoint: row.ref, + p256dh: cfg.p256dh ?? '', + auth: cfg.auth ?? '', + }; + } + if (row.channel === 'telegram') { + return { id: row.id, channel: 'telegram', label: row.label ?? DEFAULT_LABEL.telegram, verified, chatId: row.ref }; + } + if (row.channel === 'email') { + return { id: row.id, channel: 'email', label: row.label ?? row.ref, verified, address: row.ref }; + } + if (row.channel === 'dm') { + // ref = the recipient's own DID (DM to self); always verified on enable. + return { id: row.id, channel: 'dm', label: row.label ?? DEFAULT_LABEL.dm, verified, recipientDid: row.ref }; + } + if (row.channel === 'webhook') { + // The destination URL lives in config (ref is a per-user dedup key, not the URL). + const cfg = JSON.parse(row.config || '{}') as { url?: string }; + return { id: row.id, channel: 'webhook', label: row.label ?? DEFAULT_LABEL.webhook, verified, url: cfg.url ?? '' }; + } + return null; } -export interface UpsertChannelInput { +export interface UpsertDeliveryTargetInput { + id: string; did: Did; - platform: string; - platformUserId: string; - displayName: string | null; - linkedAt: number; + channel: DeliveryChannelKind; + ref: string; + label: string | null; + /** 1 when `label` is a user-chosen name (exposed to apps); 0 for an auto label. */ + named?: boolean; + verified: boolean; + config: Record; + createdAt: number; } /** - * Insert/replace a channel. `INSERT OR REPLACE` also clears any existing row - * that collides on the `(platform, platform_user_id)` unique index, so linking - * a Telegram account that was previously tied to another DID moves it cleanly. + * Insert a target, deduped on the globally-unique (channel, ref). Re-linking the + * same ref — a push re-subscribe, or a Telegram account moving between accounts — + * updates the owner, keys and verified flag but KEEPS the original id, label and + * `named` flag, so routes pinned to that instance survive and a user-renamed + * device keeps its name. (Email enforces a single address per user in ops, not here.) */ -export async function upsertChannel(db: D1Database, input: UpsertChannelInput): Promise { +export async function upsertDeliveryTarget( + db: D1Database, + input: UpsertDeliveryTargetInput, +): Promise { await db .prepare( - 'INSERT OR REPLACE INTO channels (did, platform, platform_user_id, display_name, linked_at) VALUES (?, ?, ?, ?, ?)', + `INSERT INTO delivery_targets (id, did, channel, ref, label, named, verified, config, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(channel, ref) DO UPDATE SET + did = excluded.did, + label = COALESCE(delivery_targets.label, excluded.label), + verified = excluded.verified, + config = excluded.config`, ) - .bind(input.did, input.platform, input.platformUserId, input.displayName, input.linkedAt) + .bind( + input.id, + input.did, + input.channel, + input.ref, + input.label, + input.named ? 1 : 0, + input.verified ? 1 : 0, + JSON.stringify(input.config), + input.createdAt, + ) + .run(); +} + +/** All of a user's delivery targets, newest first. */ +export async function listDeliveryTargets(db: D1Database, did: Did): Promise { + const { results } = await db + .prepare('SELECT * FROM delivery_targets WHERE did = ? ORDER BY created_at DESC') + .bind(did) + .all(); + return results; +} + +/** A target by its globally-unique (channel, ref) — resolves a Telegram chat to its owner. */ +export function getDeliveryTargetByRef( + db: D1Database, + channel: DeliveryChannelKind, + ref: string, +): Promise { + return db + .prepare('SELECT * FROM delivery_targets WHERE channel = ? AND ref = ?') + .bind(channel, ref) + .first(); +} + +export function countDeliveryTargets( + db: D1Database, + did: Did, + channel: DeliveryChannelKind, +): Promise<{ c: number } | null> { + return db + .prepare('SELECT COUNT(*) AS c FROM delivery_targets WHERE did = ? AND channel = ?') + .bind(did, channel) + .first<{ c: number }>(); +} + +/** Rename a target owned by `did`, addressed by its stable id (any channel). */ +export async function renameDeliveryTargetById( + db: D1Database, + did: Did, + id: string, + label: string, +): Promise { + const result = await db + .prepare('UPDATE delivery_targets SET label = ?, named = 1 WHERE did = ? AND id = ?') + .bind(label, did, id) + .run(); + return changed(result); +} + +/** Remove a target owned by `did` by its stable id (any channel). */ +export async function deleteDeliveryTargetById( + db: D1Database, + did: Did, + id: string, +): Promise { + const result = await db + .prepare('DELETE FROM delivery_targets WHERE did = ? AND id = ?') + .bind(did, id) + .run(); + return changed(result); +} + +/** Delete a target owned by `did` by (channel, ref) — the push "disable this + * browser" path, which knows the endpoint but not the id. */ +export async function deleteDeliveryTarget( + db: D1Database, + did: Did, + channel: DeliveryChannelKind, + ref: string, +): Promise { + const result = await db + .prepare('DELETE FROM delivery_targets WHERE did = ? AND channel = ? AND ref = ?') + .bind(did, channel, ref) .run(); + return changed(result); } -export async function deleteChannel(db: D1Database, did: Did, platform: string): Promise { +/** Reap a dead target by (channel, ref), any owner (push 404/410, Telegram block). */ +export async function deleteDeliveryTargetByRef( + db: D1Database, + channel: DeliveryChannelKind, + ref: string, +): Promise { const result = await db - .prepare('DELETE FROM channels WHERE did = ? AND platform = ?') - .bind(did, platform) + .prepare('DELETE FROM delivery_targets WHERE channel = ? AND ref = ?') + .bind(channel, ref) .run(); return changed(result); } -/** Used to reap a channel after Telegram reports the user blocked the bot. */ -export async function deleteChannelByPlatformUser( +/** Mark the user's pending email verified iff the code matches and is unexpired. */ +export async function verifyEmailTarget( db: D1Database, - platform: string, - platformUserId: string, + did: Did, + code: string, + nowMs: number, ): Promise { const result = await db - .prepare('DELETE FROM channels WHERE platform = ? AND platform_user_id = ?') - .bind(platform, platformUserId) + .prepare( + `UPDATE delivery_targets SET verified = 1, config = '{}' + WHERE did = ? AND channel = 'email' AND verified = 0 + AND json_extract(config, '$.code') = ? + AND json_extract(config, '$.expires') > ?`, + ) + .bind(did, code, nowMs) .run(); return changed(result); } @@ -245,13 +328,15 @@ export interface InsertLinkTokenInput { token: string; did: Did; platform: string; + /** Optional user-chosen name, applied to the target on link completion. */ + label?: string | null; expiresAt: number; } export async function insertLinkToken(db: D1Database, input: InsertLinkTokenInput): Promise { await db - .prepare('INSERT INTO link_tokens (token, did, platform, expires_at) VALUES (?, ?, ?, ?)') - .bind(input.token, input.did, input.platform, input.expiresAt) + .prepare('INSERT INTO link_tokens (token, did, platform, label, expires_at) VALUES (?, ?, ?, ?, ?)') + .bind(input.token, input.did, input.platform, input.label ?? null, input.expiresAt) .run(); } @@ -527,104 +612,6 @@ export async function deleteOldDeliveryLog(db: D1Database, beforeMs: number): Pr return result.meta.changes ?? 0; } -// --------------------------------------------------------------------------- -// push_subscriptions (web push) -// --------------------------------------------------------------------------- - -export interface PushSubscriptionRow { - endpoint: string; - did: Did; - p256dh: string; - auth: string; - label: string | null; - created_at: number; -} - -export interface UpsertPushSubscriptionInput { - endpoint: string; - did: Did; - p256dh: string; - auth: string; - label: string | null; - createdAt: number; -} - -/** - * Register a subscription, keyed by its endpoint. Re-subscribing refreshes the - * keys/owner but preserves a previously-set (e.g. user-renamed) label. - */ -export async function upsertPushSubscription( - db: D1Database, - input: UpsertPushSubscriptionInput, -): Promise { - await db - .prepare( - `INSERT INTO push_subscriptions (endpoint, did, p256dh, auth, label, created_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(endpoint) DO UPDATE SET - did = excluded.did, - p256dh = excluded.p256dh, - auth = excluded.auth, - label = COALESCE(push_subscriptions.label, excluded.label)`, - ) - .bind(input.endpoint, input.did, input.p256dh, input.auth, input.label, input.createdAt) - .run(); -} - -/** Rename a subscription owned by `did` (scoped). */ -export async function renamePushSubscription( - db: D1Database, - did: Did, - endpoint: string, - label: string, -): Promise { - const result = await db - .prepare('UPDATE push_subscriptions SET label = ? WHERE did = ? AND endpoint = ?') - .bind(label, did, endpoint) - .run(); - return changed(result); -} - -export async function listPushSubscriptionsForDid( - db: D1Database, - did: Did, -): Promise { - const { results } = await db - .prepare('SELECT * FROM push_subscriptions WHERE did = ? ORDER BY created_at DESC') - .bind(did) - .all(); - return results; -} - -export function countPushSubscriptionsForDid(db: D1Database, did: Did): Promise<{ c: number } | null> { - return db - .prepare('SELECT COUNT(*) AS c FROM push_subscriptions WHERE did = ?') - .bind(did) - .first<{ c: number }>(); -} - -/** Unregister a subscription owned by `did` (scoped so a user can't drop another's). */ -export async function deletePushSubscriptionForDid( - db: D1Database, - did: Did, - endpoint: string, -): Promise { - const result = await db - .prepare('DELETE FROM push_subscriptions WHERE did = ? AND endpoint = ?') - .bind(did, endpoint) - .run(); - return changed(result); -} - -/** Reap a dead subscription by endpoint (push service returned 404/410). */ -export async function deletePushSubscription(db: D1Database, endpoint: string): Promise { - const result = await db - .prepare('DELETE FROM push_subscriptions WHERE endpoint = ?') - .bind(endpoint) - .run(); - return changed(result); -} - // --------------------------------------------------------------------------- // notifications (inbox) // --------------------------------------------------------------------------- @@ -731,6 +718,19 @@ export async function markAllNotificationsRead( return result.meta.changes ?? 0; } +/** Permanently delete all of one sender's notifications to a recipient. Returns the count. */ +export async function deleteNotificationsFromSender( + db: D1Database, + recipientDid: Did, + senderDid: Did, +): Promise { + const result = await db + .prepare('DELETE FROM notifications WHERE recipient_did = ? AND sender_did = ?') + .bind(recipientDid, senderDid) + .run(); + return result.meta.changes ?? 0; +} + // --- App-scoped inbox access (one sender's notifications for one recipient) --- // Used by the federated, dual-authed `listNotifications`/`markRead` so an app // can see and acknowledge only the notifications *it* sent. diff --git a/apps/relay/src/delivery/bluesky-dm.ts b/apps/relay/src/delivery/bluesky-dm.ts new file mode 100644 index 0000000..d4227a9 --- /dev/null +++ b/apps/relay/src/delivery/bluesky-dm.ts @@ -0,0 +1,122 @@ +// Bluesky DM delivery. The relay sends DMs from a configured bot account (app +// password) through the chat service, proxied via the bot's PDS: +// createSession → chat.bsky.convo.getConvoForMembers → chat.bsky.convo.sendMessage +// +// `createSession` is heavily rate-limited (a few per day), so the session is +// cached in KV and routine access-token expiry is handled with `refreshSession` +// (not rate-limited). createSession is only hit on the first send or when the +// refresh token itself is dead. +import type { Env } from '../env'; + +const CHAT_PROXY = 'did:web:api.bsky.chat#bsky_chat'; +const SESSION_KEY = 'bsky-dm:session'; +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; // refresh token lives ~months + +/** Thrown on a non-2xx from createSession/refreshSession or a chat call. */ +export class BlueskyDMError extends Error { + readonly statusCode: number; + constructor(statusCode: number, detail: string) { + super(`bluesky DM failed: ${statusCode} ${detail}`); + this.name = 'BlueskyDMError'; + this.statusCode = statusCode; + } +} + +interface Session { + accessJwt: string; + refreshJwt: string; +} + +function service(env: Env): string { + return env.BLUESKY_DM_SERVICE && env.BLUESKY_DM_SERVICE.length > 0 + ? env.BLUESKY_DM_SERVICE + : 'https://bsky.social'; +} + +function loadSession(env: Env): Promise { + return env.CACHE.get(SESSION_KEY, 'json'); +} + +async function saveSession(env: Env, s: Session): Promise { + await env.CACHE.put(SESSION_KEY, JSON.stringify(s), { expirationTtl: SESSION_TTL_SECONDS }); + return s; +} + +/** App-password login. Rate-limited — only on first send or a dead refresh token. */ +async function createSession(env: Env): Promise { + const res = await fetch(`${service(env)}/xrpc/com.atproto.server.createSession`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + identifier: env.BLUESKY_DM_IDENTIFIER, + password: env.BLUESKY_DM_APP_PASSWORD, + }), + }); + if (!res.ok) throw new BlueskyDMError(res.status, 'createSession'); + const { accessJwt, refreshJwt } = (await res.json()) as Session; + return saveSession(env, { accessJwt, refreshJwt }); +} + +/** Exchange the refresh token for a fresh session. Not createSession-limited. */ +async function refreshSession(env: Env, refreshJwt: string): Promise { + const res = await fetch(`${service(env)}/xrpc/com.atproto.server.refreshSession`, { + method: 'POST', + headers: { authorization: `Bearer ${refreshJwt}` }, + }); + if (!res.ok) throw new BlueskyDMError(res.status, 'refreshSession'); + const { accessJwt, refreshJwt: rotated } = (await res.json()) as Session; + return saveSession(env, { accessJwt, refreshJwt: rotated }); +} + +async function chatCall( + env: Env, + jwt: string, + method: string, + init: { query?: Record; body?: unknown }, +): Promise { + const url = new URL(`${service(env)}/xrpc/${method}`); + for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, v); + const res = await fetch(url, { + method: init.body !== undefined ? 'POST' : 'GET', + headers: { + authorization: `Bearer ${jwt}`, + 'atproto-proxy': CHAT_PROXY, + ...(init.body !== undefined ? { 'content-type': 'application/json' } : {}), + }, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + }); + if (!res.ok) throw new BlueskyDMError(res.status, method); + return (await res.json()) as T; +} + +async function deliver(env: Env, jwt: string, recipientDid: string, text: string): Promise { + const { convo } = await chatCall<{ convo: { id: string } }>( + env, + jwt, + 'chat.bsky.convo.getConvoForMembers', + { query: { members: recipientDid } }, + ); + await chatCall(env, jwt, 'chat.bsky.convo.sendMessage', { + body: { convoId: convo.id, message: { text } }, + }); +} + +/** + * Send a DM from the bot account to `recipientDid`. Throws {@link BlueskyDMError}. + * On a 401 (expired access token) the session is refreshed (or, if the refresh + * token is dead, re-created) and the send retried once. + */ +export async function sendBlueskyDM(env: Env, recipientDid: string, text: string): Promise { + let session = (await loadSession(env)) ?? (await createSession(env)); + try { + await deliver(env, session.accessJwt, recipientDid, text); + } catch (err) { + if (!(err instanceof BlueskyDMError) || err.statusCode !== 401) throw err; + try { + session = await refreshSession(env, session.refreshJwt); + } catch { + session = await createSession(env); + } + await deliver(env, session.accessJwt, recipientDid, text); + } +} diff --git a/apps/relay/src/delivery/dispatcher.ts b/apps/relay/src/delivery/dispatcher.ts index bdaaea0..5f848da 100644 --- a/apps/relay/src/delivery/dispatcher.ts +++ b/apps/relay/src/delivery/dispatcher.ts @@ -1,10 +1,11 @@ import type { Did, Nsid } from '@atcute/lexicons'; import { mintRelayJwt } from '../auth/relay-signer'; -import { deleteChannelByPlatformUser, deletePushSubscription } from '../db/queries'; +import { deleteDeliveryTargetByRef } from '../db/queries'; import type { DispatchJob, Env } from '../env'; import { callbackAppFor } from '../lib/apps'; +import { BlueskyDMError, sendBlueskyDM } from './bluesky-dm'; import { EmailError, sendEmail } from './email'; import { escapeMd, @@ -12,6 +13,7 @@ import { sendMessage, TelegramApiError, } from './telegram'; +import { sendWebhook, WebhookError } from './webhook'; import { sendWebPush, WebPushError } from './webpush'; const SUBSCRIBER_CHANGED_LXM = 'pub.atmo.notify.subscriberChanged' as Nsid; @@ -49,7 +51,7 @@ async function reapIfDead(env: Env, job: DispatchJob, err: unknown): Promise= 400 && + err.statusCode < 500 && + err.statusCode !== 401 && + err.statusCode !== 429 + ) { + // Recipient blocks DMs from the bot, etc. — stop retrying this one; keep the + // target (the user can fix their DM settings). 401 (bot auth) and 429/5xx retry. + console.error(`dispatch: dropping DM to ${channel.recipientDid} (${err.statusCode})`); + return true; + } + if ( + channel.platform === 'webhook' && + err instanceof WebhookError && + err.statusCode >= 400 && + err.statusCode < 500 && + err.statusCode !== 429 + ) { + // The endpoint rejected the POST (4xx) — stop retrying. The user owns the URL, + // so we KEEP the target (never auto-reap it); they can fix or remove it. + // 429 and 5xx / network / timeout fall through to retry. + console.error(`dispatch: giving up on webhook (${err.statusCode}); keeping target`); + return true; + } return false; } @@ -152,6 +180,22 @@ async function dispatch(env: Env, job: DispatchJob): Promise { return; } + if (job.channel.platform === 'dm') { + const dmText = `${job.title}\n${job.body}${job.uri !== undefined ? `\n\n${job.uri}` : ''}`; + await sendBlueskyDM(env, job.channel.recipientDid, dmText); + return; + } + + if (job.channel.platform === 'webhook') { + await sendWebhook(job.channel, { + title: job.title, + body: job.body, + uri: job.uri, + sender: job.senderDid, + }); + return; + } + const text = `*${escapeMd(job.title)}*\n${escapeMd(job.body)}`; const replyMarkup: InlineKeyboardMarkup | undefined = job.uri !== undefined ? { inline_keyboard: [[{ text: 'Open', url: job.uri }]] } : undefined; diff --git a/apps/relay/src/delivery/webhook.ts b/apps/relay/src/delivery/webhook.ts new file mode 100644 index 0000000..f759796 --- /dev/null +++ b/apps/relay/src/delivery/webhook.ts @@ -0,0 +1,49 @@ +// Webhook delivery. The relay POSTs a small JSON envelope to a user-supplied +// https URL. Unlike push/telegram/email there is no provider — the user owns the +// endpoint — so a failure is the user's to fix: we never reap a webhook target, +// we just stop retrying permanent (4xx) failures (see dispatcher reapIfDead). +import type { WebhookChannel } from '../env'; + +/** How long we wait for the receiver before treating the POST as a transient failure. */ +const WEBHOOK_TIMEOUT_MS = 10_000; + +/** Thrown on a non-2xx response from the webhook endpoint. */ +export class WebhookError extends Error { + readonly statusCode: number; + constructor(statusCode: number) { + super(`webhook delivery failed: ${statusCode}`); + this.name = 'WebhookError'; + this.statusCode = statusCode; + } +} + +/** The JSON body POSTed to a webhook target. Stable, additive shape. */ +export interface WebhookPayload { + title: string; + body: string; + uri?: string; + /** DID of the app that sent the notification. */ + sender: string; +} + +/** + * Deliver one notification to a webhook target. Resolves on a 2xx; throws + * {@link WebhookError} on any other status, and lets fetch/timeout errors + * propagate (the dispatcher retries those). The request times out so a slow or + * hanging receiver can't stall the queue consumer. + */ +export async function sendWebhook(channel: WebhookChannel, payload: WebhookPayload): Promise { + const res = await fetch(channel.url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'user-agent': 'atmo.pub-webhook/1', + }, + // JSON.stringify drops an undefined `uri`, so the key is simply absent then. + body: JSON.stringify({ ...payload, sentAt: new Date().toISOString() }), + signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS), + }); + if (!res.ok) { + throw new WebhookError(res.status); + } +} diff --git a/apps/relay/src/env.ts b/apps/relay/src/env.ts index bb8f285..1e8fe17 100644 --- a/apps/relay/src/env.ts +++ b/apps/relay/src/env.ts @@ -21,8 +21,25 @@ export interface EmailChannel { address: string; } +/** A Bluesky DM delivery channel (the recipient's own DID). */ +export interface DMChannel { + platform: 'dm'; + recipientDid: string; +} + +/** A webhook delivery channel: an https URL the relay POSTs notification JSON to. */ +export interface WebhookChannel { + platform: 'webhook'; + url: string; +} + /** Where a notification can be delivered. */ -export type DeliveryChannel = TelegramChannel | WebPushChannel | EmailChannel; +export type DeliveryChannel = + | TelegramChannel + | WebPushChannel + | EmailChannel + | DMChannel + | WebhookChannel; /** * Work item placed on `DISPATCH_QUEUE` and handled by the `queue` consumer. @@ -100,6 +117,14 @@ export interface Env { COMAIL_DID: string; /** Enrolled sender address for the `from` field, e.g. "atmo.pub " (var). */ COMAIL_FROM: string; + + // Bluesky DM delivery — the relay sends DMs from a configured bot account. + /** Bot handle or DID used for createSession (var). */ + BLUESKY_DM_IDENTIFIER: string; + /** Bot app password (secret). */ + BLUESKY_DM_APP_PASSWORD: string; + /** Bot PDS service URL; defaults to https://bsky.social (var). */ + BLUESKY_DM_SERVICE?: string; } /** diff --git a/apps/relay/src/lib/ids.ts b/apps/relay/src/lib/ids.ts index 28ea79e..14f66b1 100644 --- a/apps/relay/src/lib/ids.ts +++ b/apps/relay/src/lib/ids.ts @@ -5,6 +5,15 @@ export function newId(): string { return nanoid(); } +/** + * Generate a short, stable id for a delivery target. It appears in route strings + * (`push:`); nanoid's url-safe alphabet (A–Z a–z 0–9 _ -) never contains the + * `+`/`:` route separators, so it's safe to embed. 12 chars ≈ 71 bits. + */ +export function newTargetId(): string { + return nanoid(12); +} + /** * Generate a 32-character URL-safe link token. * diff --git a/apps/relay/src/rpc/entrypoint.ts b/apps/relay/src/rpc/entrypoint.ts index f0c5329..a0045fd 100644 --- a/apps/relay/src/rpc/entrypoint.ts +++ b/apps/relay/src/rpc/entrypoint.ts @@ -7,23 +7,20 @@ import type { AppRoute, Capability, CategoryRoute, - DeviceView, - EmailChannelView, ListNotificationsResult, MarkReadInput, NotifsRpc, PushSubscriptionInput, RoutingConfig, + TargetView, PubAtmoNotifyDenyPending, PubAtmoNotifyGetSettings, PubAtmoNotifyGrant, PubAtmoNotifyLinkChannel, - PubAtmoNotifyListChannels, PubAtmoNotifyListGrants, PubAtmoNotifyListPending, PubAtmoNotifyMuteGrant, PubAtmoNotifyRevoke, - PubAtmoNotifyUnlinkChannel, PubAtmoNotifyUpdateSettings, } from '@atmo/notifs-lexicons'; @@ -53,11 +50,8 @@ export class RelayRpc extends WorkerEntrypoint implements NotifsRpc { muteGrant(did: Did, input: PubAtmoNotifyMuteGrant.$input) { return ops.muteGrant(this.env, did, input); } - linkChannel(did: Did, input: PubAtmoNotifyLinkChannel.$input) { - return ops.linkChannel(this.env, did, input); - } - unlinkChannel(did: Did, input: PubAtmoNotifyUnlinkChannel.$input) { - return ops.unlinkChannel(this.env, did, input); + linkChannel(did: Did, input: PubAtmoNotifyLinkChannel.$input, label?: string) { + return ops.linkChannel(this.env, did, input, label); } updateSettings(did: Did, input: PubAtmoNotifyUpdateSettings.$input) { return ops.updateSettings(this.env, did, input); @@ -68,23 +62,29 @@ export class RelayRpc extends WorkerEntrypoint implements NotifsRpc { listPending(did: Did): Promise { return ops.listPending(this.env, did); } - listChannels(did: Did): Promise { - return ops.listChannels(this.env, did); - } getSettings(did: Did): Promise { return ops.getSettings(this.env, did); } - linkEmail(did: Did, address: string) { - return ops.linkEmail(this.env, did, address); + listTargets(did: Did): Promise { + return ops.listTargets(this.env, did); + } + renameTarget(did: Did, id: string, label: string) { + return ops.renameTarget(this.env, did, id, label); + } + removeTarget(did: Did, id: string) { + return ops.removeTarget(this.env, did, id); + } + linkEmail(did: Did, address: string, label?: string) { + return ops.linkEmail(this.env, did, address, label); } verifyEmail(did: Did, code: string) { return ops.verifyEmail(this.env, did, code); } - unlinkEmail(did: Did) { - return ops.unlinkEmail(this.env, did); + addWebhook(did: Did, url: string, label: string) { + return ops.addWebhook(this.env, did, url, label); } - getEmailChannel(did: Did): Promise { - return ops.getEmailChannel(this.env, did); + enableDM(did: Did) { + return ops.enableDM(this.env, did); } registerWebPush(did: Did, sub: PushSubscriptionInput) { return ops.registerWebPush(this.env, did, sub); @@ -92,18 +92,15 @@ export class RelayRpc extends WorkerEntrypoint implements NotifsRpc { unregisterWebPush(did: Did, endpoint: string) { return ops.unregisterWebPush(this.env, did, endpoint); } - listDevices(did: Did): Promise { - return ops.listDevices(this.env, did); - } - renameDevice(did: Did, endpoint: string, label: string) { - return ops.renameDevice(this.env, did, endpoint, label); - } listNotifications(did: Did, cursor?: string): Promise { return ops.listNotifications(this.env, did, cursor); } markRead(did: Did, input: MarkReadInput) { return ops.markRead(this.env, did, input); } + clearNotificationsFromSender(did: Did, sender: Did) { + return ops.clearNotificationsFromSender(this.env, did, sender); + } getRouting(did: Did): Promise { return ops.getRouting(this.env, did); } diff --git a/apps/relay/src/rpc/ops.ts b/apps/relay/src/rpc/ops.ts index 25cebe5..23a25c6 100644 --- a/apps/relay/src/rpc/ops.ts +++ b/apps/relay/src/rpc/ops.ts @@ -11,34 +11,35 @@ import type { AppRoute, Capability, CategoryRoute, - DeviceView, - EmailChannelView, ListNotificationsResult, MarkReadInput, NotificationView, PushSubscriptionInput, + RouteInstance, + RouteInstances, RoutingApp, RoutingConfig, + TargetView, PubAtmoNotifyDenyPending, PubAtmoNotifyGetSettings, PubAtmoNotifyGrant, PubAtmoNotifyLinkChannel, - PubAtmoNotifyListChannels, PubAtmoNotifyListGrants, PubAtmoNotifyListPending, PubAtmoNotifyMuteGrant, PubAtmoNotifyRevoke, - PubAtmoNotifyUnlinkChannel, PubAtmoNotifyUpdateSettings, } from '@atmo/notifs-lexicons'; +import { emptyRouteInstances } from '@atmo/notifs-lexicons'; + import { verifyAppLoginToken } from '../auth/appLogin'; import { sendEmail } from '../delivery/email'; import * as q from '../db/queries'; import type { Env } from '../env'; import { appCatalog, callbackAppFor } from '../lib/apps'; import { invalidRequest } from '../lib/errors'; -import { newLinkToken } from '../lib/ids'; +import { newLinkToken, newTargetId } from '../lib/ids'; import { addMinutes, now, toIsoDatetime } from '../lib/time'; export async function grant( @@ -136,13 +137,16 @@ export async function linkChannel( env: Env, did: Did, input: PubAtmoNotifyLinkChannel.$input, + label?: string, ): Promise { await q.ensureUser(env.DB, did, now()); const token = newLinkToken(); + const name = label?.trim().slice(0, 64); await q.insertLinkToken(env.DB, { token, did, platform: input.platform, + label: name && name.length > 0 ? name : null, expiresAt: addMinutes(now(), 10), }); @@ -150,16 +154,6 @@ export async function linkChannel( return { token, deepLink }; } -export async function unlinkChannel( - env: Env, - did: Did, - input: PubAtmoNotifyUnlinkChannel.$input, -): Promise { - const unlinked = await q.deleteChannel(env.DB, did, input.platform); - - return { unlinked }; -} - export async function updateSettings( env: Env, did: Did, @@ -227,19 +221,72 @@ export async function listPending( }; } -export async function listChannels( +/** All of the user's delivery targets (push devices, Telegram chats, emails). */ +export async function listTargets(env: Env, did: Did): Promise { + const rows = await q.listDeliveryTargets(env.DB, did); + const views: TargetView[] = []; + for (const row of rows) { + const createdAt = toIsoDatetime(row.created_at); + if (row.channel === 'push') { + views.push({ id: row.id, channel: 'push', label: row.label ?? 'Unknown device', endpoint: row.ref, createdAt }); + } else if (row.channel === 'telegram') { + views.push({ id: row.id, channel: 'telegram', label: row.label ?? 'Telegram', createdAt }); + } else if (row.channel === 'email') { + views.push({ + id: row.id, + channel: 'email', + label: row.label ?? row.ref, + address: row.ref, + verified: row.verified === 1, + createdAt, + }); + } else if (row.channel === 'dm') { + views.push({ id: row.id, channel: 'dm', label: row.label ?? 'Bluesky DM', createdAt }); + } else if (row.channel === 'webhook') { + const url = (JSON.parse(row.config || '{}') as { url?: string }).url ?? ''; + views.push({ id: row.id, channel: 'webhook', label: row.label ?? 'Webhook', url, createdAt }); + } + } + return views; +} + +/** + * Enable Bluesky DM delivery: the relay's bot account DMs this user. The + * recipient is always the user's own DID, so `ref` = `did` (one DM target per + * user, enforced by UNIQUE(channel, ref)) and there's no address or verification + * step — it's deliverable immediately. Idempotent: re-enabling keeps the same + * target (and any user-chosen name). Disable via `removeTarget`. + */ +export async function enableDM(env: Env, did: Did): Promise<{ ok: boolean }> { + await q.ensureUser(env.DB, did, now()); + await q.upsertDeliveryTarget(env.DB, { + id: newTargetId(), + did, + channel: 'dm', + ref: did, // DM to self + label: null, // defaults to 'Bluesky DM'; user can rename + verified: true, + config: {}, + createdAt: now(), + }); + return { ok: true }; +} + +/** Give any delivery target a friendly name. */ +export async function renameTarget( env: Env, did: Did, -): Promise { - const rows = await q.listChannelsForDid(env.DB, did); + id: string, + label: string, +): Promise<{ ok: boolean }> { + const ok = await q.renameDeliveryTargetById(env.DB, did, id, label); + return { ok }; +} - return { - channels: rows.map((row) => ({ - platform: row.platform, - linkedAt: toIsoDatetime(row.linked_at), - displayName: row.display_name ?? undefined, - })), - }; +/** Remove any delivery target by its id (a Telegram chat, an email, a device). */ +export async function removeTarget(env: Env, did: Did, id: string): Promise<{ ok: boolean }> { + const ok = await q.deleteDeliveryTargetById(env.DB, did, id); + return { ok }; } export async function getSettings( @@ -249,7 +296,7 @@ export async function getSettings( // Ensure the row exists so we return stored defaults rather than guessing. await q.ensureUser(env.DB, did, now()); const user = await q.getUser(env.DB, did); - const pushDevices = (await q.countPushSubscriptionsForDid(env.DB, did))?.c ?? 0; + const pushDevices = (await q.countDeliveryTargets(env.DB, did, 'push'))?.c ?? 0; return { notifyPendingViaTelegram: (user?.notify_pending_via_telegram ?? 0) === 1, @@ -264,12 +311,15 @@ export async function registerWebPush( sub: PushSubscriptionInput, ): Promise<{ registered: boolean }> { await q.ensureUser(env.DB, did, now()); - await q.upsertPushSubscription(env.DB, { - endpoint: sub.endpoint, + await q.upsertDeliveryTarget(env.DB, { + id: newTargetId(), did, - p256dh: sub.p256dh, - auth: sub.auth, + channel: 'push', + ref: sub.endpoint, label: sub.label ?? null, + named: sub.named ?? false, + verified: true, + config: { p256dh: sub.p256dh, auth: sub.auth }, createdAt: now(), }); return { registered: true }; @@ -280,29 +330,10 @@ export async function unregisterWebPush( did: Did, endpoint: string, ): Promise<{ unregistered: boolean }> { - const unregistered = await q.deletePushSubscriptionForDid(env.DB, did, endpoint); + const unregistered = await q.deleteDeliveryTarget(env.DB, did, 'push', endpoint); return { unregistered }; } -export async function listDevices(env: Env, did: Did): Promise { - const rows = await q.listPushSubscriptionsForDid(env.DB, did); - return rows.map((row) => ({ - endpoint: row.endpoint, - label: row.label ?? 'Unknown device', - createdAt: toIsoDatetime(row.created_at), - })); -} - -export async function renameDevice( - env: Env, - did: Did, - endpoint: string, - label: string, -): Promise<{ ok: boolean }> { - const ok = await q.renamePushSubscription(env.DB, did, endpoint, label); - return { ok }; -} - const INBOX_PAGE_SIZE = 30; function toNotificationView(row: q.NotificationRow): NotificationView { @@ -347,18 +378,39 @@ export async function markRead( return { marked }; } +/** Permanently delete every notification from one app for this user. */ +export async function clearNotificationsFromSender( + env: Env, + did: Did, + sender: Did, +): Promise<{ deleted: number }> { + const deleted = await q.deleteNotificationsFromSender(env.DB, did, sender); + return { deleted }; +} + export async function getRouting(env: Env, did: Did): Promise { await q.ensureUser(env.DB, did, now()); const user = await q.getUser(env.DB, did); const defaultRoute = (user?.default_route ?? 'push') as AlertRoute; - const [grants, categories, routing, appRouting] = await Promise.all([ + const [grants, categories, routing, appRouting, deliveryTargets] = await Promise.all([ q.listGrantsForRecipient(env.DB, did), q.listAppCategoriesForRecipient(env.DB, did), q.listRoutingForRecipient(env.DB, did), q.listAppRoutingForRecipient(env.DB, did), + q.listDeliveryTargets(env.DB, did), ]); + // The user's deliverable instances per channel, so a route can target just one + // (e.g. one of several push devices). Only verified targets can be routed to. + const channels: RouteInstances = emptyRouteInstances(); + for (const row of deliveryTargets) { + const inst = q.toDeliveryInstance(row); + if (inst !== null && inst.verified) { + channels[inst.channel].push({ id: inst.id, label: inst.label } satisfies RouteInstance); + } + } + const routeBy = new Map(); for (const r of routing) routeBy.set(`${r.sender_did}${r.category}`, r.route); @@ -377,6 +429,8 @@ export async function getRouting(env: Env, did: Did): Promise { 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, + muted: g.muted === 1, + iconUrl: g.icon_url ?? g.avatar_url ?? undefined, categories: (catsBySender.get(g.sender_did) ?? []).map((c) => ({ category: c.category, description: c.description ?? undefined, @@ -384,7 +438,7 @@ export async function getRouting(env: Env, did: Did): Promise { })), })); - return { defaultRoute, apps }; + return { defaultRoute, apps, channels }; } export async function setRouting( @@ -448,14 +502,29 @@ function genVerifyCode(): string { } /** - * Set the user's email and email them a verification code (via comail). Stored + * Add an email address and email it a verification code (via comail). Stored * unverified until `verifyEmail` succeeds. Throws if comail rejects, so nothing - * is stored for an undeliverable address. + * is stored for an undeliverable address. A user can link several addresses; + * re-linking the same one just re-sends a fresh code. An optional `label` is the + * user's chosen name (named = 1, shown to apps); without one the address itself + * is the label (named = 0, hidden from apps). */ -export async function linkEmail(env: Env, did: Did, address: string): Promise<{ ok: boolean }> { +export async function linkEmail( + env: Env, + did: Did, + address: string, + label?: string, +): Promise<{ ok: boolean }> { const addr = address.trim().toLowerCase(); if (!EMAIL_RE.test(addr)) throw invalidRequest('Invalid email address'); + // `ref` is globally unique per channel; don't let one user grab an address + // already linked to another (which would break the owner's delivery). + const existing = await q.getDeliveryTargetByRef(env.DB, 'email', addr); + if (existing !== null && existing.did !== did) { + throw invalidRequest('That email is already linked to another account'); + } + const code = genVerifyCode(); await sendEmail(env, { to: addr, @@ -464,30 +533,104 @@ export async function linkEmail(env: Env, did: Did, address: string): Promise<{ category: 'verification', }); + const name = label?.trim().slice(0, 64); + const hasName = name !== undefined && name.length > 0; const t = now(); - await q.upsertEmailChannel(env.DB, { + // Re-linking the same address keeps its id (and any name) but resets it to + // unverified with a fresh code via ON CONFLICT(channel, ref). + await q.upsertDeliveryTarget(env.DB, { + id: existing?.id ?? newTargetId(), did, - address: addr, - verifyCode: code, - verifyExpires: t + VERIFY_TTL_MS, + channel: 'email', + ref: addr, + label: hasName ? name : addr, + named: hasName, + verified: false, + config: { code, expires: t + VERIFY_TTL_MS }, createdAt: t, }); return { ok: true }; } export async function verifyEmail(env: Env, did: Did, code: string): Promise<{ verified: boolean }> { - const verified = await q.verifyEmailChannel(env.DB, did, code.trim(), now()); + const verified = await q.verifyEmailTarget(env.DB, did, code.trim(), now()); return { verified }; } -export async function unlinkEmail(env: Env, did: Did): Promise<{ ok: boolean }> { - await q.deleteEmailChannel(env.DB, did); - return { ok: true }; +// --- webhook channel ------------------------------------------------------- + +const WEBHOOK_LABEL_MAX = 64; + +/** + * Reject hostnames that resolve to the local machine or a private network. This + * is defense-in-depth against SSRF: a user can only POST to a URL they entered, + * but we still don't want the relay reaching loopback/link-local/RFC1918 ranges. + * (Hostname-based, so a public name with a private A record isn't caught — Worker + * fetch can't reach the internal network from the edge regardless.) + */ +function isNonPublicHost(host: string): boolean { + const h = host.toLowerCase(); + if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true; + if (h === '::1' || h === '[::1]') return true; + // IPv4 literals in private / loopback / link-local ranges. + const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h); + if (m) { + const [a, b] = [Number(m[1]), Number(m[2])]; + if (a === 127 || a === 10 || a === 0) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + } + // IPv6 unique-local (fc00::/7) / link-local (fe80::/10) literals. + if (h.startsWith('[fc') || h.startsWith('[fd') || h.startsWith('[fe8') || h.startsWith('[fe9') || h.startsWith('[fea') || h.startsWith('[feb')) { + return true; + } + return false; } -export async function getEmailChannel(env: Env, did: Did): Promise { - const row = await q.getEmailChannel(env.DB, did); - return row === null ? null : { address: row.address, verified: row.verified === 1 }; +/** Validate + normalize a user-supplied webhook URL (https, public host). */ +function normalizeWebhookUrl(raw: string): string { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + throw invalidRequest('Invalid webhook URL'); + } + if (url.protocol !== 'https:') throw invalidRequest('Webhook URL must use https://'); + if (isNonPublicHost(url.hostname)) throw invalidRequest('Webhook URL must be a public address'); + return url.toString(); +} + +/** + * Add a webhook target: the relay POSTs notification JSON to `url`. The URL is + * validated (https, public host) and the user-supplied label is stored. No + * verification step — the user controls the endpoint — so it's deliverable + * immediately. `ref` is ` `, so the same URL can be a target for + * several users while staying deduped per user (re-adding is idempotent). + */ +export async function addWebhook( + env: Env, + did: Did, + url: string, + label: string, +): Promise<{ ok: boolean }> { + await q.ensureUser(env.DB, did, now()); + const normalized = normalizeWebhookUrl(url); + const name = label.trim().slice(0, WEBHOOK_LABEL_MAX); + if (name.length === 0) throw invalidRequest('Webhook label is required'); + + await q.upsertDeliveryTarget(env.DB, { + id: newTargetId(), + did, + channel: 'webhook', + ref: `${did} ${normalized}`, + label: name, + named: true, // the label is user-supplied at creation + verified: true, + config: { url: normalized }, + createdAt: now(), + }); + return { ok: true }; } /** diff --git a/apps/relay/src/telegram/callbacks.ts b/apps/relay/src/telegram/callbacks.ts index 7958e28..b1dc21d 100644 --- a/apps/relay/src/telegram/callbacks.ts +++ b/apps/relay/src/telegram/callbacks.ts @@ -13,7 +13,7 @@ const PLATFORM = 'telegram'; /** Handle an inline-button tap. Always answers the callback to dismiss the spinner. */ export async function handleCallback(env: Env, query: TelegramCallbackQuery): Promise { const chatId = query.from.id; - const channel = await q.getChannelByPlatformUser(env.DB, PLATFORM, String(chatId)); + const channel = await q.getDeliveryTargetByRef(env.DB, PLATFORM, String(chatId)); if (channel === null) { await answerCallbackQuery(env, { callback_query_id: query.id, diff --git a/apps/relay/src/telegram/commands.ts b/apps/relay/src/telegram/commands.ts index 6c1acfa..e7b3742 100644 --- a/apps/relay/src/telegram/commands.ts +++ b/apps/relay/src/telegram/commands.ts @@ -8,6 +8,7 @@ import { sendMessage, } from '../delivery/telegram'; import { resolveHandle } from '../identity/resolve'; +import { newTargetId } from '../lib/ids'; import { now } from '../lib/time'; import type { TelegramMessage } from './webhook'; @@ -15,7 +16,7 @@ import type { TelegramMessage } from './webhook'; // Telegram linking lives in the web app's Channels settings (atmo.pub), not the // relay (relay.atmo.pub). Deep-link straight to that tab. const DASHBOARD_URL = 'https://atmo.pub/settings?tab=channels'; -const PLATFORM = 'telegram'; +const PLATFORM = 'telegram' as const; /** Dispatch a `/command` message to its handler. */ export async function handleCommand(env: Env, message: TelegramMessage): Promise { @@ -63,13 +64,21 @@ async function handleStart(env: Env, message: TelegramMessage, token: string): P const did = row.did; const username = message.from?.username ?? null; + // A name the user typed in the web form (carried through the link token) wins + // and counts as user-chosen (named); otherwise fall back to the Telegram + // username as an auto label (hidden from apps). + const userLabel = row.label?.trim() || null; await q.ensureUser(env.DB, did, now()); - await q.upsertChannel(env.DB, { + await q.upsertDeliveryTarget(env.DB, { + id: newTargetId(), did, - platform: PLATFORM, - platformUserId: String(chatId), - displayName: username, // the Telegram account label (for the channels list) - linkedAt: now(), + channel: PLATFORM, + ref: String(chatId), + label: userLabel ?? username, + named: userLabel !== null, + verified: true, + config: {}, + createdAt: now(), }); await q.deleteLinkToken(env.DB, token); @@ -82,7 +91,7 @@ async function handleStart(env: Env, message: TelegramMessage, token: string): P } async function handleList(env: Env, chatId: number): Promise { - const channel = await q.getChannelByPlatformUser(env.DB, PLATFORM, String(chatId)); + const channel = await q.getDeliveryTargetByRef(env.DB, PLATFORM, String(chatId)); if (channel === null) { await replyText(env, chatId, NOT_LINKED); return; @@ -105,7 +114,7 @@ async function handleList(env: Env, chatId: number): Promise { } async function handleRevoke(env: Env, chatId: number, arg: string): Promise { - const channel = await q.getChannelByPlatformUser(env.DB, PLATFORM, String(chatId)); + const channel = await q.getDeliveryTargetByRef(env.DB, PLATFORM, String(chatId)); if (channel === null) { await replyText(env, chatId, NOT_LINKED); return; @@ -131,7 +140,7 @@ async function handleRevoke(env: Env, chatId: number, arg: string): Promise { - const channel = await q.getChannelByPlatformUser(env.DB, PLATFORM, String(chatId)); + const channel = await q.getDeliveryTargetByRef(env.DB, PLATFORM, String(chatId)); if (channel === null) { await replyText(env, chatId, NOT_LINKED); return; diff --git a/apps/relay/src/xrpc/getRouting.ts b/apps/relay/src/xrpc/getRouting.ts index bd08f68..dfee76c 100644 --- a/apps/relay/src/xrpc/getRouting.ts +++ b/apps/relay/src/xrpc/getRouting.ts @@ -7,6 +7,39 @@ import type { AppContext } from '../env'; const LXM = 'pub.atmo.notify.getRouting'; +// Privacy-safe generic labels per channel (no PII), with a capitalized-id fallback. +const GENERIC_LABEL: Record = { + push: 'Push device', + telegram: 'Telegram', + email: 'Email', + dm: 'Direct message', + webhook: 'Webhook', +}; +const genericLabel = (channel: string): string => + GENERIC_LABEL[channel] ?? channel.charAt(0).toUpperCase() + channel.slice(1); + +/** + * Build the app-facing target catalog. Labels never leak the raw email + * address / telegram handle: a user-chosen name (`named`) is used as-is; push + * device labels (a UA descriptor, not PII) pass through; everything else gets a + * generic label, numbered when a channel has more than one target. + */ +function safeTargets(rows: q.DeliveryTargetRow[]): { type: string; id: string; label: string }[] { + const verified = rows.filter((r) => r.verified === 1); + const counts: Record = {}; + for (const r of verified) counts[r.channel] = (counts[r.channel] ?? 0) + 1; + const seen: Record = {}; + return verified.map((r) => { + const n = (seen[r.channel] ?? 0) + 1; + seen[r.channel] = n; + let label: string; + if (r.named === 1 && r.label) label = r.label; + else if (r.channel === 'push' && r.label) label = r.label; + else label = (counts[r.channel] ?? 0) > 1 ? `${genericLabel(r.channel)} ${n}` : genericLabel(r.channel); + return { type: r.channel, id: r.id, label }; + }); +} + /** * 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. A self-scoped management @@ -24,11 +57,12 @@ export function makeGetRouting( lxm: LXM, }); - const [user, appRoute, cats, routes] = await Promise.all([ + const [user, appRoute, cats, routes, deliveryTargets] = await Promise.all([ q.getUser(app.env.DB, userDid), q.getAppRoute(app.env.DB, userDid, senderDid), q.listAppCategoriesForSender(app.env.DB, userDid, senderDid), q.listRoutingForSender(app.env.DB, userDid, senderDid), + q.listDeliveryTargets(app.env.DB, userDid), ]); const routeByCategory = new Map(routes.map((r) => [r.category, r.route])); @@ -46,6 +80,7 @@ export function makeGetRouting( route: (routeByCategory.get(c.category) ?? 'app') as PubAtmoNotifyGetRouting.Category['route'], }), ), + targets: safeTargets(deliveryTargets), }); }, }; diff --git a/apps/relay/src/xrpc/manage.ts b/apps/relay/src/xrpc/manage.ts index 3e88cd4..4153fc1 100644 --- a/apps/relay/src/xrpc/manage.ts +++ b/apps/relay/src/xrpc/manage.ts @@ -10,7 +10,6 @@ import { type PubAtmoNotifyLinkChannel, type PubAtmoNotifyMuteGrant, type PubAtmoNotifyRevoke, - type PubAtmoNotifyUnlinkChannel, type PubAtmoNotifyUpdateSettings, type PushSubscriptionInput, } from '@atmo/notifs-lexicons'; @@ -34,10 +33,8 @@ 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), - getEmailChannel: (env, did) => ops.getEmailChannel(env, did), + listTargets: (env, did) => ops.listTargets(env, did), getRouting: (env, did) => ops.getRouting(env, did), listNotifications: (env, did, p) => ops.listNotifications(env, did, (p as { cursor?: string } | undefined)?.cursor), @@ -47,21 +44,21 @@ const OPS: Record = { 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), linkEmail: (env, did, p) => ops.linkEmail(env, did, (p as { address: string }).address), verifyEmail: (env, did, p) => ops.verifyEmail(env, did, (p as { code: string }).code), - unlinkEmail: (env, did) => ops.unlinkEmail(env, did), + renameTarget: (env, did, p) => { + const { id, label } = p as { id: string; label: string }; + return ops.renameTarget(env, did, id, label); + }, + removeTarget: (env, did, p) => ops.removeTarget(env, did, (p as { id: string }).id), 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), + clearNotificationsFromSender: (env, did, p) => + ops.clearNotificationsFromSender(env, did, (p as { sender: Did }).sender), setRouting: (env, did, p) => { const { sender, category, route } = p as { sender: Did; category: string; route: CategoryRoute }; return ops.setRouting(env, did, sender, category, route); diff --git a/apps/relay/src/xrpc/requestPermission.ts b/apps/relay/src/xrpc/requestPermission.ts index d59fd45..758d54c 100644 --- a/apps/relay/src/xrpc/requestPermission.ts +++ b/apps/relay/src/xrpc/requestPermission.ts @@ -141,15 +141,16 @@ async function maybeNotifyPending( if (user === null || user.notify_pending_via_telegram !== 1) { return; } - const channels = await q.listChannelsForDid(app.env.DB, recipient); - const telegram = channels.find((channel) => channel.platform === 'telegram'); + const telegram = (await q.listDeliveryTargets(app.env.DB, recipient)).find( + (target) => target.channel === 'telegram', + ); if (telegram === undefined) { return; } await app.env.DISPATCH_QUEUE.send({ kind: 'pendingRequest', - channel: { platform: 'telegram', platformUserId: telegram.platform_user_id }, + channel: { platform: 'telegram', platformUserId: telegram.ref }, requestId, senderTitle: title, senderDescription: description ?? undefined, diff --git a/apps/relay/src/xrpc/send.ts b/apps/relay/src/xrpc/send.ts index 8608561..02e4199 100644 --- a/apps/relay/src/xrpc/send.ts +++ b/apps/relay/src/xrpc/send.ts @@ -1,4 +1,4 @@ -import { PubAtmoNotifySend } from '@atmo/notifs-lexicons'; +import { PubAtmoNotifySend, routeSelection } from '@atmo/notifs-lexicons'; import type { Did } from '@atcute/lexicons'; import { json, type ProcedureConfig } from '@atcute/xrpc-server'; @@ -25,14 +25,44 @@ export function makeSend(app: AppContext): ProcedureConfig() : new Set(route.split('+')); - - const telegramChannels = channels.has('telegram') - ? (await q.listChannelsForDid(app.env.DB, recipient)).filter((c) => c.platform === 'telegram') - : []; - const pushSubs = channels.has('push') - ? await q.listPushSubscriptionsForDid(app.env.DB, recipient) - : []; - const emailAddress = channels.has('email') - ? await q.getVerifiedEmail(app.env.DB, recipient) - : null; - const deliveredCount = telegramChannels.length + pushSubs.length + (emailAddress ? 1 : 0); + // 5. Resolve the token set against the user's deliverable targets. A bare + // channel ('push') fires all its instances; 'push:' fires just one. + const selection = routeSelection(route); + const targets = (await q.listDeliveryTargets(app.env.DB, recipient)) + .map(q.toDeliveryInstance) + .filter((t): t is q.DeliveryInstance => t !== null && t.verified) + .filter((t) => { + const sel = selection[t.channel]; + return sel !== undefined && (sel.all || sel.ids.includes(t.id)); + }); + const deliveredCount = targets.length; // No targets → accept but deliver to nobody. if (deliveredCount === 0) { @@ -115,48 +119,10 @@ export function makeSend(app: AppContext): ProcedureConfig ({ - body: { - kind: 'notification' as const, - channel: { platform: 'telegram' as const, platformUserId: channel.platform_user_id }, - title: input.title, - body: input.body, - uri: input.uri, - senderDid, - }, - })), - ...pushSubs.map((sub) => ({ - body: { - kind: 'notification' as const, - channel: { - platform: 'webpush' as const, - endpoint: sub.endpoint, - p256dh: sub.p256dh, - auth: sub.auth, - }, - title: input.title, - body: input.body, - uri: input.uri, - senderDid, - }, - })), - ...(emailAddress !== null - ? [ - { - body: { - kind: 'notification' as const, - channel: { platform: 'email' as const, address: emailAddress }, - title: input.title, - body: input.body, - uri: input.uri, - senderDid, - }, - }, - ] - : []), - ]; + // 5. Enqueue one dispatch job per matched target. + const jobs = targets.map((target) => ({ + body: toNotificationJob(target, input, senderDid), + })); await app.env.DISPATCH_QUEUE.sendBatch(jobs); // 6. Record the delivery. @@ -166,6 +132,42 @@ export function makeSend(app: AppContext): ProcedureConfig t.channel === 'dm'); +} + +it('enableDM creates a verified DM target (recipient = the user, no verify step)', async () => { + const did: Did = 'did:plc:dm-enable'; + await ops.enableDM(env, did); + expect(await dms(did)).toEqual([expect.objectContaining({ channel: 'dm', label: 'Bluesky DM' })]); + + const row = await q.getDeliveryTargetByRef(env.DB, 'dm', did); + expect(row?.ref).toBe(did); // DM to self + expect(row?.verified).toBe(1); + expect(row?.named).toBe(0); // default label, not user-chosen +}); + +it('enableDM is idempotent — one DM per user, keeping the same id', async () => { + const did: Did = 'did:plc:dm-idem'; + await ops.enableDM(env, did); + const first = (await dms(did))[0]; + await ops.enableDM(env, did); + const list = await dms(did); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe(first?.id); +}); + +it('removeTarget disables the DM (scoped to the owner)', async () => { + const did: Did = 'did:plc:dm-remove'; + await ops.enableDM(env, did); + const dm = (await dms(did))[0]; + + // Another user can't remove it. + expect((await ops.removeTarget(env, 'did:plc:dm-other' as Did, dm!.id)).ok).toBe(false); + expect(await dms(did)).toHaveLength(1); + + // The owner can. + expect((await ops.removeTarget(env, did, dm!.id)).ok).toBe(true); + expect(await dms(did)).toHaveLength(0); +}); + +it('renaming a DM stores the name and marks it named (shown to apps)', async () => { + const did: Did = 'did:plc:dm-rename'; + await ops.enableDM(env, did); + const dm = (await dms(did))[0]; + await ops.renameTarget(env, did, dm!.id, 'My Bluesky'); + + const row = await q.getDeliveryTargetByRef(env.DB, 'dm', did); + expect(row?.label).toBe('My Bluesky'); + expect(row?.named).toBe(1); +}); diff --git a/apps/relay/test/email-channel.test.ts b/apps/relay/test/email-channel.test.ts index 602661b..0796783 100644 --- a/apps/relay/test/email-channel.test.ts +++ b/apps/relay/test/email-channel.test.ts @@ -12,46 +12,86 @@ beforeEach(() => { mockComailOk(); }); +/** Email targets for `did`, as the unified TargetView (channel === 'email'). */ +async function emails(did: Did) { + return (await ops.listTargets(env, did)).filter((t) => t.channel === 'email'); +} + it('linkEmail stores an unverified, normalized address', async () => { const did: Did = 'did:plc:email-link'; await ops.linkEmail(env, did, ' Me@Example.com '); - expect(await ops.getEmailChannel(env, did)).toEqual({ address: 'me@example.com', verified: false }); + expect(await emails(did)).toEqual([ + expect.objectContaining({ channel: 'email', address: 'me@example.com', verified: false }), + ]); +}); + +it('without a label the address is the label and the target is not `named`', async () => { + const did: Did = 'did:plc:email-noname'; + await ops.linkEmail(env, did, 'plain@x.com'); + const row = await q.getDeliveryTargetByRef(env.DB, 'email', 'plain@x.com'); + expect(row?.label).toBe('plain@x.com'); + expect(row?.named).toBe(0); +}); + +it('a user-supplied label is stored and marked `named` (shown to apps)', async () => { + const did: Did = 'did:plc:email-named'; + await ops.linkEmail(env, did, 'work@x.com', ' Work inbox '); + const row = await q.getDeliveryTargetByRef(env.DB, 'email', 'work@x.com'); + expect(row?.label).toBe('Work inbox'); // trimmed + expect(row?.named).toBe(1); + expect((await emails(did))[0]).toMatchObject({ address: 'work@x.com', label: 'Work inbox' }); }); it('rejects an invalid address', async () => { const did: Did = 'did:plc:email-bad'; await expect(ops.linkEmail(env, did, 'not-an-email')).rejects.toThrow(); - expect(await ops.getEmailChannel(env, did)).toBeNull(); + expect(await emails(did)).toHaveLength(0); }); it('verifyEmail: right code verifies, wrong code does not', async () => { const did: Did = 'did:plc:email-verify'; await ops.linkEmail(env, did, 'a@b.com'); - const code = (await q.getEmailChannel(env.DB, did))?.verify_code; + const target = await q.getDeliveryTargetByRef(env.DB, 'email', 'a@b.com'); + const code = (JSON.parse(target?.config ?? '{}') as { code?: string }).code; if (!code) throw new Error('expected a verify code'); expect((await ops.verifyEmail(env, did, '000000')).verified).toBe(false); expect((await ops.verifyEmail(env, did, code)).verified).toBe(true); - expect((await ops.getEmailChannel(env, did))?.verified).toBe(true); - // Delivery query now sees it. - expect(await q.getVerifiedEmail(env.DB, did)).toBe('a@b.com'); + expect((await emails(did))[0]?.verified).toBe(true); }); it('verifyEmail fails once expired', async () => { const did: Did = 'did:plc:email-expired'; - await q.upsertEmailChannel(env.DB, { + await q.upsertDeliveryTarget(env.DB, { + id: 'expired-email-target', did, - address: 'c@d.com', - verifyCode: '123456', - verifyExpires: Date.now() - 1000, // already expired + channel: 'email', + ref: 'c@d.com', + label: 'c@d.com', + verified: false, + config: { code: '123456', expires: Date.now() - 1000 }, // already expired createdAt: Date.now() - 2000, }); expect((await ops.verifyEmail(env, did, '123456')).verified).toBe(false); }); -it('unlinkEmail removes it', async () => { - const did: Did = 'did:plc:email-unlink'; - await ops.linkEmail(env, did, 'x@y.com'); - await ops.unlinkEmail(env, did); - expect(await ops.getEmailChannel(env, did)).toBeNull(); +it('a user can link several emails and remove one by id', async () => { + const did: Did = 'did:plc:email-multi'; + await ops.linkEmail(env, did, 'one@x.com'); + await ops.linkEmail(env, did, 'two@x.com'); + let list = await emails(did); + expect(list).toHaveLength(2); + + const one = list.find((e) => e.channel === 'email' && e.address === 'one@x.com'); + await ops.removeTarget(env, did, one!.id); + list = await emails(did); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ address: 'two@x.com' }); +}); + +it('linkEmail rejects an address already linked to another account', async () => { + const owner: Did = 'did:plc:email-owner'; + const other: Did = 'did:plc:email-thief'; + await ops.linkEmail(env, owner, 'shared@x.com'); + await expect(ops.linkEmail(env, other, 'shared@x.com')).rejects.toThrow(); }); diff --git a/apps/relay/test/helpers.ts b/apps/relay/test/helpers.ts index 2c311b2..e1139e5 100644 --- a/apps/relay/test/helpers.ts +++ b/apps/relay/test/helpers.ts @@ -2,8 +2,108 @@ import { P256PrivateKeyExportable } from '@atcute/crypto'; import type { Did, Nsid } from '@atcute/lexicons'; import { createServiceJwt } from '@atcute/xrpc-server/auth'; +import * as q from '../src/db/queries'; +import { newTargetId } from '../src/lib/ids'; + export const RELAY_DID = 'did:web:relay.atmo.pub'; +// --- delivery-target seeding (unified push/telegram/email) ------------------ + +/** Seed a verified Telegram chat for `did`; returns its stable target id. */ +export async function addTelegram( + db: D1Database, + did: Did, + chatId: string, + label: string | null = null, +): Promise { + const id = newTargetId(); + await q.upsertDeliveryTarget(db, { + id, + did, + channel: 'telegram', + ref: chatId, + label, + verified: true, + config: {}, + createdAt: Date.now(), + }); + return id; +} + +/** Seed a verified web-push device for `did`; returns its stable target id. */ +export async function addPush( + db: D1Database, + did: Did, + endpoint: string, + label: string | null = null, +): Promise { + const id = newTargetId(); + await q.upsertDeliveryTarget(db, { + id, + did, + channel: 'push', + ref: endpoint, + label, + verified: true, + config: { p256dh: 'k', auth: 'a' }, + createdAt: Date.now(), + }); + return id; +} + +/** Seed a verified email for `did`; returns its stable target id. */ +export async function addVerifiedEmail(db: D1Database, did: Did, address: string): Promise { + const id = newTargetId(); + await q.upsertDeliveryTarget(db, { + id, + did, + channel: 'email', + ref: address, + label: address, + verified: true, + config: {}, + createdAt: Date.now(), + }); + return id; +} + +/** Seed a verified Bluesky DM target for `did` (recipient = the user); returns its id. */ +export async function addDMTarget(db: D1Database, did: Did): Promise { + const id = newTargetId(); + await q.upsertDeliveryTarget(db, { + id, + did, + channel: 'dm', + ref: did, + label: null, + verified: true, + config: {}, + createdAt: Date.now(), + }); + return id; +} + +/** Seed a verified webhook target for `did`; returns its stable target id. */ +export async function addWebhookTarget( + db: D1Database, + did: Did, + url: string, + label = 'Webhook', +): Promise { + const id = newTargetId(); + await q.upsertDeliveryTarget(db, { + id, + did, + channel: 'webhook', + ref: `${did} ${url}`, + label, + verified: true, + config: { url }, + createdAt: Date.now(), + }); + return id; +} + // --------------------------------------------------------------------------- // Outbound fetch mocking // diff --git a/apps/relay/test/inbox.test.ts b/apps/relay/test/inbox.test.ts index 372891c..006dd1e 100644 --- a/apps/relay/test/inbox.test.ts +++ b/apps/relay/test/inbox.test.ts @@ -46,3 +46,26 @@ it('markRead({ ids }) marks those; markRead({ all }) clears the rest', async () expect((await ops.markRead(env, user, { all: true })).marked).toBe(1); expect((await ops.listNotifications(env, user)).unread).toBe(0); }); + +it('clearNotificationsFromSender deletes only that app’s notifications', async () => { + const user = 'did:plc:inbox-clear' as Did; + const other: Did = 'did:plc:inbox-other-sender'; + await seed(user, 'c1', 1000, null); + await seed(user, 'c2', 2000, null); + await q.insertNotification(env.DB, { + id: 'c-other', + recipientDid: user, + senderDid: other, + category: null, + title: 'keep', + body: 'b', + uri: null, + actors: null, + createdAt: 3000, + }); + + expect((await ops.clearNotificationsFromSender(env, user, SENDER)).deleted).toBe(2); + + const rest = await ops.listNotifications(env, user); + expect(rest.notifications.map((n) => n.id)).toEqual(['c-other']); +}); diff --git a/apps/relay/test/management-auth.test.ts b/apps/relay/test/management-auth.test.ts index 26db3bd..6683c46 100644 --- a/apps/relay/test/management-auth.test.ts +++ b/apps/relay/test/management-auth.test.ts @@ -5,7 +5,17 @@ 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'; +import { + addPush, + addTelegram, + addVerifiedEmail, + installFetchMock, + makeIdentity, + makeJwt, + mockPlc, + xrpcPost, + type TestIdentity, +} from './helpers'; beforeAll(() => { installFetchMock(); @@ -35,7 +45,8 @@ async function dualCall( return call(xrpcPost(lxm, appJwt, { userToken, ...body })); } -/** Granted but NOT designated (manage='none'). */ +/** Granted and explicitly designated 'none' (no management access). New grants + * default to 'self', so pin it to 'none' to exercise the no-access tier. */ 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`); @@ -49,6 +60,7 @@ async function plain(tag: string): Promise<{ app: TestIdentity; user: TestIdenti description: null, iconUrl: null, }); + await q.setGrantManage(env.DB, user.did, app.did, 'none'); return { app, user }; } @@ -72,6 +84,20 @@ it('undesignated app: self-WRITE is denied (write policy defaults to user-allowl expect(await q.getAppRoute(env.DB, user.did, app.did)).toBeNull(); }); +it('a fresh grant defaults to self-management', async () => { + const user = await makeIdentity('did:plc:mgmt-default-user'); + const app = await makeIdentity('did:plc:mgmt-default-app'); + await q.upsertGrant(env.DB, { + recipientDid: user.did, + senderDid: app.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null, + }); + expect((await q.getGrant(env.DB, user.did, app.did))?.manage).toBe('self'); +}); + it('designated `self`: self-WRITE is allowed', async () => { const { app, user } = await plain('mgmt-self-ok'); await designate(user.did, app.did, 'self'); @@ -117,3 +143,32 @@ it('a `full` designation also satisfies self-scoped writes', async () => { const res = await dualCall(SETROUTING, app, user, { route: 'push' }); expect(res.status).toBe(200); }); + +it('getRouting returns the target catalog with privacy-safe labels', async () => { + const { app, user } = await plain('mgmt-targets'); + await addVerifiedEmail(env.DB, user.did, 'secret@example.com'); + await addTelegram(env.DB, user.did, '424242', 'secrethandle'); + await addPush(env.DB, user.did, 'https://push.example/dev', 'Chrome · macOS'); + + const res = await dualCall(GETROUTING, app, user); + expect(res.status).toBe(200); + const data = (await res.json()) as { targets: { type: string; id: string; label: string }[] }; + const labelByType = Object.fromEntries(data.targets.map((t) => [t.type, t.label])); + + expect(labelByType.email).toBe('Email'); // raw address NOT exposed + expect(labelByType.telegram).toBe('Telegram'); // raw @handle NOT exposed + expect(labelByType.push).toBe('Chrome · macOS'); // device label is fine + const dump = JSON.stringify(data.targets); + expect(dump).not.toContain('secret@example.com'); + expect(dump).not.toContain('secrethandle'); +}); + +it('getRouting exposes a user-chosen target name once renamed', async () => { + const { app, user } = await plain('mgmt-targets-named'); + const emailId = await addVerifiedEmail(env.DB, user.did, 'me@example.com'); + await q.renameDeliveryTargetById(env.DB, user.did, emailId, 'Work'); + + const res = await dualCall(GETROUTING, app, user); + const data = (await res.json()) as { targets: { type: string; label: string }[] }; + expect(data.targets.find((t) => t.type === 'email')?.label).toBe('Work'); +}); diff --git a/apps/relay/test/management-full.test.ts b/apps/relay/test/management-full.test.ts index c959ba2..fa16629 100644 --- a/apps/relay/test/management-full.test.ts +++ b/apps/relay/test/management-full.test.ts @@ -81,19 +81,18 @@ it('undesignated (granted but manage=none) is denied', async () => { expect(res.status).toBe(403); }); -it('vouch (no user token) works for a designated full manager', async () => { +it('vouch (no user token) is rejected even 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`. + // No userToken — the vouch path is gone, so a body `did` alone is not enough. const res = await call(xrpcPost(MANAGE, appJwt, { method: 'getSettings', did: user.did })); - expect(res.status).toBe(200); + expect(res.status).toBe(403); }); -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('a full manager works with a user token', async () => { + const { app, user } = await setup('mf-token', 'full'); + const res = await manage(app, user, { method: 'getSettings' }); + expect(res.status).toBe(200); }); it('unknown method → 400', async () => { diff --git a/apps/relay/test/send.test.ts b/apps/relay/test/send.test.ts index 4c56ee4..c3324fe 100644 --- a/apps/relay/test/send.test.ts +++ b/apps/relay/test/send.test.ts @@ -5,7 +5,19 @@ import { beforeAll, expect, it } from 'vitest'; import * as q from '../src/db/queries'; import worker from '../src/index'; -import { installFetchMock, makeIdentity, makeJwt, mockPlc, mockTelegramOk, xrpcPost } from './helpers'; +import { + addDMTarget, + addPush, + addTelegram, + addVerifiedEmail, + addWebhookTarget, + installFetchMock, + makeIdentity, + makeJwt, + mockPlc, + mockTelegramOk, + xrpcPost, +} from './helpers'; beforeAll(() => { installFetchMock(); @@ -66,13 +78,7 @@ it('enqueues and reports delivered=1 with a linked channel', async () => { description: null, iconUrl: null }); - await q.upsertChannel(env.DB, { - did: RECIPIENT, - platform: 'telegram', - platformUserId: '12345', - displayName: null, - linkedAt: Date.now(), - }); + await addTelegram(env.DB, RECIPIENT, '12345'); // Default route is 'push'; opt this recipient into Telegram so the channel fires. await q.ensureUser(env.DB, RECIPIENT, Date.now()); await q.setDefaultRoute(env.DB, RECIPIENT, 'push+telegram'); @@ -121,13 +127,7 @@ it('per-category routing gates which channels fire', async () => { description: null, iconUrl: null }); - await q.upsertChannel(env.DB, { - did: recip, - platform: 'telegram', - platformUserId: '99999', - displayName: null, - linkedAt: Date.now() - }); + await addTelegram(env.DB, recip, '99999'); // Default 'push' would skip Telegram, but this category is routed to Telegram. await q.setDefaultRoute(env.DB, recip, 'push'); await q.upsertRouting(env.DB, recip, sender.did, 'mention', 'telegram'); @@ -154,13 +154,7 @@ it('app-wide routing gates delivery when the notification has no category', asyn description: null, iconUrl: null }); - await q.upsertChannel(env.DB, { - did: recip, - platform: 'telegram', - platformUserId: '88888', - displayName: null, - linkedAt: Date.now() - }); + await addTelegram(env.DB, recip, '88888'); // Account default 'push' would skip Telegram, but the app-wide route is Telegram. await q.setDefaultRoute(env.DB, recip, 'push'); await q.upsertAppRoute(env.DB, recip, sender.did, 'telegram'); @@ -184,13 +178,7 @@ it('accepts silently with delivered=0 when the grant is muted', async () => { iconUrl: null }); await q.setGrantMuted(env.DB, RECIPIENT, sender.did, true); - await q.upsertChannel(env.DB, { - did: RECIPIENT, - platform: 'telegram', - platformUserId: '54321', - displayName: null, - linkedAt: Date.now(), - }); + await addTelegram(env.DB, RECIPIENT, '54321'); const jwt = await makeJwt(sender, { lxm: SEND }); const res = await call(send(jwt)); @@ -213,14 +201,7 @@ it('delivers to a verified email when the route includes email', async () => { iconUrl: null }); // A verified email + a route that includes it. - await q.upsertEmailChannel(env.DB, { - did: recip, - address: 'me@example.com', - verifyCode: '111111', - verifyExpires: Date.now() + 60_000, - createdAt: Date.now() - }); - await q.verifyEmailChannel(env.DB, recip, '111111', Date.now()); + await addVerifiedEmail(env.DB, recip, 'me@example.com'); await q.setDefaultRoute(env.DB, recip, 'push+email'); const jwt = await makeJwt(sender, { lxm: SEND }); @@ -230,3 +211,151 @@ it('delivers to a verified email when the route includes email', async () => { expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ delivered: 1 }); }); + +it('delivers to a Bluesky DM when the route includes dm', async () => { + const sender = await makeIdentity('did:plc:senddm'); + mockPlc(sender); + const recip: Did = 'did:plc:dmrecipient'; + await q.ensureUser(env.DB, recip, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: recip, + senderDid: sender.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null + }); + await addDMTarget(env.DB, recip); + await q.setDefaultRoute(env.DB, recip, 'dm'); + const jwt = await makeJwt(sender, { lxm: SEND }); + + const res = await call(xrpcPost(SEND, jwt, { recipient: recip, title: 'Hi', body: 'B' })); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ delivered: 1 }); +}); + +it('delivers to a webhook when the route includes webhook', async () => { + const sender = await makeIdentity('did:plc:sendwebhook'); + mockPlc(sender); + const recip: Did = 'did:plc:webhookrecipient'; + await q.ensureUser(env.DB, recip, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: recip, + senderDid: sender.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null + }); + await addWebhookTarget(env.DB, recip, 'https://hook.example/r'); + await q.setDefaultRoute(env.DB, recip, 'webhook'); + const jwt = await makeJwt(sender, { lxm: SEND }); + + const res = await call(xrpcPost(SEND, jwt, { recipient: recip, title: 'Hi', body: 'B' })); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ delivered: 1 }); +}); + +it("route 'off' drops the notification entirely — not even recorded in the inbox", async () => { + const sender = await makeIdentity('did:plc:sendoff'); + mockPlc(sender); + const recip: Did = 'did:plc:offrecipient'; + await q.ensureUser(env.DB, recip, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: recip, + senderDid: sender.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null + }); + await addPush(env.DB, recip, 'https://push.example/off-device'); + await q.setDefaultRoute(env.DB, recip, 'off'); + const jwt = await makeJwt(sender, { lxm: SEND }); + + const res = await call(xrpcPost(SEND, jwt, { recipient: recip, title: 'X', body: 'Y' })); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ delivered: 0 }); + expect(await q.listNotificationsForRecipient(env.DB, recip, 50)).toHaveLength(0); +}); + +it("route 'inbox' records the notification but fires no alerts", async () => { + const sender = await makeIdentity('did:plc:sendinboxonly'); + mockPlc(sender); + const recip: Did = 'did:plc:inboxonlyrecipient'; + await q.ensureUser(env.DB, recip, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: recip, + senderDid: sender.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null + }); + // A live push target, but the route is inbox-only → no alerts. + await addPush(env.DB, recip, 'https://push.example/inbox-device'); + await q.setDefaultRoute(env.DB, recip, 'inbox'); + const jwt = await makeJwt(sender, { lxm: SEND }); + + const res = await call(xrpcPost(SEND, jwt, { recipient: recip, title: 'Hi', body: 'B' })); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ delivered: 0 }); + expect(await q.listNotificationsForRecipient(env.DB, recip, 50)).toHaveLength(1); +}); + +it('a mixed route delivers to all of one channel AND a specific instance of another', async () => { + const sender = await makeIdentity('did:plc:sendmixed'); + mockPlc(sender); + const recip: Did = 'did:plc:mixedrecipient'; + await q.ensureUser(env.DB, recip, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: recip, + senderDid: sender.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null + }); + // Two push devices (route to ALL) + two telegram chats (route to ONE). + await addPush(env.DB, recip, 'https://push.example/d1'); + await addPush(env.DB, recip, 'https://push.example/d2'); + const chat1 = await addTelegram(env.DB, recip, '111'); + await addTelegram(env.DB, recip, '222'); + await q.setDefaultRoute(env.DB, recip, `push+telegram:${chat1}`); + const jwt = await makeJwt(sender, { lxm: SEND }); + + const res = await call(xrpcPost(SEND, jwt, { recipient: recip, title: 'Hi', body: 'B' })); + + expect(res.status).toBe(200); + // both push devices + only chat1 = 3. + expect(await res.json()).toMatchObject({ delivered: 3 }); +}); + +it('an instance-scoped route delivers to just that one device', async () => { + const sender = await makeIdentity('did:plc:sendoneinstance'); + mockPlc(sender); + const recip: Did = 'did:plc:oneinstancerecipient'; + await q.ensureUser(env.DB, recip, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: recip, + senderDid: sender.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null + }); + // Two push devices; route to only the first by its instance id. + const laptop = await addPush(env.DB, recip, 'https://push.example/laptop', 'Laptop'); + await addPush(env.DB, recip, 'https://push.example/phone', 'Phone'); + await q.setDefaultRoute(env.DB, recip, `push:${laptop}`); + const jwt = await makeJwt(sender, { lxm: SEND }); + + const res = await call(xrpcPost(SEND, jwt, { recipient: recip, title: 'Hi', body: 'B' })); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ delivered: 1 }); +}); diff --git a/apps/relay/test/telegram-link.test.ts b/apps/relay/test/telegram-link.test.ts index 5fd9e69..cb58046 100644 --- a/apps/relay/test/telegram-link.test.ts +++ b/apps/relay/test/telegram-link.test.ts @@ -51,12 +51,40 @@ it('/start links the Telegram channel and consumes the token', asy ); expect(res.status).toBe(200); - const channel = await q.getChannelByPlatformUser(env.DB, 'telegram', '999'); + const channel = await q.getDeliveryTargetByRef(env.DB, 'telegram', '999'); expect(channel?.did).toBe(did); - expect(channel?.display_name).toBe('alice'); + expect(channel?.label).toBe('alice'); expect(await q.getLinkToken(env.DB, 'tok-valid')).toBeNull(); }); +it('/start applies a label carried on the link token and marks it named', async () => { + const did: Did = 'did:plc:tglabeled'; + await q.insertLinkToken(env.DB, { + token: 'tok-labeled', + did, + platform: 'telegram', + label: 'Work phone', + expiresAt: Date.now() + 600_000, + }); + + const res = await call( + webhook({ + update_id: 5, + message: { + message_id: 5, + chat: { id: 555, type: 'private' }, + from: { id: 555, username: 'carol' }, + text: '/start tok-labeled', + }, + }), + ); + + expect(res.status).toBe(200); + const channel = await q.getDeliveryTargetByRef(env.DB, 'telegram', '555'); + expect(channel?.label).toBe('Work phone'); // user label, not the @username + expect(channel?.named).toBe(1); +}); + it('/start does not link', async () => { const did: Did = 'did:plc:tgexpired'; await q.insertLinkToken(env.DB, { @@ -79,7 +107,7 @@ it('/start does not link', async () => { ); expect(res.status).toBe(200); - expect(await q.getChannelByPlatformUser(env.DB, 'telegram', '888')).toBeNull(); + expect(await q.getDeliveryTargetByRef(env.DB, 'telegram', '888')).toBeNull(); }); it('/start with no argument responds 200 without linking', async () => { @@ -96,7 +124,7 @@ it('/start with no argument responds 200 without linking', async () => { ); expect(res.status).toBe(200); - expect(await q.getChannelByPlatformUser(env.DB, 'telegram', '777')).toBeNull(); + expect(await q.getDeliveryTargetByRef(env.DB, 'telegram', '777')).toBeNull(); }); it('rejects a webhook with the wrong secret', async () => { diff --git a/apps/relay/test/webhook-channel.test.ts b/apps/relay/test/webhook-channel.test.ts new file mode 100644 index 0000000..bcd9fbd --- /dev/null +++ b/apps/relay/test/webhook-channel.test.ts @@ -0,0 +1,89 @@ +import type { Did } from '@atcute/lexicons'; +import { env } from 'cloudflare:test'; +import { expect, it } from 'vitest'; + +import * as q from '../src/db/queries'; +import * as ops from '../src/rpc/ops'; + +/** Webhook targets for `did`, as the unified TargetView (channel === 'webhook'). */ +async function webhooks(did: Did) { + return (await ops.listTargets(env, did)).filter((t) => t.channel === 'webhook'); +} + +it('addWebhook stores a webhook target with its url + label', async () => { + const did: Did = 'did:plc:wh-add'; + await ops.addWebhook(env, did, 'https://hook.example/abc', ' My server '); + expect(await webhooks(did)).toEqual([ + expect.objectContaining({ + channel: 'webhook', + url: 'https://hook.example/abc', + label: 'My server', // trimmed + }), + ]); + // The label is user-supplied at creation, so it's marked `named` (shown to apps). + const row = await q.getDeliveryTargetByRef(env.DB, 'webhook', `${did} https://hook.example/abc`); + expect(row?.named).toBe(1); +}); + +it('rejects a non-https URL', async () => { + const did: Did = 'did:plc:wh-http'; + await expect(ops.addWebhook(env, did, 'http://hook.example/x', 'L')).rejects.toThrow(); + expect(await webhooks(did)).toHaveLength(0); +}); + +it('rejects an invalid URL', async () => { + const did: Did = 'did:plc:wh-bad'; + await expect(ops.addWebhook(env, did, 'not a url', 'L')).rejects.toThrow(); + expect(await webhooks(did)).toHaveLength(0); +}); + +it('rejects non-public hosts (SSRF defense-in-depth)', async () => { + const did: Did = 'did:plc:wh-local'; + for (const url of [ + 'https://localhost/x', + 'https://127.0.0.1/x', + 'https://192.168.0.5/x', + 'https://10.0.0.1/x', + 'https://169.254.1.1/x', + 'https://printer.local/x', + ]) { + await expect(ops.addWebhook(env, did, url, 'L')).rejects.toThrow(); + } + expect(await webhooks(did)).toHaveLength(0); +}); + +it('requires a non-empty label', async () => { + const did: Did = 'did:plc:wh-nolabel'; + await expect(ops.addWebhook(env, did, 'https://hook.example/x', ' ')).rejects.toThrow(); + expect(await webhooks(did)).toHaveLength(0); +}); + +it('a user can add several webhooks and remove one by id', async () => { + const did: Did = 'did:plc:wh-multi'; + await ops.addWebhook(env, did, 'https://hook.example/one', 'One'); + await ops.addWebhook(env, did, 'https://hook.example/two', 'Two'); + let list = await webhooks(did); + expect(list).toHaveLength(2); + + const one = list.find((w) => w.channel === 'webhook' && w.url === 'https://hook.example/one'); + await ops.removeTarget(env, did, one!.id); + list = await webhooks(did); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ url: 'https://hook.example/two' }); +}); + +it('re-adding the same URL is idempotent (deduped per user)', async () => { + const did: Did = 'did:plc:wh-dup'; + await ops.addWebhook(env, did, 'https://hook.example/dup', 'First'); + await ops.addWebhook(env, did, 'https://hook.example/dup', 'Second'); + expect(await webhooks(did)).toHaveLength(1); +}); + +it('the same URL can belong to two different users', async () => { + const a: Did = 'did:plc:wh-shareA'; + const b: Did = 'did:plc:wh-shareB'; + await ops.addWebhook(env, a, 'https://hook.example/shared', 'A'); + await ops.addWebhook(env, b, 'https://hook.example/shared', 'B'); + expect(await webhooks(a)).toHaveLength(1); + expect(await webhooks(b)).toHaveLength(1); +}); diff --git a/apps/relay/test/webhook.test.ts b/apps/relay/test/webhook.test.ts new file mode 100644 index 0000000..87e9174 --- /dev/null +++ b/apps/relay/test/webhook.test.ts @@ -0,0 +1,64 @@ +import { afterEach, expect, it } from 'vitest'; + +import { sendWebhook, WebhookError } from '../src/delivery/webhook'; + +const channel = { platform: 'webhook' as const, url: 'https://hook.example/x' }; +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +it('POSTs a JSON envelope and resolves on 2xx', async () => { + let captured: Request | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + captured = new Request(input as RequestInfo, init); + return new Response(null, { status: 204 }); + }) as typeof fetch; + + await sendWebhook(channel, { + title: 'Hi', + body: 'There', + uri: 'https://x.example/post', + sender: 'did:plc:abc', + }); + + expect(captured).toBeDefined(); + expect(captured!.method).toBe('POST'); + expect(captured!.url).toBe('https://hook.example/x'); + expect(captured!.headers.get('content-type')).toContain('application/json'); + const body = (await captured!.json()) as Record; + expect(body).toMatchObject({ + title: 'Hi', + body: 'There', + uri: 'https://x.example/post', + sender: 'did:plc:abc', + }); + expect(typeof body.sentAt).toBe('string'); +}); + +it('omits uri when absent', async () => { + let captured: Request | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + captured = new Request(input as RequestInfo, init); + return new Response(null, { status: 200 }); + }) as typeof fetch; + + await sendWebhook(channel, { title: 'a', body: 'b', sender: 'did:plc:x' }); + + const body = (await captured!.json()) as Record; + expect('uri' in body).toBe(false); +}); + +it('throws WebhookError on a non-2xx response', async () => { + globalThis.fetch = (async () => new Response('nope', { status: 404 })) as typeof fetch; + await expect( + sendWebhook(channel, { title: 'a', body: 'b', sender: 'did:plc:x' }), + ).rejects.toBeInstanceOf(WebhookError); +}); + +it('surfaces the status code on WebhookError (so the dispatcher can reap 4xx)', async () => { + globalThis.fetch = (async () => new Response('', { status: 410 })) as typeof fetch; + await expect( + sendWebhook(channel, { title: 'a', body: 'b', sender: 'did:plc:x' }), + ).rejects.toMatchObject({ statusCode: 410 }); +}); diff --git a/apps/relay/test/webpush.test.ts b/apps/relay/test/webpush.test.ts index 1fea772..362b091 100644 --- a/apps/relay/test/webpush.test.ts +++ b/apps/relay/test/webpush.test.ts @@ -2,7 +2,6 @@ import type { Did } from '@atcute/lexicons'; import { env } from 'cloudflare:test'; import { expect, it } from 'vitest'; -import * as q from '../src/db/queries'; import { b64urlDecode, b64urlEncode, @@ -95,28 +94,33 @@ it('vapidAuthHeader signs a verifiable ES256 JWT with the right aud/sub', async expect(ok).toBe(true); }); +/** Push targets owned by `did`, as the unified TargetView (channel === 'push'). */ +async function pushTargets(did: Did) { + return (await ops.listTargets(env, did)).filter((t) => t.channel === 'push'); +} + it('registerWebPush stores a subscription; unregisterWebPush removes it (scoped to the user)', async () => { const did = 'did:plc:pushuser' as Did; const sub = { endpoint: 'https://push.example/abc', p256dh: 'fakePub', auth: 'fakeAuth' }; expect(await ops.registerWebPush(env, did, sub)).toEqual({ registered: true }); - let rows = await q.listPushSubscriptionsForDid(env.DB, did); + let rows = await pushTargets(did); expect(rows).toHaveLength(1); - expect(rows[0]?.endpoint).toBe(sub.endpoint); + expect(rows[0]).toMatchObject({ channel: 'push', endpoint: sub.endpoint }); // Another user can't drop it. expect(await ops.unregisterWebPush(env, 'did:plc:other' as Did, sub.endpoint)).toEqual({ unregistered: false, }); - expect(await q.listPushSubscriptionsForDid(env.DB, did)).toHaveLength(1); + expect(await pushTargets(did)).toHaveLength(1); // The owner can. expect(await ops.unregisterWebPush(env, did, sub.endpoint)).toEqual({ unregistered: true }); - rows = await q.listPushSubscriptionsForDid(env.DB, did); + rows = await pushTargets(did); expect(rows).toHaveLength(0); }); -it('listDevices labels devices; renameDevice updates; re-register preserves a label', async () => { +it('listTargets labels devices; renameTarget updates; re-register preserves a label', async () => { const did = 'did:plc:pushdevices' as Did; await ops.registerWebPush(env, did, { endpoint: 'https://push.example/d1', @@ -126,14 +130,16 @@ it('listDevices labels devices; renameDevice updates; re-register preserves a la }); await ops.registerWebPush(env, did, { endpoint: 'https://push.example/d2', p256dh: 'k', auth: 'a' }); - let devices = await ops.listDevices(env, did); + let devices = await pushTargets(did); expect(devices).toHaveLength(2); - expect(devices.find((d) => d.endpoint === 'https://push.example/d1')?.label).toBe('Chrome · macOS'); - expect(devices.find((d) => d.endpoint === 'https://push.example/d2')?.label).toBe('Unknown device'); + const d1 = devices.find((d) => d.channel === 'push' && d.endpoint === 'https://push.example/d1'); + const d2 = devices.find((d) => d.channel === 'push' && d.endpoint === 'https://push.example/d2'); + expect(d1?.label).toBe('Chrome · macOS'); + expect(d2?.label).toBe('Unknown device'); - expect((await ops.renameDevice(env, did, 'https://push.example/d2', 'Work laptop')).ok).toBe(true); - devices = await ops.listDevices(env, did); - expect(devices.find((d) => d.endpoint === 'https://push.example/d2')?.label).toBe('Work laptop'); + expect((await ops.renameTarget(env, did, d2!.id, 'Work laptop')).ok).toBe(true); + devices = await pushTargets(did); + expect(devices.find((d) => d.id === d2!.id)?.label).toBe('Work laptop'); // Re-registering (e.g. key refresh) keeps the existing label. await ops.registerWebPush(env, did, { @@ -143,6 +149,7 @@ it('listDevices labels devices; renameDevice updates; re-register preserves a la label: 'ignored' }); expect( - (await ops.listDevices(env, did)).find((d) => d.endpoint === 'https://push.example/d1')?.label + (await pushTargets(did)).find((d) => d.channel === 'push' && d.endpoint === 'https://push.example/d1') + ?.label ).toBe('Chrome · macOS'); }); diff --git a/apps/web/src/lib/components/ChannelRoutePicker.svelte b/apps/web/src/lib/components/ChannelRoutePicker.svelte index 4a6a813..fb06861 100644 --- a/apps/web/src/lib/components/ChannelRoutePicker.svelte +++ b/apps/web/src/lib/components/ChannelRoutePicker.svelte @@ -1,59 +1,255 @@ -
- {#if inherit} +{#snippet box(state: 'on' | 'partial' | 'off')} + + {#if state === 'on'} + + {:else if state === 'partial'} + + {/if} + +{/snippet} + +
+
+ {#if inherit} + + {/if} - {/if} - {#each CHANNELS as c (c.id)} - {@const active = !inheriting && selected.includes(c.id)} - {/each} + +
+ + {#if mode === 'custom'} + {@const available = CHANNELS.filter((c) => instancesFor(c).length > 0)} +
+ {#if available.length === 0} +

+ No channels connected. + Add one in settings. +

+ {:else} +
+ {#each available as c (c)} + {@const pstate = parentState(c)} + {@const kids = instancesFor(c)} +
+ + {#if kids.length > 1} +
+ {#each kids as k (k.id)} + + {/each} +
+ {/if} +
+ {/each} +
+ {/if} +
+ {/if}
diff --git a/apps/web/src/lib/components/Icon.svelte b/apps/web/src/lib/components/Icon.svelte index 98c7390..f33d862 100644 --- a/apps/web/src/lib/components/Icon.svelte +++ b/apps/web/src/lib/components/Icon.svelte @@ -20,6 +20,7 @@ | 'moon' | 'mute' | 'arrow-right' + | 'info' | 'send'; interface Props { @@ -100,6 +101,10 @@ {:else if name === 'arrow-right'} + {:else if name === 'info'} + + + {:else if name === 'send'} diff --git a/apps/web/src/lib/components/RouteChip.svelte b/apps/web/src/lib/components/RouteChip.svelte index ad7ee3c..0cde603 100644 --- a/apps/web/src/lib/components/RouteChip.svelte +++ b/apps/web/src/lib/components/RouteChip.svelte @@ -9,6 +9,8 @@ push: 145, telegram: 220, email: 25, + dm: 195, + webhook: 290, inbox: 250 }; diff --git a/apps/web/src/lib/push.ts b/apps/web/src/lib/push.ts index 50053d4..67bc09c 100644 --- a/apps/web/src/lib/push.ts +++ b/apps/web/src/lib/push.ts @@ -7,8 +7,10 @@ export interface FlatPushSubscription { endpoint: string; p256dh: string; auth: string; - /** Auto-detected device label (only set by `subscribe`). */ + /** Device label: a user-chosen name if `named`, else the auto UA descriptor. */ label?: string; + /** True when `label` is a name the user typed (shown to apps as-is). */ + named?: boolean; } /** Best-effort "Browser · OS" label from the User-Agent (user can rename later). */ @@ -66,28 +68,44 @@ function flatten(sub: PushSubscription): FlatPushSubscription { export async function currentSubscription(): Promise { if (!pushSupported()) return null; - const reg = await navigator.serviceWorker.ready; + // Use getRegistration() (resolves immediately, even before the SW is active) + // rather than `.ready`, which never resolves until a worker is active and so + // hangs detection when the SW is still registering (notably in dev). + const reg = await navigator.serviceWorker.getRegistration(); + if (!reg) return null; const sub = await reg.pushManager.getSubscription(); return sub ? flatten(sub) : null; } -/** Request permission + subscribe this browser; returns the flattened subscription. */ -export async function subscribe(): Promise { +/** The SW registration, preferring an existing one (no hang) over `.ready`. */ +async function registration(): Promise { + return (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.ready); +} + +/** + * Request permission + subscribe this browser; returns the flattened subscription. + * An optional `name` is the user's chosen device label (named); without one we + * fall back to the auto "Browser · OS" descriptor. + */ +export async function subscribe(name?: string): Promise { const permission = await Notification.requestPermission(); if (permission !== 'granted') { throw new Error('Notification permission was not granted'); } - const reg = await navigator.serviceWorker.ready; + const reg = await registration(); const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) }); - return { ...flatten(sub), label: deviceLabel() }; + const trimmed = name?.trim(); + return trimmed + ? { ...flatten(sub), label: trimmed, named: true } + : { ...flatten(sub), label: deviceLabel(), named: false }; } /** Unsubscribe this browser; returns the endpoint that was removed (or null). */ export async function unsubscribe(): Promise { - const reg = await navigator.serviceWorker.ready; + const reg = await registration(); const sub = await reg.pushManager.getSubscription(); if (!sub) return null; const { endpoint } = sub; diff --git a/apps/web/src/lib/remote/notifs.remote.ts b/apps/web/src/lib/remote/notifs.remote.ts index beda3a6..c4fe48e 100644 --- a/apps/web/src/lib/remote/notifs.remote.ts +++ b/apps/web/src/lib/remote/notifs.remote.ts @@ -46,20 +46,22 @@ export const setNotifyPending = command(v.object({ value: v.boolean() }), async await requireRelay().updateSettings({ notifyPendingViaTelegram: value }); }); -/** Returns the Telegram deep link; the client navigates to it. */ -export const linkTelegram = command(async () => { - const { deepLink } = await requireRelay().linkChannel({ platform: 'telegram' }); - return { deepLink }; -}); - -export const unlinkTelegram = command(async () => { - await requireRelay().unlinkChannel({ platform: 'telegram' }); -}); +const optionalLabel = v.optional(v.pipe(v.string(), v.trim(), v.maxLength(64))); + +/** Returns the Telegram deep link; the client navigates to it. Optional `label` + * is the user's chosen name, applied to the chat once linked. */ +export const linkTelegram = command( + v.object({ label: optionalLabel }), + async ({ label }) => { + const { deepLink } = await requireRelay().linkChannel({ platform: 'telegram' }, label); + return { deepLink }; + } +); export const linkEmail = command( - v.object({ address: v.pipe(v.string(), v.email()) }), - async ({ address }) => { - await requireRelay().linkEmail(address); + v.object({ address: v.pipe(v.string(), v.email()), label: optionalLabel }), + async ({ address, label }) => { + await requireRelay().linkEmail(address, label); } ); @@ -68,8 +70,20 @@ export const verifyEmail = command( async ({ code }) => requireRelay().verifyEmail(code) ); -export const unlinkEmail = command(async () => { - await requireRelay().unlinkEmail(); +/** Add a webhook target: the relay POSTs notifications to `url`. Relay re-validates. */ +export const addWebhook = command( + v.object({ + url: v.pipe(v.string(), v.url(), v.startsWith('https://', 'Webhook URL must use https://')), + label: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(64)) + }), + async ({ url, label }) => { + await requireRelay().addWebhook(url, label); + } +); + +/** Enable Bluesky DM delivery (the relay's bot DMs the signed-in user). */ +export const enableDM = command(async () => { + await requireRelay().enableDM(); }); export const registerPush = command( @@ -77,7 +91,8 @@ export const registerPush = command( endpoint: v.string(), p256dh: v.string(), auth: v.string(), - label: v.optional(v.string()) + label: v.optional(v.string()), + named: v.optional(v.boolean()) }), async (sub) => { await requireRelay().registerWebPush(sub); @@ -88,13 +103,19 @@ export const unregisterPush = command(v.object({ endpoint: v.string() }), async await requireRelay().unregisterWebPush(endpoint); }); -export const renameDevice = command( - v.object({ endpoint: v.string(), label: v.string() }), - async ({ endpoint, label }) => { - await requireRelay().renameDevice(endpoint, label); +/** Rename any delivery target (push device, Telegram chat, email). */ +export const renameTarget = command( + v.object({ id: v.string(), label: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(64)) }), + async ({ id, label }) => { + await requireRelay().renameTarget(id, label); } ); +/** Remove any delivery target by id (unlink a Telegram chat, drop an email/device). */ +export const removeTarget = command(v.object({ id: v.string() }), async ({ id }) => { + await requireRelay().removeTarget(id); +}); + export const markNotificationsRead = command( v.object({ ids: v.optional(v.array(v.string())), all: v.optional(v.boolean()) }), async (input) => { @@ -102,6 +123,12 @@ export const markNotificationsRead = command( } ); +/** Permanently delete every notification from one app. Returns the deleted count. */ +export const clearAppNotifications = command( + v.object({ sender: didSchema }), + async ({ sender }) => requireRelay().clearNotificationsFromSender(sender as Did) +); + // A route is a `+`-joined channel set or 'off'; app/category add an inherit sentinel. const concreteRoute = v.pipe(v.string(), v.check(isConcreteRoute, 'Invalid route')); const appRoute = v.pipe( diff --git a/apps/web/src/lib/routes.ts b/apps/web/src/lib/routes.ts index 43fc66f..5a581d2 100644 --- a/apps/web/src/lib/routes.ts +++ b/apps/web/src/lib/routes.ts @@ -3,23 +3,27 @@ // See @atmo/notifs-lexicons (routeChannels / channelsRoute). import { type Channel, routeChannels } from '@atmo/notifs-lexicons'; -/** Channels offered in the routing picker, in display order. */ -export const CHANNELS: { id: Channel; label: string }[] = [ - { id: 'push', label: 'Push' }, - { id: 'telegram', label: 'Telegram' }, - { id: 'email', label: 'Email' } -]; - -const CHANNEL_LABEL: Record = { +// Partial so adding a Channel never leaves this stale; `channelLabel` falls back +// to a capitalized id for any channel without an explicit label here. +const CHANNEL_LABEL: Partial> = { push: 'Push', telegram: 'Telegram', - email: 'Email' + email: 'Email', + dm: 'DM', + webhook: 'Webhook' }; -/** Human label for a route string (a channel set, 'off', or an inherit sentinel). */ +/** Display label for a single channel (explicit, else capitalized id). */ +export function channelLabel(c: Channel): string { + return CHANNEL_LABEL[c] ?? c.charAt(0).toUpperCase() + c.slice(1); +} + +/** Human label for a route string: inherit sentinel, 'off', 'inbox', or a token set. */ export function routeLabel(route: string): string { if (route === 'default') return 'Account default'; - if (route === 'app') return 'Like app'; + if (route === 'app') return 'App default'; + if (route === 'off') return 'Off'; + if (route === 'inbox') return 'Inbox only'; const ch = routeChannels(route); - return ch.length > 0 ? ch.map((c) => CHANNEL_LABEL[c]).join(' + ') : 'Off'; + return ch.length > 0 ? ch.map(channelLabel).join(' + ') : 'Inbox only'; } diff --git a/apps/web/src/lib/server/relay.ts b/apps/web/src/lib/server/relay.ts index 93e80ef..af5049f 100644 --- a/apps/web/src/lib/server/relay.ts +++ b/apps/web/src/lib/server/relay.ts @@ -21,7 +21,6 @@ import type { PubAtmoNotifyLinkChannel, PubAtmoNotifyMuteGrant, PubAtmoNotifyRevoke, - PubAtmoNotifyUnlinkChannel, PubAtmoNotifyUpdateSettings } from '@atmo/notifs-lexicons'; @@ -45,26 +44,27 @@ export function relayFor(platform: App.Platform | undefined, did: Did | null) { listGrants: () => svc.listGrants(did), listPending: () => svc.listPending(did), listApps: () => svc.listApps(), - listChannels: () => svc.listChannels(did), getSettings: () => svc.getSettings(did), grant: (input: PubAtmoNotifyGrant.$input) => svc.grant(did, input), revoke: (input: PubAtmoNotifyRevoke.$input) => svc.revoke(did, input), denyPending: (input: PubAtmoNotifyDenyPending.$input) => svc.denyPending(did, input), muteGrant: (input: PubAtmoNotifyMuteGrant.$input) => svc.muteGrant(did, input), - linkChannel: (input: PubAtmoNotifyLinkChannel.$input) => svc.linkChannel(did, input), - unlinkChannel: (input: PubAtmoNotifyUnlinkChannel.$input) => svc.unlinkChannel(did, input), - linkEmail: (address: string) => svc.linkEmail(did, address), + linkChannel: (input: PubAtmoNotifyLinkChannel.$input, label?: string) => + svc.linkChannel(did, input, label), + linkEmail: (address: string, label?: string) => svc.linkEmail(did, address, label), verifyEmail: (code: string) => svc.verifyEmail(did, code), - unlinkEmail: () => svc.unlinkEmail(did), - getEmailChannel: () => svc.getEmailChannel(did), + addWebhook: (url: string, label: string) => svc.addWebhook(did, url, label), + enableDM: () => svc.enableDM(did), + listTargets: () => svc.listTargets(did), + renameTarget: (id: string, label: string) => svc.renameTarget(did, id, label), + removeTarget: (id: string) => svc.removeTarget(did, id), updateSettings: (input: PubAtmoNotifyUpdateSettings.$input) => svc.updateSettings(did, input), registerWebPush: (sub: PushSubscriptionInput) => svc.registerWebPush(did, sub), unregisterWebPush: (endpoint: string) => svc.unregisterWebPush(did, endpoint), - listDevices: () => svc.listDevices(did), - renameDevice: (endpoint: string, label: string) => svc.renameDevice(did, endpoint, label), listNotifications: (cursor?: string) => svc.listNotifications(did, cursor), markRead: (input: MarkReadInput) => svc.markRead(did, input), + clearNotificationsFromSender: (sender: Did) => svc.clearNotificationsFromSender(did, sender), getRouting: () => svc.getRouting(did), setRouting: (sender: Did, category: string, route: CategoryRoute) => svc.setRouting(did, sender, category, route), diff --git a/apps/web/src/routes/(app)/apps/+page.svelte b/apps/web/src/routes/(app)/apps/+page.svelte index c875a10..55e29a3 100644 --- a/apps/web/src/routes/(app)/apps/+page.svelte +++ b/apps/web/src/routes/(app)/apps/+page.svelte @@ -2,15 +2,13 @@ import { invalidateAll } from '$app/navigation'; import AppMark from '$lib/components/AppMark.svelte'; import Icon from '$lib/components/Icon.svelte'; - import IOSToggle from '$lib/components/IOSToggle.svelte'; import RelativeTime from '$lib/components/RelativeTime.svelte'; - import { approve, deny, revoke, setMuted } from '$lib/remote/notifs.remote'; + import { approve, deny } from '$lib/remote/notifs.remote'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); let busy = $state>({}); - let confirming = $state>({}); let errorMsg = $state(''); /** Run a mutation, refresh page data, and surface errors. */ @@ -38,7 +36,8 @@

Apps

- {data.grants.length} connected · {data.pending.length} pending + {data.grants.length} connected{#if data.pending.length > 0} + · {data.pending.length} pending{/if}

@@ -52,24 +51,15 @@ {/if}
{errorMsg}
- -
-

- Pending requests · {data.pending.length} -

- {#if data.pending.length === 0} -
-

No pending requests

-

- When an app asks to notify you, it'll show up here for approval. -

-
- {:else} + + {#if data.pending.length > 0} +
+

+ Pending requests · {data.pending.length} +

    {#each data.pending as p (p.id)} -
  • +
  • {#if p.iconUrl ?? p.senderBskyAvatar} run(`approve:${p.id}`, () => approve({ sender: p.sender, requestId: p.id }))} + onclick={() => + run(`approve:${p.id}`, () => approve({ sender: p.sender, requestId: p.id }))} > Approve @@ -126,8 +117,8 @@
  • {/each}
- {/if} -
+
+ {/if}
@@ -138,91 +129,32 @@

No apps yet

- When you approve an app, it shows up here. You can mute or revoke it any time. + When you approve an app, it shows up here. Tap one to manage its routing, mute, or revoke + it.

{:else} - diff --git a/apps/web/src/routes/(app)/apps/[sender]/+page.server.ts b/apps/web/src/routes/(app)/apps/[sender]/+page.server.ts index 6da1c34..37e9719 100644 --- a/apps/web/src/routes/(app)/apps/[sender]/+page.server.ts +++ b/apps/web/src/routes/(app)/apps/[sender]/+page.server.ts @@ -1,3 +1,5 @@ +import { emptyRouteInstances } from '@atmo/notifs-lexicons'; + import { relayFor } from '$lib/server/relay'; import type { PageServerLoad } from './$types'; @@ -10,5 +12,9 @@ export const load: PageServerLoad = async ({ locals, platform, params }) => { const routing = await relay.getRouting(); const app = routing?.apps.find((a) => a.sender === params.sender) ?? null; - return { app, defaultRoute: routing?.defaultRoute ?? 'push' }; + return { + app, + defaultRoute: routing?.defaultRoute ?? 'push', + channels: routing?.channels ?? emptyRouteInstances() + }; }; diff --git a/apps/web/src/routes/(app)/apps/[sender]/+page.svelte b/apps/web/src/routes/(app)/apps/[sender]/+page.svelte index 05288ef..ff3ab01 100644 --- a/apps/web/src/routes/(app)/apps/[sender]/+page.svelte +++ b/apps/web/src/routes/(app)/apps/[sender]/+page.svelte @@ -1,9 +1,17 @@ Settings · atmo.pub @@ -222,10 +263,21 @@ {#if tab === 'channels'}

- Where atmo.pub can reach you. Apps and categories route to one or more of these channels — - everything also lands in your inbox. + Where atmo.pub can reach you. You can connect several of each and give them names; apps and + categories route to one or more of these — everything also lands in your inbox.

+
+ +

+ Apps you connect can see the names you give these channels + (so they can show you a routing picker) — but never the email address, phone number, or device + behind them. +

+
+
@@ -244,28 +296,26 @@ {#if data.devices.length > 0}
    - {#each data.devices as d (d.endpoint)} + {#each data.devices as d (d.id)}
  • - {#if renaming === d.endpoint} + {#if renaming === d.id} { - if (e.key === 'Enter') saveRename(d.endpoint); + if (e.key === 'Enter') saveRename(d.id); else if (e.key === 'Escape') renaming = null; }} />
    - +
    {:else}
    @@ -284,28 +334,19 @@
- - Rename - {#if d.endpoint === currentEndpoint} - {:else} {/if}
@@ -315,22 +356,43 @@ {/if} - {#if pushState === 'unsupported'} + {#if pushState === 'loading'} +

Checking this browser…

+ {:else if pushState === 'unsupported'}

Push isn't available in this browser.

- {:else if pushState === 'ready' && currentEndpoint === null} + {:else if !thisDeviceConnected} + {@const reconnect = currentEndpoint !== null}
0 ? 'mt-3 border-t border-line-2 pt-3' : ''}> - {#if data.devices.length === 0} -

+

+ {#if reconnect} + This browser was subscribed but isn't linked to your account anymore — reconnect it. + {:else} Get notifications in this browser, even when it's closed. -

- {/if} - + {/if} +

+
+ {#if !reconnect} + { + if (e.key === 'Enter') enablePush(); + }} + /> + {/if} + +
{/if}
@@ -340,41 +402,58 @@

Telegram

-
-
- - Telegram DM -
- {#if telegram} - - {:else} - - {/if} +
+ + Telegram DM
- {#if telegram} -
-
-
{telegram.displayName ?? 'Linked'}
-
- linked -
-
-
-
+ + {#if data.telegrams.length > 0} +
    + {#each data.telegrams as t (t.id)} +
  • + {#if renaming === t.id} + { + if (e.key === 'Enter') saveRename(t.id); + else if (e.key === 'Escape') renaming = null; + }} + /> +
    + + +
    + {:else} +
    +
    {t.label}
    +
    + linked +
    +
    +
    + + +
    + {/if} +
  • + {/each} +
+ +
@@ -386,8 +465,100 @@ onchange={(value) => run('setting', () => setNotifyPending({ value }))} />
+ {/if} + +
0 ? 'mt-3 border-t border-line-2 pt-3' : ''}> + {#if data.telegrams.length === 0} +

Link a Telegram chat to get notifications there.

+ {/if} +
+ { + if (e.key === 'Enter') connectTelegram(); + }} + /> + +
+
+
+
+ + +
+

Bluesky DM

+
+
+ + Bluesky DM +
+ + {#if data.dms.length > 0} + {#each data.dms as dm (dm.id)} + {#if renaming === dm.id} +
+ { + if (ev.key === 'Enter') saveRename(dm.id); + else if (ev.key === 'Escape') renaming = null; + }} + /> +
+ + +
+
+ {:else} +
+
+ + {dm.label} +
+
+ + +
+
+

+ The atmo.pub bot will DM you on Bluesky. Make sure your Bluesky chat settings allow + messages from it. +

+ {/if} + {/each} {:else} -

Link a Telegram chat to get notifications there.

+

+ Get notifications as a Bluesky direct message from the atmo.pub bot. +

+ {/if}
@@ -396,68 +567,210 @@

Email

- {#if !data.email} -

Get notifications by email.

-
+
+ + Email +
+ + {#if data.emails.length > 0} +
    + {#each data.emails as e (e.id)} +
  • + {#if renaming === e.id} +
    + { + if (ev.key === 'Enter') saveRename(e.id); + else if (ev.key === 'Escape') renaming = null; + }} + /> +
    + + +
    +
    + {:else} +
    +
    + {#if e.verified} + + {/if} + {e.label} +
    +
    + + +
    +
    + {#if e.label !== e.address} +
    + {e.address} +
    + {/if} + {#if !e.verified} +
    + Pending — enter the 6-digit code we emailed you. +
    +
    + + +
    + {/if} + {/if} +
  • + {/each} +
+ {/if} + +
0 ? 'border-t border-line-2 pt-3' : ''}> + {#if data.emails.length === 0} +

Get notifications by email.

+ {/if} +
+ { + if (e.key === 'Enter') submitEmail(); + }} + /> { + if (e.key === 'Enter') submitEmail(); + }} />
- {:else if !data.email.verified} -
-
-
{data.email.address}
-
Pending — enter the 6-digit code we emailed you.
-
- -
-
+
+
+
+ + +
+

Webhooks

+
+
+ + Webhook +
+ + {#if data.webhooks.length > 0} +
    + {#each data.webhooks as w (w.id)} +
  • + {#if renaming === w.id} +
    + { + if (ev.key === 'Enter') saveRename(w.id); + else if (ev.key === 'Escape') renaming = null; + }} + /> +
    + + +
    +
    + {:else} +
    + {w.label} +
    + + +
    +
    +
    {w.url}
    + {/if} +
  • + {/each} +
+ {/if} + +
0 ? 'border-t border-line-2 pt-3' : ''}> + {#if data.webhooks.length === 0} +

+ Send notifications to your own server. We POST JSON ({'{'} title, body, uri, sender, sentAt + {'}'}) to the URL over HTTPS. +

+ {/if} +
{ + if (e.key === 'Enter') submitWebhook(); + }} + /> + { + if (e.key === 'Enter') submitWebhook(); + }} /> -
- {:else} -
-
- - {data.email.address} -
-
- {/if} +
{:else if tab === 'routing'} @@ -467,13 +780,16 @@
Where notifications go by default

- Pick which channels fire by default — everything always lands in your inbox regardless. - Apps set to “Account default” use this; override per-app (and per-category) from + Pick where notifications go by default. Inbox only + saves them with no alerts; Off drops them + entirely. Apps set to “Account default” use this; override per-app (and per-category) from Apps.

run('defaultRoute', () => setDefaultRoute({ route }))} /> diff --git a/docs/MANAGEMENT-AUTH.md b/docs/MANAGEMENT-AUTH.md index e72ffba..903a5a7 100644 --- a/docs/MANAGEMENT-AUTH.md +++ b/docs/MANAGEMENT-AUTH.md @@ -66,10 +66,11 @@ or flag may widen a `self` app beyond its own slice. The knobs below govern ### 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. +- **Vouch** — app token + a user DID asserted in the body, **no user token**. + **Removed from the public XRPC surface (2026-05): every federated management call + now requires a fresh user token (dual-auth), so there is no standing token-free + access.** The only remaining vouch is the first-party service **Binding** (below) — + a transport-level vouch for atmo.pub's own UI, not reachable by third-party apps. - **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). @@ -97,23 +98,24 @@ separately; each takes `off | relay-allowlist | user-allowlist | open`: 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. +A per-user `self`/`full` designation satisfies both read and write admission, but +the app must still present a fresh user token on every call (no 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 | +| Caller for `(user, app)` | May touch | Vouch (binding only) | 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) | ✅ | +| First-party binding (atmo.pub) | whole account | ✅ (transport-level) | n/a | +| Per-user `full` manager (XRPC) | whole account | ❌ | ✅ | +| Per-user `self` app (XRPC) | own slice | ❌ | ✅ | +| Granted, undesignated, policy=`open`/allowed | own slice | ❌ | ✅ | | 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.** +Rule of thumb: **over XRPC every management call is dual-auth (a fresh user token); +vouch exists only as the first-party service binding; whole-account needs manager +status.** ## Per-method capability @@ -197,11 +199,11 @@ relay-local-now / repo-portable-later shape as the subscriber sync in 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. + 2. Require `input.userToken` → `verifyServiceToken` → user DID. No token → 403 + (no vouch path over XRPC). 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. + 4. Admit only if capability ≥ `need` (or the undesignated-self open policy). 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. diff --git a/packages/lexicons/lexicons/pub/atmo/notify/getRouting.json b/packages/lexicons/lexicons/pub/atmo/notify/getRouting.json index f8afc18..edc5867 100644 --- a/packages/lexicons/lexicons/pub/atmo/notify/getRouting.json +++ b/packages/lexicons/lexicons/pub/atmo/notify/getRouting.json @@ -22,26 +22,46 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["route", "defaultRoute", "categories"], + "required": ["route", "defaultRoute", "categories", "targets"], "properties": { "route": { "type": "string", - "description": "App-wide route: a '+'-joined channel set (push|telegram|email), 'off', or 'default' to inherit the account default." + "description": "App-wide route: a '+'-joined set of channel tokens (a bare channel push|telegram|email, or 'channel:' for one delivery instance), 'off', or 'default' to inherit the account default." }, "defaultRoute": { "type": "string", - "description": "The user's account-default route: a '+'-joined channel set (push|telegram|email) or 'off'." + "description": "The user's account-default route: a '+'-joined set of channel tokens (bare channel or 'channel:') or 'off'." }, "categories": { "type": "array", "description": "Categories this app has used for the user, with their current routing.", "items": { "type": "ref", "ref": "#category" } + }, + "targets": { + "type": "array", + "description": "The user's deliverable targets (one per push device / telegram chat / email / etc.), so the app can render a full route picker — including channels not in the current route. Use a target's `id` in a `channel:` route token. Labels are privacy-safe: a user-chosen name, else a generic one (no raw email/handle unless the user named it that).", + "items": { "type": "ref", "ref": "#target" } } } } }, "errors": [{ "name": "NotAuthorized" }] }, + "target": { + "type": "object", + "required": ["type", "id", "label"], + "properties": { + "type": { + "type": "string", + "description": "The channel this target belongs to (push|telegram|email|dm|webhook)." + }, + "id": { + "type": "string", + "description": "Opaque, stable instance id — use it in a 'channel:' route token." + }, + "label": { "type": "string", "description": "Privacy-safe display name for the target." } + } + }, "category": { "type": "object", "required": ["id", "route"], @@ -53,7 +73,7 @@ }, "route": { "type": "string", - "description": "Route for this category: a '+'-joined channel set (push|telegram|email), 'off', or 'app' to inherit the app-wide route." + "description": "Route for this category: a '+'-joined set of channel tokens (bare channel or 'channel:'), 'off', or 'app' to inherit the app-wide route." } } } diff --git a/packages/lexicons/lexicons/pub/atmo/notify/setRouting.json b/packages/lexicons/lexicons/pub/atmo/notify/setRouting.json index 59cdeaf..89d9d90 100644 --- a/packages/lexicons/lexicons/pub/atmo/notify/setRouting.json +++ b/packages/lexicons/lexicons/pub/atmo/notify/setRouting.json @@ -17,7 +17,7 @@ }, "route": { "type": "string", - "description": "App-wide route: a '+'-joined channel set of push|telegram|email (e.g. 'push+email'), 'off' for none, or 'default' to inherit the account default. Omit to leave unchanged." + "description": "App-wide route: a '+'-joined set of channel tokens (e.g. 'push+email'), 'off' for none, or 'default' to inherit the account default. A token is a bare channel (push|telegram|email = all of that channel's instances) or a channel narrowed to one delivery instance as 'channel:' (e.g. 'push:a1b2c3' = one specific device). Omit to leave unchanged." }, "categories": { "type": "array", @@ -49,7 +49,7 @@ }, "route": { "type": "string", - "description": "Route for this category: a '+'-joined channel set of push|telegram|email, 'off' for none, or 'app' to inherit the app-wide route." + "description": "Route for this category: a '+'-joined set of channel tokens (a bare channel, or 'channel:' to target one delivery instance), 'off' for none, or 'app' to inherit the app-wide route." } } } diff --git a/packages/lexicons/src/rpc.ts b/packages/lexicons/src/rpc.ts index 59debd9..e1bf78d 100644 --- a/packages/lexicons/src/rpc.ts +++ b/packages/lexicons/src/rpc.ts @@ -18,12 +18,10 @@ import type { PubAtmoNotifyGetSettings, PubAtmoNotifyGrant, PubAtmoNotifyLinkChannel, - PubAtmoNotifyListChannels, PubAtmoNotifyListGrants, PubAtmoNotifyListPending, PubAtmoNotifyMuteGrant, PubAtmoNotifyRevoke, - PubAtmoNotifyUnlinkChannel, PubAtmoNotifyUpdateSettings, } from './lexicons/index.js'; @@ -32,23 +30,33 @@ export interface PushSubscriptionInput { endpoint: string; p256dh: string; auth: string; - /** Auto-detected device label (from the User-Agent); preserved across re-subscribes. */ + /** Device label: a user-chosen name if `named`, else the auto User-Agent + * descriptor; preserved across re-subscribes. */ label?: string; + /** True when `label` is a user-chosen name (shown to apps as-is). */ + named?: boolean; } -/** A registered web push device (binding-only). */ -export interface DeviceView { - endpoint: string; - label: string; - createdAt: string; -} - -/** A user's email delivery channel (binding-only). */ -export interface EmailChannelView { - address: string; - /** false while a verification code is outstanding. */ - verified: boolean; -} +/** + * One of a user's delivery targets — a push device, a Telegram chat, an email, + * a Bluesky DM, or a webhook (binding-only). Discriminated by `channel`. `id` is + * the stable token a route uses to target this instance; `label` is the + * user-editable name. + */ +export type TargetView = + | { id: string; channel: 'push'; label: string; endpoint: string; createdAt: string } + | { id: string; channel: 'telegram'; label: string; createdAt: string } + | { + id: string; + channel: 'email'; + label: string; + address: string; + /** false while a verification code is outstanding. */ + verified: boolean; + createdAt: string; + } + | { id: string; channel: 'dm'; label: string; createdAt: string } + | { id: string; channel: 'webhook'; label: string; url: string; createdAt: string }; /** A notification as shown in the inbox (binding-only; no public lexicon). */ export interface NotificationView { @@ -76,42 +84,122 @@ export interface MarkReadInput { } /** The alert channels a route can fire. Everything is in the inbox regardless. */ -export const CHANNELS = ['push', 'telegram', 'email'] as const; +export const CHANNELS = ['push', 'telegram', 'email', 'dm', 'webhook'] as const; export type Channel = (typeof CHANNELS)[number]; -// A route is a concrete channel SET, encoded as a `+`-joined string in canonical -// CHANNELS order ('off' = none) — e.g. 'push', 'push+email', 'off'. App-wide and +// A route is a `+`-joined set of TOKENS in canonical CHANNELS order ('off' = none). +// A token is either a bare channel ('push' = all instances of that channel) or a +// channel with a specific instance id ('push:' = just that device/chat), so a +// user can route to one of several push devices / telegram chats. Examples: +// 'push', 'push+email', 'push:a1b2c3', 'push:a1b2c3+telegram', 'off'. App-wide and // per-category routes add the inherit sentinels 'default' / 'app'. Stored as a -// string; existing values ('push'/'telegram'/'push+telegram'/'off') are valid sets. -/** A concrete route: a `+`-joined channel set, or 'off'. */ +// string; legacy values ('push'/'telegram'/'push+telegram'/'off') are valid sets. +/** A concrete route: a `+`-joined token set, or 'off'. */ export type AlertRoute = string; /** App-wide route: a concrete route, or 'default' (inherit the account default). */ export type AppRoute = string; /** Per-category route: a concrete route, or 'app' (inherit the app-wide route). */ export type CategoryRoute = string; -/** Parse a route string into its channels ('off'/'default'/'app'/'' → []). */ +/** A parsed route token: a channel, optionally narrowed to one instance id. */ +export interface RouteToken { + channel: Channel; + /** A specific delivery-instance id (see RouteInstance); absent = all instances. */ + instance?: string; +} + +/** Instance-id format: lowercase hex/alphanumeric, kept short. */ +const INSTANCE_ID_RE = /^[a-z0-9]+$/i; + +/** True for routes that carry no channel tokens: the inherit sentinels, plus + * 'off' (drop entirely) and 'inbox' (inbox only, no alerts), and ''. */ +function isSentinel(route: string): boolean { + return ( + route === 'off' || + route === 'inbox' || + route === 'default' || + route === 'app' || + route === '' + ); +} + +/** Parse a route string into its tokens (sentinels/'' → []). Unknown channels skipped. */ +export function parseRoute(route: string): RouteToken[] { + if (isSentinel(route)) return []; + const tokens: RouteToken[] = []; + for (const part of route.split('+')) { + const idx = part.indexOf(':'); + const channel = (idx === -1 ? part : part.slice(0, idx)) as Channel; + if (!(CHANNELS as readonly string[]).includes(channel)) continue; + const instance = idx === -1 ? undefined : part.slice(idx + 1); + tokens.push(instance ? { channel, instance } : { channel }); + } + return tokens; +} + +/** Encode tokens as a canonical route string ('off' when empty). A bare channel + * token subsumes any instance tokens for the same channel. */ +export function formatRoute(tokens: readonly RouteToken[]): string { + const parts: string[] = []; + for (const c of CHANNELS) { + const forChannel = tokens.filter((t) => t.channel === c); + if (forChannel.length === 0) continue; + if (forChannel.some((t) => t.instance === undefined)) { + parts.push(c); + continue; + } + const seen = new Set(); + for (const t of forChannel) { + if (t.instance !== undefined && !seen.has(t.instance)) { + seen.add(t.instance); + parts.push(`${c}:${t.instance}`); + } + } + } + return parts.length > 0 ? parts.join('+') : 'off'; +} + +/** The distinct channels a route fires ('off'/'default'/'app'/'' → []). */ export function routeChannels(route: string): Channel[] { - if (route === 'off' || route === 'default' || route === 'app' || route === '') return []; - const set = new Set(route.split('+')); - return CHANNELS.filter((c) => set.has(c)); + const present = new Set(); + for (const t of parseRoute(route)) present.add(t.channel); + return CHANNELS.filter((c) => present.has(c)); } -/** Encode channels as a canonical route string ('off' when empty). */ +/** Encode whole channels as a canonical route string ('off' when empty) — each + * fires all of that channel's instances. */ export function channelsRoute(channels: readonly Channel[]): string { - const ordered = CHANNELS.filter((c) => channels.includes(c)); - return ordered.length > 0 ? ordered.join('+') : 'off'; + return formatRoute(CHANNELS.filter((c) => channels.includes(c)).map((channel) => ({ channel }))); } -/** True if `route` is a valid concrete route (a channel set or 'off'). */ +/** Per-channel instance selection parsed from a route. An absent channel is not + * fired; `all` fires every instance; otherwise only the listed instance ids. */ +export type RouteSelection = Partial>; + +/** Resolve a route to its per-channel instance selection (used by delivery). */ +export function routeSelection(route: string): RouteSelection { + const sel: RouteSelection = {}; + for (const tok of parseRoute(route)) { + const cur = sel[tok.channel] ?? { all: false, ids: [] }; + if (tok.instance === undefined) cur.all = true; + else cur.ids.push(tok.instance); + sel[tok.channel] = cur; + } + return sel; +} + +/** True if `route` is a valid concrete route: a channel-token set, 'off' (drop), + * or 'inbox' (inbox only). Excludes the inherit sentinels 'default'/'app'. */ export function isConcreteRoute(route: string): boolean { - if (route === 'off') return true; + if (route === 'off' || route === 'inbox') return true; const parts = route.split('+'); - return ( - parts.length > 0 && - new Set(parts).size === parts.length && - parts.every((p) => (CHANNELS as readonly string[]).includes(p)) - ); + if (parts.length === 0 || new Set(parts).size !== parts.length) return false; + return parts.every((p) => { + const idx = p.indexOf(':'); + const channel = idx === -1 ? p : p.slice(0, idx); + if (!(CHANNELS as readonly string[]).includes(channel)) return false; + return idx === -1 || INSTANCE_ID_RE.test(p.slice(idx + 1)); + }); } /** Management capability the user designated for an app. See MANAGEMENT-AUTH.md. */ @@ -129,11 +217,34 @@ export interface RoutingApp { route: AppRoute; /** What this app may manage on the user's behalf ('none'|'self'|'full'). */ manage: Capability; + /** Whether the user has muted this app (no alerts; still recorded in the inbox). */ + muted: boolean; + /** Best-effort icon (app-supplied or Bluesky avatar) for the app screen header. */ + iconUrl?: string; categories: RoutingCategory[]; } +/** A selectable delivery instance for instance-level routing (one push device, + * one telegram chat, the verified email). `id` is the opaque token used in routes. */ +export interface RouteInstance { + id: string; + label: string; +} +/** The user's current delivery instances per channel, for rendering route pickers. */ +export type RouteInstances = Record; + +/** An empty per-channel instance catalog (derived from CHANNELS, so adding a + * channel never leaves a literal stale). */ +export function emptyRouteInstances(): RouteInstances { + const out = {} as RouteInstances; + for (const c of CHANNELS) out[c] = []; + return out; +} + export interface RoutingConfig { defaultRoute: AlertRoute; apps: RoutingApp[]; + /** Available delivery instances per channel, so a route can target just one. */ + channels: RouteInstances; } /** A catalog entry for an app the user can enable from the web (binding-only). */ @@ -155,38 +266,57 @@ export interface NotifsRpc { did: Did, input: PubAtmoNotifyMuteGrant.$input, ): Promise; - linkChannel( - did: Did, - input: PubAtmoNotifyLinkChannel.$input, - ): Promise; - unlinkChannel( - did: Did, - input: PubAtmoNotifyUnlinkChannel.$input, - ): Promise; updateSettings( did: Did, input: PubAtmoNotifyUpdateSettings.$input, ): Promise; listGrants(did: Did): Promise; listPending(did: Did): Promise; - listChannels(did: Did): Promise; getSettings(did: Did): Promise; - // Email channel (binding-only). `linkEmail` emails a verification code via comail. - linkEmail(did: Did, address: string): Promise<{ ok: boolean }>; + // Delivery targets — push devices, Telegram chats, emails (binding-only). One + // user can have several of each; routes can target one specific instance. + /** All of the user's delivery targets across channels. */ + listTargets(did: Did): Promise; + /** Rename any target (give the device/chat/email a friendly name). */ + renameTarget(did: Did, id: string, label: string): Promise<{ ok: boolean }>; + /** Remove any target by its id (unlink a Telegram chat, drop an email/device). */ + removeTarget(did: Did, id: string): Promise<{ ok: boolean }>; + + // Telegram linking starts a deep-link handshake; call again to add another chat. + // `label` is an optional user-chosen name applied to the chat once linked. + linkChannel( + did: Did, + input: PubAtmoNotifyLinkChannel.$input, + label?: string, + ): Promise; + + // Email: `linkEmail` adds an address and emails a code via comail; a user can + // verify several. `label` is an optional user-chosen name (else the address is + // used, hidden from apps). Remove one with `removeTarget`. + linkEmail(did: Did, address: string, label?: string): Promise<{ ok: boolean }>; verifyEmail(did: Did, code: string): Promise<{ verified: boolean }>; - unlinkEmail(did: Did): Promise<{ ok: boolean }>; - getEmailChannel(did: Did): Promise; - // Web push (binding-only; no public lexicon). + // Webhook: add an https URL the relay POSTs notification JSON to. No + // verification step (the user controls the endpoint); a user can add several. + // Remove one with `removeTarget`. Throws on an invalid/non-public URL. + addWebhook(did: Did, url: string, label: string): Promise<{ ok: boolean }>; + + // Bluesky DM: enable the relay's bot to DM this user (the recipient is always + // the user's own DID, so there's no address and no verification — one per user, + // idempotent). Disable with `removeTarget`. + enableDM(did: Did): Promise<{ ok: boolean }>; + + // Web push (binding-only; no public lexicon). `unregisterWebPush` is the + // "disable on this browser" path (keyed by the endpoint the SW reports). registerWebPush(did: Did, sub: PushSubscriptionInput): Promise<{ registered: boolean }>; unregisterWebPush(did: Did, endpoint: string): Promise<{ unregistered: boolean }>; - listDevices(did: Did): Promise; - renameDevice(did: Did, endpoint: string, label: string): Promise<{ ok: boolean }>; // Inbox (binding-only; no public lexicon). listNotifications(did: Did, cursor?: string): Promise; markRead(did: Did, input: MarkReadInput): Promise<{ marked: number }>; + /** Permanently delete every notification from one app for this user. */ + clearNotificationsFromSender(did: Did, sender: Did): Promise<{ deleted: number }>; // Routing (binding-only). Three levels: category → app → account default. getRouting(did: Did): Promise; -- 2.51.2