diff --git a/apps/relay/migrations/0005_routing.sql b/apps/relay/migrations/0005_routing.sql new file mode 100644 index 0000000..7e9b276 --- /dev/null +++ b/apps/relay/migrations/0005_routing.sql @@ -0,0 +1,28 @@ +-- 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/src/db/queries.ts b/apps/relay/src/db/queries.ts index 5eab091..94cd2cf 100644 --- a/apps/relay/src/db/queries.ts +++ b/apps/relay/src/db/queries.ts @@ -8,6 +8,7 @@ export interface UserRow { did: Did; created_at: number; notify_pending_via_telegram: number; + default_route: string; } export interface ChannelRow { @@ -615,3 +616,111 @@ export async function markAllNotificationsRead( .run(); return result.meta.changes ?? 0; } + +// --------------------------------------------------------------------------- +// app_categories + routing (per-category routing) +// --------------------------------------------------------------------------- + +export interface AppCategoryRow { + recipient_did: Did; + sender_did: Did; + category: string; + description: string | null; + last_seen: number; +} + +export interface UpsertAppCategoryInput { + recipientDid: Did; + senderDid: Did; + category: string; + description: string | null; + lastSeen: number; +} + +/** Record a category seen from a sender (keeps the latest description). */ +export async function upsertAppCategory(db: D1Database, input: UpsertAppCategoryInput): Promise { + await db + .prepare( + `INSERT INTO app_categories (recipient_did, sender_did, category, description, last_seen) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(recipient_did, sender_did, category) DO UPDATE SET + description = COALESCE(excluded.description, app_categories.description), + last_seen = excluded.last_seen`, + ) + .bind(input.recipientDid, input.senderDid, input.category, input.description, input.lastSeen) + .run(); +} + +export async function listAppCategoriesForRecipient( + db: D1Database, + recipientDid: Did, +): Promise { + const { results } = await db + .prepare('SELECT * FROM app_categories WHERE recipient_did = ? ORDER BY category ASC') + .bind(recipientDid) + .all(); + return results; +} + +export interface RoutingRow { + recipient_did: Did; + sender_did: Did; + category: string; + route: string; +} + +export function getRoutingRoute( + db: D1Database, + recipientDid: Did, + senderDid: Did, + category: string, +): Promise { + return db + .prepare('SELECT * FROM routing WHERE recipient_did = ? AND sender_did = ? AND category = ?') + .bind(recipientDid, senderDid, category) + .first(); +} + +export async function listRoutingForRecipient( + db: D1Database, + recipientDid: Did, +): Promise { + const { results } = await db + .prepare('SELECT * FROM routing WHERE recipient_did = ?') + .bind(recipientDid) + .all(); + return results; +} + +export async function upsertRouting( + db: D1Database, + recipientDid: Did, + senderDid: Did, + category: string, + route: string, +): Promise { + await db + .prepare( + `INSERT INTO routing (recipient_did, sender_did, category, route) + VALUES (?, ?, ?, ?) + ON CONFLICT(recipient_did, sender_did, category) DO UPDATE SET route = excluded.route`, + ) + .bind(recipientDid, senderDid, category, route) + .run(); +} + +export async function deleteRouting( + db: D1Database, + recipientDid: Did, + senderDid: Did, + category: string, +): Promise { + await db + .prepare('DELETE FROM routing WHERE recipient_did = ? AND sender_did = ? AND category = ?') + .bind(recipientDid, senderDid, category) + .run(); +} + +export async function setDefaultRoute(db: D1Database, did: Did, route: string): Promise { + await db.prepare('UPDATE users SET default_route = ? WHERE did = ?').bind(route, did).run(); +} diff --git a/apps/relay/src/lib/trusted.ts b/apps/relay/src/lib/trusted.ts new file mode 100644 index 0000000..c9a4998 --- /dev/null +++ b/apps/relay/src/lib/trusted.ts @@ -0,0 +1,11 @@ +import type { Did } from '@atcute/lexicons'; + +// "Trusted apps": sender DIDs that are auto-granted at `requestPermission` +// (skipping the pending step). Hardcoded for now — add known-good app DIDs here. +export const TRUSTED_SENDERS: readonly Did[] = [ + // 'did:web:example.notify.atmo.tools', +]; + +export function isTrustedSender(did: Did): boolean { + return TRUSTED_SENDERS.includes(did); +} diff --git a/apps/relay/src/rpc/entrypoint.ts b/apps/relay/src/rpc/entrypoint.ts index 0e4a4d6..d83e2ca 100644 --- a/apps/relay/src/rpc/entrypoint.ts +++ b/apps/relay/src/rpc/entrypoint.ts @@ -2,10 +2,13 @@ import { WorkerEntrypoint } from 'cloudflare:workers'; import type { Did } from '@atcute/lexicons'; import type { + AlertRoute, + CategoryRoute, ListNotificationsResult, MarkReadInput, NotifsRpc, PushSubscriptionInput, + RoutingConfig, ToolsAtmoNotifsDenyPending, ToolsAtmoNotifsGetSettings, ToolsAtmoNotifsGrant, @@ -78,4 +81,13 @@ export class RelayRpc extends WorkerEntrypoint implements NotifsRpc { markRead(did: Did, input: MarkReadInput) { return ops.markRead(this.env, did, input); } + getRouting(did: Did): Promise { + return ops.getRouting(this.env, did); + } + setRouting(did: Did, sender: Did, category: string, route: CategoryRoute) { + return ops.setRouting(this.env, did, sender, category, route); + } + setDefaultRoute(did: Did, route: AlertRoute) { + return ops.setDefaultRoute(this.env, did, route); + } } diff --git a/apps/relay/src/rpc/ops.ts b/apps/relay/src/rpc/ops.ts index 212d130..a298996 100644 --- a/apps/relay/src/rpc/ops.ts +++ b/apps/relay/src/rpc/ops.ts @@ -6,10 +6,14 @@ import type { Did } from '@atcute/lexicons'; import type { + AlertRoute, + CategoryRoute, ListNotificationsResult, MarkReadInput, NotificationView, PushSubscriptionInput, + RoutingApp, + RoutingConfig, ToolsAtmoNotifsDenyPending, ToolsAtmoNotifsGetSettings, ToolsAtmoNotifsGrant, @@ -280,3 +284,62 @@ export async function markRead( : await q.markNotificationsRead(env.DB, did, input.ids ?? [], readAt); return { marked }; } + +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] = await Promise.all([ + q.listGrantsForRecipient(env.DB, did), + q.listAppCategoriesForRecipient(env.DB, did), + q.listRoutingForRecipient(env.DB, did), + ]); + + const routeBy = new Map(); + for (const r of routing) routeBy.set(`${r.sender_did}${r.category}`, r.route); + + const catsBySender = new Map(); + for (const c of categories) { + const list = catsBySender.get(c.sender_did) ?? []; + list.push(c); + catsBySender.set(c.sender_did, list); + } + + const apps: RoutingApp[] = grants.map((g) => ({ + sender: g.sender_did, + title: g.title ?? g.display_name ?? g.handle ?? g.sender_did, + categories: (catsBySender.get(g.sender_did) ?? []).map((c) => ({ + category: c.category, + description: c.description ?? undefined, + route: (routeBy.get(`${g.sender_did}${c.category}`) ?? 'default') as CategoryRoute, + })), + })); + + return { defaultRoute, apps }; +} + +export async function setRouting( + env: Env, + did: Did, + sender: Did, + category: string, + route: CategoryRoute, +): Promise<{ ok: boolean }> { + if (route === 'default') { + await q.deleteRouting(env.DB, did, sender, category); + } else { + await q.upsertRouting(env.DB, did, sender, category, route); + } + return { ok: true }; +} + +export async function setDefaultRoute( + env: Env, + did: Did, + route: AlertRoute, +): Promise<{ ok: boolean }> { + await q.ensureUser(env.DB, did, now()); + await q.setDefaultRoute(env.DB, did, route); + return { ok: true }; +} diff --git a/apps/relay/src/xrpc/requestPermission.ts b/apps/relay/src/xrpc/requestPermission.ts index 7b45898..7ac5c94 100644 --- a/apps/relay/src/xrpc/requestPermission.ts +++ b/apps/relay/src/xrpc/requestPermission.ts @@ -9,6 +9,7 @@ import type { AppContext } from '../env'; import { rateLimited } from '../lib/errors'; import { newId } from '../lib/ids'; import { addDays, now } from '../lib/time'; +import { isTrustedSender } from '../lib/trusted'; import { ensureSenderProfile } from '../profile/fetch'; import { checkAndIncrement } from '../ratelimit'; @@ -38,6 +39,19 @@ export function makeRequestPermission( // 2. Ensure the user row exists. await q.ensureUser(app.env.DB, userDid, now()); + // Trusted apps skip the pending step and are granted immediately. + if (isTrustedSender(senderDid)) { + await q.upsertGrant(app.env.DB, { + recipientDid: userDid, + senderDid, + grantedAt: now(), + title: input.title, + description: input.description ?? null, + iconUrl: input.iconUrl ?? null, + }); + return json({ id: pseudoGrantId(userDid, senderDid), status: 'alreadyGranted' }); + } + // 3. Already granted? Short-circuit. const existingGrant = await q.getGrant(app.env.DB, userDid, senderDid); if (existingGrant !== null) { diff --git a/apps/relay/src/xrpc/send.ts b/apps/relay/src/xrpc/send.ts index 902a589..9ff4403 100644 --- a/apps/relay/src/xrpc/send.ts +++ b/apps/relay/src/xrpc/send.ts @@ -45,6 +45,17 @@ export function makeSend(app: AppContext): ProcedureConfig channel.platform === 'telegram', - ); - const pushSubs = await q.listPushSubscriptionsForDid(app.env.DB, recipient); + // 4. Resolve the alert route (per-category override, else the user default) + // and collect the enabled targets. Everything is already in the inbox; + // the route only gates which alert channels fire. + const user = await q.getUser(app.env.DB, recipient); + let route = user?.default_route ?? 'push'; + if (input.category != null) { + const override = await q.getRoutingRoute(app.env.DB, recipient, senderDid, input.category); + if (override) route = override.route; + } + const usePush = route === 'push' || route === 'push+telegram'; + const useTelegram = route === 'telegram' || route === 'push+telegram'; + + const telegramChannels = useTelegram + ? (await q.listChannelsForDid(app.env.DB, recipient)).filter((c) => c.platform === 'telegram') + : []; + const pushSubs = usePush ? await q.listPushSubscriptionsForDid(app.env.DB, recipient) : []; const deliveredCount = telegramChannels.length + pushSubs.length; // No targets → accept but deliver to nobody. diff --git a/apps/relay/test/routing.test.ts b/apps/relay/test/routing.test.ts new file mode 100644 index 0000000..0029f66 --- /dev/null +++ b/apps/relay/test/routing.test.ts @@ -0,0 +1,75 @@ +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'; + +const SENDER = 'did:plc:routesender' as Did; + +it('getRouting lists granted apps with discovered categories and the default route', async () => { + const user = 'did:plc:routing1' as Did; + await q.ensureUser(env.DB, user, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: user, + senderDid: SENDER, + grantedAt: Date.now(), + title: 'Bsky', + description: null, + iconUrl: null, + }); + await q.upsertAppCategory(env.DB, { + recipientDid: user, + senderDid: SENDER, + category: 'mention', + description: 'Mentions', + lastSeen: Date.now(), + }); + await q.upsertAppCategory(env.DB, { + recipientDid: user, + senderDid: SENDER, + category: 'reply', + description: null, + lastSeen: Date.now(), + }); + + const cfg = await ops.getRouting(env, user); + + expect(cfg.defaultRoute).toBe('push'); + const app = cfg.apps.find((a) => a.sender === SENDER); + expect(app?.title).toBe('Bsky'); + expect(app?.categories.map((c) => c.category).sort()).toEqual(['mention', 'reply']); + expect(app?.categories.every((c) => c.route === 'default')).toBe(true); +}); + +it('setRouting overrides a category route and reverts to default', async () => { + const user = 'did:plc:routing2' as Did; + await q.ensureUser(env.DB, user, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: user, + senderDid: SENDER, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null, + }); + await q.upsertAppCategory(env.DB, { + recipientDid: user, + senderDid: SENDER, + category: 'mention', + description: null, + lastSeen: Date.now(), + }); + + await ops.setRouting(env, user, SENDER, 'mention', 'push+telegram'); + expect((await ops.getRouting(env, user)).apps[0]?.categories[0]?.route).toBe('push+telegram'); + + await ops.setRouting(env, user, SENDER, 'mention', 'default'); + expect((await ops.getRouting(env, user)).apps[0]?.categories[0]?.route).toBe('default'); +}); + +it('setDefaultRoute updates the user-wide default', async () => { + const user = 'did:plc:routing3' as Did; + await ops.setDefaultRoute(env, user, 'telegram'); + expect((await ops.getRouting(env, user)).defaultRoute).toBe('telegram'); +}); diff --git a/apps/relay/test/send.test.ts b/apps/relay/test/send.test.ts index fcac17b..3f3ed83 100644 --- a/apps/relay/test/send.test.ts +++ b/apps/relay/test/send.test.ts @@ -73,6 +73,9 @@ it('enqueues and reports delivered=1 with a linked channel', async () => { displayName: null, linkedAt: Date.now(), }); + // 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'); const jwt = await makeJwt(sender, { lxm: SEND }); const res = await call(send(jwt)); @@ -105,6 +108,39 @@ it('records the notification in the inbox', async () => { expect(rows.some((r) => r.sender_did === sender.did && r.title === 'Hello')).toBe(true); }); +it('per-category routing gates which channels fire', async () => { + const sender = await makeIdentity('did:plc:sendroute'); + mockPlc(sender); + const recip: Did = 'did:plc:routerecipient'; + 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 q.upsertChannel(env.DB, { + did: recip, + platform: 'telegram', + platformUserId: '99999', + displayName: null, + linkedAt: Date.now() + }); + // 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'); + const jwt = await makeJwt(sender, { lxm: SEND }); + + const res = await call( + xrpcPost(SEND, jwt, { recipient: recip, title: 'Hi', body: 'B', category: 'mention' }) + ); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ delivered: 1 }); +}); + it('accepts silently with delivered=0 when the grant is muted', async () => { const sender = await makeIdentity('did:plc:sendmuted'); mockPlc(sender); diff --git a/apps/web/src/lib/remote/notifs.remote.ts b/apps/web/src/lib/remote/notifs.remote.ts index 33e35d3..5c9a4c1 100644 --- a/apps/web/src/lib/remote/notifs.remote.ts +++ b/apps/web/src/lib/remote/notifs.remote.ts @@ -72,3 +72,21 @@ export const markNotificationsRead = command( await requireRelay().markRead(input); } ); + +export const setDefaultRoute = command( + v.object({ route: v.picklist(['push', 'telegram', 'push+telegram', 'off']) }), + async ({ route }) => { + await requireRelay().setDefaultRoute(route); + } +); + +export const setRouting = command( + v.object({ + sender: didSchema, + category: v.string(), + route: v.picklist(['default', 'push', 'telegram', 'push+telegram', 'off']) + }), + async ({ sender, category, route }) => { + await requireRelay().setRouting(sender as Did, category, route); + } +); diff --git a/apps/web/src/lib/routes.ts b/apps/web/src/lib/routes.ts new file mode 100644 index 0000000..39ecc38 --- /dev/null +++ b/apps/web/src/lib/routes.ts @@ -0,0 +1,26 @@ +// Route options for the routing UI. Alert routes gate push/telegram; everything +// is in the inbox regardless. `default` (per-category) inherits the user default. +import type { AlertRoute, CategoryRoute } from '@atmo/notifs-lexicons'; + +export const ALERT_ROUTES = [ + 'push', + 'telegram', + 'push+telegram', + 'off' +] as const satisfies readonly AlertRoute[]; + +export const CATEGORY_ROUTES = [ + 'default', + 'push', + 'telegram', + 'push+telegram', + 'off' +] as const satisfies readonly CategoryRoute[]; + +export const ROUTE_LABELS: Record = { + default: 'Default', + push: 'Push', + telegram: 'Telegram', + 'push+telegram': 'Push + Telegram', + off: 'Off' +}; diff --git a/apps/web/src/lib/server/relay.ts b/apps/web/src/lib/server/relay.ts index dddd36a..bfa15d5 100644 --- a/apps/web/src/lib/server/relay.ts +++ b/apps/web/src/lib/server/relay.ts @@ -10,6 +10,8 @@ // `relayFor` throws a clear error rather than failing cryptically. import type { Did } from '@atcute/lexicons'; import type { + AlertRoute, + CategoryRoute, MarkReadInput, PushSubscriptionInput, ToolsAtmoNotifsDenyPending, @@ -53,6 +55,10 @@ export function relayFor(platform: App.Platform | undefined, did: Did | null) { registerWebPush: (sub: PushSubscriptionInput) => svc.registerWebPush(did, sub), unregisterWebPush: (endpoint: string) => svc.unregisterWebPush(did, endpoint), listNotifications: (cursor?: string) => svc.listNotifications(did, cursor), - markRead: (input: MarkReadInput) => svc.markRead(did, input) + markRead: (input: MarkReadInput) => svc.markRead(did, input), + getRouting: () => svc.getRouting(did), + setRouting: (sender: Did, category: string, route: CategoryRoute) => + svc.setRouting(did, sender, category, route), + setDefaultRoute: (route: AlertRoute) => svc.setDefaultRoute(did, route) }; } diff --git a/apps/web/src/routes/(app)/apps/+page.svelte b/apps/web/src/routes/(app)/apps/+page.svelte index 1302b31..361433e 100644 --- a/apps/web/src/routes/(app)/apps/+page.svelte +++ b/apps/web/src/routes/(app)/apps/+page.svelte @@ -187,7 +187,13 @@ -
+
+ + Routing → + {#if confirming[g.sender]}
+ + +
+

Default routing

+
+
+
+
Where notifications go by default
+

+ Apps and categories set to “Default” use this. Everything always lands in your inbox. +

+
+ +
+
+
diff --git a/packages/lexicons/src/rpc.ts b/packages/lexicons/src/rpc.ts index 913b1d1..037e3e3 100644 --- a/packages/lexicons/src/rpc.ts +++ b/packages/lexicons/src/rpc.ts @@ -59,6 +59,26 @@ export interface MarkReadInput { all?: boolean; } +/** Alert routes (binding-only). Everything is in the inbox regardless; these gate alerts. */ +export type AlertRoute = 'push' | 'telegram' | 'push+telegram' | 'off'; +/** Per-category route, plus 'default' (inherit the user-wide default). */ +export type CategoryRoute = AlertRoute | 'default'; + +export interface RoutingCategory { + category: string; + description?: string; + route: CategoryRoute; +} +export interface RoutingApp { + sender: Did; + title: string; + categories: RoutingCategory[]; +} +export interface RoutingConfig { + defaultRoute: AlertRoute; + apps: RoutingApp[]; +} + export interface NotifsRpc { grant(did: Did, input: ToolsAtmoNotifsGrant.$input): Promise; revoke(did: Did, input: ToolsAtmoNotifsRevoke.$input): Promise; @@ -94,4 +114,14 @@ export interface NotifsRpc { // Inbox (binding-only; no public lexicon). listNotifications(did: Did, cursor?: string): Promise; markRead(did: Did, input: MarkReadInput): Promise<{ marked: number }>; + + // Per-category routing (binding-only). + getRouting(did: Did): Promise; + setRouting( + did: Did, + sender: Did, + category: string, + route: CategoryRoute, + ): Promise<{ ok: boolean }>; + setDefaultRoute(did: Did, route: AlertRoute): Promise<{ ok: boolean }>; }