[READ-ONLY] Mirror of https://github.com/flo-bit/atproto-notify.
0

Configure Feed

Select the types of activity you want to include in your feed.

updates

+3160 -1804
+13 -11
README.md
··· 1 1 # atmo.pub 2 2 3 3 Notifications for the AT Protocol. Any atproto app can ask a user for permission 4 - to notify them; the user approves which apps may notify them in a web dashboard, 5 - and deliveries go out (v1) through a Telegram bot. 4 + to notify them; the user approves which apps may notify them in a web dashboard 5 + and chooses where alerts go — web push, Telegram, email, Bluesky DM, or a webhook 6 + — while everything lands in an inbox. 6 7 7 - - **Dashboard** — sign in, approve apps, link Telegram: https://atmo.pub 8 - - **Developer docs**: https://atmo.pub/docs 8 + - **Dashboard** — sign in, approve apps, connect channels: https://atmo.pub 9 + - **Developer docs**: https://docs.atmo.pub/docs 9 10 - **Live example sender**: https://example.atmo.pub · [source](apps/example-sender) 10 11 - **Relay API + `did:web`**: https://relay.atmo.pub 11 12 ··· 21 22 - **`send`** — authenticated by **your app's own DID key**. Delivers to a user who 22 23 has granted you permission. 23 24 24 - Full walkthrough with code is at **https://atmo.pub/docs**, and 25 + Full walkthrough with code is at **https://docs.atmo.pub/docs**, and 25 26 [`apps/example-sender`](apps/example-sender) is a complete, ~300-line reference 26 27 implementation of both flows (live at https://example.atmo.pub). 27 28 28 29 ## This repo 29 30 30 31 - **`apps/relay`** — the Cloudflare Worker at `relay.atmo.pub`. Pure XRPC: it 31 - verifies inbound service-auth JWTs, stores grants / pending requests / channels 32 - in D1, rate-limits with KV, and dispatches deliveries through a Cloudflare Queue. 33 - No firehose, no repo records, no signing. 34 - - **`apps/web`** — the SvelteKit dashboard at `atmo.pub` (+ `/docs`). 32 + verifies inbound service-auth JWTs; stores grants, pending requests, delivery 33 + targets, the inbox, and routing in D1; rate-limits with KV; and dispatches 34 + deliveries (web push, Telegram, email, Bluesky DM, webhook) through a Cloudflare 35 + Queue. No firehose, no repo records, no signing. 36 + - **`apps/web`** — the SvelteKit dashboard at `atmo.pub`. 37 + - **`apps/homepage`** — the marketing landing + developer docs at `docs.atmo.pub`. 35 38 - **`apps/example-sender`** — one-page sender demo at `example.atmo.pub`. 36 - - **`packages/lexicons`** — the shared `pub.atmo.notify.*` lexicons (13) and 37 - generated types. 39 + - **`packages/lexicons`** — the shared `pub.atmo.notify.*` lexicons and generated types. 38 40 39 41 Running, configuring, and deploying everything: **[DEVELOPMENT.md](docs/DEVELOPMENT.md)**. 40 42 Want notifications for your own app without depending on atmo.pub? Run the relay
+159
apps/example-sender/src/lib/components/RoutePicker.svelte
··· 1 + <script lang="ts"> 2 + // A compact channel-set route picker, modelled on atmo.pub's own picker but 3 + // self-contained and channel-level (no per-instance pinning). Modes: 4 + // inherit (optional) · Inbox only · Off · Custom (channel checkboxes) 5 + import { CHANNELS, channelLabel, channelsRoute, routeChannels } from '$lib/routes'; 6 + 7 + let { 8 + value, 9 + inherit, 10 + available, 11 + disabled = false, 12 + onchange 13 + }: { 14 + /** Current route: an inherit sentinel, 'off', 'inbox', or a channel set. */ 15 + value: string; 16 + /** Optional inherit choice (account default for the app, app default for categories). */ 17 + inherit?: { token: 'default' | 'app'; label: string }; 18 + /** Channel types the user has a target for; omitted = show all. */ 19 + available?: string[]; 20 + disabled?: boolean; 21 + onchange: (route: string) => void; 22 + } = $props(); 23 + 24 + // Did the user explicitly open Custom? An empty custom selection stores 'inbox', 25 + // so without this the tree would collapse back to "Inbox only". 26 + let customOpen = $state(false); 27 + 28 + const inheriting = $derived(inherit !== undefined && value === inherit.token); 29 + const selected = $derived(routeChannels(value)); 30 + const hasChannels = $derived(selected.length > 0); 31 + 32 + // Only offer channels the user has a target for — but keep showing one that's 33 + // already in the route (so it can still be turned off after the target is gone). 34 + const visibleChannels = $derived( 35 + CHANNELS.filter((c) => available === undefined || available.includes(c) || selected.includes(c)) 36 + ); 37 + 38 + type Mode = 'inherit' | 'inbox' | 'off' | 'custom'; 39 + const mode = $derived<Mode>( 40 + inheriting ? 'inherit' : value === 'off' ? 'off' : hasChannels || customOpen ? 'custom' : 'inbox' 41 + ); 42 + 43 + function pickInherit() { 44 + customOpen = false; 45 + if (inherit) onchange(inherit.token); 46 + } 47 + function pickInbox() { 48 + customOpen = false; 49 + onchange('inbox'); 50 + } 51 + function pickOff() { 52 + customOpen = false; 53 + onchange('off'); 54 + } 55 + function pickCustom() { 56 + customOpen = true; 57 + if (!hasChannels) onchange('inbox'); 58 + } 59 + 60 + function toggle(channel: string) { 61 + const next = selected.includes(channel) 62 + ? selected.filter((c) => c !== channel) 63 + : [...selected, channel]; 64 + // channelsRoute returns 'off' for an empty set; in Custom mode we mean 65 + // "inbox only" (recorded, no alerts) instead. 66 + const route = channelsRoute(next); 67 + onchange(route === 'off' ? 'inbox' : route); 68 + } 69 + 70 + const modeBtn = 71 + 'rounded-md border px-2.5 py-1 text-sm font-medium transition-colors disabled:opacity-50'; 72 + const on = 'border-accent bg-accent-soft text-accent'; 73 + const off = 'border-line text-muted hover:bg-surface-2 hover:text-fg'; 74 + </script> 75 + 76 + <div class="flex flex-col gap-2"> 77 + <div class="flex flex-wrap gap-1.5"> 78 + {#if inherit} 79 + <button 80 + type="button" 81 + {disabled} 82 + onclick={pickInherit} 83 + aria-pressed={mode === 'inherit'} 84 + class="{modeBtn} {mode === 'inherit' ? on : off}" 85 + > 86 + {inherit.label} 87 + </button> 88 + {/if} 89 + <button 90 + type="button" 91 + {disabled} 92 + onclick={pickInbox} 93 + aria-pressed={mode === 'inbox'} 94 + class="{modeBtn} {mode === 'inbox' ? on : off}" 95 + > 96 + Inbox only 97 + </button> 98 + <button 99 + type="button" 100 + {disabled} 101 + onclick={pickOff} 102 + aria-pressed={mode === 'off'} 103 + class="{modeBtn} {mode === 'off' ? on : off}" 104 + > 105 + Off 106 + </button> 107 + <button 108 + type="button" 109 + {disabled} 110 + onclick={pickCustom} 111 + aria-pressed={mode === 'custom'} 112 + class="{modeBtn} {mode === 'custom' ? on : off}" 113 + > 114 + Custom 115 + </button> 116 + </div> 117 + 118 + {#if mode === 'custom'} 119 + <div class="flex flex-col gap-0.5 rounded-md border border-line bg-surface-2 p-2"> 120 + {#each visibleChannels as c (c)} 121 + {@const checked = selected.includes(c)} 122 + <button 123 + type="button" 124 + {disabled} 125 + role="checkbox" 126 + aria-checked={checked} 127 + onclick={() => toggle(c)} 128 + class="flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm text-fg transition-colors hover:bg-surface disabled:opacity-50" 129 + > 130 + <span 131 + class="flex size-4 shrink-0 items-center justify-center rounded border {checked 132 + ? 'border-accent bg-accent' 133 + : 'border-line'}" 134 + > 135 + {#if checked} 136 + <svg 137 + width="11" 138 + height="11" 139 + viewBox="0 0 24 24" 140 + fill="none" 141 + stroke="currentColor" 142 + stroke-width="3" 143 + stroke-linecap="round" 144 + stroke-linejoin="round" 145 + class="text-accent-fg" 146 + aria-hidden="true" 147 + > 148 + <path d="M5 12l5 5L20 7" /> 149 + </svg> 150 + {/if} 151 + </span> 152 + {channelLabel(c)} 153 + </button> 154 + {:else} 155 + <p class="px-1.5 py-1 text-xs text-muted-2">No channels connected.</p> 156 + {/each} 157 + </div> 158 + {/if} 159 + </div>
+14 -1
apps/example-sender/src/lib/config.ts
··· 7 7 /** This app's sender DID — what the user approves and what `send` authenticates as. */ 8 8 export const SENDER_DID = `did:web:${APP_DOMAIN}`; 9 9 10 - /** Display name + description shown to the user when requesting permission. */ 10 + /** Display name + description + icon shown to the user when requesting permission. */ 11 11 export const APP_TITLE = 'Example Sender'; 12 12 export const APP_DESCRIPTION = 'Demo of how to integrate with atmo.pub.'; 13 + /** Absolute URL — the relay stores it on the grant and atmo.pub shows it in the app grid. */ 14 + export const APP_ICON_URL = `https://${APP_DOMAIN}/icon.svg`; 13 15 14 16 /** 15 17 * The relay's XRPC API. In dev (`vite dev`) talk to the local relay running via ··· 46 48 'rpc?lxm=pub.atmo.auth&aud=*', 47 49 'rpc?lxm=pub.atmo.notify.setRouting&aud=*', 48 50 'rpc?lxm=pub.atmo.notify.getRouting&aud=*', 51 + 'rpc?lxm=pub.atmo.notify.setCategories&aud=*', 49 52 'rpc?lxm=pub.atmo.notify.listNotifications&aud=*', 50 53 'rpc?lxm=pub.atmo.notify.markRead&aud=*' 51 54 ].join(' '); 55 + 56 + /** 57 + * The categories this app declares to atmo.pub (via `setCategories`). A real app 58 + * lists the kinds of notifications it sends so the user can route each one 59 + * separately; we declare two so the per-category routing UI has something to show. 60 + */ 61 + export const DEMO_CATEGORIES = [ 62 + { id: 'mentions', title: 'Mentions', description: 'When someone mentions you' }, 63 + { id: 'digest', title: 'Weekly digest', description: 'A periodic summary' } 64 + ] as const; 52 65 53 66 /** Branding. */ 54 67 export const PROJECT_NAME = 'atmo.pub · example sender';
+36 -19
apps/example-sender/src/lib/relay.remote.ts
··· 1 - // Remote command functions the page calls. requestNotifications goes through the 2 - // user's OAuth session; sendTest signs with this app's own key; the routing/inbox 3 - // commands carry both tokens (dual-auth). 1 + // Remote command functions the page calls. `connect` (requestPermission) goes 2 + // through the user's OAuth session; `sendTest` signs with this app's own key; the 3 + // routing commands carry both tokens (dual-auth). After a mutation the page calls 4 + // invalidateAll() so the server `load` re-reads the live state. 4 5 import { command, getRequestEvent } from '$app/server'; 5 6 import { error } from '@sveltejs/kit'; 6 7 import * as v from 'valibot'; 7 8 8 9 import { APP_DOMAIN } from '$lib/config'; 9 10 import { 10 - getRoutingForUser, 11 11 listNotificationsForUser, 12 12 markAllReadForUser, 13 13 mintAppLoginUrl, ··· 15 15 sendAsSender, 16 16 setRoutingForUser 17 17 } from '$lib/server/relay'; 18 - import type { AppRoute, NotificationView, RoutingView } from '$lib/types'; 18 + import type { NotificationView } from '$lib/types'; 19 19 20 20 /** The user's OAuth client, or a 401 if they're not signed in. */ 21 21 function requireClient() { ··· 26 26 return locals.client; 27 27 } 28 28 29 - export const requestNotifications = command(() => requestPermissionForUser(requireClient())); 29 + /** Ask the user (via their PDS-minted token) to grant this app notify permission. */ 30 + export const connect = command( 31 + (): Promise<{ id: string; status: 'pending' | 'alreadyGranted' }> => 32 + requestPermissionForUser(requireClient()) 33 + ); 30 34 31 - /** Build a one-time link that signs the user into atmo.pub (cross-app login). */ 32 - export const openInAtmo = command(async (): Promise<{ url: string }> => { 33 - return { url: await mintAppLoginUrl(requireClient()) }; 34 - }); 35 + /** Build a one-time link that signs the user into atmo.pub, deep-linked to `redirect`. */ 36 + export const openInAtmo = command( 37 + v.object({ redirect: v.optional(v.string()) }), 38 + async ({ redirect }): Promise<{ url: string }> => { 39 + return { url: await mintAppLoginUrl(requireClient(), redirect) }; 40 + } 41 + ); 35 42 36 43 export type SendResult = 37 44 | { ok: true; id: string; delivered: number } 38 45 | { ok: false; reason: 'notApproved' }; 39 46 40 47 export const sendTest = command( 41 - v.object({ title: v.optional(v.string()), body: v.optional(v.string()) }), 42 - async ({ title, body }): Promise<SendResult> => { 48 + v.object({ 49 + title: v.optional(v.string()), 50 + body: v.optional(v.string()), 51 + category: v.optional(v.string()) 52 + }), 53 + async ({ title, body, category }): Promise<SendResult> => { 43 54 const { locals } = getRequestEvent(); 44 55 if (!locals.did) { 45 56 error(401, 'Not signed in'); ··· 49 60 recipient: locals.did, 50 61 title: title?.trim() || 'Hello from the example sender', 51 62 body: body?.trim() || 'If you can read this, the integration works end to end.', 52 - uri: `https://${APP_DOMAIN}` 63 + uri: `https://${APP_DOMAIN}`, 64 + category: category?.trim() || undefined 53 65 }); 54 66 return { ok: true, ...result }; 55 67 } catch (e) { ··· 64 76 65 77 // --- Dual-auth: manage this app's own routing + inbox for the signed-in user --- 66 78 67 - export const getRouting = command((): Promise<RoutingView> => getRoutingForUser(requireClient())); 79 + /** Set the app-wide route ('default' inherits the account default). */ 80 + export const setAppRoute = command( 81 + v.object({ route: v.string() }), 82 + ({ route }): Promise<{ ok: boolean }> => setRoutingForUser(requireClient(), { route }) 83 + ); 68 84 69 - export const setRouting = command( 70 - v.object({ route: v.picklist(['push', 'telegram', 'push+telegram', 'off', 'default']) }), 71 - ({ route }): Promise<{ ok: boolean }> => 72 - setRoutingForUser(requireClient(), { route: route as AppRoute }) 85 + /** Set one category's route ('app' inherits the app-wide route). */ 86 + export const setCategoryRoute = command( 87 + v.object({ id: v.string(), route: v.string() }), 88 + ({ id, route }): Promise<{ ok: boolean }> => 89 + setRoutingForUser(requireClient(), { categories: [{ id, route }] }) 73 90 ); 74 91 75 92 export const listNotifications = command( 76 93 (): Promise<{ notifications: NotificationView[]; cursor?: string }> => 77 - listNotificationsForUser(requireClient(), { limit: 25 }) 94 + listNotificationsForUser(requireClient(), { limit: 10 }) 78 95 ); 79 96 80 97 export const markAllRead = command((): Promise<{ marked: number }> =>
+49
apps/example-sender/src/lib/routes.ts
··· 1 + // Minimal, hand-written route helpers — the same channel-set format atmo.pub 2 + // uses, kept dependency-light for the demo. A route is a `+`-joined set of 3 + // channel tokens; a token may be a bare channel ('push') or pin one instance 4 + // ('push:<id>'). This demo routes at the channel level, so it reads any 5 + // instance-pinned tokens as "that whole channel is on" and writes bare channels. 6 + export const CHANNELS = ['push', 'telegram', 'email', 'dm', 'webhook'] as const; 7 + export type Channel = (typeof CHANNELS)[number]; 8 + 9 + const CHANNEL_LABEL: Record<string, string> = { 10 + push: 'Push', 11 + telegram: 'Telegram', 12 + email: 'Email', 13 + dm: 'Bluesky DM', 14 + webhook: 'Webhook' 15 + }; 16 + 17 + /** Display label for a single channel (capitalized id as a fallback). */ 18 + export function channelLabel(c: string): string { 19 + return CHANNEL_LABEL[c] ?? c.charAt(0).toUpperCase() + c.slice(1); 20 + } 21 + 22 + const SENTINELS = new Set(['off', 'inbox', 'default', 'app', '']); 23 + 24 + /** The distinct channels a route fires (sentinels → none), in canonical order. */ 25 + export function routeChannels(route: string): string[] { 26 + if (SENTINELS.has(route)) return []; 27 + const present = new Set<string>(); 28 + for (const part of route.split('+')) { 29 + const channel = part.split(':')[0]; 30 + if ((CHANNELS as readonly string[]).includes(channel)) present.add(channel); 31 + } 32 + return CHANNELS.filter((c) => present.has(c)); 33 + } 34 + 35 + /** Encode whole channels as a canonical route string ('off' when empty). */ 36 + export function channelsRoute(channels: string[]): string { 37 + const set = CHANNELS.filter((c) => channels.includes(c)); 38 + return set.length > 0 ? set.join('+') : 'off'; 39 + } 40 + 41 + /** Human label for a route value (sentinel, 'off'/'inbox', or a channel set). */ 42 + export function routeLabel(route: string): string { 43 + if (route === 'default') return 'Account default'; 44 + if (route === 'app') return 'App default'; 45 + if (route === 'off') return 'Off'; 46 + if (route === 'inbox') return 'Inbox only'; 47 + const ch = routeChannels(route); 48 + return ch.length > 0 ? ch.map(channelLabel).join(' + ') : 'Inbox only'; 49 + }
+24 -3
apps/example-sender/src/lib/server/relay.ts
··· 7 7 8 8 import { 9 9 APP_DESCRIPTION, 10 + APP_ICON_URL, 10 11 APP_TITLE, 11 12 APPLOGIN_LXM, 12 13 DASHBOARD_ORIGIN, ··· 75 76 return postRelay(token, lxm, { 76 77 senderDid: SENDER_DID, 77 78 title: APP_TITLE, 78 - description: APP_DESCRIPTION 79 + description: APP_DESCRIPTION, 80 + iconUrl: APP_ICON_URL 79 81 }) as Promise<{ id: string; status: 'pending' | 'alreadyGranted' }>; 80 82 } 81 83 ··· 85 87 * deep-linked to THIS app's settings page. No login form, no PDS round-trip on 86 88 * atmo.pub's side. See CROSS-APP-AUTH.md. 87 89 */ 88 - export async function mintAppLoginUrl(client: NonNullable<AppClient>): Promise<string> { 90 + export async function mintAppLoginUrl( 91 + client: NonNullable<AppClient>, 92 + redirect = `/apps/${SENDER_DID}` 93 + ): Promise<string> { 89 94 const token = encodeURIComponent(await mintUserToken(client, APPLOGIN_LXM)); 90 - const redirect = `/apps/${SENDER_DID}`; 91 95 return `${DASHBOARD_ORIGIN}/applogin?token=${token}&redirect=${encodeURIComponent(redirect)}`; 92 96 } 93 97 ··· 100 104 title: string; 101 105 body: string; 102 106 uri?: string; 107 + /** Optional routing category (one of the app's declared categories). */ 108 + category?: string; 103 109 }): Promise<{ id: string; delivered: number }> { 104 110 const lxm = 'pub.atmo.notify.send'; 105 111 const jwt = await mintSenderJwt(lxm); ··· 134 140 input: { route?: AppRoute; categories?: { id: string; route: CategoryRoute }[] } 135 141 ): Promise<{ ok: boolean }> { 136 142 return dualAuthCall(client, 'pub.atmo.notify.setRouting', input) as Promise<{ ok: boolean }>; 143 + } 144 + 145 + /** 146 + * Declare this app's category catalog for the user (full sync). A real app calls 147 + * this once it knows which kinds of notifications it sends, so the user gets a 148 + * per-category routing UI. Omitting `route` just declares the category (it keeps 149 + * any route the user already chose). 150 + */ 151 + export function setCategoriesForUser( 152 + client: NonNullable<AppClient>, 153 + categories: { id: string; title?: string; description?: string; route?: CategoryRoute }[] 154 + ): Promise<{ ok: boolean }> { 155 + return dualAuthCall(client, 'pub.atmo.notify.setCategories', { categories }) as Promise<{ 156 + ok: boolean; 157 + }>; 137 158 } 138 159 139 160 /** List the notifications this app has sent the user (read state + delivery). */
+27 -4
apps/example-sender/src/lib/types.ts
··· 2 2 // `$lib/server` so the component can import them too. A real integrator would 3 3 // likely codegen these from the published lexicons; hand-typing keeps the demo 4 4 // dependency-light. 5 + // 6 + // A route is a `+`-joined set of channel tokens (e.g. 'push+telegram'), 'off' 7 + // (drop), or 'inbox' (record only). App-wide routes add the inherit sentinel 8 + // 'default'; per-category routes add 'app'. See $lib/routes for helpers. 5 9 6 - export type AlertRoute = 'push' | 'telegram' | 'push+telegram' | 'off'; 10 + /** A concrete route: a channel-token set, or 'off' / 'inbox'. */ 11 + export type AlertRoute = string; 7 12 /** App-wide route; 'default' inherits the user's account default. */ 8 - export type AppRoute = AlertRoute | 'default'; 13 + export type AppRoute = string; 9 14 /** Per-category route; 'app' inherits the app-wide route. */ 10 - export type CategoryRoute = AlertRoute | 'app'; 15 + export type CategoryRoute = string; 16 + 17 + /** A user delivery target, with a privacy-safe label (no raw email/handle). */ 18 + export interface RoutingTarget { 19 + type: string; 20 + id: string; 21 + label: string; 22 + } 23 + 24 + export interface RoutingCategory { 25 + id: string; 26 + title?: string; 27 + description?: string; 28 + route: CategoryRoute; 29 + } 11 30 12 31 export interface RoutingView { 32 + /** App-wide route for everything from this app. */ 13 33 route: AppRoute; 34 + /** The user's account default (what 'default' resolves to). */ 14 35 defaultRoute: AlertRoute; 15 - categories: { id: string; description?: string; route: CategoryRoute }[]; 36 + categories: RoutingCategory[]; 37 + /** The user's deliverable targets, so we can show which channels are usable. */ 38 + targets: RoutingTarget[]; 16 39 } 17 40 18 41 export interface NotificationView {
+55 -2
apps/example-sender/src/routes/+page.server.ts
··· 1 1 import { loadHandle } from '@svelte-atproto/oauth/helper'; 2 2 import { memory } from '@svelte-atproto/oauth/server/stores/memory'; 3 3 4 + import { DEMO_CATEGORIES } from '$lib/config'; 5 + import { 6 + getRoutingForUser, 7 + listNotificationsForUser, 8 + setCategoriesForUser 9 + } from '$lib/server/relay'; 4 10 import { listSubscribers } from '$lib/server/subscribers'; 11 + import type { NotificationView, RoutingView } from '$lib/types'; 5 12 6 13 import type { PageServerLoad } from './$types'; 7 14 8 15 // Tiny in-memory cache for handle lookups (fine for a demo). 9 16 const handleCache = memory(); 10 17 18 + /** 19 + * On open, figure out whether this user has connected the app: `getRouting` is a 20 + * self-scoped call that only succeeds once a grant exists, so a success means 21 + * "connected" and hands us the routing + targets in one shot. We also declare the 22 + * app's demo categories (once) so the per-category UI has something to show. 23 + */ 11 24 export const load: PageServerLoad = async ({ locals }) => { 12 25 const handle = locals.did ? await loadHandle(locals.did, { cache: handleCache }) : null; 13 - // Users the relay told us enabled us via atmo.pub's "enable apps" flow. 14 - return { did: locals.did, handle, subscribers: listSubscribers() }; 26 + const base = { 27 + did: locals.did, 28 + handle, 29 + subscribers: listSubscribers(), 30 + categoriesError: null as string | null 31 + }; 32 + const disconnected = { 33 + ...base, 34 + connected: false, 35 + routing: null as RoutingView | null, 36 + notifications: [] as NotificationView[] 37 + }; 38 + 39 + if (!locals.client) return disconnected; 40 + 41 + try { 42 + let routing = await getRoutingForUser(locals.client); 43 + 44 + // Declare our categories the first time (idempotent; preserves any route the 45 + // user already chose). Isolated so a failure here can't flip us to 46 + // "not connected"; we surface the reason instead of hiding it. 47 + let categoriesError: string | null = null; 48 + const have = new Set(routing.categories.map((c) => c.id)); 49 + if (!DEMO_CATEGORIES.every((c) => have.has(c.id))) { 50 + try { 51 + await setCategoriesForUser( 52 + locals.client, 53 + DEMO_CATEGORIES.map((c) => ({ id: c.id, title: c.title, description: c.description })) 54 + ); 55 + routing = await getRoutingForUser(locals.client); 56 + } catch (err) { 57 + categoriesError = err instanceof Error ? err.message : 'Could not declare categories'; 58 + console.warn('[example] could not declare categories', err); 59 + } 60 + } 61 + 62 + const { notifications } = await listNotificationsForUser(locals.client, { limit: 10 }); 63 + return { ...base, connected: true, routing, notifications, categoriesError }; 64 + } catch { 65 + // No grant yet (or a token couldn't be minted) → not connected. 66 + return disconnected; 67 + } 15 68 };
+282 -256
apps/example-sender/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { invalidateAll } from '$app/navigation'; 2 3 import { oauthLogin, oauthLogout } from '$lib/atproto/oauth.remote'; 4 + import RoutePicker from '$lib/components/RoutePicker.svelte'; 3 5 import { 4 6 APP_DESCRIPTION, 5 7 APP_TITLE, 6 8 DASHBOARD_ORIGIN, 9 + DEMO_CATEGORIES, 7 10 GITHUB_URL, 8 11 PROJECT_NAME, 9 12 SENDER_DID 10 13 } from '$lib/config'; 11 14 import { 12 - getRouting, 13 - listNotifications, 15 + connect, 14 16 markAllRead, 15 17 openInAtmo, 16 - requestNotifications, 17 18 sendTest, 18 - setRouting, 19 + setAppRoute, 20 + setCategoryRoute, 19 21 type SendResult 20 22 } from '$lib/relay.remote'; 21 - import type { AppRoute, NotificationView, RoutingView } from '$lib/types'; 23 + import { routeLabel } from '$lib/routes'; 22 24 import type { PageServerData } from './$types'; 23 25 24 26 let { data }: { data: PageServerData } = $props(); 25 27 28 + let busy = $state<Record<string, boolean>>({}); 29 + let errorMsg = $state(''); 30 + 31 + // Remote-command errors arrive as { body: { message } }, not Error instances. 32 + function errMsg(err: unknown, fallback: string): string { 33 + const e = err as { body?: { message?: string }; message?: string }; 34 + return e?.body?.message ?? e?.message ?? fallback; 35 + } 36 + 37 + /** Run a mutation, refresh server data, surface errors. */ 38 + async function run(key: string, fn: () => Promise<unknown>) { 39 + busy[key] = true; 40 + errorMsg = ''; 41 + try { 42 + await fn(); 43 + await invalidateAll(); 44 + } catch (err) { 45 + errorMsg = errMsg(err, 'Something went wrong'); 46 + } finally { 47 + busy[key] = false; 48 + } 49 + } 50 + 26 51 // --- sign in / out ----------------------------------------------------- 27 52 let handle = $state(''); 28 53 let authBusy = $state(false); ··· 38 63 const { url } = await oauthLogin({ handle: value, returnTo: '/' }); 39 64 window.location.href = url; 40 65 } catch (err) { 41 - // Remote-function errors arrive as { body: { message } }, not Error instances. 42 - const e = err as { body?: { message?: string }; message?: string }; 43 - authError = e?.body?.message ?? e?.message ?? 'Sign-in failed'; 66 + authError = errMsg(err, 'Sign-in failed'); 44 67 authBusy = false; 45 68 } 46 69 } ··· 50 73 window.location.href = '/'; 51 74 } 52 75 53 - // --- step 1: request permission --------------------------------------- 54 - let reqBusy = $state(false); 55 - let reqResult = $state<{ id: string; status: string } | null>(null); 56 - let reqError = $state(''); 76 + // --- connect (request permission) ------------------------------------- 77 + let pendingApproval = $state(false); 57 78 58 - async function request() { 59 - reqBusy = true; 60 - reqError = ''; 79 + async function connectApp() { 80 + busy['connect'] = true; 81 + errorMsg = ''; 61 82 try { 62 - reqResult = await requestNotifications(); 83 + const { status } = await connect(); 84 + if (status === 'alreadyGranted') await invalidateAll(); 85 + else pendingApproval = true; 63 86 } catch (err) { 64 - reqError = err instanceof Error ? err.message : 'Request failed'; 87 + errorMsg = errMsg(err, 'Could not start connecting'); 65 88 } finally { 66 - reqBusy = false; 89 + busy['connect'] = false; 67 90 } 68 91 } 69 92 70 - // --- cross-app login: jump into atmo.pub already signed in ------------- 71 - let openBusy = $state(false); 72 - let openError = $state(''); 73 - 74 - async function openAtmo() { 75 - openBusy = true; 76 - openError = ''; 77 - // Open the tab synchronously on the click so the browser keeps the user 78 - // gesture (no popup blocker), then point it at the link once minted. 93 + // --- cross-app login: open atmo.pub already signed in ------------------ 94 + async function openAtmo(redirect: string, key: string) { 95 + busy[key] = true; 96 + errorMsg = ''; 97 + // Open synchronously on the click so the browser keeps the user gesture 98 + // (no popup blocker), then point the tab at the minted link. 79 99 const w = window.open('about:blank'); 80 100 try { 81 - const { url } = await openInAtmo(); 101 + const { url } = await openInAtmo({ redirect }); 82 102 if (w) w.location.href = url; 83 103 else window.location.href = url; 84 104 } catch (err) { 85 105 w?.close(); 86 - openError = err instanceof Error ? err.message : 'Failed to open atmo.pub'; 106 + errorMsg = errMsg(err, 'Failed to open atmo.pub'); 87 107 } finally { 88 - openBusy = false; 108 + busy[key] = false; 89 109 } 90 110 } 91 111 92 - // --- step 2: send a test ---------------------------------------------- 112 + // --- send a test ------------------------------------------------------ 93 113 let title = $state(''); 94 114 let body = $state(''); 95 - let sendBusy = $state(false); 115 + let testCategory = $state(''); 96 116 let sendResult = $state<SendResult | null>(null); 97 - let sendError = $state(''); 98 117 99 118 async function send(event: SubmitEvent) { 100 119 event.preventDefault(); 101 - sendBusy = true; 102 - sendError = ''; 120 + busy['send'] = true; 121 + errorMsg = ''; 122 + sendResult = null; 103 123 try { 104 - sendResult = await sendTest({ title, body }); 124 + sendResult = await sendTest({ title, body, category: testCategory || undefined }); 125 + await invalidateAll(); 105 126 } catch (err) { 106 - sendError = err instanceof Error ? err.message : 'Send failed'; 127 + errorMsg = errMsg(err, 'Send failed'); 107 128 } finally { 108 - sendBusy = false; 129 + busy['send'] = false; 109 130 } 110 131 } 111 132 112 - // --- step 3: manage routing + inbox (dual-auth) ----------------------- 113 - let manageBusy = $state(false); 114 - let manageError = $state(''); 115 - let routing = $state<RoutingView | null>(null); 116 - let notifs = $state<NotificationView[]>([]); 117 - let routeChoice = $state<AppRoute>('default'); 118 - let loaded = $state(false); 119 - 120 - // Remote-command errors arrive as { body: { message } }, not Error instances. 121 - function errMsg(err: unknown, fallback: string): string { 122 - const e = err as { body?: { message?: string }; message?: string }; 123 - return e?.body?.message ?? e?.message ?? fallback; 124 - } 125 - 126 - async function loadSettings() { 127 - manageBusy = true; 128 - manageError = ''; 129 - try { 130 - const [r, list] = await Promise.all([getRouting(), listNotifications()]); 131 - routing = r; 132 - routeChoice = r.route; 133 - notifs = list.notifications; 134 - loaded = true; 135 - } catch (err) { 136 - manageError = errMsg(err, 'Failed to load settings'); 137 - } finally { 138 - manageBusy = false; 139 - } 140 - } 141 - 142 - async function changeRoute() { 143 - manageBusy = true; 144 - manageError = ''; 145 - try { 146 - await setRouting({ route: routeChoice }); 147 - routing = await getRouting(); 148 - } catch (err) { 149 - manageError = errMsg(err, 'Failed to update routing'); 150 - } finally { 151 - manageBusy = false; 152 - } 153 - } 154 - 155 - async function markRead() { 156 - manageBusy = true; 157 - manageError = ''; 158 - try { 159 - await markAllRead(); 160 - notifs = (await listNotifications()).notifications; 161 - } catch (err) { 162 - manageError = errMsg(err, 'Failed to mark read'); 163 - } finally { 164 - manageBusy = false; 165 - } 166 - } 133 + const noTargets = $derived(data.connected && (data.routing?.targets.length ?? 0) === 0); 134 + // Distinct channel types the user actually has a target for (for the pickers). 135 + const availableTypes = $derived([...new Set((data.routing?.targets ?? []).map((t) => t.type))]); 167 136 168 137 const card = 'rounded-card border border-line bg-surface p-5'; 169 138 const btnPrimary = 170 139 'rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50'; 140 + const btnGhost = 141 + 'rounded-md border border-line px-3 py-1.5 text-sm font-medium text-fg hover:bg-surface-2 disabled:opacity-50'; 171 142 const inputCls = 172 143 'w-full rounded-md border border-line bg-surface px-3 py-2 text-sm text-fg placeholder:text-muted-2 focus:border-accent'; 173 - const selectCls = 174 - 'w-full rounded-md border border-line bg-surface px-3 py-2 text-sm text-fg focus:border-accent'; 175 144 const noticeOk = 'mt-3 rounded-md border border-line bg-accent-soft px-3 py-2 text-sm text-fg'; 176 145 const noticeWarn = 'mt-3 rounded-md border border-line px-3 py-2 text-sm text-warn'; 177 146 </script> 178 147 179 148 <svelte:head><title>{PROJECT_NAME}</title></svelte:head> 180 149 181 - <main class="mx-auto w-full max-w-[640px] space-y-8 px-4 py-10"> 150 + <main class="mx-auto w-full max-w-[640px] space-y-6 px-4 py-10"> 182 151 <header> 183 - <h1 class="text-2xl font-semibold tracking-tight text-fg">{PROJECT_NAME}</h1> 152 + <h1 class="text-2xl font-semibold tracking-tight text-fg">{APP_TITLE}</h1> 184 153 <p class="mt-3 max-w-prose text-sm leading-relaxed text-muted"> 185 - A demo app showing how any AT&nbsp;Protocol app can send notifications through 186 - atmo.pub. The code is open source — it wires up both auth mechanisms (user-OAuth 187 - for requesting permission, sender-DID for sending) in ~300 lines. 154 + {APP_DESCRIPTION} A demo of how any AT&nbsp;Protocol app integrates with atmo.pub: connect, 155 + let the user route notifications per category, and send. 188 156 </p> 189 157 190 158 {#if data.did} ··· 193 161 Signed in as 194 162 <span class="font-medium text-fg">{data.handle ? `@${data.handle}` : data.did}</span> 195 163 </span> 196 - <button 197 - onclick={signOut} 198 - class="rounded-md border border-line px-3 py-1.5 font-medium text-fg hover:bg-surface-2" 199 - > 200 - Sign out 201 - </button> 164 + <button onclick={signOut} class={btnGhost}>Sign out</button> 202 165 </div> 203 166 {:else} 204 167 <form onsubmit={signIn} class="mt-5 flex max-w-sm flex-col gap-2 sm:flex-row"> 205 168 <input 206 169 bind:value={handle} 207 - placeholder="jcsalterego.bsky.social" 170 + placeholder="you.bsky.social" 208 171 autocomplete="off" 209 172 autocapitalize="none" 210 173 autocorrect="off" ··· 222 185 {/if} 223 186 </header> 224 187 188 + {#if errorMsg} 189 + <p class="rounded-card border border-line bg-danger/10 px-3 py-2 text-sm text-danger" role="alert"> 190 + {errorMsg} 191 + </p> 192 + {/if} 193 + 225 194 {#if data.did} 195 + <!-- Connection status --> 226 196 <section class={card}> 227 - <h2 class="text-base font-semibold text-fg">Step 1 — Request permission</h2> 228 - <p class="mt-2 text-sm leading-relaxed text-muted"> 229 - Calls <code class="font-mono text-fg">requestPermission</code> on the relay using a 230 - service-auth token minted by your PDS (proves you authorized this app). The request names 231 - this app's own DID (<code class="font-mono break-all text-fg">{SENDER_DID}</code>) as the 232 - sender — “{APP_TITLE}”, {APP_DESCRIPTION} 233 - </p> 234 - <button onclick={request} disabled={reqBusy} class="{btnPrimary} mt-4"> 235 - {reqBusy ? 'Requesting…' : 'Request notifications'} 236 - </button> 237 - {#if reqResult} 238 - {#if reqResult.status === 'alreadyGranted'} 239 - <p class={noticeOk}>✓ You've already approved this app. Skip to Step 2.</p> 197 + {#if data.connected} 198 + <div class="flex flex-wrap items-center justify-between gap-3"> 199 + <div class="flex items-center gap-2 text-sm font-medium text-fg"> 200 + <span 201 + class="flex size-5 items-center justify-center rounded-full bg-accent text-accent-fg" 202 + > 203 + <svg 204 + width="12" 205 + height="12" 206 + viewBox="0 0 24 24" 207 + fill="none" 208 + stroke="currentColor" 209 + stroke-width="3" 210 + stroke-linecap="round" 211 + stroke-linejoin="round" 212 + aria-hidden="true"><path d="M5 12l5 5L20 7" /></svg 213 + > 214 + </span> 215 + Connected to atmo.pub 216 + </div> 217 + <button 218 + class={btnGhost} 219 + disabled={busy['manage']} 220 + onclick={() => openAtmo(`/apps/${SENDER_DID}`, 'manage')} 221 + > 222 + {busy['manage'] ? 'Opening…' : 'Manage in atmo.pub ↗'} 223 + </button> 224 + </div> 225 + {:else} 226 + <h2 class="text-base font-semibold text-fg">Connect to atmo.pub</h2> 227 + <p class="mt-2 text-sm leading-relaxed text-muted"> 228 + Ask for permission to send <span class="font-medium text-fg">{APP_TITLE}</span> 229 + notifications. You approve it once in atmo.pub, then choose how they reach you. 230 + </p> 231 + {#if !pendingApproval} 232 + <button onclick={connectApp} disabled={busy['connect']} class="{btnPrimary} mt-4"> 233 + {busy['connect'] ? 'Requesting…' : 'Connect to atmo.pub'} 234 + </button> 240 235 {:else} 241 - <p class={noticeOk}> 242 - ✓ Request sent. Approve it at 243 - <a 244 - class="text-accent hover:underline" 245 - href={DASHBOARD_ORIGIN} 246 - target="_blank" 247 - rel="noreferrer">atmo.pub</a 248 - >, where you can also connect Telegram. Already approved? Skip to Step 2. 249 - </p> 236 + <p class={noticeOk}>✓ Request sent — approve it in atmo.pub, then come back.</p> 237 + <div class="mt-3 flex flex-wrap gap-2"> 238 + <button 239 + class={btnPrimary} 240 + disabled={busy['approve']} 241 + onclick={() => openAtmo('/apps', 'approve')} 242 + > 243 + {busy['approve'] ? 'Opening…' : 'Allow app in atmo.pub ↗'} 244 + </button> 245 + <button class={btnGhost} disabled={busy['recheck']} onclick={() => run('recheck', async () => {})}> 246 + {busy['recheck'] ? 'Checking…' : "I've approved — refresh"} 247 + </button> 248 + </div> 250 249 {/if} 251 250 {/if} 252 - {#if reqError}<p class="mt-3 text-sm text-danger" role="alert">{reqError}</p>{/if} 253 251 </section> 254 252 255 - <section class={card}> 256 - <h2 class="text-base font-semibold text-fg">Step 2 — Send a test</h2> 257 - <p class="mt-2 text-sm leading-relaxed text-muted"> 258 - Calls <code class="font-mono text-fg">send</code> using a JWT signed by this app's own 259 - private key — no user OAuth involved. The recipient (you) is in the body. 260 - </p> 261 - <form onsubmit={send} class="mt-4 space-y-2"> 262 - <input bind:value={title} placeholder="Hello from the example sender" class={inputCls} /> 263 - <input 264 - bind:value={body} 265 - placeholder="If you can read this, the integration works end to end." 266 - class={inputCls} 267 - /> 268 - <button type="submit" disabled={sendBusy} class={btnPrimary}> 269 - {sendBusy ? 'Sending…' : 'Send test notification'} 270 - </button> 271 - </form> 272 - {#if sendResult} 273 - {#if sendResult.ok && sendResult.delivered > 0} 274 - <p class={noticeOk}>✓ Sent — check your Telegram.</p> 275 - {:else if sendResult.ok} 276 - <p class={noticeWarn}> 277 - ⚠ Sent, but you have no delivery channels linked. Connect Telegram at 278 - <a class="underline" href={DASHBOARD_ORIGIN} target="_blank" rel="noreferrer" 279 - >atmo.pub</a 280 - >. 253 + {#if data.connected && data.routing} 254 + {#if noTargets} 255 + <!-- No delivery channels: route changes wouldn't go anywhere yet. --> 256 + <section class={card}> 257 + <h2 class="text-base font-semibold text-fg">Set up where notifications go</h2> 258 + <p class="mt-2 text-sm leading-relaxed text-muted"> 259 + You haven't connected any delivery channels yet (push, Telegram, email, …). Add one in 260 + atmo.pub, then come back to route this app's notifications. 281 261 </p> 282 - {:else} 283 - <p class={noticeWarn}> 284 - ⚠ Approve this app first at 285 - <a class="underline" href={DASHBOARD_ORIGIN} target="_blank" rel="noreferrer" 286 - >atmo.pub</a 287 - >, then try again. 262 + <button 263 + class="{btnPrimary} mt-4" 264 + disabled={busy['settings']} 265 + onclick={() => openAtmo('/settings?tab=channels', 'settings')} 266 + > 267 + {busy['settings'] ? 'Opening…' : 'Set up channels in atmo.pub ↗'} 268 + </button> 269 + </section> 270 + {:else} 271 + <!-- App-wide routing --> 272 + <section class={card}> 273 + <h2 class="text-base font-semibold text-fg">App defaults</h2> 274 + <p class="mt-2 text-sm leading-relaxed text-muted"> 275 + Where everything from {APP_TITLE} goes. 276 + <span class="font-medium text-fg">Account default</span> follows your atmo.pub default ({routeLabel( 277 + data.routing.defaultRoute 278 + )}). 288 279 </p> 289 - {/if} 280 + <div class="mt-3"> 281 + <RoutePicker 282 + value={data.routing.route} 283 + inherit={{ token: 'default', label: 'Account default' }} 284 + available={availableTypes} 285 + disabled={busy['__app']} 286 + onchange={(route) => run('__app', () => setAppRoute({ route }))} 287 + /> 288 + </div> 289 + </section> 290 + 291 + <!-- Per-category routing --> 292 + <section class={card}> 293 + <h2 class="text-base font-semibold text-fg">Categories</h2> 294 + <p class="mt-2 text-sm leading-relaxed text-muted"> 295 + This app declares the kinds of notifications it sends. Route each one separately, or let 296 + it follow the app default. 297 + </p> 298 + {#if data.categoriesError} 299 + <p class={noticeWarn}> 300 + ⚠ Couldn't declare categories: {data.categoriesError}. If this mentions a permission 301 + or token, sign out and back in to refresh this app's access. 302 + </p> 303 + {/if} 304 + <ul class="mt-4 space-y-5"> 305 + {#each data.routing.categories as c (c.id)} 306 + <li class="border-t border-line pt-4 first:border-t-0 first:pt-0"> 307 + <div class="text-sm font-medium text-fg">{c.title ?? c.id}</div> 308 + {#if c.description}<div class="text-xs text-muted">{c.description}</div>{/if} 309 + <div class="mt-2"> 310 + <RoutePicker 311 + value={c.route} 312 + inherit={{ token: 'app', label: 'App default' }} 313 + available={availableTypes} 314 + disabled={busy[c.id]} 315 + onchange={(route) => run(c.id, () => setCategoryRoute({ id: c.id, route }))} 316 + /> 317 + </div> 318 + </li> 319 + {:else} 320 + <li class="text-sm text-muted-2">No categories declared yet.</li> 321 + {/each} 322 + </ul> 323 + </section> 290 324 {/if} 291 - {#if sendError}<p class="mt-3 text-sm text-danger" role="alert">{sendError}</p>{/if} 292 - </section> 293 325 294 - <section class={card}> 295 - <h2 class="text-base font-semibold text-fg">Step 3 — Manage your settings (dual-auth)</h2> 296 - <p class="mt-2 text-sm leading-relaxed text-muted"> 297 - Read and change how <em>this app's</em> notifications reach you — from inside this app. Each 298 - call carries <strong class="text-fg">two</strong> service-auth tokens: one signed by this app 299 - (proves which app), and a fresh one minted by your PDS (proves you consented). The relay 300 - scopes every change to you&nbsp;+&nbsp;this app — it can't touch your account default or 301 - other apps. Requires an approved grant (Step&nbsp;1). 302 - </p> 303 - <button onclick={loadSettings} disabled={manageBusy} class="{btnPrimary} mt-4"> 304 - {manageBusy ? 'Working…' : loaded ? 'Refresh' : 'Load my settings'} 305 - </button> 326 + <!-- Debug: send a test notification --> 327 + <section class={card}> 328 + <h2 class="text-base font-semibold text-fg">Send a test notification</h2> 329 + <p class="mt-2 text-sm leading-relaxed text-muted"> 330 + Debug tool — calls <code class="font-mono text-fg">send</code> with this app's own key. Pick 331 + a category to test per-category routing. 332 + </p> 333 + <form onsubmit={send} class="mt-4 space-y-2"> 334 + <input bind:value={title} placeholder="Title" class={inputCls} /> 335 + <input bind:value={body} placeholder="Body" class={inputCls} /> 336 + <select bind:value={testCategory} class={inputCls}> 337 + <option value="">General (no category)</option> 338 + {#each DEMO_CATEGORIES as c (c.id)} 339 + <option value={c.id}>{c.title}</option> 340 + {/each} 341 + </select> 342 + <button type="submit" disabled={busy['send']} class={btnPrimary}> 343 + {busy['send'] ? 'Sending…' : 'Send test notification'} 344 + </button> 345 + </form> 346 + {#if sendResult} 347 + {#if sendResult.ok && sendResult.delivered > 0} 348 + <p class={noticeOk}>✓ Sent — delivered to {sendResult.delivered} channel{sendResult.delivered === 1 ? '' : 's'}.</p> 349 + {:else if sendResult.ok} 350 + <p class={noticeWarn}> 351 + ⚠ Recorded in your inbox, but it fired no alert channels (routed to inbox/off, or no 352 + matching targets). 353 + </p> 354 + {:else} 355 + <p class={noticeWarn}>⚠ Not approved yet — connect the app first.</p> 356 + {/if} 357 + {/if} 358 + </section> 306 359 307 - {#if routing} 308 - <div class="mt-5 space-y-5"> 309 - <label class="block text-sm"> 310 - <span class="font-medium text-fg">Route all of this app's notifications to</span> 311 - <select 312 - bind:value={routeChoice} 313 - onchange={changeRoute} 314 - disabled={manageBusy} 315 - class="{selectCls} mt-1" 316 - > 317 - <option value="default">Account default ({routing.defaultRoute})</option> 318 - <option value="push">Push</option> 319 - <option value="telegram">Telegram</option> 320 - <option value="push+telegram">Push + Telegram</option> 321 - <option value="off">Off</option> 322 - </select> 323 - </label> 324 - 325 - <div> 326 - <div class="flex items-center justify-between"> 327 - <h3 class="text-sm font-medium text-fg">Notifications we've sent you</h3> 328 - <button 329 - onclick={markRead} 330 - disabled={manageBusy || notifs.length === 0} 331 - class="rounded-md border border-line px-3 py-1.5 text-xs font-medium text-fg hover:bg-surface-2 disabled:opacity-50" 332 - > 333 - Mark all read 334 - </button> 335 - </div> 336 - {#if notifs.length === 0} 337 - <p class="mt-2 text-sm text-muted">None yet — send one in Step 2, then Refresh.</p> 338 - {:else} 339 - <ul class="mt-2 divide-y divide-line text-sm"> 340 - {#each notifs as n (n.id)} 341 - <li class="flex items-center justify-between gap-3 py-2"> 342 - <span class="min-w-0 truncate text-fg">{n.read ? '' : '• '}{n.title}</span> 343 - <span class="shrink-0 text-xs text-muted-2"> 344 - delivered to {n.delivered ?? 0} · {n.read ? 'read' : 'unread'} 345 - </span> 346 - </li> 347 - {/each} 348 - </ul> 349 - {/if} 350 - </div> 360 + <!-- Debug: recent notifications --> 361 + <section class={card}> 362 + <div class="flex items-center justify-between"> 363 + <h2 class="text-base font-semibold text-fg">Recent notifications</h2> 364 + <button 365 + onclick={() => run('__read', () => markAllRead())} 366 + disabled={busy['__read'] || data.notifications.length === 0} 367 + class={btnGhost} 368 + > 369 + Mark all read 370 + </button> 351 371 </div> 352 - {/if} 353 - {#if manageError}<p class="mt-3 text-sm text-danger" role="alert">{manageError}</p>{/if} 354 - </section> 372 + <p class="mt-1 text-xs text-muted-2">The 10 most recent we've sent you (debug view).</p> 373 + {#if data.notifications.length === 0} 374 + <p class="mt-3 text-sm text-muted">None yet — send one above.</p> 375 + {:else} 376 + <ul class="mt-3 divide-y divide-line text-sm"> 377 + {#each data.notifications as n (n.id)} 378 + <li class="flex items-start justify-between gap-3 py-2"> 379 + <div class="min-w-0"> 380 + <div class="truncate text-fg"> 381 + {#if !n.read}<span class="text-accent">•</span> {/if}{n.title} 382 + </div> 383 + <div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-2"> 384 + <span>{n.createdAt.slice(0, 10)}</span> 385 + {#if n.category} 386 + <span class="rounded bg-surface-2 px-1.5 py-0.5 font-mono">{n.category}</span> 387 + {/if} 388 + </div> 389 + </div> 390 + <span class="shrink-0 text-xs text-muted-2"> 391 + → {n.delivered ?? 0} · {n.read ? 'read' : 'unread'} 392 + </span> 393 + </li> 394 + {/each} 395 + </ul> 396 + {/if} 397 + </section> 398 + {/if} 355 399 400 + <!-- Subscribers from atmo.pub's "enable apps" flow (debug) --> 356 401 <section class={card}> 357 - <h2 class="text-base font-semibold text-fg">Cross-app login</h2> 402 + <h2 class="text-base font-semibold text-fg">Subscribers from atmo.pub</h2> 358 403 <p class="mt-2 text-sm leading-relaxed text-muted"> 359 - Mints a one-time <code class="font-mono text-fg">pub.atmo.auth</code> token on your PDS and 360 - opens 361 - <a class="text-accent hover:underline" href={DASHBOARD_ORIGIN} target="_blank" rel="noreferrer" 362 - >atmo.pub</a 363 - > 364 - <em>already signed in</em> — deep-linked to this app's settings. No login form. 404 + Users who enabled this app from inside atmo.pub. The relay tells us via a signed 405 + <code class="font-mono text-fg">subscriberChanged</code> callback. Demo store is in-memory. 365 406 </p> 366 - <button onclick={openAtmo} disabled={openBusy} class="{btnPrimary} mt-4"> 367 - {openBusy ? 'Opening…' : 'Open in atmo.pub'} 368 - </button> 369 - {#if openError}<p class="mt-3 text-sm text-danger" role="alert">{openError}</p>{/if} 407 + {#if data.subscribers.length === 0} 408 + <p class="mt-3 text-sm text-muted-2">None yet.</p> 409 + {:else} 410 + <ul class="mt-3 space-y-1"> 411 + {#each data.subscribers as sub (sub)} 412 + <li class="font-mono text-xs break-all text-fg">{sub}</li> 413 + {/each} 414 + </ul> 415 + {/if} 370 416 </section> 371 417 {/if} 372 418 373 - <section class={card}> 374 - <h2 class="text-base font-semibold text-fg">Subscribers from atmo.pub</h2> 375 - <p class="mt-2 text-sm leading-relaxed text-muted"> 376 - Users who enabled this app from inside atmo.pub. The relay tells us via a signed 377 - <code class="font-mono text-fg">subscriberChanged</code> callback (we verify it's really the 378 - relay), and a real app would start sending them notifications. Demo store is in-memory. 379 - </p> 380 - {#if data.subscribers.length === 0} 381 - <p class="mt-3 text-sm text-muted-2">None yet — enable this app from atmo.pub's Apps screen.</p> 382 - {:else} 383 - <ul class="mt-3 space-y-1"> 384 - {#each data.subscribers as sub (sub)} 385 - <li class="font-mono text-xs break-all text-fg">{sub}</li> 386 - {/each} 387 - </ul> 388 - {/if} 389 - </section> 390 - 391 419 <footer class="border-t border-line pt-6 text-xs text-muted-2"> 392 420 Source: 393 421 <a class="hover:text-fg" href={GITHUB_URL} target="_blank" rel="noreferrer">{GITHUB_URL}</a>. 394 422 Powered by 395 - <a class="hover:text-fg" href={DASHBOARD_ORIGIN} target="_blank" rel="noreferrer" 396 - >atmo.pub</a 397 - >. 423 + <a class="hover:text-fg" href={DASHBOARD_ORIGIN} target="_blank" rel="noreferrer">atmo.pub</a>. 398 424 </footer> 399 425 </main>
+7
apps/example-sender/static/icon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Example Sender"> 2 + <rect width="64" height="64" rx="14" fill="#3a6ff0" /> 3 + <g transform="translate(8 8) scale(2)" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 4 + <path d="m22 2-7 20-4-9-9-4Z" /> 5 + <path d="M22 2 11 13" /> 6 + </g> 7 + </svg>
+7 -3
apps/homepage/src/routes/+page.svelte
··· 4 4 5 5 const features = [ 6 6 { t: 'Permission first', d: 'Apps request access; nothing is sent until you approve.' }, 7 - { t: 'Delivered to Telegram', d: 'Link your Telegram once and get notifications in chat.' }, 7 + { 8 + t: 'Your channels', 9 + d: 'Route alerts to web push, Telegram, email, Bluesky DM, or a webhook — per app and per category.' 10 + }, 8 11 { t: 'Revoke anytime', d: 'Mute or revoke any app from your dashboard.' } 9 12 ]; 10 13 </script> ··· 22 25 {PROJECT_NAME} 23 26 </h1> 24 27 <p class="mt-4 max-w-prose text-base leading-relaxed text-muted"> 25 - Lets any AT&nbsp;Protocol app send you notifications via Telegram (and soon more). You stay in 26 - control: every app must ask permission, and you can revoke any time. 28 + Lets any AT&nbsp;Protocol app send you notifications — delivered via web push, Telegram, email, 29 + Bluesky DM, or a webhook, with everything kept in an inbox. You stay in control: every app must 30 + ask permission, and you can revoke any time. 27 31 </p> 28 32 29 33 <div class="mt-8 flex flex-wrap gap-3">
+59 -7
apps/homepage/src/routes/docs/+page.svelte
··· 45 45 <header> 46 46 <h1 class="text-2xl font-semibold tracking-tight text-fg">Developer docs</h1> 47 47 <p class="mt-2 max-w-prose text-sm leading-relaxed text-muted"> 48 - Any atproto app can ask users to receive notifications via {PROJECT_NAME}. Users approve in 49 - the dashboard; the relay delivers via Telegram. 48 + Any atproto app can ask users to receive notifications via {PROJECT_NAME}. Users approve in the 49 + dashboard and choose where alerts go — web push, Telegram, email, Bluesky DM, or a webhook — 50 + while everything also lands in their inbox. 50 51 </p> 51 52 <p class="mt-3 max-w-prose rounded-card border border-line bg-surface p-3 text-sm text-muted"> 52 53 <strong class="text-fg">Two endpoints, two auth mechanisms.</strong> ··· 113 114 <p class="max-w-prose text-sm leading-relaxed text-muted"> 114 115 Returns <code class="font-mono text-fg">&#123; id, status &#125;</code> 115 116 (<code class="font-mono text-fg">pending</code> or 116 - <code class="font-mono text-fg">alreadyGranted</code>). The user approves in their dashboard or 117 - via Telegram. <code class="font-mono text-fg">title</code> ≤ 50 chars, 117 + <code class="font-mono text-fg">alreadyGranted</code>). The user approves in their dashboard. 118 + <code class="font-mono text-fg">title</code> ≤ 50 chars, 118 119 <code class="font-mono text-fg">description</code> ≤ 200 chars, optional 119 120 <code class="font-mono text-fg">iconUrl</code>. 120 121 </p> ··· 126 127 Once granted, sign with your app's own key (no user involved) and send. Field limits: 127 128 <code class="font-mono text-fg">title</code> ≤ 100, <code class="font-mono text-fg">body</code> 128 129 ≤ 500, optional <code class="font-mono text-fg">uri</code> and 129 - <code class="font-mono text-fg">threadKey</code>. 130 + <code class="font-mono text-fg">threadKey</code>. Optional 131 + <code class="font-mono text-fg">category</code> (≤ 64) tags the notification so the user can 132 + route that category separately; <code class="font-mono text-fg">categoryDescription</code> labels 133 + it in their settings. The response is 134 + <code class="font-mono text-fg">&#123; id, delivered &#125;</code> — 135 + <code class="font-mono text-fg">delivered: 0</code> is normal (saved to the inbox, but the user 136 + may have muted you, routed you to “off”, or connected no channels). 130 137 </p> 131 138 {@render codeblock(data.code.sendJwt)} 132 139 <p class="max-w-prose text-sm leading-relaxed text-muted"> ··· 178 185 </section> 179 186 180 187 <section class="space-y-3"> 181 - <h2 class="text-lg font-semibold text-fg">6. Rate limits</h2> 188 + <h2 class="text-lg font-semibold text-fg">6. Let users tune your app (optional)</h2> 189 + <p class="max-w-prose text-sm leading-relaxed text-muted"> 190 + If the user grants your app “manage its own settings”, it can read and adjust how 191 + <em>its own</em> notifications reach them — over <strong class="text-fg">dual-auth</strong> XRPC 192 + (your app token in the header + a fresh user token in the body 193 + <code class="font-mono text-fg">userToken</code>, both scoped to the method). These only ever 194 + touch your app's slice for that user. 195 + </p> 196 + <ul class="ml-5 list-disc space-y-1 text-sm text-muted"> 197 + <li> 198 + <code class="font-mono text-fg">getRouting</code> / 199 + <code class="font-mono text-fg">setRouting</code> — read the user's routable targets (to render 200 + a picker) and set which channels your notifications use, app-wide and per-category. 201 + </li> 202 + <li> 203 + <code class="font-mono text-fg">getCategories</code> / 204 + <code class="font-mono text-fg">setCategories</code> / 205 + <code class="font-mono text-fg">addCategory</code> / 206 + <code class="font-mono text-fg">removeCategory</code> — declare your categories up front (e.g. 207 + one per webhook the user configures), each with a display title, so they show in routing 208 + before the first send. 209 + </li> 210 + <li> 211 + <code class="font-mono text-fg">listNotifications</code> / 212 + <code class="font-mono text-fg">markRead</code>, 213 + <code class="font-mono text-fg">muteSelf</code>, 214 + <code class="font-mono text-fg">revokeSelf</code> — read/ack your inbox slice; mute or remove 215 + yourself. 216 + </li> 217 + </ul> 218 + <p class="max-w-prose text-sm leading-relaxed text-muted"> 219 + A <strong class="text-fg">route</strong> is a <code class="font-mono text-fg">+</code>-joined set 220 + of channel tokens (<code class="font-mono text-fg">push</code>, 221 + <code class="font-mono text-fg">telegram</code>, <code class="font-mono text-fg">email</code>, 222 + <code class="font-mono text-fg">dm</code>, <code class="font-mono text-fg">webhook</code>; or 223 + <code class="font-mono text-fg">channel:&lt;id&gt;</code> for one specific device/chat), plus 224 + <code class="font-mono text-fg">off</code>, <code class="font-mono text-fg">inbox</code> (inbox 225 + only), and the inherit sentinels <code class="font-mono text-fg">default</code> / 226 + <code class="font-mono text-fg">app</code>. Full method reference: the 227 + <a class="text-accent hover:underline" href="/llms.txt">LLM integration guide</a> or each lexicon 228 + at <code class="font-mono text-fg">/lexicons/&lt;nsid&gt;</code>. 229 + </p> 230 + </section> 231 + 232 + <section class="space-y-3"> 233 + <h2 class="text-lg font-semibold text-fg">7. Rate limits</h2> 182 234 <ul class="ml-5 list-disc space-y-1 text-sm text-muted"> 183 235 <li>At most <strong class="text-fg">1 outstanding pending request</strong> per (sender, recipient).</li> 184 236 <li><code class="font-mono text-fg">requestPermission</code>: <strong class="text-fg">50 / hour</strong> per recipient and <strong class="text-fg">100 / hour</strong> per sender.</li> ··· 187 239 </section> 188 240 189 241 <section class="space-y-3"> 190 - <h2 class="text-lg font-semibold text-fg">7. Error handling</h2> 242 + <h2 class="text-lg font-semibold text-fg">8. Error handling</h2> 191 243 <p class="max-w-prose text-sm leading-relaxed text-muted">Common XRPC errors:</p> 192 244 <ul class="ml-5 list-disc space-y-1 text-sm text-muted"> 193 245 <li><code class="font-mono text-fg">AuthenticationRequired</code> — missing/invalid JWT.</li>
+28 -11
apps/homepage/static/llms.txt
··· 3 3 This is a complete, self-contained guide for adding notifications to an AT Protocol 4 4 app via atmo.pub. It is written to be implemented from directly. atmo.pub is a 5 5 notification *relay*: an app asks a user for permission, then sends notifications; 6 - the relay fans them out to the channels the user chose (web push, Telegram, …) and 7 - records them in the user's inbox. 6 + the relay fans them out to the channels the user chose (web push, Telegram, email, 7 + Bluesky DM, webhook) and records them in the user's inbox. 8 8 9 9 You integrate as a **sender** (the common case). Optionally you can also let users 10 10 manage your app's settings from inside your app, and receive opt-in/opt-out ··· 147 147 must have granted your app "manage its own settings" in the dashboard; reads are 148 148 open). On a relay you run yourself, you can allow your own DID by default. 149 149 150 - A **route** is a `+`-joined set of channel tokens, e.g. `"push+email"`, `"off"` for 151 - none, plus the inherit sentinels `"default"` (app-wide → account default) and `"app"` 152 - (category → app-wide). A token is either a bare channel — `push`/`telegram`/`email`, 153 - meaning all of that channel's instances — or a channel narrowed to one delivery 154 - instance as `channel:<id>` (e.g. `"push:a1b2c3"` = one specific device). Users pick 155 - instance ids in the dashboard; an unknown id simply delivers to nothing. 150 + A **route** is a `+`-joined set of channel tokens, e.g. `"push+email"`. Special 151 + values: `"off"` (drop — not even saved to the inbox), `"inbox"` (saved, no alerts), 152 + and the inherit sentinels `"default"` (app-wide → account default) and `"app"` 153 + (category → app-wide). A token is either a bare channel — 154 + `push`/`telegram`/`email`/`dm`/`webhook`, meaning all of that channel's instances — 155 + or a channel narrowed to one delivery instance as `channel:<id>` (e.g. 156 + `"push:a1b2c3"` = one specific device). Users pick instance ids in the dashboard; 157 + an unknown id simply delivers to nothing. 156 158 - `setRouting` — body `{ userToken, route?, categories? }` 157 - - `route`: app-wide route — a `+`-joined token set, `"off"`, or `"default"`. Omit to leave unchanged. 158 - - `categories`: `[{ "id": "<category>", "route": "<token set>" | "off" | "app" }]` 159 + - `route`: app-wide route — a `+`-joined token set, `"off"`, `"inbox"`, or `"default"`. Omit to leave unchanged. 160 + - `categories`: `[{ "id": "<category>", "route": "<token set>" | "off" | "inbox" | "app" }]` 159 161 - → `{ "ok": true }` 160 162 - `getRouting` — body `{ userToken }` → 161 163 `{ "route": "<app-wide>", "defaultRoute": "<account default>", 162 - "categories": [{ "id", "description?", "route" }], 164 + "categories": [{ "id", "title?", "description?", "route" }], 163 165 "targets": [{ "type", "id", "label" }] }` 164 166 (each route is a `+`-joined token set / `off` / sentinel as above. `targets` is the 165 167 user's deliverable instances — render a picker and use a target's `id` in a 166 168 `channel:<id>` token; labels are privacy-safe, never a raw email/handle.) 169 + 170 + **Categories** are per (user, app) — never shared across users. They're 171 + auto-discovered from `send` (the `category` field), or you can declare them up 172 + front (e.g. one per webhook the user configures) with a display `title`: 173 + - `setCategories` — body `{ userToken, categories: [{ id, title?, description?, route? }] }`. 174 + Full sync: replaces your category set for this user (omitted ones are removed, 175 + along with their routing). `route` is an optional initial route (token set / `off` / 176 + `inbox` / `app`). → `{ "ok": true }` 177 + - `addCategory` — body `{ userToken, id, title?, description?, route? }` (add/update one) → `{ "ok": true }` 178 + - `removeCategory` — body `{ userToken, id }` (drops it + its routing) → `{ "removed": bool }` 179 + - `getCategories` — body `{ userToken }` → `{ "categories": [{ id, title?, description?, route }] }` 167 180 - `listNotifications` — body `{ userToken, limit?(1–100, default 50), cursor? }` → 168 181 `{ "notifications": [{ "id","title","body","uri?","category?", 169 182 "createdAt"(ISO datetime),"read"(bool),"delivered?"(int) }], "cursor?" }` ··· 292 305 rpc?lxm=pub.atmo.notify.requestPermission&aud=* 293 306 rpc?lxm=pub.atmo.notify.setRouting&aud=* 294 307 rpc?lxm=pub.atmo.notify.getRouting&aud=* 308 + rpc?lxm=pub.atmo.notify.setCategories&aud=* 309 + rpc?lxm=pub.atmo.notify.addCategory&aud=* 310 + rpc?lxm=pub.atmo.notify.removeCategory&aud=* 311 + rpc?lxm=pub.atmo.notify.getCategories&aud=* 295 312 rpc?lxm=pub.atmo.notify.listNotifications&aud=* 296 313 rpc?lxm=pub.atmo.notify.markRead&aud=* 297 314 rpc?lxm=pub.atmo.notify.revokeSelf&aud=*
+12 -5
apps/relay/migrations/0001_init.sql
··· 4 4 CREATE TABLE users ( 5 5 did TEXT PRIMARY KEY, 6 6 created_at INTEGER NOT NULL, 7 - notify_pending_via_telegram INTEGER NOT NULL DEFAULT 0, 8 7 -- Account-default alert route: a '+'-joined token set (see lexicons/rpc.ts). 9 - default_route TEXT NOT NULL DEFAULT 'push', 8 + -- New accounts start at 'inbox' (recorded, no alerts) — it always "works" even 9 + -- before any delivery channel is connected; the web app nudges the user to pick 10 + -- a real route once they add their first channel. 11 + default_route TEXT NOT NULL DEFAULT 'inbox', 10 12 -- Incoming-request policy: 'all' | 'trusted' (TRUSTED_SENDERS) | 'none'. 11 - auto_allow TEXT NOT NULL DEFAULT 'trusted' 13 + auto_allow TEXT NOT NULL DEFAULT 'trusted', 14 + -- Where permission-request alerts go: a concrete route (token set) or 'off'. 15 + pending_route TEXT NOT NULL DEFAULT 'off' 12 16 ); 13 17 14 18 -- Unified delivery targets. Every place a notification can be delivered — a web ··· 123 127 ); 124 128 CREATE INDEX notifications_by_recipient ON notifications (recipient_did, created_at DESC); 125 129 126 - -- Categories discovered from `send` (per recipient+sender), so the routing UI can 127 - -- list them with a description. 130 + -- Categories per (recipient, sender): auto-discovered from `send`, or declared up 131 + -- front by an app (setCategories/addCategory). `category` is the routing key; the 132 + -- optional `title` is a human display name (e.g. a webhook's name). Per-user by 133 + -- construction (recipient_did in the PK) — never shared across users. 128 134 CREATE TABLE app_categories ( 129 135 recipient_did TEXT NOT NULL, 130 136 sender_did TEXT NOT NULL, 131 137 category TEXT NOT NULL, 138 + title TEXT, 132 139 description TEXT, 133 140 last_seen INTEGER NOT NULL, 134 141 PRIMARY KEY (recipient_did, sender_did, category)
+103 -15
apps/relay/src/db/queries.ts
··· 7 7 export interface UserRow { 8 8 did: Did; 9 9 created_at: number; 10 - notify_pending_via_telegram: number; 11 10 default_route: string; 12 11 auto_allow: string; 12 + /** Where permission-request alerts go: a concrete route (token set) or 'off'. */ 13 + pending_route: string; 13 14 } 14 15 15 16 export interface LinkTokenRow { ··· 33 34 id: string; 34 35 recipient_did: Did; 35 36 sender_did: Did; 36 - reason: string | null; // legacy (migration 0001); unused 37 37 title: string | null; 38 38 description: string | null; 39 39 icon_url: string | null; ··· 78 78 return db.prepare('SELECT * FROM users WHERE did = ?').bind(did).first<UserRow>(); 79 79 } 80 80 81 - /** Insert a users row if absent. No-op when it already exists. */ 81 + /** 82 + * Insert a users row if absent. No-op when it already exists. `default_route` is 83 + * set explicitly to 'inbox' (rather than leaning on the column default) so new 84 + * accounts start at inbox-only even on a DB whose column default predates that. 85 + */ 82 86 export async function ensureUser(db: D1Database, did: Did, createdAt: number): Promise<void> { 83 87 await db 84 - .prepare('INSERT OR IGNORE INTO users (did, created_at, notify_pending_via_telegram) VALUES (?, ?, 0)') 88 + .prepare("INSERT OR IGNORE INTO users (did, created_at, default_route) VALUES (?, ?, 'inbox')") 85 89 .bind(did, createdAt) 86 90 .run(); 87 91 } 88 92 89 - export async function setNotifyPending(db: D1Database, did: Did, value: boolean): Promise<void> { 90 - await db 91 - .prepare('UPDATE users SET notify_pending_via_telegram = ? WHERE did = ?') 92 - .bind(toInt(value), did) 93 - .run(); 93 + /** Set where permission-request alerts go (a concrete route, or 'off'). */ 94 + export async function setPendingRoute(db: D1Database, did: Did, route: string): Promise<void> { 95 + await db.prepare('UPDATE users SET pending_route = ? WHERE did = ?').bind(route, did).run(); 94 96 } 95 97 96 98 // --------------------------------------------------------------------------- ··· 506 508 } 507 509 508 510 /** 509 - * Insert or refresh a grant. Re-granting resets `muted` to 0. Metadata is copied 510 - * from the pending request; `COALESCE` keeps any previously-stored metadata when 511 - * a re-grant provides none (e.g. a manual grant with no requestId). 511 + * Insert or refresh a grant. New grants default to `manage = 'self'` (an app may 512 + * manage its own routing/inbox) — set explicitly here rather than relying on the 513 + * column default, so the behaviour is consistent even on a DB whose `grants` 514 + * table predates that default. Re-granting resets `muted` to 0 but KEEPS the 515 + * user's chosen `manage` capability. Metadata is copied from the pending request; 516 + * `COALESCE` keeps previously-stored metadata when a re-grant provides none. 512 517 */ 513 518 export async function upsertGrant(db: D1Database, input: UpsertGrantInput): Promise<void> { 514 519 await db 515 520 .prepare( 516 - `INSERT INTO grants (recipient_did, sender_did, granted_at, muted, title, description, icon_url) 517 - VALUES (?, ?, ?, 0, ?, ?, ?) 521 + `INSERT INTO grants (recipient_did, sender_did, granted_at, muted, title, description, icon_url, manage) 522 + VALUES (?, ?, ?, 0, ?, ?, ?, 'self') 518 523 ON CONFLICT(recipient_did, sender_did) DO UPDATE SET 519 524 granted_at = excluded.granted_at, 520 525 muted = 0, ··· 731 736 return result.meta.changes ?? 0; 732 737 } 733 738 739 + /** Retention backstop: drop inbox entries older than `beforeMs` (read or not). */ 740 + export async function deleteOldNotifications(db: D1Database, beforeMs: number): Promise<number> { 741 + const result = await db 742 + .prepare('DELETE FROM notifications WHERE created_at < ?') 743 + .bind(beforeMs) 744 + .run(); 745 + return result.meta.changes ?? 0; 746 + } 747 + 734 748 // --- App-scoped inbox access (one sender's notifications for one recipient) --- 735 749 // Used by the federated, dual-authed `listNotifications`/`markRead` so an app 736 750 // can see and acknowledge only the notifications *it* sent. ··· 804 818 recipient_did: Did; 805 819 sender_did: Did; 806 820 category: string; 821 + title: string | null; 807 822 description: string | null; 808 823 last_seen: number; 809 824 } ··· 816 831 lastSeen: number; 817 832 } 818 833 819 - /** Record a category seen from a sender (keeps the latest description). */ 834 + /** Record a category seen from a sender (keeps the latest description + any title). */ 820 835 export async function upsertAppCategory(db: D1Database, input: UpsertAppCategoryInput): Promise<void> { 821 836 await db 822 837 .prepare( ··· 830 845 .run(); 831 846 } 832 847 848 + export interface DeclareAppCategoryInput { 849 + recipientDid: Did; 850 + senderDid: Did; 851 + category: string; 852 + title: string | null; 853 + description: string | null; 854 + lastSeen: number; 855 + } 856 + 857 + /** App-declared category (setCategories/addCategory): upsert key + title + 858 + * description. Null title/description preserve any existing value. */ 859 + export async function declareAppCategory( 860 + db: D1Database, 861 + input: DeclareAppCategoryInput, 862 + ): Promise<void> { 863 + await db 864 + .prepare( 865 + `INSERT INTO app_categories (recipient_did, sender_did, category, title, description, last_seen) 866 + VALUES (?, ?, ?, ?, ?, ?) 867 + ON CONFLICT(recipient_did, sender_did, category) DO UPDATE SET 868 + title = COALESCE(excluded.title, app_categories.title), 869 + description = COALESCE(excluded.description, app_categories.description), 870 + last_seen = excluded.last_seen`, 871 + ) 872 + .bind( 873 + input.recipientDid, 874 + input.senderDid, 875 + input.category, 876 + input.title, 877 + input.description, 878 + input.lastSeen, 879 + ) 880 + .run(); 881 + } 882 + 883 + /** Remove one app-declared category (its routing row is cleaned up separately). */ 884 + export async function deleteAppCategory( 885 + db: D1Database, 886 + recipientDid: Did, 887 + senderDid: Did, 888 + category: string, 889 + ): Promise<boolean> { 890 + const result = await db 891 + .prepare('DELETE FROM app_categories WHERE recipient_did = ? AND sender_did = ? AND category = ?') 892 + .bind(recipientDid, senderDid, category) 893 + .run(); 894 + return changed(result); 895 + } 896 + 833 897 export async function listAppCategoriesForRecipient( 834 898 db: D1Database, 835 899 recipientDid: Did, ··· 993 1057 .bind(recipientDid, senderDid) 994 1058 .run(); 995 1059 } 1060 + 1061 + /** 1062 + * Cascade for `revoke`: drop everything scoped to one (recipient, sender) app — 1063 + * its app-wide route, per-category routes, and declared/seen categories — so a 1064 + * revoked app leaves no stale routing/category rows (and a re-grant starts clean). 1065 + * Inbox notifications are intentionally kept (historical). 1066 + */ 1067 + export async function deleteSenderRoutingData( 1068 + db: D1Database, 1069 + recipientDid: Did, 1070 + senderDid: Did, 1071 + ): Promise<void> { 1072 + await db.batch([ 1073 + db 1074 + .prepare('DELETE FROM app_routing WHERE recipient_did = ? AND sender_did = ?') 1075 + .bind(recipientDid, senderDid), 1076 + db 1077 + .prepare('DELETE FROM routing WHERE recipient_did = ? AND sender_did = ?') 1078 + .bind(recipientDid, senderDid), 1079 + db 1080 + .prepare('DELETE FROM app_categories WHERE recipient_did = ? AND sender_did = ?') 1081 + .bind(recipientDid, senderDid), 1082 + ]); 1083 + }
-77
apps/relay/src/db/schema.sql
··· 1 - -- Canonical schema reference for the relay's D1 database. 2 - -- 3 - -- This file is documentation: the source of truth applied to D1 is the numbered 4 - -- migrations in `apps/relay/migrations/`. Keep this in sync with them (currently 5 - -- it mirrors migrations/0001_init.sql). 6 - -- 7 - -- All timestamps are unix milliseconds (Date.now()). 8 - 9 - CREATE TABLE users ( 10 - did TEXT PRIMARY KEY, 11 - created_at INTEGER NOT NULL, 12 - notify_pending_via_telegram INTEGER NOT NULL DEFAULT 0 13 - ); 14 - 15 - CREATE TABLE channels ( 16 - did TEXT NOT NULL, 17 - platform TEXT NOT NULL, 18 - platform_user_id TEXT NOT NULL, 19 - display_name TEXT, 20 - linked_at INTEGER NOT NULL, 21 - PRIMARY KEY (did, platform) 22 - ); 23 - CREATE UNIQUE INDEX channels_by_platform_user ON channels (platform, platform_user_id); 24 - 25 - CREATE TABLE link_tokens ( 26 - token TEXT PRIMARY KEY, 27 - did TEXT NOT NULL, 28 - platform TEXT NOT NULL, 29 - expires_at INTEGER NOT NULL 30 - ); 31 - CREATE INDEX link_tokens_by_did ON link_tokens (did); 32 - CREATE INDEX link_tokens_by_expires ON link_tokens (expires_at); 33 - 34 - CREATE TABLE senders ( 35 - did TEXT PRIMARY KEY, 36 - handle TEXT, 37 - display_name TEXT, 38 - avatar_url TEXT, 39 - profile_fetched_at INTEGER 40 - ); 41 - 42 - CREATE TABLE pending_requests ( 43 - id TEXT PRIMARY KEY, 44 - recipient_did TEXT NOT NULL, 45 - sender_did TEXT NOT NULL, 46 - reason TEXT, -- legacy (migration 0001); no longer written/read 47 - title TEXT, -- migration 0002: user-supplied display name 48 - description TEXT, -- migration 0002 49 - icon_url TEXT, -- migration 0002 50 - created_at INTEGER NOT NULL, 51 - expires_at INTEGER NOT NULL, 52 - UNIQUE (recipient_did, sender_did) 53 - ); 54 - CREATE INDEX pending_by_recipient ON pending_requests (recipient_did); 55 - CREATE INDEX pending_by_expires ON pending_requests (expires_at); 56 - 57 - CREATE TABLE grants ( 58 - recipient_did TEXT NOT NULL, 59 - sender_did TEXT NOT NULL, 60 - granted_at INTEGER NOT NULL, 61 - muted INTEGER NOT NULL DEFAULT 0, 62 - title TEXT, -- migration 0002: copied from the pending request 63 - description TEXT, -- migration 0002 64 - icon_url TEXT, -- migration 0002 65 - PRIMARY KEY (recipient_did, sender_did) 66 - ); 67 - CREATE INDEX grants_by_recipient ON grants (recipient_did); 68 - 69 - CREATE TABLE delivery_log ( 70 - id TEXT PRIMARY KEY, 71 - recipient_did TEXT NOT NULL, 72 - sender_did TEXT NOT NULL, 73 - title TEXT, 74 - delivered_count INTEGER NOT NULL, 75 - created_at INTEGER NOT NULL 76 - ); 77 - CREATE INDEX delivery_by_recipient ON delivery_log (recipient_did, created_at DESC);
+41
apps/relay/src/delivery/channel.ts
··· 1 + import type { DeliveryInstance } from '../db/queries'; 2 + import type { DeliveryChannel } from '../env'; 3 + 4 + /** Map a resolved delivery instance to its dispatch-queue channel descriptor. */ 5 + export function deliveryChannelFor(target: DeliveryInstance): DeliveryChannel { 6 + switch (target.channel) { 7 + case 'push': 8 + return { 9 + platform: 'webpush', 10 + endpoint: target.endpoint, 11 + p256dh: target.p256dh, 12 + auth: target.auth, 13 + }; 14 + case 'telegram': 15 + return { platform: 'telegram', platformUserId: target.chatId }; 16 + case 'email': 17 + return { platform: 'email', address: target.address }; 18 + case 'dm': 19 + return { platform: 'dm', recipientDid: target.recipientDid }; 20 + case 'webhook': 21 + return { platform: 'webhook', url: target.url }; 22 + } 23 + } 24 + 25 + /** Resolve a user's verified delivery targets that a route selection fires. */ 26 + import type { RouteSelection } from '@atmo/notifs-lexicons'; 27 + import type { DeliveryTargetRow } from '../db/queries'; 28 + import { toDeliveryInstance } from '../db/queries'; 29 + 30 + export function selectTargets( 31 + rows: DeliveryTargetRow[], 32 + selection: RouteSelection, 33 + ): DeliveryInstance[] { 34 + return rows 35 + .map(toDeliveryInstance) 36 + .filter((t): t is DeliveryInstance => t !== null && t.verified) 37 + .filter((t) => { 38 + const sel = selection[t.channel]; 39 + return sel !== undefined && (sel.all || sel.ids.includes(t.id)); 40 + }); 41 + }
+77
apps/relay/src/delivery/limits.ts
··· 1 + // Per-channel delivery caps, enforced at send-time fan-out (see xrpc/send.ts). 2 + // 3 + // Two scopes per capped channel, both rolling-daily: 4 + // * per-recipient — how many of this channel ONE user receives per day, across 5 + // all apps (anti-spam / cost control for them). 6 + // * relay-global — how many of this channel the whole relay sends per day 7 + // (stay under the provider's quota, e.g. comail's email plan). 8 + // 9 + // Counting happens when we COMMIT to a delivery (before enqueue). That's 10 + // conservative for the global cap — a send that later fails still consumed its 11 + // slot — which is the safe direction for a provider quota (under-send, never 12 + // over-send). Retries of a queued job are not separately counted. 13 + import type { DeliveryChannelKind } from '../db/queries'; 14 + import type { Env } from '../env'; 15 + import { checkAndIncrementAll, type RateCheck } from '../ratelimit'; 16 + 17 + const DAY_SECONDS = 24 * 60 * 60; 18 + 19 + export interface ChannelLimit { 20 + /** Max deliveries of this channel to one recipient per rolling day. */ 21 + perRecipientPerDay?: number; 22 + /** Max deliveries of this channel across the whole relay per rolling day. */ 23 + globalPerDay?: number; 24 + } 25 + 26 + /** Parse a non-negative integer env var, falling back to `fallback`. */ 27 + function intEnv(value: string | undefined, fallback: number): number { 28 + const n = value !== undefined ? Number.parseInt(value, 10) : Number.NaN; 29 + return Number.isFinite(n) && n >= 0 ? n : fallback; 30 + } 31 + 32 + /** 33 + * The daily caps for a channel, or `undefined` if the channel is uncapped. Only 34 + * email is capped today (it costs money and the provider has a quota); push / 35 + * telegram / dm / webhook are free fan-out, so they're unlimited. Add a channel 36 + * here to cap it. 37 + */ 38 + export function channelLimit(env: Env, channel: DeliveryChannelKind): ChannelLimit | undefined { 39 + if (channel === 'email') { 40 + return { 41 + perRecipientPerDay: intEnv(env.EMAIL_DAILY_PER_RECIPIENT, 10), 42 + globalPerDay: intEnv(env.EMAIL_DAILY_GLOBAL, 100), 43 + }; 44 + } 45 + return undefined; 46 + } 47 + 48 + /** 49 + * Whether a delivery of `channel` to `recipient` is within the channel's daily 50 + * caps, consuming one slot from each configured cap when allowed (and nothing 51 + * when denied — see {@link checkAndIncrementAll}). Uncapped channels always pass 52 + * and never touch KV. 53 + */ 54 + export async function withinChannelLimits( 55 + env: Env, 56 + channel: DeliveryChannelKind, 57 + recipient: string, 58 + ): Promise<boolean> { 59 + const limit = channelLimit(env, channel); 60 + if (limit === undefined) return true; 61 + 62 + const checks: RateCheck[] = []; 63 + if (limit.perRecipientPerDay !== undefined) { 64 + checks.push({ 65 + key: `rl:chan:${channel}:rcpt:${recipient}`, 66 + limit: limit.perRecipientPerDay, 67 + windowSeconds: DAY_SECONDS, 68 + }); 69 + } 70 + if (limit.globalPerDay !== undefined) { 71 + checks.push({ key: `rl:chan:${channel}:all`, limit: limit.globalPerDay, windowSeconds: DAY_SECONDS }); 72 + } 73 + if (checks.length === 0) return true; 74 + 75 + const { allowed } = await checkAndIncrementAll(env.CACHE, checks); 76 + return allowed; 77 + }
+4
apps/relay/src/env.ts
··· 117 117 COMAIL_DID: string; 118 118 /** Enrolled sender address for the `from` field, e.g. "atmo.pub <notify@atmo.pub>" (var). */ 119 119 COMAIL_FROM: string; 120 + /** Max emails delivered to ONE recipient per rolling day (var; default 10). See delivery/limits.ts. */ 121 + EMAIL_DAILY_PER_RECIPIENT?: string; 122 + /** Max emails the relay delivers in total per rolling day (var; default 100). Keep ≤ the comail plan. */ 123 + EMAIL_DAILY_GLOBAL?: string; 120 124 121 125 // Bluesky DM delivery — the relay sends DMs from a configured bot account. 122 126 /** Bot handle or DID used for createSession (var). */
+5
apps/relay/src/index.ts
··· 2 2 deleteExpiredLinkTokens, 3 3 deleteExpiredPending, 4 4 deleteOldDeliveryLog, 5 + deleteOldNotifications, 5 6 } from './db/queries'; 6 7 import { handleQueue } from './delivery/dispatcher'; 7 8 import type { DispatchJob, Env } from './env'; ··· 16 17 17 18 const TELEGRAM_WEBHOOK_RE = /^\/telegram\/webhook\/(.+)$/; 18 19 const DELIVERY_LOG_RETENTION_MS = 30 * DAY_MS; 20 + // Inbox backstop so `notifications` can't grow without bound. Generous; the inbox 21 + // is the canonical history, but very old entries are dropped. 22 + const NOTIFICATION_RETENTION_MS = 90 * DAY_MS; 19 23 20 24 export default { 21 25 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { ··· 49 53 await deleteExpiredPending(env.DB, ts); 50 54 await deleteExpiredLinkTokens(env.DB, ts); 51 55 await deleteOldDeliveryLog(env.DB, ts - DELIVERY_LOG_RETENTION_MS); 56 + await deleteOldNotifications(env.DB, ts - NOTIFICATION_RETENTION_MS); 52 57 }, 53 58 } satisfies ExportedHandler<Env, DispatchJob>;
+6
apps/relay/src/lib/apps.ts
··· 33 33 did: 'did:web:example.atmo.pub', 34 34 title: 'Example Sender', 35 35 description: 'Demo app showing the atmo.pub integration.', 36 + iconUrl: 'https://example.atmo.pub/icon.svg', 36 37 // For local end-to-end testing, point this at your local example-sender. 37 38 callbackUrl: 'https://example.atmo.pub', 38 39 // Relay-allowlisted for self-management so the example's "Step 3" demo works ··· 54 55 /** The registered app for `did`, if it has a callback URL configured. */ 55 56 export function callbackAppFor(did: string): RegisteredApp | undefined { 56 57 return APPS.find((app) => app.did === did && app.callbackUrl !== undefined); 58 + } 59 + 60 + /** The registered catalog entry for `did`, if any (for grant display metadata). */ 61 + export function registeredApp(did: string): RegisteredApp | undefined { 62 + return APPS.find((app) => app.did === did); 57 63 } 58 64 59 65 /** Public catalog metadata for the web "enable apps" UI (no callback URLs). */
+48
apps/relay/src/ratelimit.ts
··· 66 66 resetIn: resetInSeconds, 67 67 }; 68 68 } 69 + 70 + /** One cap in a multi-key check. */ 71 + export interface RateCheck { 72 + key: string; 73 + limit: number; 74 + windowSeconds: number; 75 + } 76 + 77 + /** 78 + * All-or-nothing fixed-window check across several keys. Reads every counter; if 79 + * ANY is already at/over its limit, denies WITHOUT incrementing any of them; 80 + * otherwise increments all. This gates one action behind multiple caps at once 81 + * (e.g. a per-recipient AND a relay-global daily channel cap) without burning a 82 + * slot from one cap when a different one is what blocks. Same KV 83 + * eventual-consistency caveat as {@link checkAndIncrement}. 84 + */ 85 + export async function checkAndIncrementAll( 86 + kv: KVNamespace, 87 + checks: readonly RateCheck[], 88 + ): Promise<{ allowed: boolean }> { 89 + if (checks.length === 0) return { allowed: true }; 90 + const nowMs = Date.now(); 91 + 92 + const states = await Promise.all( 93 + checks.map(async (c) => { 94 + const { value, metadata } = await kv.getWithMetadata<CounterMetadata>(c.key, 'text'); 95 + if (value !== null && metadata !== null && metadata.expiresAt > nowMs) { 96 + return { key: c.key, limit: c.limit, count: Number.parseInt(value, 10), expiresAt: metadata.expiresAt }; 97 + } 98 + return { key: c.key, limit: c.limit, count: 0, expiresAt: nowMs + c.windowSeconds * 1000 }; 99 + }), 100 + ); 101 + 102 + if (states.some((s) => s.count >= s.limit)) { 103 + return { allowed: false }; 104 + } 105 + 106 + await Promise.all( 107 + states.map((s) => { 108 + const resetIn = Math.max(1, Math.ceil((s.expiresAt - nowMs) / 1000)); 109 + return kv.put(s.key, String(s.count + 1), { 110 + expirationTtl: Math.max(KV_MIN_TTL_SECONDS, resetIn), 111 + metadata: { expiresAt: s.expiresAt } satisfies CounterMetadata, 112 + }); 113 + }), 114 + ); 115 + return { allowed: true }; 116 + }
+12
apps/relay/src/router.ts
··· 1 1 import { 2 + PubAtmoNotifyAddCategory, 3 + PubAtmoNotifyGetCategories, 2 4 PubAtmoNotifyGetRouting, 3 5 PubAtmoNotifyListNotifications, 4 6 PubAtmoNotifyManage, 5 7 PubAtmoNotifyMarkRead, 6 8 PubAtmoNotifyMuteSelf, 9 + PubAtmoNotifyRemoveCategory, 7 10 PubAtmoNotifyRequestPermission, 8 11 PubAtmoNotifyRevokeSelf, 9 12 PubAtmoNotifySend, 13 + PubAtmoNotifySetCategories, 10 14 PubAtmoNotifySetRouting, 11 15 } from '@atmo/notifs-lexicons'; 12 16 import { XRPCRouter } from '@atcute/xrpc-server'; 13 17 14 18 import { getVerifier } from './auth/verifier'; 15 19 import type { AppContext, Env } from './env'; 20 + import { makeAddCategory } from './xrpc/addCategory'; 21 + import { makeGetCategories } from './xrpc/getCategories'; 16 22 import { makeGetRouting } from './xrpc/getRouting'; 17 23 import { makeListNotifications } from './xrpc/listNotifications'; 18 24 import { makeManage } from './xrpc/manage'; 19 25 import { makeMarkRead } from './xrpc/markRead'; 20 26 import { makeMuteSelf } from './xrpc/muteSelf'; 27 + import { makeRemoveCategory } from './xrpc/removeCategory'; 21 28 import { makeRequestPermission } from './xrpc/requestPermission'; 22 29 import { makeRevokeSelf } from './xrpc/revokeSelf'; 23 30 import { makeSend } from './xrpc/send'; 31 + import { makeSetCategories } from './xrpc/setCategories'; 24 32 import { makeSetRouting } from './xrpc/setRouting'; 25 33 26 34 /** ··· 64 72 router.addProcedure(PubAtmoNotifyMarkRead.mainSchema, makeMarkRead(app)); 65 73 router.addProcedure(PubAtmoNotifyRevokeSelf.mainSchema, makeRevokeSelf(app)); 66 74 router.addProcedure(PubAtmoNotifyMuteSelf.mainSchema, makeMuteSelf(app)); 75 + router.addProcedure(PubAtmoNotifySetCategories.mainSchema, makeSetCategories(app)); 76 + router.addProcedure(PubAtmoNotifyAddCategory.mainSchema, makeAddCategory(app)); 77 + router.addProcedure(PubAtmoNotifyRemoveCategory.mainSchema, makeRemoveCategory(app)); 78 + router.addProcedure(PubAtmoNotifyGetCategories.mainSchema, makeGetCategories(app)); 67 79 router.addProcedure(PubAtmoNotifyManage.mainSchema, makeManage(app)); 68 80 69 81 return router;
+236 -47
apps/relay/src/rpc/ops.ts
··· 37 37 import { sendEmail } from '../delivery/email'; 38 38 import * as q from '../db/queries'; 39 39 import type { Env } from '../env'; 40 - import { appCatalog, callbackAppFor } from '../lib/apps'; 40 + import { appCatalog, callbackAppFor, registeredApp } from '../lib/apps'; 41 41 import { invalidRequest } from '../lib/errors'; 42 42 import { newLinkToken, newTargetId } from '../lib/ids'; 43 43 import { addMinutes, now, toIsoDatetime } from '../lib/time'; ··· 102 102 input: PubAtmoNotifyRevoke.$input, 103 103 ): Promise<PubAtmoNotifyRevoke.$output> { 104 104 const revoked = await q.deleteGrant(env.DB, did, input.sender); 105 - // The pending request (if any) for this pair is now irrelevant. 105 + // The pending request (if any) for this pair is now irrelevant, and the app's 106 + // routing + declared categories should not outlive the grant. 106 107 await q.deletePendingByPair(env.DB, did, input.sender); 108 + await q.deleteSenderRoutingData(env.DB, did, input.sender); 107 109 108 110 if (revoked) { 109 111 await notifySubscriberChanged(env, did, input.sender, false); ··· 162 164 await q.ensureUser(env.DB, did, now()); 163 165 164 166 // Partial PATCH: only touch fields present in the input. 165 - if (input.notifyPendingViaTelegram !== undefined) { 166 - await q.setNotifyPending(env.DB, did, input.notifyPendingViaTelegram); 167 + if (input.pendingRoute !== undefined) { 168 + await q.setPendingRoute(env.DB, did, input.pendingRoute); 167 169 } 168 170 if (input.autoAllow !== undefined) { 169 171 await q.setAutoAllow(env.DB, did, input.autoAllow); ··· 171 173 172 174 const user = await q.getUser(env.DB, did); 173 175 return { 174 - notifyPendingViaTelegram: (user?.notify_pending_via_telegram ?? 0) === 1, 176 + pendingRoute: user?.pending_route ?? 'off', 175 177 }; 176 178 } 177 179 ··· 182 184 const rows = await q.listGrantsForRecipient(env.DB, did); 183 185 184 186 return { 185 - grants: rows.map((row) => ({ 186 - sender: row.sender_did, 187 - // user-supplied title; fall back to the Bluesky name/handle, then the DID 188 - title: row.title ?? row.display_name ?? row.handle ?? row.sender_did, 189 - description: row.description ?? undefined, 190 - iconUrl: row.icon_url ?? undefined, 191 - senderHandle: row.handle ?? undefined, 192 - senderBskyDisplayName: row.display_name ?? undefined, 193 - senderBskyAvatar: row.avatar_url ?? undefined, 194 - grantedAt: toIsoDatetime(row.granted_at), 195 - muted: row.muted === 1, 196 - })), 187 + grants: rows.map((row) => { 188 + // For a registered app, fall back to its catalog branding when the grant 189 + // has no stored metadata (e.g. enabled from the web, not via requestPermission). 190 + const app = registeredApp(row.sender_did); 191 + return { 192 + sender: row.sender_did, 193 + // grant metadata → registered catalog → Bluesky name/handle → the DID 194 + title: row.title ?? app?.title ?? row.display_name ?? row.handle ?? row.sender_did, 195 + description: row.description ?? app?.description ?? undefined, 196 + iconUrl: row.icon_url ?? app?.iconUrl ?? undefined, 197 + senderHandle: row.handle ?? undefined, 198 + senderBskyDisplayName: row.display_name ?? undefined, 199 + senderBskyAvatar: row.avatar_url ?? undefined, 200 + grantedAt: toIsoDatetime(row.granted_at), 201 + muted: row.muted === 1, 202 + }; 203 + }), 197 204 }; 198 205 } 199 206 ··· 204 211 const rows = await q.listPendingForRecipient(env.DB, did, now()); 205 212 206 213 return { 207 - pending: rows.map((row) => ({ 208 - id: row.id, 209 - sender: row.sender_did, 210 - // user-supplied display metadata (fall back to the DID for title) 211 - title: row.title ?? row.sender_did, 212 - description: row.description ?? undefined, 213 - iconUrl: row.icon_url ?? undefined, 214 - // best-effort Bluesky profile (informational "verified on Bluesky") 215 - senderHandle: row.handle ?? undefined, 216 - senderBskyDisplayName: row.display_name ?? undefined, 217 - senderBskyAvatar: row.avatar_url ?? undefined, 218 - createdAt: toIsoDatetime(row.created_at), 219 - expiresAt: toIsoDatetime(row.expires_at), 220 - })), 214 + pending: rows.map((row) => { 215 + const app = registeredApp(row.sender_did); 216 + return { 217 + id: row.id, 218 + sender: row.sender_did, 219 + // requester metadata → registered catalog → the DID 220 + title: row.title ?? app?.title ?? row.sender_did, 221 + description: row.description ?? app?.description ?? undefined, 222 + iconUrl: row.icon_url ?? app?.iconUrl ?? undefined, 223 + // best-effort Bluesky profile (informational "verified on Bluesky") 224 + senderHandle: row.handle ?? undefined, 225 + senderBskyDisplayName: row.display_name ?? undefined, 226 + senderBskyAvatar: row.avatar_url ?? undefined, 227 + createdAt: toIsoDatetime(row.created_at), 228 + expiresAt: toIsoDatetime(row.expires_at), 229 + }; 230 + }), 221 231 }; 222 232 } 223 233 ··· 299 309 const pushDevices = (await q.countDeliveryTargets(env.DB, did, 'push'))?.c ?? 0; 300 310 301 311 return { 302 - notifyPendingViaTelegram: (user?.notify_pending_via_telegram ?? 0) === 1, 312 + pendingRoute: user?.pending_route ?? 'off', 303 313 autoAllow: (user?.auto_allow ?? 'trusted') as 'all' | 'trusted' | 'none', 304 314 pushDevices, 305 315 }; ··· 391 401 export async function getRouting(env: Env, did: Did): Promise<RoutingConfig> { 392 402 await q.ensureUser(env.DB, did, now()); 393 403 const user = await q.getUser(env.DB, did); 394 - const defaultRoute = (user?.default_route ?? 'push') as AlertRoute; 404 + const defaultRoute = (user?.default_route ?? 'inbox') as AlertRoute; 395 405 396 406 const [grants, categories, routing, appRouting, deliveryTargets] = await Promise.all([ 397 407 q.listGrantsForRecipient(env.DB, did), ··· 412 422 } 413 423 414 424 const routeBy = new Map<string, string>(); 415 - for (const r of routing) routeBy.set(`${r.sender_did}�${r.category}`, r.route); 425 + for (const r of routing) routeBy.set(JSON.stringify([r.sender_did, r.category]), r.route); 416 426 417 427 const appRouteBy = new Map<string, string>(); 418 428 for (const a of appRouting) appRouteBy.set(a.sender_did, a.route); ··· 424 434 catsBySender.set(c.sender_did, list); 425 435 } 426 436 427 - const apps: RoutingApp[] = grants.map((g) => ({ 428 - sender: g.sender_did, 429 - title: g.title ?? g.display_name ?? g.handle ?? g.sender_did, 430 - route: (appRouteBy.get(g.sender_did) ?? 'default') as AppRoute, 431 - manage: g.manage as Capability, 432 - muted: g.muted === 1, 433 - iconUrl: g.icon_url ?? g.avatar_url ?? undefined, 434 - categories: (catsBySender.get(g.sender_did) ?? []).map((c) => ({ 435 - category: c.category, 437 + const apps: RoutingApp[] = grants.map((g) => { 438 + // Registered apps fall back to their catalog branding (covers grants made via 439 + // the web "Enable" flow, which stores no per-grant title/icon). 440 + const reg = registeredApp(g.sender_did); 441 + return { 442 + sender: g.sender_did, 443 + title: g.title ?? reg?.title ?? g.display_name ?? g.handle ?? g.sender_did, 444 + route: (appRouteBy.get(g.sender_did) ?? 'default') as AppRoute, 445 + manage: g.manage as Capability, 446 + muted: g.muted === 1, 447 + iconUrl: g.icon_url ?? reg?.iconUrl ?? g.avatar_url ?? undefined, 448 + categories: (catsBySender.get(g.sender_did) ?? []).map((c) => ({ 449 + category: c.category, 450 + title: c.title ?? undefined, 451 + description: c.description ?? undefined, 452 + route: (routeBy.get(JSON.stringify([g.sender_did, c.category])) ?? 'app') as CategoryRoute, 453 + })), 454 + }; 455 + }); 456 + 457 + return { defaultRoute, apps, channels }; 458 + } 459 + 460 + // --- federated getRouting (one app's slice, for third-party apps) ----------- 461 + 462 + /** A single delivery target as exposed to apps: opaque id + privacy-safe label. */ 463 + export interface AppTargetView { 464 + type: string; 465 + id: string; 466 + label: string; 467 + } 468 + 469 + export interface AppRoutingView { 470 + route: AppRoute; 471 + defaultRoute: AlertRoute; 472 + categories: CategoryView[]; 473 + targets: AppTargetView[]; 474 + } 475 + 476 + // Privacy-safe generic labels per channel (no PII), with a capitalized-id fallback. 477 + const GENERIC_LABEL: Record<string, string> = { 478 + push: 'Push device', 479 + telegram: 'Telegram', 480 + email: 'Email', 481 + dm: 'Direct message', 482 + webhook: 'Webhook', 483 + }; 484 + const genericLabel = (channel: string): string => 485 + GENERIC_LABEL[channel] ?? channel.charAt(0).toUpperCase() + channel.slice(1); 486 + 487 + /** 488 + * The app-facing target catalog. Labels never leak the raw email address / 489 + * telegram handle: a user-chosen name (`named`) is used as-is; push device labels 490 + * (a UA descriptor, not PII) pass through; everything else gets a generic label, 491 + * numbered when a channel has more than one verified target. 492 + */ 493 + export function safeTargets(rows: q.DeliveryTargetRow[]): AppTargetView[] { 494 + const verified = rows.filter((r) => r.verified === 1); 495 + const counts: Record<string, number> = {}; 496 + for (const r of verified) counts[r.channel] = (counts[r.channel] ?? 0) + 1; 497 + const seen: Record<string, number> = {}; 498 + return verified.map((r) => { 499 + const n = (seen[r.channel] ?? 0) + 1; 500 + seen[r.channel] = n; 501 + let label: string; 502 + if (r.named === 1 && r.label) label = r.label; 503 + else if (r.channel === 'push' && r.label) label = r.label; 504 + else label = (counts[r.channel] ?? 0) > 1 ? `${genericLabel(r.channel)} ${n}` : genericLabel(r.channel); 505 + return { type: r.channel, id: r.id, label }; 506 + }); 507 + } 508 + 509 + /** 510 + * One app's routing slice for a user (the federated `getRouting` view): the app's 511 + * own route + categories, the account default (so 'default'/'app' can be labelled), 512 + * and the privacy-safe target catalog so the app can render a full route picker. 513 + */ 514 + export async function getAppRouting(env: Env, did: Did, sender: Did): Promise<AppRoutingView> { 515 + const [user, appRoute, cats, routes, deliveryTargets] = await Promise.all([ 516 + q.getUser(env.DB, did), 517 + q.getAppRoute(env.DB, did, sender), 518 + q.listAppCategoriesForSender(env.DB, did, sender), 519 + q.listRoutingForSender(env.DB, did, sender), 520 + q.listDeliveryTargets(env.DB, did), 521 + ]); 522 + const routeByCategory = new Map(routes.map((r) => [r.category, r.route])); 523 + return { 524 + route: (appRoute?.route ?? 'default') as AppRoute, 525 + defaultRoute: (user?.default_route ?? 'inbox') as AlertRoute, 526 + categories: cats.map((c) => ({ 527 + id: c.category, 528 + title: c.title ?? undefined, 436 529 description: c.description ?? undefined, 437 - route: (routeBy.get(`${g.sender_did}�${c.category}`) ?? 'app') as CategoryRoute, 530 + route: (routeByCategory.get(c.category) ?? 'app') as CategoryRoute, 438 531 })), 439 - })); 440 - 441 - return { defaultRoute, apps, channels }; 532 + targets: safeTargets(deliveryTargets), 533 + }; 442 534 } 443 535 444 536 export async function setRouting( ··· 489 581 ): Promise<{ ok: boolean }> { 490 582 await q.setGrantManage(env.DB, did, sender, manage); 491 583 return { ok: true }; 584 + } 585 + 586 + // --- app-declared categories (per user, per app) --------------------------- 587 + 588 + /** A category an app declares for a user: key + optional title/description and an 589 + * optional initial route. Scoped to (user, app), so never shared across users. */ 590 + export interface CategoryInput { 591 + id: string; 592 + title?: string; 593 + description?: string; 594 + route?: CategoryRoute; 595 + } 596 + 597 + /** Declare/update one category for (user, app), optionally setting its route. */ 598 + async function applyCategory(env: Env, did: Did, sender: Did, cat: CategoryInput): Promise<void> { 599 + await q.declareAppCategory(env.DB, { 600 + recipientDid: did, 601 + senderDid: sender, 602 + category: cat.id, 603 + title: cat.title ?? null, 604 + description: cat.description ?? null, 605 + lastSeen: now(), 606 + }); 607 + if (cat.route !== undefined) { 608 + if (cat.route === 'app') await q.deleteRouting(env.DB, did, sender, cat.id); 609 + else await q.upsertRouting(env.DB, did, sender, cat.id, cat.route); 610 + } 611 + } 612 + 613 + /** Add or update one of the app's categories for the user. */ 614 + export async function addCategory( 615 + env: Env, 616 + did: Did, 617 + sender: Did, 618 + cat: CategoryInput, 619 + ): Promise<{ ok: boolean }> { 620 + await applyCategory(env, did, sender, cat); 621 + return { ok: true }; 622 + } 623 + 624 + /** Full sync: upsert the listed categories, prune the rest (and their routing). */ 625 + export async function setCategories( 626 + env: Env, 627 + did: Did, 628 + sender: Did, 629 + categories: CategoryInput[], 630 + ): Promise<{ ok: boolean }> { 631 + const keep = new Set(categories.map((c) => c.id)); 632 + const existing = await q.listAppCategoriesForSender(env.DB, did, sender); 633 + for (const cat of categories) await applyCategory(env, did, sender, cat); 634 + for (const row of existing) { 635 + if (!keep.has(row.category)) { 636 + await q.deleteAppCategory(env.DB, did, sender, row.category); 637 + await q.deleteRouting(env.DB, did, sender, row.category); 638 + } 639 + } 640 + return { ok: true }; 641 + } 642 + 643 + /** Remove one category + its per-category routing. */ 644 + export async function removeCategory( 645 + env: Env, 646 + did: Did, 647 + sender: Did, 648 + id: string, 649 + ): Promise<{ removed: boolean }> { 650 + const removed = await q.deleteAppCategory(env.DB, did, sender, id); 651 + await q.deleteRouting(env.DB, did, sender, id); 652 + return { removed }; 653 + } 654 + 655 + export interface CategoryView { 656 + id: string; 657 + title?: string; 658 + description?: string; 659 + route: CategoryRoute; 660 + } 661 + 662 + /** List the app's categories for the user, with each one's current route. */ 663 + export async function getCategories( 664 + env: Env, 665 + did: Did, 666 + sender: Did, 667 + ): Promise<{ categories: CategoryView[] }> { 668 + const [cats, routes] = await Promise.all([ 669 + q.listAppCategoriesForSender(env.DB, did, sender), 670 + q.listRoutingForSender(env.DB, did, sender), 671 + ]); 672 + const routeBy = new Map(routes.map((r) => [r.category, r.route])); 673 + return { 674 + categories: cats.map((c) => ({ 675 + id: c.category, 676 + title: c.title ?? undefined, 677 + description: c.description ?? undefined, 678 + route: (routeBy.get(c.category) ?? 'app') as CategoryRoute, 679 + })), 680 + }; 492 681 } 493 682 494 683 // --- email channel ---------------------------------------------------------
-31
apps/relay/src/telegram/callbacks.ts
··· 5 5 import { answerCallbackQuery, editMessageText } from '../delivery/telegram'; 6 6 import { now } from '../lib/time'; 7 7 8 - import { settingsKeyboard, settingsText } from './commands'; 9 8 import type { TelegramCallbackQuery } from './webhook'; 10 9 11 10 const PLATFORM = 'telegram'; ··· 32 31 } 33 32 if (data.startsWith('deny:')) { 34 33 await handleDeny(env, query, channel.did, data.slice('deny:'.length), messageId); 35 - return; 36 - } 37 - if (data === 'toggle:notifyPending') { 38 - await handleToggle(env, query, channel.did, messageId); 39 34 return; 40 35 } 41 36 ··· 96 91 } 97 92 } 98 93 await answerCallbackQuery(env, { callback_query_id: query.id, text: 'Denied' }); 99 - } 100 - 101 - async function handleToggle( 102 - env: Env, 103 - query: TelegramCallbackQuery, 104 - recipientDid: Did, 105 - messageId: number | undefined, 106 - ): Promise<void> { 107 - await q.ensureUser(env.DB, recipientDid, now()); 108 - const user = await q.getUser(env.DB, recipientDid); 109 - const next = (user?.notify_pending_via_telegram ?? 0) === 0; 110 - await q.setNotifyPending(env.DB, recipientDid, next); 111 - 112 - if (messageId !== undefined) { 113 - await editMessageText(env, { 114 - chat_id: query.from.id, 115 - message_id: messageId, 116 - text: settingsText(next), 117 - parse_mode: 'MarkdownV2', 118 - reply_markup: settingsKeyboard(next), 119 - }); 120 - } 121 - await answerCallbackQuery(env, { 122 - callback_query_id: query.id, 123 - text: next ? 'Enabled' : 'Disabled', 124 - }); 125 94 } 126 95 127 96 /** A human-friendly label for a sender (cached handle, falling back to DID). */
+3 -46
apps/relay/src/telegram/commands.ts
··· 2 2 3 3 import * as q from '../db/queries'; 4 4 import type { Env } from '../env'; 5 - import { 6 - escapeMd, 7 - type InlineKeyboardMarkup, 8 - sendMessage, 9 - } from '../delivery/telegram'; 5 + import { escapeMd, sendMessage } from '../delivery/telegram'; 10 6 import { resolveHandle } from '../identity/resolve'; 11 7 import { newTargetId } from '../lib/ids'; 12 8 import { now } from '../lib/time'; ··· 35 31 return; 36 32 case '/revoke': 37 33 await handleRevoke(env, message.chat.id, arg); 38 - return; 39 - case '/settings': 40 - await handleSettings(env, message.chat.id); 41 34 return; 42 35 default: 43 36 await handleHelp(env, message.chat.id); ··· 139 132 ); 140 133 } 141 134 142 - async function handleSettings(env: Env, chatId: number): Promise<void> { 143 - const channel = await q.getDeliveryTargetByRef(env.DB, PLATFORM, String(chatId)); 144 - if (channel === null) { 145 - await replyText(env, chatId, NOT_LINKED); 146 - return; 147 - } 148 - 149 - await q.ensureUser(env.DB, channel.did, now()); 150 - const user = await q.getUser(env.DB, channel.did); 151 - const enabled = (user?.notify_pending_via_telegram ?? 0) === 1; 152 - await sendMessage(env, { 153 - chat_id: chatId, 154 - text: settingsText(enabled), 155 - parse_mode: 'MarkdownV2', 156 - reply_markup: settingsKeyboard(enabled), 157 - }); 158 - } 159 - 160 135 async function handleHelp(env: Env, chatId: number): Promise<void> { 161 136 await replyText( 162 137 env, ··· 166 141 '/start — link your account', 167 142 '/list — list authorized apps', 168 143 '/revoke <handle-or-did> — revoke an app', 169 - '/settings — notification settings', 144 + '', 145 + `Manage where notifications go at ${DASHBOARD_URL}`, 170 146 ].join('\n'), 171 147 ); 172 148 } ··· 179 155 const handle = input.replace(/^@/, ''); 180 156 const sender = await q.getSenderByHandle(env.DB, handle); 181 157 return sender?.did ?? null; 182 - } 183 - 184 - // --- shared settings rendering (also used by callbacks.ts) ------------------ 185 - 186 - export function settingsText(enabled: boolean): string { 187 - return `*Settings*\n\nNotify me on Telegram about new permission requests: *${enabled ? 'ON' : 'OFF'}*`; 188 - } 189 - 190 - export function settingsKeyboard(enabled: boolean): InlineKeyboardMarkup { 191 - return { 192 - inline_keyboard: [ 193 - [ 194 - { 195 - text: enabled ? '🔔 Pending alerts: ON' : '🔕 Pending alerts: OFF', 196 - callback_data: 'toggle:notifyPending', 197 - }, 198 - ], 199 - ], 200 - }; 201 158 } 202 159 203 160 const NOT_LINKED = `This Telegram account is not linked yet. Link it from ${DASHBOARD_URL}`;
+8
apps/relay/src/well-known.ts
··· 1 + import addCategory from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/addCategory.json'; 2 + import getCategories from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/getCategories.json'; 1 3 import getRouting from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/getRouting.json'; 2 4 import listNotifications from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/listNotifications.json'; 5 + import removeCategory from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/removeCategory.json'; 6 + import setCategories from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/setCategories.json'; 3 7 import manage from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/manage.json'; 4 8 import markRead from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/markRead.json'; 5 9 import muteSelf from '@atmo/notifs-lexicons/lexicons/pub/atmo/notify/muteSelf.json'; ··· 21 25 'pub.atmo.notify.send': send, 22 26 'pub.atmo.notify.setRouting': setRouting, 23 27 'pub.atmo.notify.getRouting': getRouting, 28 + 'pub.atmo.notify.setCategories': setCategories, 29 + 'pub.atmo.notify.addCategory': addCategory, 30 + 'pub.atmo.notify.removeCategory': removeCategory, 31 + 'pub.atmo.notify.getCategories': getCategories, 24 32 'pub.atmo.notify.listNotifications': listNotifications, 25 33 'pub.atmo.notify.manage': manage, 26 34 'pub.atmo.notify.markRead': markRead,
+36
apps/relay/src/xrpc/addCategory.ts
··· 1 + import { isCategoryRoute, PubAtmoNotifyAddCategory } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyManagementCall } from '../auth/management'; 5 + import type { AppContext } from '../env'; 6 + import { invalidRequest } from '../lib/errors'; 7 + import * as ops from '../rpc/ops'; 8 + 9 + const LXM = 'pub.atmo.notify.addCategory'; 10 + 11 + /** Declare (add/update) one of the calling app's categories for a user. Self-scoped. */ 12 + export function makeAddCategory( 13 + app: AppContext, 14 + ): ProcedureConfig<PubAtmoNotifyAddCategory.mainSchema> { 15 + return { 16 + handler: async ({ request, input }) => { 17 + const { appDid, userDid } = await verifyManagementCall(app, request, input, { 18 + scope: 'self', 19 + write: true, 20 + lxm: LXM, 21 + }); 22 + 23 + if (input.route !== undefined && !isCategoryRoute(input.route)) { 24 + throw invalidRequest('Invalid category route'); 25 + } 26 + 27 + await ops.addCategory(app.env, userDid, appDid, { 28 + id: input.id, 29 + title: input.title, 30 + description: input.description, 31 + route: input.route, 32 + }); 33 + return json({ ok: true }); 34 + }, 35 + }; 36 + }
+26
apps/relay/src/xrpc/getCategories.ts
··· 1 + import { PubAtmoNotifyGetCategories } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyManagementCall } from '../auth/management'; 5 + import type { AppContext } from '../env'; 6 + import * as ops from '../rpc/ops'; 7 + 8 + const LXM = 'pub.atmo.notify.getCategories'; 9 + 10 + /** List the calling app's categories for a user (its own only), with each route. Self-scoped read. */ 11 + export function makeGetCategories( 12 + app: AppContext, 13 + ): ProcedureConfig<PubAtmoNotifyGetCategories.mainSchema> { 14 + return { 15 + handler: async ({ request, input }) => { 16 + const { appDid, userDid } = await verifyManagementCall(app, request, input, { 17 + scope: 'self', 18 + write: false, 19 + lxm: LXM, 20 + }); 21 + 22 + const { categories } = await ops.getCategories(app.env, userDid, appDid); 23 + return json({ categories }); 24 + }, 25 + }; 26 + }
+7 -61
apps/relay/src/xrpc/getRouting.ts
··· 2 2 import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 3 4 4 import { verifyManagementCall } from '../auth/management'; 5 - import * as q from '../db/queries'; 6 5 import type { AppContext } from '../env'; 6 + import * as ops from '../rpc/ops'; 7 7 8 8 const LXM = 'pub.atmo.notify.getRouting'; 9 9 10 - // Privacy-safe generic labels per channel (no PII), with a capitalized-id fallback. 11 - const GENERIC_LABEL: Record<string, string> = { 12 - push: 'Push device', 13 - telegram: 'Telegram', 14 - email: 'Email', 15 - dm: 'Direct message', 16 - webhook: 'Webhook', 17 - }; 18 - const genericLabel = (channel: string): string => 19 - GENERIC_LABEL[channel] ?? channel.charAt(0).toUpperCase() + channel.slice(1); 20 - 21 - /** 22 - * Build the app-facing target catalog. Labels never leak the raw email 23 - * address / telegram handle: a user-chosen name (`named`) is used as-is; push 24 - * device labels (a UA descriptor, not PII) pass through; everything else gets a 25 - * generic label, numbered when a channel has more than one target. 26 - */ 27 - function safeTargets(rows: q.DeliveryTargetRow[]): { type: string; id: string; label: string }[] { 28 - const verified = rows.filter((r) => r.verified === 1); 29 - const counts: Record<string, number> = {}; 30 - for (const r of verified) counts[r.channel] = (counts[r.channel] ?? 0) + 1; 31 - const seen: Record<string, number> = {}; 32 - return verified.map((r) => { 33 - const n = (seen[r.channel] ?? 0) + 1; 34 - seen[r.channel] = n; 35 - let label: string; 36 - if (r.named === 1 && r.label) label = r.label; 37 - else if (r.channel === 'push' && r.label) label = r.label; 38 - else label = (counts[r.channel] ?? 0) > 1 ? `${genericLabel(r.channel)} ${n}` : genericLabel(r.channel); 39 - return { type: r.channel, id: r.id, label }; 40 - }); 41 - } 42 - 43 10 /** 44 11 * Read how the calling app's own notifications are currently routed for a user, 45 12 * so the app can render an accurate in-app settings UI. A self-scoped management 46 - * read (see MANAGEMENT-AUTH.md); returns only this app's slice plus the 47 - * account-default value (so 'default'/'app' can be labelled by the caller). 13 + * read (see MANAGEMENT-AUTH.md). Delegates to `ops.getAppRouting`; returns only 14 + * this app's slice plus the account-default value and the privacy-safe target 15 + * catalog (so 'default'/'app' can be labelled and a full picker rendered). 48 16 */ 49 17 export function makeGetRouting( 50 18 app: AppContext, ··· 57 25 lxm: LXM, 58 26 }); 59 27 60 - const [user, appRoute, cats, routes, deliveryTargets] = await Promise.all([ 61 - q.getUser(app.env.DB, userDid), 62 - q.getAppRoute(app.env.DB, userDid, senderDid), 63 - q.listAppCategoriesForSender(app.env.DB, userDid, senderDid), 64 - q.listRoutingForSender(app.env.DB, userDid, senderDid), 65 - q.listDeliveryTargets(app.env.DB, userDid), 66 - ]); 67 - 68 - const routeByCategory = new Map(routes.map((r) => [r.category, r.route])); 69 - 70 - // DB columns are plain strings; cast to the lexicon's route unions at the 71 - // boundary (values were validated when written). 72 - type Out = PubAtmoNotifyGetRouting.$output; 73 - return json({ 74 - route: (appRoute?.route ?? 'default') as Out['route'], 75 - defaultRoute: (user?.default_route ?? 'push') as Out['defaultRoute'], 76 - categories: cats.map( 77 - (c): PubAtmoNotifyGetRouting.Category => ({ 78 - id: c.category, 79 - description: c.description ?? undefined, 80 - route: (routeByCategory.get(c.category) ?? 'app') as PubAtmoNotifyGetRouting.Category['route'], 81 - }), 82 - ), 83 - targets: safeTargets(deliveryTargets), 84 - }); 28 + const view = await ops.getAppRouting(app.env, userDid, senderDid); 29 + // DB strings → the lexicon's route unions at the boundary (validated on write). 30 + return json(view as PubAtmoNotifyGetRouting.$output); 85 31 }, 86 32 }; 87 33 }
+5
apps/relay/src/xrpc/manage.ts
··· 29 29 // gate (`verifyManagementCall` at `scope: 'full'`) runs before any op. 30 30 type OpFn = (env: Env, did: Did, params: unknown) => Promise<unknown>; 31 31 32 + // NOTE: category management (setCategories/addCategory/removeCategory/getCategories) 33 + // and addWebhook are deliberately NOT exposed here. They are inherently per-app 34 + // self-scope (an app declares ITS OWN categories / adds a webhook), so they belong 35 + // on the self surface; a `full` account-manager has no business mutating another 36 + // app's categories. setGrantManage is likewise user-only. 32 37 const OPS: Record<string, OpFn> = { 33 38 // reads 34 39 listGrants: (env, did) => ops.listGrants(env, did),
+2 -2
apps/relay/src/xrpc/muteSelf.ts
··· 2 2 import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 3 4 4 import { verifyManagementCall } from '../auth/management'; 5 - import * as q from '../db/queries'; 6 5 import type { AppContext } from '../env'; 6 + import * as ops from '../rpc/ops'; 7 7 8 8 const LXM = 'pub.atmo.notify.muteSelf'; 9 9 ··· 16 16 write: true, 17 17 lxm: LXM, 18 18 }); 19 - await q.setGrantMuted(app.env.DB, userDid, appDid, input.muted); 19 + await ops.muteGrant(app.env, userDid, { sender: appDid, muted: input.muted }); 20 20 return json({ ok: true }); 21 21 }, 22 22 };
+26
apps/relay/src/xrpc/removeCategory.ts
··· 1 + import { PubAtmoNotifyRemoveCategory } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyManagementCall } from '../auth/management'; 5 + import type { AppContext } from '../env'; 6 + import * as ops from '../rpc/ops'; 7 + 8 + const LXM = 'pub.atmo.notify.removeCategory'; 9 + 10 + /** Remove one of the calling app's categories for a user (+ its routing). Self-scoped. */ 11 + export function makeRemoveCategory( 12 + app: AppContext, 13 + ): ProcedureConfig<PubAtmoNotifyRemoveCategory.mainSchema> { 14 + return { 15 + handler: async ({ request, input }) => { 16 + const { appDid, userDid } = await verifyManagementCall(app, request, input, { 17 + scope: 'self', 18 + write: true, 19 + lxm: LXM, 20 + }); 21 + 22 + const { removed } = await ops.removeCategory(app.env, userDid, appDid, input.id); 23 + return json({ removed }); 24 + }, 25 + }; 26 + }
+39 -19
apps/relay/src/xrpc/requestPermission.ts
··· 1 - import { PubAtmoNotifyRequestPermission } from '@atmo/notifs-lexicons'; 1 + import { PubAtmoNotifyRequestPermission, routeSelection } from '@atmo/notifs-lexicons'; 2 2 import type { Did } from '@atcute/lexicons'; 3 3 import { isDid } from '@atcute/lexicons/syntax'; 4 4 import { InvalidRequestError, json, type ProcedureConfig } from '@atcute/xrpc-server'; 5 5 6 6 import { verifyUserRequest } from '../auth/user'; 7 7 import * as q from '../db/queries'; 8 - import type { AppContext } from '../env'; 8 + import { deliveryChannelFor, selectTargets } from '../delivery/channel'; 9 + import type { AppContext, DispatchJob } from '../env'; 9 10 import { rateLimited } from '../lib/errors'; 10 11 import { newId } from '../lib/ids'; 11 12 import { addDays, now } from '../lib/time'; ··· 124 125 return `granted:${recipient}:${senderDid}`; 125 126 } 126 127 128 + /** Where users review/approve permission requests. */ 129 + const PENDING_URL = 'https://atmo.pub/apps'; 130 + 127 131 /** 128 - * If the recipient linked Telegram and opted into pending-request alerts, enqueue 129 - * a `pendingRequest` job so they can approve/deny from the chat. 132 + * Alert the recipient about a new permission request, on whichever channels they 133 + * chose (`users.pending_route`). Telegram gets an inline approve/deny prompt; any 134 + * other channel gets a plain notification linking to the dashboard. 130 135 */ 131 136 async function maybeNotifyPending( 132 137 app: AppContext, ··· 138 143 iconUrl: string | null, 139 144 ): Promise<void> { 140 145 const user = await q.getUser(app.env.DB, recipient); 141 - if (user === null || user.notify_pending_via_telegram !== 1) { 146 + const route = user?.pending_route ?? 'off'; 147 + if (route === 'off' || route === '') { 142 148 return; 143 149 } 144 - const telegram = (await q.listDeliveryTargets(app.env.DB, recipient)).find( 145 - (target) => target.channel === 'telegram', 150 + 151 + const targets = selectTargets( 152 + await q.listDeliveryTargets(app.env.DB, recipient), 153 + routeSelection(route), 146 154 ); 147 - if (telegram === undefined) { 148 - return; 149 - } 155 + if (targets.length === 0) return; 150 156 151 - await app.env.DISPATCH_QUEUE.send({ 152 - kind: 'pendingRequest', 153 - channel: { platform: 'telegram', platformUserId: telegram.ref }, 154 - requestId, 155 - senderTitle: title, 156 - senderDescription: description ?? undefined, 157 - senderIconUrl: iconUrl ?? undefined, 158 - senderDid, 159 - }); 157 + const body = `${title} wants to send you notifications — review it in atmo.pub.`; 158 + const jobs = targets.map((t) => ({ 159 + body: 160 + t.channel === 'telegram' 161 + ? ({ 162 + kind: 'pendingRequest' as const, 163 + channel: { platform: 'telegram' as const, platformUserId: t.chatId }, 164 + requestId, 165 + senderTitle: title, 166 + senderDescription: description ?? undefined, 167 + senderIconUrl: iconUrl ?? undefined, 168 + senderDid, 169 + } satisfies DispatchJob) 170 + : ({ 171 + kind: 'notification' as const, 172 + channel: deliveryChannelFor(t), 173 + title: 'Permission request', 174 + body, 175 + uri: PENDING_URL, 176 + senderDid, 177 + } satisfies DispatchJob), 178 + })); 179 + await app.env.DISPATCH_QUEUE.sendBatch(jobs); 160 180 }
+5 -3
apps/relay/src/xrpc/revokeSelf.ts
··· 2 2 import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 3 4 4 import { verifyManagementCall } from '../auth/management'; 5 - import * as q from '../db/queries'; 6 5 import type { AppContext } from '../env'; 6 + import * as ops from '../rpc/ops'; 7 7 8 8 const LXM = 'pub.atmo.notify.revokeSelf'; 9 9 10 - /** An app removes its own grant for a user (turns itself off). Self-scoped. */ 10 + /** An app removes its own grant for a user (turns itself off). Self-scoped. 11 + * Delegates to `ops.revoke` so it also cascades routing/categories + fires the 12 + * subscriberChanged callback. */ 11 13 export function makeRevokeSelf( 12 14 app: AppContext, 13 15 ): ProcedureConfig<PubAtmoNotifyRevokeSelf.mainSchema> { ··· 18 20 write: true, 19 21 lxm: LXM, 20 22 }); 21 - await q.deleteGrant(app.env.DB, userDid, appDid); 23 + await ops.revoke(app.env, userDid, { sender: appDid }); 22 24 return json({ ok: true }); 23 25 }, 24 26 };
+47 -66
apps/relay/src/xrpc/send.ts
··· 4 4 5 5 import { verifySenderRequest } from '../auth/sender'; 6 6 import * as q from '../db/queries'; 7 + import { deliveryChannelFor, selectTargets } from '../delivery/channel'; 8 + import { withinChannelLimits } from '../delivery/limits'; 7 9 import type { AppContext, DispatchJob } from '../env'; 8 10 import { notAuthorized, rateLimited } from '../lib/errors'; 9 11 import { newId } from '../lib/ids'; ··· 31 33 throw notAuthorized(); 32 34 } 33 35 36 + // 3. Rate limits (per recipient+sender pair) — checked before any write so a 37 + // rate-limited send has no side effects (no inbox row, no category). 38 + const perSecond = await checkAndIncrement( 39 + app.env.CACHE, 40 + `rl:send:1s:${senderDid}:${recipient}`, 41 + PER_SECOND_LIMIT, 42 + PER_SECOND_WINDOW, 43 + ); 44 + if (!perSecond.allowed) { 45 + throw rateLimited(perSecond.resetIn, 'Sending too fast'); 46 + } 47 + const perDay = await checkAndIncrement( 48 + app.env.CACHE, 49 + `rl:send:1d:${senderDid}:${recipient}`, 50 + PER_DAY_LIMIT, 51 + PER_DAY_WINDOW, 52 + ); 53 + if (!perDay.allowed) { 54 + throw rateLimited(perDay.resetIn, 'Daily notification limit reached for this recipient'); 55 + } 56 + 34 57 // Register the category up front so it stays configurable in the routing UI 35 58 // even when this notification is dropped (route 'off'). 36 59 if (input.category != null) { ··· 54 77 route = (await q.getAppRoute(app.env.DB, recipient, senderDid))?.route; 55 78 } 56 79 if (route === undefined) { 57 - route = (await q.getUser(app.env.DB, recipient))?.default_route ?? 'push'; 80 + route = (await q.getUser(app.env.DB, recipient))?.default_route ?? 'inbox'; 58 81 } 59 82 60 83 // 'off' → dropped entirely: not recorded, not delivered. ··· 81 104 return json({ id, delivered: 0 }); 82 105 } 83 106 84 - // 4. Rate limits (per recipient+sender pair) — only for actual alert delivery. 85 - const perSecond = await checkAndIncrement( 86 - app.env.CACHE, 87 - `rl:send:1s:${senderDid}:${recipient}`, 88 - PER_SECOND_LIMIT, 89 - PER_SECOND_WINDOW, 90 - ); 91 - if (!perSecond.allowed) { 92 - throw rateLimited(perSecond.resetIn, 'Sending too fast'); 93 - } 94 - const perDay = await checkAndIncrement( 95 - app.env.CACHE, 96 - `rl:send:1d:${senderDid}:${recipient}`, 97 - PER_DAY_LIMIT, 98 - PER_DAY_WINDOW, 107 + // 4. Resolve the token set against the user's deliverable targets. A bare 108 + // channel ('push') fires all its instances; 'push:<id>' fires just one. 109 + const matched = selectTargets( 110 + await q.listDeliveryTargets(app.env.DB, recipient), 111 + routeSelection(route), 99 112 ); 100 - if (!perDay.allowed) { 101 - throw rateLimited(perDay.resetIn, 'Daily notification limit reached for this recipient'); 113 + 114 + // 5. Apply per-channel daily caps (per-recipient + relay-global, e.g. email). 115 + // A channel over its cap is skipped for this notification — it's still 116 + // recorded in the inbox above; uncapped channels pass without touching the 117 + // limiter. Sequential so the relay-global counter is consumed accurately. 118 + const targets: typeof matched = []; 119 + for (const target of matched) { 120 + if (await withinChannelLimits(app.env, target.channel, recipient)) { 121 + targets.push(target); 122 + } 102 123 } 103 - 104 - // 5. Resolve the token set against the user's deliverable targets. A bare 105 - // channel ('push') fires all its instances; 'push:<id>' fires just one. 106 - const selection = routeSelection(route); 107 - const targets = (await q.listDeliveryTargets(app.env.DB, recipient)) 108 - .map(q.toDeliveryInstance) 109 - .filter((t): t is q.DeliveryInstance => t !== null && t.verified) 110 - .filter((t) => { 111 - const sel = selection[t.channel]; 112 - return sel !== undefined && (sel.all || sel.ids.includes(t.id)); 113 - }); 114 124 const deliveredCount = targets.length; 115 125 116 126 // No targets → accept but deliver to nobody. ··· 121 131 122 132 // 5. Enqueue one dispatch job per matched target. 123 133 const jobs = targets.map((target) => ({ 124 - body: toNotificationJob(target, input, senderDid), 134 + body: { 135 + kind: 'notification' as const, 136 + channel: deliveryChannelFor(target), 137 + title: input.title, 138 + body: input.body, 139 + uri: input.uri, 140 + senderDid, 141 + } satisfies DispatchJob, 125 142 })); 126 143 await app.env.DISPATCH_QUEUE.sendBatch(jobs); 127 144 ··· 130 147 return json({ id, delivered: deliveredCount }); 131 148 }, 132 149 }; 133 - } 134 - 135 - /** Build a notification dispatch job for one resolved delivery instance. */ 136 - function toNotificationJob( 137 - target: q.DeliveryInstance, 138 - input: { title: string; body: string; uri?: string }, 139 - senderDid: string, 140 - ): DispatchJob { 141 - const common = { 142 - kind: 'notification' as const, 143 - title: input.title, 144 - body: input.body, 145 - uri: input.uri, 146 - senderDid, 147 - }; 148 - if (target.channel === 'push') { 149 - return { 150 - ...common, 151 - channel: { 152 - platform: 'webpush', 153 - endpoint: target.endpoint, 154 - p256dh: target.p256dh, 155 - auth: target.auth, 156 - }, 157 - }; 158 - } 159 - if (target.channel === 'telegram') { 160 - return { ...common, channel: { platform: 'telegram', platformUserId: target.chatId } }; 161 - } 162 - if (target.channel === 'email') { 163 - return { ...common, channel: { platform: 'email', address: target.address } }; 164 - } 165 - if (target.channel === 'dm') { 166 - return { ...common, channel: { platform: 'dm', recipientDid: target.recipientDid } }; 167 - } 168 - return { ...common, channel: { platform: 'webhook', url: target.url } }; 169 150 } 170 151 171 152 function logDelivery(
+47
apps/relay/src/xrpc/setCategories.ts
··· 1 + import { isCategoryRoute, PubAtmoNotifySetCategories } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyManagementCall } from '../auth/management'; 5 + import type { AppContext } from '../env'; 6 + import { invalidRequest } from '../lib/errors'; 7 + import * as ops from '../rpc/ops'; 8 + 9 + const LXM = 'pub.atmo.notify.setCategories'; 10 + 11 + /** 12 + * Replace the calling app's category catalog for a user (full sync). Self-scoped 13 + * management write (app JWT + a fresh user token). Only ever touches categories + 14 + * routing keyed by (userDid, appDid). 15 + */ 16 + export function makeSetCategories( 17 + app: AppContext, 18 + ): ProcedureConfig<PubAtmoNotifySetCategories.mainSchema> { 19 + return { 20 + handler: async ({ request, input }) => { 21 + const { appDid, userDid } = await verifyManagementCall(app, request, input, { 22 + scope: 'self', 23 + write: true, 24 + lxm: LXM, 25 + }); 26 + 27 + for (const c of input.categories) { 28 + if (c.route !== undefined && !isCategoryRoute(c.route)) { 29 + throw invalidRequest('Invalid category route'); 30 + } 31 + } 32 + 33 + await ops.setCategories( 34 + app.env, 35 + userDid, 36 + appDid, 37 + input.categories.map((c) => ({ 38 + id: c.id, 39 + title: c.title, 40 + description: c.description, 41 + route: c.route, 42 + })), 43 + ); 44 + return json({ ok: true }); 45 + }, 46 + }; 47 + }
+9 -18
apps/relay/src/xrpc/setRouting.ts
··· 1 - import { isConcreteRoute, PubAtmoNotifySetRouting } from '@atmo/notifs-lexicons'; 1 + import { isAppRoute, isCategoryRoute, PubAtmoNotifySetRouting } from '@atmo/notifs-lexicons'; 2 2 import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 3 4 4 import { verifyManagementCall } from '../auth/management'; 5 - import * as q from '../db/queries'; 6 5 import type { AppContext } from '../env'; 7 6 import { invalidRequest } from '../lib/errors'; 7 + import * as ops from '../rpc/ops'; 8 8 9 9 const LXM = 'pub.atmo.notify.setRouting'; 10 10 ··· 14 14 * A self-scoped management write (see MANAGEMENT-AUTH.md): the app is 15 15 * authenticated by its service-auth bearer and the user by the body `userToken`; 16 16 * `verifyManagementCall` enforces the (user, app) capability + self-write policy. 17 - * Only ever touches routing rows keyed by (userDid, senderDid) — never the 18 - * account default, other apps, or channels. 17 + * Delegates to the same `ops.*` the binding uses (one implementation), always 18 + * scoped to (userDid, senderDid) — never the account default, other apps, or channels. 19 19 */ 20 20 export function makeSetRouting( 21 21 app: AppContext, ··· 28 28 lxm: LXM, 29 29 }); 30 30 31 - // The lexicon route fields are free strings; validate the channel-set format. 32 - if (input.route !== undefined && input.route !== 'default' && !isConcreteRoute(input.route)) { 31 + // The lexicon route fields are free strings; validate the token-set format. 32 + if (input.route !== undefined && !isAppRoute(input.route)) { 33 33 throw invalidRequest('Invalid route'); 34 34 } 35 35 for (const c of input.categories ?? []) { 36 - if (c.route !== 'app' && !isConcreteRoute(c.route)) { 36 + if (!isCategoryRoute(c.route)) { 37 37 throw invalidRequest('Invalid category route'); 38 38 } 39 39 } 40 40 41 41 if (input.route !== undefined) { 42 - if (input.route === 'default') { 43 - await q.deleteAppRoute(app.env.DB, userDid, senderDid); 44 - } else { 45 - await q.upsertAppRoute(app.env.DB, userDid, senderDid, input.route); 46 - } 42 + await ops.setAppRouting(app.env, userDid, senderDid, input.route); 47 43 } 48 - 49 44 for (const c of input.categories ?? []) { 50 - if (c.route === 'app') { 51 - await q.deleteRouting(app.env.DB, userDid, senderDid, c.id); 52 - } else { 53 - await q.upsertRouting(app.env.DB, userDid, senderDid, c.id, c.route); 54 - } 45 + await ops.setRouting(app.env, userDid, senderDid, c.id, c.route); 55 46 } 56 47 57 48 return json({ ok: true });
+1 -1
apps/relay/test/app-routing-inbox.test.ts
··· 182 182 expect(res.status).toBe(200); 183 183 const data = (await res.json()) as { route: string; defaultRoute: string; categories: unknown[] }; 184 184 expect(data.route).toBe('default'); 185 - expect(data.defaultRoute).toBe('push'); 185 + expect(data.defaultRoute).toBe('inbox'); // new accounts start at inbox-only 186 186 expect(data.categories).toEqual([]); 187 187 }); 188 188
+94
apps/relay/test/category-mgmt.test.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { env } from 'cloudflare:test'; 3 + import { expect, it } from 'vitest'; 4 + 5 + import * as q from '../src/db/queries'; 6 + import * as ops from '../src/rpc/ops'; 7 + 8 + const APP = 'did:plc:catapp' as Did; 9 + 10 + it('addCategory declares a category with title + an optional initial route', async () => { 11 + const user = 'did:plc:cat-user1' as Did; 12 + await ops.addCategory(env, user, APP, { id: 'wh_1', title: 'My Webhook', route: 'inbox' }); 13 + const { categories } = await ops.getCategories(env, user, APP); 14 + expect(categories).toEqual([{ id: 'wh_1', title: 'My Webhook', route: 'inbox' }]); 15 + }); 16 + 17 + it('addCategory without a route leaves it inheriting the app route', async () => { 18 + const user = 'did:plc:cat-noroute' as Did; 19 + await ops.addCategory(env, user, APP, { id: 'wh_x', title: 'X' }); 20 + expect((await ops.getCategories(env, user, APP)).categories[0]).toMatchObject({ 21 + id: 'wh_x', 22 + route: 'app', 23 + }); 24 + }); 25 + 26 + it('setCategories full-sync upserts the listed ones and prunes the rest (with routing)', async () => { 27 + const user = 'did:plc:cat-sync' as Did; 28 + await ops.setCategories(env, user, APP, [ 29 + { id: 'a', title: 'A', route: 'push' }, 30 + { id: 'b', title: 'B' }, 31 + ]); 32 + expect((await q.getRoutingRoute(env.DB, user, APP, 'a'))?.route).toBe('push'); 33 + 34 + // Re-sync to just [a]; b (and its routing) is pruned, a's route is preserved. 35 + await ops.setCategories(env, user, APP, [{ id: 'a', title: 'A renamed' }]); 36 + const { categories } = await ops.getCategories(env, user, APP); 37 + expect(categories.map((c) => c.id)).toEqual(['a']); 38 + expect(categories[0]?.title).toBe('A renamed'); 39 + expect(await q.getRoutingRoute(env.DB, user, APP, 'b')).toBeNull(); 40 + expect((await q.getRoutingRoute(env.DB, user, APP, 'a'))?.route).toBe('push'); 41 + }); 42 + 43 + it('removeCategory deletes the category and its routing', async () => { 44 + const user = 'did:plc:cat-remove' as Did; 45 + await ops.addCategory(env, user, APP, { id: 'gone', title: 'Gone', route: 'telegram' }); 46 + expect((await ops.removeCategory(env, user, APP, 'gone')).removed).toBe(true); 47 + expect((await ops.getCategories(env, user, APP)).categories).toHaveLength(0); 48 + expect(await q.getRoutingRoute(env.DB, user, APP, 'gone')).toBeNull(); 49 + }); 50 + 51 + it('categories are per-user: one user never sees another’s', async () => { 52 + const userA = 'did:plc:cat-A' as Did; 53 + const userB = 'did:plc:cat-B' as Did; 54 + await ops.addCategory(env, userA, APP, { id: 'secret', title: 'A only' }); 55 + const { categories } = await ops.getCategories(env, userB, APP); 56 + expect(categories.find((c) => c.id === 'secret')).toBeUndefined(); 57 + }); 58 + 59 + it('revoke cascades: the app’s routing + categories are removed', async () => { 60 + const user = 'did:plc:cat-revoke' as Did; 61 + await q.upsertGrant(env.DB, { 62 + recipientDid: user, 63 + senderDid: APP, 64 + grantedAt: Date.now(), 65 + title: null, 66 + description: null, 67 + iconUrl: null, 68 + }); 69 + await ops.setAppRouting(env, user, APP, 'push'); 70 + await ops.addCategory(env, user, APP, { id: 'c', title: 'C', route: 'telegram' }); 71 + 72 + await ops.revoke(env, user, { sender: APP }); 73 + 74 + expect(await q.getAppRoute(env.DB, user, APP)).toBeNull(); 75 + expect(await q.getRoutingRoute(env.DB, user, APP, 'c')).toBeNull(); 76 + expect((await ops.getCategories(env, user, APP)).categories).toHaveLength(0); 77 + }); 78 + 79 + it('getRouting surfaces the category title', async () => { 80 + const user = 'did:plc:cat-routing' as Did; 81 + await q.upsertGrant(env.DB, { 82 + recipientDid: user, 83 + senderDid: APP, 84 + grantedAt: Date.now(), 85 + title: null, 86 + description: null, 87 + iconUrl: null, 88 + }); 89 + await ops.addCategory(env, user, APP, { id: 'wh', title: 'Webhook Name', route: 'push' }); 90 + 91 + const routing = await ops.getRouting(env, user); 92 + const cat = routing.apps.find((a) => a.sender === APP)?.categories.find((c) => c.category === 'wh'); 93 + expect(cat).toMatchObject({ category: 'wh', title: 'Webhook Name', route: 'push' }); 94 + });
+27
apps/relay/test/grant.test.ts
··· 33 33 expect(grant?.title).toBe('Bookhive'); 34 34 expect(grant?.description).toBe('New comments on your books'); 35 35 expect(grant?.icon_url).toBe('https://bookhive.example/icon.png'); 36 + // New grants default to self-management ('self'), set explicitly (not via the 37 + // column default) so it holds even on an older DB. 38 + expect(grant?.manage).toBe('self'); 39 + }); 40 + 41 + it('a re-grant keeps the user-chosen manage capability', async () => { 42 + const user: Did = 'did:plc:regrantmanage'; 43 + await q.upsertGrant(env.DB, { 44 + recipientDid: user, 45 + senderDid: SENDER, 46 + grantedAt: Date.now(), 47 + title: null, 48 + description: null, 49 + iconUrl: null, 50 + }); 51 + // User downgrades to 'none'. 52 + await q.setGrantManage(env.DB, user, SENDER, 'none'); 53 + // Re-granting (e.g. the app re-requests) must not silently restore 'self'. 54 + await q.upsertGrant(env.DB, { 55 + recipientDid: user, 56 + senderDid: SENDER, 57 + grantedAt: Date.now(), 58 + title: null, 59 + description: null, 60 + iconUrl: null, 61 + }); 62 + expect((await q.getGrant(env.DB, user, SENDER))?.manage).toBe('none'); 36 63 }); 37 64 38 65 it('revoke removes the grant', async () => {
+41
apps/relay/test/limits.test.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { env } from 'cloudflare:test'; 3 + import { expect, it } from 'vitest'; 4 + 5 + import { channelLimit, withinChannelLimits } from '../src/delivery/limits'; 6 + 7 + it('email is capped; push/telegram/dm/webhook are not', () => { 8 + const email = channelLimit(env, 'email'); 9 + expect(email?.perRecipientPerDay).toBeGreaterThan(0); 10 + expect(email?.globalPerDay).toBeGreaterThan(0); 11 + for (const c of ['push', 'telegram', 'dm', 'webhook'] as const) { 12 + expect(channelLimit(env, c)).toBeUndefined(); 13 + } 14 + }); 15 + 16 + it('uncapped channels always pass and never limit', async () => { 17 + const did: Did = 'did:plc:limits-push'; 18 + for (let i = 0; i < 50; i++) { 19 + expect(await withinChannelLimits(env, 'push', did)).toBe(true); 20 + } 21 + }); 22 + 23 + it('email caps per recipient per day, independently per recipient', async () => { 24 + const a: Did = 'did:plc:limits-emailA'; 25 + const b: Did = 'did:plc:limits-emailB'; 26 + const perRecipient = channelLimit(env, 'email')!.perRecipientPerDay!; 27 + // Isolate the per-recipient cap from the relay-global cap: raise the global so 28 + // A exhausting its own budget can't also drain the shared global budget (which 29 + // would block B for the wrong reason). Tests the per-recipient dimension only. 30 + env.EMAIL_DAILY_GLOBAL = String(perRecipient * 100); 31 + 32 + // A can receive up to the per-recipient cap… 33 + for (let i = 0; i < perRecipient; i++) { 34 + expect(await withinChannelLimits(env, 'email', a)).toBe(true); 35 + } 36 + // …the next email to A is blocked. 37 + expect(await withinChannelLimits(env, 'email', a)).toBe(false); 38 + 39 + // B has its own budget — unaffected by A hitting the cap. 40 + expect(await withinChannelLimits(env, 'email', b)).toBe(true); 41 + });
+57
apps/relay/test/management-auth.test.ts
··· 25 25 const GETROUTING = 'pub.atmo.notify.getRouting'; // self-read 26 26 const REVOKESELF = 'pub.atmo.notify.revokeSelf'; // self-write 27 27 const MUTESELF = 'pub.atmo.notify.muteSelf'; // self-write 28 + const ADDCATEGORY = 'pub.atmo.notify.addCategory'; // self-write 29 + const SETCATEGORIES = 'pub.atmo.notify.setCategories'; // self-write 30 + const REMOVECATEGORY = 'pub.atmo.notify.removeCategory'; // self-write 31 + const GETCATEGORIES = 'pub.atmo.notify.getCategories'; // self-read 32 + 33 + interface CategoryList { 34 + categories: { id: string; title?: string; route: string }[]; 35 + } 28 36 29 37 async function call(req: Request): Promise<Response> { 30 38 const ctx = createExecutionContext(); ··· 142 150 await designate(user.did, app.did, 'full'); 143 151 const res = await dualCall(SETROUTING, app, user, { route: 'push' }); 144 152 expect(res.status).toBe(200); 153 + }); 154 + 155 + // --- category management (federated, self-scoped) -------------------------- 156 + 157 + it('addCategory is a write: denied undesignated, allowed once designated `self`', async () => { 158 + const { app, user } = await plain('cat-fed-write'); 159 + const denied = await dualCall(ADDCATEGORY, app, user, { id: 'wh', title: 'WH' }); 160 + expect(denied.status).toBe(403); 161 + 162 + await designate(user.did, app.did, 'self'); 163 + const ok = await dualCall(ADDCATEGORY, app, user, { id: 'wh', title: 'WH', route: 'inbox' }); 164 + expect(ok.status).toBe(200); 165 + }); 166 + 167 + it('addCategory rejects an invalid route', async () => { 168 + const { app, user } = await plain('cat-fed-badroute'); 169 + await designate(user.did, app.did, 'self'); 170 + const res = await dualCall(ADDCATEGORY, app, user, { id: 'wh', route: 'nope!' }); 171 + expect(res.status).toBe(400); 172 + }); 173 + 174 + it('setCategories full-sync + getCategories over XRPC (only this app’s)', async () => { 175 + const { app, user } = await plain('cat-fed-sync'); 176 + await designate(user.did, app.did, 'self'); 177 + 178 + await dualCall(SETCATEGORIES, app, user, { 179 + categories: [ 180 + { id: 'a', title: 'A' }, 181 + { id: 'b', title: 'B', route: 'push' }, 182 + ], 183 + }); 184 + let data = (await (await dualCall(GETCATEGORIES, app, user)).json()) as CategoryList; 185 + expect(data.categories.map((c) => c.id).sort()).toEqual(['a', 'b']); 186 + 187 + await dualCall(SETCATEGORIES, app, user, { categories: [{ id: 'a', title: 'A2' }] }); 188 + data = (await (await dualCall(GETCATEGORIES, app, user)).json()) as CategoryList; 189 + expect(data.categories.map((c) => c.id)).toEqual(['a']); 190 + expect(data.categories[0]?.title).toBe('A2'); 191 + }); 192 + 193 + it('removeCategory over XRPC', async () => { 194 + const { app, user } = await plain('cat-fed-remove'); 195 + await designate(user.did, app.did, 'self'); 196 + await dualCall(ADDCATEGORY, app, user, { id: 'gone', title: 'G' }); 197 + const res = await dualCall(REMOVECATEGORY, app, user, { id: 'gone' }); 198 + expect(res.status).toBe(200); 199 + expect((await (await dualCall(GETCATEGORIES, app, user)).json()) as CategoryList).toEqual({ 200 + categories: [], 201 + }); 145 202 }); 146 203 147 204 it('getRouting returns the target catalog with privacy-safe labels', async () => {
+32 -1
apps/relay/test/ratelimit.test.ts
··· 1 1 import { env } from 'cloudflare:test'; 2 2 import { expect, it, vi } from 'vitest'; 3 3 4 - import { checkAndIncrement } from '../src/ratelimit'; 4 + import { checkAndIncrement, checkAndIncrementAll } from '../src/ratelimit'; 5 5 6 6 it('allows requests under the limit and denies those over it', async () => { 7 7 const key = 'rl:test:underover'; ··· 36 36 vi.useRealTimers(); 37 37 } 38 38 }); 39 + 40 + it('checkAndIncrementAll: empty checks are allowed', async () => { 41 + expect((await checkAndIncrementAll(env.CACHE, [])).allowed).toBe(true); 42 + }); 43 + 44 + it('checkAndIncrementAll allows up to the limit on a single key', async () => { 45 + const checks = [{ key: 'rl:test:all:single', limit: 2, windowSeconds: 60 }]; 46 + expect((await checkAndIncrementAll(env.CACHE, checks)).allowed).toBe(true); 47 + expect((await checkAndIncrementAll(env.CACHE, checks)).allowed).toBe(true); 48 + expect((await checkAndIncrementAll(env.CACHE, checks)).allowed).toBe(false); 49 + }); 50 + 51 + it('checkAndIncrementAll denies when ANY key is over — without consuming the others', async () => { 52 + const a = 'rl:test:all:a'; 53 + const b = 'rl:test:all:b'; 54 + const checks = [ 55 + { key: a, limit: 1, windowSeconds: 60 }, 56 + { key: b, limit: 5, windowSeconds: 60 }, 57 + ]; 58 + 59 + // First call: both under → allowed, both incremented (a=1, b=1). 60 + expect((await checkAndIncrementAll(env.CACHE, checks)).allowed).toBe(true); 61 + // Second: a is now at its limit → denied, and b must NOT be incremented. 62 + expect((await checkAndIncrementAll(env.CACHE, checks)).allowed).toBe(false); 63 + 64 + // Prove b is still at 1 (not 2): exactly 4 more single-key increments fit under 5. 65 + for (let i = 0; i < 4; i++) { 66 + expect((await checkAndIncrement(env.CACHE, b, 5, 60)).allowed).toBe(true); 67 + } 68 + expect((await checkAndIncrement(env.CACHE, b, 5, 60)).allowed).toBe(false); 69 + });
+37
apps/relay/test/route-validation.test.ts
··· 1 + import { isAppRoute, isCategoryRoute, isConcreteRoute } from '@atmo/notifs-lexicons'; 2 + import { expect, it } from 'vitest'; 3 + 4 + import { newTargetId } from '../src/lib/ids'; 5 + 6 + it('accepts channel sets, off, and inbox', () => { 7 + for (const r of ['push', 'push+telegram', 'email+dm+webhook', 'off', 'inbox']) { 8 + expect(isConcreteRoute(r)).toBe(true); 9 + } 10 + }); 11 + 12 + it('accepts instance-scoped tokens whose ids use nanoid chars (incl. _ and -)', () => { 13 + // Regression: target ids are nanoids (A–Z a–z 0–9 _ -). The validator used to 14 + // reject ids containing _ or -, silently dropping per-instance routes. 15 + expect(isConcreteRoute('dm:qvHICr-uJhd5')).toBe(true); 16 + expect(isConcreteRoute('push:abc_DE-12')).toBe(true); 17 + expect(isConcreteRoute('push:a1b2c3+telegram')).toBe(true); 18 + 19 + // Any freshly-generated target id must validate as an instance token. 20 + for (let i = 0; i < 50; i++) { 21 + expect(isConcreteRoute(`push:${newTargetId()}`)).toBe(true); 22 + } 23 + }); 24 + 25 + it('rejects malformed routes and the inherit sentinels', () => { 26 + expect(isConcreteRoute('default')).toBe(false); 27 + expect(isConcreteRoute('app')).toBe(false); 28 + expect(isConcreteRoute('push+push')).toBe(false); // duplicate token 29 + expect(isConcreteRoute('push:bad/id')).toBe(false); // illegal id char 30 + }); 31 + 32 + it('app/category routes also accept their inherit sentinel', () => { 33 + expect(isAppRoute('default')).toBe(true); 34 + expect(isAppRoute('dm:qvHICr-uJhd5')).toBe(true); 35 + expect(isCategoryRoute('app')).toBe(true); 36 + expect(isCategoryRoute('dm:qvHICr-uJhd5')).toBe(true); 37 + });
+2 -1
apps/relay/test/routing.test.ts
··· 30 30 const user = 'did:plc:routing1' as Did; 31 31 await grantWithCategory(user); 32 32 const cfg = await ops.getRouting(env, user); 33 - expect(cfg.defaultRoute).toBe('push'); 33 + expect(cfg.defaultRoute).toBe('inbox'); // new accounts start at inbox-only 34 + 34 35 const app = cfg.apps.find((a) => a.sender === SENDER); 35 36 expect(app?.route).toBe('default'); 36 37 expect(app?.categories[0]?.route).toBe('app');
+15 -5
apps/relay/wrangler.toml
··· 10 10 [[d1_databases]] 11 11 binding = "DB" 12 12 database_name = "notifs-relay" 13 - database_id = "c83e2e03-0081-4fc4-b25f-6897a614e169" 13 + database_id = "53f56b8c-d25e-468a-9d02-e8575bf8964f" 14 14 migrations_dir = "migrations" 15 15 16 16 [[kv_namespaces]] ··· 44 44 VAPID_SUBJECT = "https://atmo.pub" 45 45 # Email via comail (https://comail.at). COMAIL_DID = the account DID for the 46 46 # X-Atmos-DID header; COMAIL_FROM = an enrolled sender address. 47 - COMAIL_DID = "did:web:relay.atmo.pub" 47 + COMAIL_DID = "did:plc:jf3acz4zktzkaptswh5so43u" 48 48 COMAIL_FROM = "atmo.pub <notify@atmo.pub>" 49 + # Daily email caps (rolling 24h). Per-recipient = anti-spam/cost; global = keep 50 + # under the comail plan. Omit to use the defaults (10 / 100). See delivery/limits.ts. 51 + EMAIL_DAILY_PER_RECIPIENT = "10" 52 + EMAIL_DAILY_GLOBAL = "10" 53 + # Bluesky DM via a bot account. BLUESKY_DM_IDENTIFIER = the bot's handle or DID 54 + # (used for createSession); BLUESKY_DM_SERVICE = its PDS (defaults to 55 + # https://bsky.social if omitted). The app password is the BLUESKY_DM_APP_PASSWORD secret. 56 + BLUESKY_DM_IDENTIFIER = "did:plc:jf3acz4zktzkaptswh5so43u" 57 + BLUESKY_DM_SERVICE = "https://bsky.social" 49 58 50 59 # Secrets set via `wrangler secret put`: 51 60 # - TELEGRAM_BOT_TOKEN 52 61 # - TELEGRAM_WEBHOOK_SECRET 53 - # - VAPID_PRIVATE_JWK (the private JWK from `pnpm vapid:keygen`) 54 - # - RELAY_PRIVATE_KEY (the private multikey from `pnpm relay:keygen`) 55 - # - COMAIL_API_KEY (atmos_… from comail.at) 62 + # - VAPID_PRIVATE_JWK (the private JWK from `pnpm vapid:keygen`) 63 + # - RELAY_PRIVATE_KEY (the private multikey from `pnpm relay:keygen`) 64 + # - COMAIL_API_KEY (atmos_… from comail.at) 65 + # - BLUESKY_DM_APP_PASSWORD (an app password from the bot's Bluesky account)
+23 -14
apps/web/src/lib/components/ChannelRoutePicker.svelte
··· 15 15 value, 16 16 inherit, 17 17 instances, 18 + allowInbox = true, 18 19 disabled = false, 19 20 onchange 20 21 }: { ··· 24 25 inherit?: { token: 'default' | 'app'; label: string }; 25 26 /** Routable instances per channel, listed as targets under each channel. */ 26 27 instances?: RouteInstances; 28 + /** Offer the "Inbox only" mode (off for contexts like pending requests). */ 29 + allowInbox?: boolean; 27 30 disabled?: boolean; 28 31 onchange: (route: string) => void; 29 32 } = $props(); 33 + 34 + // When inbox-only isn't offered, an empty custom selection collapses to 'off'. 35 + const emptyValue = $derived(allowInbox ? 'inbox' : 'off'); 30 36 31 37 // Local: did the user explicitly open the Custom tree? An empty custom selection 32 38 // stores 'inbox', so without this the tree would collapse back to "Inbox only". ··· 61 67 } 62 68 function pickCustom() { 63 69 customOpen = true; 64 - // Entering Custom from a non-custom value starts empty (= inbox only) until a 65 - // box is checked. 66 - if (!hasChannels) onchange('inbox'); 70 + // Entering Custom from a non-custom value starts empty until a box is checked. 71 + if (!hasChannels) onchange(emptyValue); 67 72 } 68 73 69 74 function instancesFor(c: Channel): { id: string; label: string }[] { ··· 99 104 else for (const id of s) tokens.push({ channel: c, instance: id }); 100 105 } 101 106 const route = formatRoute(tokens); 102 - onchange(route === 'off' ? 'inbox' : route); 107 + onchange(route === 'off' ? emptyValue : route); 103 108 } 104 109 105 110 function parentState(c: Channel): 'on' | 'partial' | 'off' { ··· 175 180 {inherit.label} 176 181 </button> 177 182 {/if} 178 - <button 179 - type="button" 180 - {disabled} 181 - onclick={pickInbox} 182 - aria-pressed={mode === 'inbox'} 183 - class="{modeBtn} {mode === 'inbox' ? on : off}" 184 - > 185 - Inbox only 186 - </button> 183 + {#if allowInbox} 184 + <button 185 + type="button" 186 + {disabled} 187 + onclick={pickInbox} 188 + aria-pressed={mode === 'inbox'} 189 + class="{modeBtn} {mode === 'inbox' ? on : off}" 190 + > 191 + Inbox only 192 + </button> 193 + {/if} 187 194 <button 188 195 type="button" 189 196 {disabled} ··· 210 217 {#if available.length === 0} 211 218 <p class="px-1 py-0.5 text-xs text-muted"> 212 219 No channels connected. 213 - <a href="/settings?tab=channels" class="text-accent hover:underline">Add one in settings</a>. 220 + <a href="/settings?tab=channels" class="text-accent hover:underline" 221 + >Add one in settings</a 222 + >. 214 223 </p> 215 224 {:else} 216 225 <div class="flex flex-col gap-0.5">
-83
apps/web/src/lib/components/ThemeToggle.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - 4 - type Theme = 'system' | 'light' | 'dark'; 5 - 6 - const order: Theme[] = ['system', 'light', 'dark']; 7 - const labels: Record<Theme, string> = { system: 'System', light: 'Light', dark: 'Dark' }; 8 - 9 - let theme = $state<Theme>('system'); 10 - 11 - onMount(() => { 12 - const stored = localStorage.getItem('theme'); 13 - theme = stored === 'light' || stored === 'dark' ? stored : 'system'; 14 - }); 15 - 16 - function cycle() { 17 - const next = order[(order.indexOf(theme) + 1) % order.length] ?? 'system'; 18 - theme = next; 19 - if (next === 'system') { 20 - localStorage.removeItem('theme'); 21 - delete document.documentElement.dataset.theme; 22 - } else { 23 - localStorage.setItem('theme', next); 24 - document.documentElement.dataset.theme = next; 25 - } 26 - } 27 - </script> 28 - 29 - <button 30 - type="button" 31 - onclick={cycle} 32 - title={`Theme: ${labels[theme]}`} 33 - aria-label={`Theme: ${labels[theme]}. Click to change.`} 34 - class="grid size-9 place-items-center rounded-md text-muted transition-colors hover:bg-surface-2 hover:text-fg" 35 - > 36 - {#if theme === 'system'} 37 - <svg 38 - width="16" 39 - height="16" 40 - viewBox="0 0 24 24" 41 - fill="none" 42 - stroke="currentColor" 43 - stroke-width="2" 44 - stroke-linecap="round" 45 - stroke-linejoin="round" 46 - aria-hidden="true" 47 - > 48 - <rect x="2" y="3" width="20" height="14" rx="2" /> 49 - <path d="M8 21h8M12 17v4" /> 50 - </svg> 51 - {:else if theme === 'light'} 52 - <svg 53 - width="16" 54 - height="16" 55 - viewBox="0 0 24 24" 56 - fill="none" 57 - stroke="currentColor" 58 - stroke-width="2" 59 - stroke-linecap="round" 60 - stroke-linejoin="round" 61 - aria-hidden="true" 62 - > 63 - <circle cx="12" cy="12" r="4" /> 64 - <path 65 - d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" 66 - /> 67 - </svg> 68 - {:else} 69 - <svg 70 - width="16" 71 - height="16" 72 - viewBox="0 0 24 24" 73 - fill="none" 74 - stroke="currentColor" 75 - stroke-width="2" 76 - stroke-linecap="round" 77 - stroke-linejoin="round" 78 - aria-hidden="true" 79 - > 80 - <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> 81 - </svg> 82 - {/if} 83 - </button>
+13 -13
apps/web/src/lib/remote/notifs.remote.ts
··· 2 2 // `load` functions; after a command runs, the client calls `invalidateAll()` to 3 3 // refresh the page data. 4 4 import type { Did } from '@atcute/lexicons'; 5 - import { isConcreteRoute } from '@atmo/notifs-lexicons'; 5 + import { isAppRoute, isCategoryRoute, isConcreteRoute } from '@atmo/notifs-lexicons'; 6 6 import { command, getRequestEvent } from '$app/server'; 7 7 import { error } from '@sveltejs/kit'; 8 8 import * as v from 'valibot'; 9 9 10 + import { emailEnabledFor } from '$lib/server/featureAccess'; 10 11 import { relayFor } from '$lib/server/relay'; 11 12 12 13 /** Resolve the relay bound to the signed-in user, or 401. */ ··· 42 43 } 43 44 ); 44 45 45 - export const setNotifyPending = command(v.object({ value: v.boolean() }), async ({ value }) => { 46 - await requireRelay().updateSettings({ notifyPendingViaTelegram: value }); 47 - }); 48 - 49 46 const optionalLabel = v.optional(v.pipe(v.string(), v.trim(), v.maxLength(64))); 50 47 51 48 /** Returns the Telegram deep link; the client navigates to it. Optional `label` ··· 61 58 export const linkEmail = command( 62 59 v.object({ address: v.pipe(v.string(), v.email()), label: optionalLabel }), 63 60 async ({ address, label }) => { 61 + // Email is allowlisted; reject non-whitelisted DIDs (the UI is hidden for them 62 + // too, so this only fires on a crafted request). 63 + const { locals } = getRequestEvent(); 64 + if (!emailEnabledFor(locals.did)) error(403, 'Email is not available for your account'); 64 65 await requireRelay().linkEmail(address, label); 65 66 } 66 67 ); ··· 131 132 132 133 // A route is a `+`-joined channel set or 'off'; app/category add an inherit sentinel. 133 134 const concreteRoute = v.pipe(v.string(), v.check(isConcreteRoute, 'Invalid route')); 134 - const appRoute = v.pipe( 135 - v.string(), 136 - v.check((s) => s === 'default' || isConcreteRoute(s), 'Invalid route') 137 - ); 138 - const categoryRoute = v.pipe( 139 - v.string(), 140 - v.check((s) => s === 'app' || isConcreteRoute(s), 'Invalid route') 141 - ); 135 + const appRoute = v.pipe(v.string(), v.check(isAppRoute, 'Invalid route')); 136 + const categoryRoute = v.pipe(v.string(), v.check(isCategoryRoute, 'Invalid route')); 142 137 143 138 export const setDefaultRoute = command(v.object({ route: concreteRoute }), async ({ route }) => { 144 139 await requireRelay().setDefaultRoute(route); 140 + }); 141 + 142 + /** Where permission-request alerts are sent (a concrete route, or 'off'). */ 143 + export const setPendingRoute = command(v.object({ route: concreteRoute }), async ({ route }) => { 144 + await requireRelay().updateSettings({ pendingRoute: route }); 145 145 }); 146 146 147 147 export const setRouting = command(
+12
apps/web/src/lib/server/featureAccess.ts
··· 1 + // Server-only feature gating by DID allowlist. Email delivery is limited to these 2 + // users for now: everyone else doesn't see the Email section in Settings and can't 3 + // link an address (the `linkEmail` command rejects them too). Add DIDs here to 4 + // grant access. Kept server-side so the list never ships to the browser. 5 + const EMAIL_WHITELIST = new Set<string>([ 6 + // 'did:plc:257wekqxg4hyapkq6k47igmp', 7 + ]); 8 + 9 + /** True if `did` may use the email delivery channel. */ 10 + export function emailEnabledFor(did: string | null | undefined): boolean { 11 + return did != null && EMAIL_WHITELIST.has(did); 12 + }
+13 -24
apps/web/src/routes/(app)/+layout.svelte
··· 4 4 import { page } from '$app/state'; 5 5 import { oauthLogout } from '$lib/atproto/oauth.remote'; 6 6 import Icon from '$lib/components/Icon.svelte'; 7 - import ThemeToggle from '$lib/components/ThemeToggle.svelte'; 8 7 import Wordmark from '$lib/components/Wordmark.svelte'; 9 8 import { DOCS_URL } from '$lib/config'; 10 9 import type { LayoutData } from './$types'; ··· 44 43 45 44 <div class="md:grid md:h-screen md:grid-cols-[15.5rem_1fr]"> 46 45 <!-- Desktop sidebar --> 47 - <aside 48 - class="sticky top-0 hidden h-screen flex-col gap-1 border-r border-line bg-bg p-3 md:flex" 49 - > 46 + <aside class="sticky top-0 hidden h-screen flex-col gap-1 border-r border-line bg-bg p-3 md:flex"> 50 47 <div class="px-2 pt-2 pb-4"> 51 48 <Wordmark size={15} /> 52 49 </div> ··· 81 78 <span>Developer docs</span> 82 79 </a> 83 80 84 - <!-- Account / theme / sign out --> 81 + <!-- Account / sign out --> 85 82 <div class="mt-1 flex flex-col gap-2 border-t border-line pt-3"> 86 83 <div class="px-2" title={data.did}> 87 84 {#if data.handle} ··· 90 87 <div class="truncate font-mono text-xs text-muted-2">{shortDid}</div> 91 88 {/if} 92 89 </div> 93 - <div class="flex items-center gap-2"> 94 - <ThemeToggle /> 95 - <button 96 - type="button" 97 - onclick={signOut} 98 - disabled={signingOut} 99 - class="flex flex-1 items-center justify-start gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-50" 100 - > 101 - <Icon name="logout" size={16} /> 102 - <span>{signingOut ? 'Signing out…' : 'Sign out'}</span> 103 - </button> 104 - </div> 90 + <button 91 + type="button" 92 + onclick={signOut} 93 + disabled={signingOut} 94 + class="flex items-center justify-start gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-50" 95 + > 96 + <Icon name="logout" size={16} /> 97 + <span>{signingOut ? 'Signing out…' : 'Sign out'}</span> 98 + </button> 105 99 </div> 106 100 </aside> 107 101 ··· 109 103 pinned and only the content scrolls), the grid's 2nd cell on desktop. --> 110 104 <div class="flex h-dvh min-w-0 flex-col md:h-screen"> 111 105 <!-- Mobile top bar --> 112 - <header 113 - class="flex h-14 shrink-0 items-center justify-between border-b border-line bg-bg px-4 md:hidden" 114 - > 106 + <header class="flex h-14 shrink-0 items-center border-b border-line bg-bg px-4 md:hidden"> 115 107 <Wordmark size={15} /> 116 - <ThemeToggle /> 117 108 </header> 118 109 119 110 <main bind:this={mainEl} class="min-h-0 flex-1 overflow-y-auto"> ··· 122 113 123 114 <!-- Mobile bottom tab bar — in-flow, so it always sits at the column's 124 115 bottom (no `fixed`, which is unreliable with iOS scrolling). --> 125 - <nav 126 - class="flex shrink-0 justify-around border-t border-line bg-bg pt-2 pb-6 md:hidden" 127 - > 116 + <nav class="flex shrink-0 justify-around border-t border-line bg-bg pt-2 pb-6 md:hidden"> 128 117 {#each nav as item (item.id)} 129 118 {@const isActive = active === item.id} 130 119 <a
+1 -1
apps/web/src/routes/(app)/apps/[sender]/+page.server.ts
··· 14 14 15 15 return { 16 16 app, 17 - defaultRoute: routing?.defaultRoute ?? 'push', 17 + defaultRoute: routing?.defaultRoute ?? 'inbox', 18 18 channels: routing?.channels ?? emptyRouteInstances() 19 19 }; 20 20 };
+40 -29
apps/web/src/routes/(app)/apps/[sender]/+page.svelte
··· 76 76 }); 77 77 } 78 78 79 + // Temporarily hidden: the per-app management-access control. The capability 80 + // still exists end-to-end (new grants default to 'self'); we just don't expose 81 + // the picker yet. Flip to `true` to bring the section back. 82 + const SHOW_MANAGEMENT_ACCESS = false; 83 + 79 84 const CAPABILITIES: Capability[] = ['none', 'self', 'full']; 80 85 const CAP_LABELS: Record<Capability, string> = { 81 86 none: 'No access', ··· 183 188 : ''}" 184 189 > 185 190 <div class="min-w-0"> 186 - <div class="text-sm font-semibold text-fg">{c.category}</div> 191 + <div class="text-sm font-semibold text-fg">{c.title ?? c.category}</div> 192 + {#if c.title} 193 + <div class="truncate font-mono text-[0.65rem] text-muted-2">{c.category}</div> 194 + {/if} 187 195 {#if c.description}<div class="text-xs text-muted">{c.description}</div>{/if} 188 196 </div> 189 197 <ChannelRoutePicker ··· 199 207 {/if} 200 208 </section> 201 209 202 - <!-- Management access (capability designation; see MANAGEMENT-AUTH.md) --> 203 - <section class="mt-8 max-w-2xl"> 204 - <h2 class="mb-3 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase"> 205 - Management access 206 - </h2> 207 - <div class="rounded-card border border-line bg-surface p-4"> 208 - <div class="flex items-center justify-between gap-4"> 209 - <div class="min-w-0"> 210 - <div class="text-sm font-medium text-fg">Let this app change your settings</div> 211 - <p class="mt-1 text-xs text-muted"> 212 - <span class="font-medium text-fg">Manage its own settings</span> lets the app adjust 213 - how 214 - <em>its</em> notifications reach you, from inside the app. 215 - <span class="font-medium text-fg">Manage your whole account</span> lets it act as a full 216 - dashboard — every app, channel and setting. Grant full access only to apps you trust. 217 - </p> 210 + <!-- Management access (capability designation; see MANAGEMENT-AUTH.md). 211 + Temporarily hidden via SHOW_MANAGEMENT_ACCESS; backend still works. --> 212 + {#if SHOW_MANAGEMENT_ACCESS} 213 + <section class="mt-8 max-w-2xl"> 214 + <h2 class="mb-3 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase"> 215 + Management access 216 + </h2> 217 + <div class="rounded-card border border-line bg-surface p-4"> 218 + <div class="flex items-center justify-between gap-4"> 219 + <div class="min-w-0"> 220 + <div class="text-sm font-medium text-fg">Let this app change your settings</div> 221 + <p class="mt-1 text-xs text-muted"> 222 + <span class="font-medium text-fg">Manage its own settings</span> lets the app adjust 223 + how 224 + <em>its</em> notifications reach you, from inside the app. 225 + <span class="font-medium text-fg">Manage your whole account</span> lets it act as a full 226 + dashboard — every app, channel and setting. Grant full access only to apps you trust. 227 + </p> 228 + </div> 229 + <select 230 + class={selectClass} 231 + value={data.app.manage} 232 + disabled={busy['__manage']} 233 + onchange={(e) => changeManage(e.currentTarget.value as Capability)} 234 + > 235 + {#each CAPABILITIES as c (c)} 236 + <option value={c}>{CAP_LABELS[c]}</option> 237 + {/each} 238 + </select> 218 239 </div> 219 - <select 220 - class={selectClass} 221 - value={data.app.manage} 222 - disabled={busy['__manage']} 223 - onchange={(e) => changeManage(e.currentTarget.value as Capability)} 224 - > 225 - {#each CAPABILITIES as c (c)} 226 - <option value={c}>{CAP_LABELS[c]}</option> 227 - {/each} 228 - </select> 229 240 </div> 230 - </div> 231 - </section> 241 + </section> 242 + {/if} 232 243 233 244 <!-- Notifications --> 234 245 <section class="mt-8 max-w-2xl">
+5 -2
apps/web/src/routes/(app)/settings/+page.server.ts
··· 1 1 import { emptyRouteInstances } from '@atmo/notifs-lexicons'; 2 2 3 + import { emailEnabledFor } from '$lib/server/featureAccess'; 3 4 import { relayFor } from '$lib/server/relay'; 4 5 5 6 import type { PageServerLoad } from './$types'; ··· 23 24 emails: all.filter((t) => t.channel === 'email'), 24 25 dms: all.filter((t) => t.channel === 'dm'), 25 26 webhooks: all.filter((t) => t.channel === 'webhook'), 26 - notifyPendingViaTelegram: settings?.notifyPendingViaTelegram ?? false, 27 + // Email is allowlisted for now (hidden + blocked for everyone else). 28 + emailEnabled: emailEnabledFor(locals.did), 29 + pendingRoute: settings?.pendingRoute ?? 'off', 27 30 autoAllow: settings?.autoAllow ?? 'trusted', 28 - defaultRoute: routing?.defaultRoute ?? 'push', 31 + defaultRoute: routing?.defaultRoute ?? 'inbox', 29 32 // Catalog of routable instances per channel, for the routing picker. 30 33 channels: routing?.channels ?? emptyRouteInstances() 31 34 };
+12 -832
apps/web/src/routes/(app)/settings/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 2 import { page } from '$app/state'; 4 - import { invalidateAll } from '$app/navigation'; 5 - import ChannelRoutePicker from '$lib/components/ChannelRoutePicker.svelte'; 6 - import Icon from '$lib/components/Icon.svelte'; 7 - import IOSToggle from '$lib/components/IOSToggle.svelte'; 8 - import RelativeTime from '$lib/components/RelativeTime.svelte'; 9 - import RouteChip from '$lib/components/RouteChip.svelte'; 10 - import { currentSubscription, pushSupported, subscribe, unsubscribe } from '$lib/push'; 11 - import { 12 - addWebhook, 13 - enableDM, 14 - linkEmail, 15 - linkTelegram, 16 - registerPush, 17 - removeTarget, 18 - renameTarget, 19 - setAutoAllow, 20 - setDefaultRoute, 21 - setNotifyPending, 22 - unregisterPush, 23 - verifyEmail 24 - } from '$lib/remote/notifs.remote'; 25 - import { DOCS_URL } from '$lib/config'; 3 + import AppearanceTab from './AppearanceTab.svelte'; 4 + import ChannelsTab from './ChannelsTab.svelte'; 5 + import DeveloperTab from './DeveloperTab.svelte'; 6 + import RequestsTab from './RequestsTab.svelte'; 7 + import RoutingTab from './RoutingTab.svelte'; 26 8 import type { PageData } from './$types'; 27 9 28 10 let { data }: { data: PageData } = $props(); 29 - 30 - let busy = $state<Record<string, boolean>>({}); 31 - let errorMsg = $state(''); 32 11 33 12 const TABS = [ 34 13 { id: 'channels', label: 'Channels' }, 35 14 { id: 'routing', label: 'Routing' }, 36 - { id: 'auto-allow', label: 'Auto-allow' }, 15 + { id: 'requests', label: 'Requests' }, 37 16 { id: 'appearance', label: 'Appearance' }, 38 17 { id: 'developer', label: 'Developer docs' } 39 18 ] as const; 40 19 const initialTab = page.url.searchParams.get('tab') ?? 'channels'; 41 20 let tab = $state(TABS.some((t) => t.id === initialTab) ? initialTab : 'channels'); 42 - 43 - const autoAllowOptions = [ 44 - { value: 'all', label: 'All apps', desc: 'Any app that asks is approved automatically.' }, 45 - { 46 - value: 'trusted', 47 - label: 'Trusted apps only', 48 - desc: 'A small built-in allowlist is auto-approved; everything else waits for you.' 49 - }, 50 - { value: 'none', label: 'No apps', desc: 'Every request waits for your approval in Apps.' } 51 - ] as const; 52 - 53 - type Theme = 'system' | 'light' | 'dark'; 54 - let theme = $state<Theme>('system'); 55 - const themeOptions = [ 56 - { value: 'system', label: 'System', desc: 'Match your device appearance.' }, 57 - { value: 'light', label: 'Light', desc: 'Always use the light theme.' }, 58 - { value: 'dark', label: 'Dark', desc: 'Always use the dark theme.' } 59 - ] as const; 60 - 61 - function setTheme(value: Theme) { 62 - theme = value; 63 - if (value === 'system') { 64 - localStorage.removeItem('theme'); 65 - delete document.documentElement.dataset.theme; 66 - } else { 67 - localStorage.setItem('theme', value); 68 - document.documentElement.dataset.theme = value; 69 - } 70 - } 71 - 72 - /** Run a mutation, refresh page data, and surface errors. */ 73 - async function run(key: string, fn: () => Promise<unknown>) { 74 - busy[key] = true; 75 - errorMsg = ''; 76 - try { 77 - await fn(); 78 - await invalidateAll(); 79 - } catch (err) { 80 - errorMsg = err instanceof Error ? err.message : 'Something went wrong'; 81 - } finally { 82 - busy[key] = false; 83 - } 84 - } 85 - 86 - // Rename any delivery target (device / telegram chat / email) inline. 87 - let renaming = $state<string | null>(null); 88 - let renameLabel = $state(''); 89 - function startRename(id: string, label: string) { 90 - renaming = id; 91 - renameLabel = label; 92 - } 93 - async function saveRename(id: string) { 94 - const label = renameLabel.trim(); 95 - if (!label) { 96 - renaming = null; 97 - return; 98 - } 99 - await run(`rename:${id}`, () => renameTarget({ id, label })); 100 - renaming = null; 101 - } 102 - 103 - // Email: add a new address (+ optional name) + verify pending ones. 104 - let emailInput = $state(''); 105 - let emailLabel = $state(''); 106 - let codeInputs = $state<Record<string, string>>({}); 107 - 108 - function submitEmail() { 109 - const address = emailInput.trim(); 110 - const label = emailLabel.trim() || undefined; 111 - if (address) 112 - run('email', async () => { 113 - await linkEmail({ address, label }); 114 - emailInput = ''; 115 - emailLabel = ''; 116 - }); 117 - } 118 - 119 - function submitCode(emailId: string) { 120 - const code = (codeInputs[emailId] ?? '').trim(); 121 - if (!/^\d{6}$/.test(code)) return; 122 - run(`email-verify:${emailId}`, async () => { 123 - const { verified } = await verifyEmail({ code }); 124 - if (!verified) throw new Error('That code is invalid or expired.'); 125 - codeInputs[emailId] = ''; 126 - }); 127 - } 128 - 129 - // Webhook: enter a URL + label; the relay POSTs notifications to it. 130 - let webhookUrl = $state(''); 131 - let webhookLabel = $state(''); 132 - 133 - function submitWebhook() { 134 - const url = webhookUrl.trim(); 135 - const label = webhookLabel.trim(); 136 - if (!url || !label) return; 137 - run('webhook', async () => { 138 - await addWebhook({ url, label }); 139 - webhookUrl = ''; 140 - webhookLabel = ''; 141 - }); 142 - } 143 - 144 - // Telegram: optional name typed before linking, carried through the handshake. 145 - let telegramLabel = $state(''); 146 - 147 - async function connectTelegram() { 148 - busy['link'] = true; 149 - errorMsg = ''; 150 - try { 151 - const label = telegramLabel.trim() || undefined; 152 - const { deepLink } = await linkTelegram({ label }); 153 - telegramLabel = ''; 154 - // Open Telegram in a new tab so the dashboard stays put; the user comes 155 - // back and the linked chat shows after the next load/invalidate. 156 - window.open(deepLink, '_blank', 'noopener,noreferrer'); 157 - } catch (err) { 158 - errorMsg = err instanceof Error ? err.message : 'Could not start linking'; 159 - } finally { 160 - busy['link'] = false; 161 - } 162 - } 163 - 164 - // Web push for THIS browser. The device list comes from the server; we track 165 - // this browser's own subscription endpoint to mark it as "this device". 166 - type PushState = 'loading' | 'unsupported' | 'ready'; 167 - let pushState = $state<PushState>('loading'); 168 - let currentEndpoint = $state<string | null>(null); 169 - // Optional name for this device, typed before enabling push. 170 - let deviceName = $state(''); 171 - 172 - // This browser counts as connected only if its live subscription is also known 173 - // server-side. A browser can hold a stale subscription the server no longer has 174 - // (e.g. after a DB reset); we surface that as "reconnect" rather than hiding it. 175 - const thisDeviceConnected = $derived( 176 - currentEndpoint !== null && data.devices.some((d) => d.endpoint === currentEndpoint) 177 - ); 178 - 179 - onMount(async () => { 180 - const stored = localStorage.getItem('theme'); 181 - theme = stored === 'light' || stored === 'dark' ? stored : 'system'; 182 - 183 - if (!pushSupported()) { 184 - pushState = 'unsupported'; 185 - return; 186 - } 187 - try { 188 - currentEndpoint = (await currentSubscription())?.endpoint ?? null; 189 - } catch { 190 - currentEndpoint = null; 191 - } 192 - pushState = 'ready'; 193 - }); 194 - 195 - async function enablePush() { 196 - busy['push'] = true; 197 - errorMsg = ''; 198 - try { 199 - const sub = await subscribe(deviceName.trim() || undefined); 200 - await registerPush(sub); 201 - currentEndpoint = sub.endpoint; 202 - deviceName = ''; 203 - await invalidateAll(); 204 - } catch (err) { 205 - errorMsg = err instanceof Error ? err.message : 'Could not enable push'; 206 - } finally { 207 - busy['push'] = false; 208 - } 209 - } 210 - 211 - async function disableCurrent() { 212 - busy['push'] = true; 213 - errorMsg = ''; 214 - try { 215 - const endpoint = await unsubscribe(); 216 - if (endpoint) await unregisterPush({ endpoint }); 217 - currentEndpoint = null; 218 - await invalidateAll(); 219 - } catch (err) { 220 - errorMsg = err instanceof Error ? err.message : 'Could not disable push'; 221 - } finally { 222 - busy['push'] = false; 223 - } 224 - } 225 - 226 - const sectionLabel = 'mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase'; 227 - const card = 'rounded-card border border-line bg-surface p-4'; 228 - const rowBtn = 'text-xs font-medium text-muted hover:text-fg'; 229 - const dangerBtn = 'text-xs font-medium text-danger hover:underline disabled:opacity-50'; 230 21 </script> 231 22 232 23 <svelte:head><title>Settings · atmo.pub</title></svelte:head> ··· 251 42 </nav> 252 43 </header> 253 44 254 - {#if errorMsg} 255 - <p 256 - class="mt-4 rounded-card border border-line bg-danger/10 px-3 py-2 text-sm text-danger" 257 - role="alert" 258 - > 259 - {errorMsg} 260 - </p> 261 - {/if} 262 - <div aria-live="polite" class="sr-only">{errorMsg}</div> 263 - 264 45 {#if tab === 'channels'} 265 - <p class="mt-6 max-w-2xl text-sm text-muted"> 266 - Where atmo.pub can reach you. You can connect several of each and give them names; apps and 267 - categories route to one or more of these — everything also lands in your inbox. 268 - </p> 269 - 270 - <div 271 - class="mt-4 flex max-w-2xl items-start gap-2.5 rounded-card border border-line bg-surface-2 px-3 py-2.5" 272 - > 273 - <Icon name="info" size={16} stroke={2} class="mt-0.5 shrink-0 text-muted-2" /> 274 - <p class="text-xs text-muted"> 275 - Apps you connect can see the <span class="font-medium text-fg">names</span> you give these channels 276 - (so they can show you a routing picker) — but never the email address, phone number, or device 277 - behind them. 278 - </p> 279 - </div> 280 - 281 - <!-- Push devices --> 282 - <section class="mt-4 max-w-2xl"> 283 - <div class="flex items-center justify-between"> 284 - <h2 class={sectionLabel}>Push devices</h2> 285 - {#if data.devices.length > 0} 286 - <span class="font-mono text-[0.7rem] text-muted-2"> 287 - {data.devices.length} device{data.devices.length === 1 ? '' : 's'} 288 - </span> 289 - {/if} 290 - </div> 291 - <div class={card}> 292 - <div class="mb-3 flex items-center gap-2.5"> 293 - <RouteChip route="push" size="md" /> 294 - <span class="text-sm font-semibold text-fg">Web push</span> 295 - </div> 296 - 297 - {#if data.devices.length > 0} 298 - <ul class="divide-y divide-line-2"> 299 - {#each data.devices as d (d.id)} 300 - <li class="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"> 301 - {#if renaming === d.id} 302 - <input 303 - class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent" 304 - bind:value={renameLabel} 305 - aria-label="Device name" 306 - onkeydown={(e) => { 307 - if (e.key === 'Enter') saveRename(d.id); 308 - else if (e.key === 'Escape') renaming = null; 309 - }} 310 - /> 311 - <div class="flex shrink-0 items-center gap-3"> 312 - <button 313 - class="text-xs font-medium text-accent" 314 - onclick={() => saveRename(d.id)} 315 - > 316 - Save 317 - </button> 318 - <button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button> 319 - </div> 320 - {:else} 321 - <div class="min-w-0 flex-1"> 322 - <div class="flex items-center gap-2"> 323 - <span class="truncate text-sm font-medium text-fg">{d.label}</span> 324 - {#if d.endpoint === currentEndpoint} 325 - <span 326 - class="rounded bg-accent-soft px-1.5 py-0.5 font-mono text-[0.6rem] font-bold tracking-wide text-accent uppercase" 327 - > 328 - This device 329 - </span> 330 - {/if} 331 - </div> 332 - <div class="mt-0.5 font-mono text-[0.65rem] text-muted-2"> 333 - added <RelativeTime date={d.createdAt} /> 334 - </div> 335 - </div> 336 - <div class="flex shrink-0 items-center gap-3"> 337 - <button class={rowBtn} onclick={() => startRename(d.id, d.label)}>Rename</button 338 - > 339 - {#if d.endpoint === currentEndpoint} 340 - <button class={dangerBtn} disabled={busy['push']} onclick={disableCurrent}> 341 - Disable 342 - </button> 343 - {:else} 344 - <button 345 - class={dangerBtn} 346 - disabled={busy[`rm:${d.id}`]} 347 - onclick={() => run(`rm:${d.id}`, () => removeTarget({ id: d.id }))} 348 - > 349 - Remove 350 - </button> 351 - {/if} 352 - </div> 353 - {/if} 354 - </li> 355 - {/each} 356 - </ul> 357 - {/if} 358 - 359 - {#if pushState === 'loading'} 360 - <p class="text-xs text-muted-2">Checking this browser…</p> 361 - {:else if pushState === 'unsupported'} 362 - <p class="text-xs text-muted">Push isn't available in this browser.</p> 363 - {:else if !thisDeviceConnected} 364 - {@const reconnect = currentEndpoint !== null} 365 - <div class={data.devices.length > 0 ? 'mt-3 border-t border-line-2 pt-3' : ''}> 366 - <p class="mb-2 text-xs text-muted"> 367 - {#if reconnect} 368 - This browser was subscribed but isn't linked to your account anymore — reconnect it. 369 - {:else} 370 - Get notifications in this browser, even when it's closed. 371 - {/if} 372 - </p> 373 - <div class="flex flex-col gap-2 sm:flex-row"> 374 - {#if !reconnect} 375 - <input 376 - bind:value={deviceName} 377 - placeholder="Device name (optional)" 378 - class="min-w-0 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent sm:w-52" 379 - onkeydown={(e) => { 380 - if (e.key === 'Enter') enablePush(); 381 - }} 382 - /> 383 - {/if} 384 - <button 385 - class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50" 386 - disabled={busy['push']} 387 - onclick={enablePush} 388 - > 389 - {busy['push'] 390 - ? 'Enabling…' 391 - : reconnect 392 - ? 'Reconnect this device' 393 - : 'Enable on this device'} 394 - </button> 395 - </div> 396 - </div> 397 - {/if} 398 - </div> 399 - </section> 400 - 401 - <!-- Telegram --> 402 - <section class="mt-6 max-w-2xl"> 403 - <h2 class={sectionLabel}>Telegram</h2> 404 - <div class={card}> 405 - <div class="mb-3 flex items-center gap-2.5"> 406 - <RouteChip route="telegram" size="md" /> 407 - <span class="text-sm font-semibold text-fg">Telegram DM</span> 408 - </div> 409 - 410 - {#if data.telegrams.length > 0} 411 - <ul class="mb-3 divide-y divide-line-2"> 412 - {#each data.telegrams as t (t.id)} 413 - <li class="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"> 414 - {#if renaming === t.id} 415 - <input 416 - class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent" 417 - bind:value={renameLabel} 418 - aria-label="Chat name" 419 - onkeydown={(e) => { 420 - if (e.key === 'Enter') saveRename(t.id); 421 - else if (e.key === 'Escape') renaming = null; 422 - }} 423 - /> 424 - <div class="flex shrink-0 items-center gap-3"> 425 - <button 426 - class="text-xs font-medium text-accent" 427 - onclick={() => saveRename(t.id)} 428 - > 429 - Save 430 - </button> 431 - <button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button> 432 - </div> 433 - {:else} 434 - <div class="min-w-0 flex-1"> 435 - <div class="truncate text-sm font-medium text-fg">{t.label}</div> 436 - <div class="mt-0.5 font-mono text-[0.65rem] text-muted-2"> 437 - linked <RelativeTime date={t.createdAt} /> 438 - </div> 439 - </div> 440 - <div class="flex shrink-0 items-center gap-3"> 441 - <button class={rowBtn} onclick={() => startRename(t.id, t.label)}>Rename</button 442 - > 443 - <button 444 - class={dangerBtn} 445 - disabled={busy[`rm:${t.id}`]} 446 - onclick={() => run(`rm:${t.id}`, () => removeTarget({ id: t.id }))} 447 - > 448 - Unlink 449 - </button> 450 - </div> 451 - {/if} 452 - </li> 453 - {/each} 454 - </ul> 455 - 456 - <div class="flex items-start justify-between gap-4 border-t border-line-2 pt-3"> 457 - <label for="notify-pending" class="text-sm text-fg"> 458 - Telegram me about new permission requests 459 - </label> 460 - <IOSToggle 461 - id="notify-pending" 462 - checked={data.notifyPendingViaTelegram} 463 - label="Telegram me about new permission requests" 464 - disabled={busy['setting']} 465 - onchange={(value) => run('setting', () => setNotifyPending({ value }))} 466 - /> 467 - </div> 468 - {/if} 469 - 470 - <div class={data.telegrams.length > 0 ? 'mt-3 border-t border-line-2 pt-3' : ''}> 471 - {#if data.telegrams.length === 0} 472 - <p class="mb-2 text-xs text-muted">Link a Telegram chat to get notifications there.</p> 473 - {/if} 474 - <div class="flex flex-col gap-2 sm:flex-row"> 475 - <input 476 - bind:value={telegramLabel} 477 - placeholder="Name (optional)" 478 - class="min-w-0 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent sm:w-52" 479 - onkeydown={(e) => { 480 - if (e.key === 'Enter') connectTelegram(); 481 - }} 482 - /> 483 - <button 484 - class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50" 485 - disabled={busy['link']} 486 - onclick={connectTelegram} 487 - > 488 - {busy['link'] 489 - ? 'Opening…' 490 - : data.telegrams.length > 0 491 - ? 'Link another' 492 - : 'Link'} 493 - </button> 494 - </div> 495 - </div> 496 - </div> 497 - </section> 498 - 499 - <!-- Bluesky DM --> 500 - <section class="mt-6 max-w-2xl"> 501 - <h2 class={sectionLabel}>Bluesky DM</h2> 502 - <div class={card}> 503 - <div class="mb-3 flex items-center gap-2.5"> 504 - <RouteChip route="dm" size="md" /> 505 - <span class="text-sm font-semibold text-fg">Bluesky DM</span> 506 - </div> 507 - 508 - {#if data.dms.length > 0} 509 - {#each data.dms as dm (dm.id)} 510 - {#if renaming === dm.id} 511 - <div class="flex items-center justify-between gap-3"> 512 - <input 513 - class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent" 514 - bind:value={renameLabel} 515 - aria-label="DM name" 516 - onkeydown={(ev) => { 517 - if (ev.key === 'Enter') saveRename(dm.id); 518 - else if (ev.key === 'Escape') renaming = null; 519 - }} 520 - /> 521 - <div class="flex shrink-0 items-center gap-3"> 522 - <button class="text-xs font-medium text-accent" onclick={() => saveRename(dm.id)}> 523 - Save 524 - </button> 525 - <button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button> 526 - </div> 527 - </div> 528 - {:else} 529 - <div class="flex items-center justify-between gap-3"> 530 - <div class="flex min-w-0 items-center gap-2"> 531 - <Icon name="check" size={16} stroke={2.4} class="shrink-0 text-accent" /> 532 - <span class="truncate text-sm font-medium text-fg">{dm.label}</span> 533 - </div> 534 - <div class="flex shrink-0 items-center gap-3"> 535 - <button class={rowBtn} onclick={() => startRename(dm.id, dm.label)}>Rename</button> 536 - <button 537 - class={dangerBtn} 538 - disabled={busy[`rm:${dm.id}`]} 539 - onclick={() => run(`rm:${dm.id}`, () => removeTarget({ id: dm.id }))} 540 - > 541 - Disable 542 - </button> 543 - </div> 544 - </div> 545 - <p class="mt-2 text-xs text-muted-2"> 546 - The atmo.pub bot will DM you on Bluesky. Make sure your Bluesky chat settings allow 547 - messages from it. 548 - </p> 549 - {/if} 550 - {/each} 551 - {:else} 552 - <p class="mb-2 text-xs text-muted"> 553 - Get notifications as a Bluesky direct message from the atmo.pub bot. 554 - </p> 555 - <button 556 - class="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50" 557 - disabled={busy['dm']} 558 - onclick={() => run('dm', () => enableDM())} 559 - > 560 - {busy['dm'] ? 'Enabling…' : 'Enable'} 561 - </button> 562 - {/if} 563 - </div> 564 - </section> 565 - 566 - <!-- Email --> 567 - <section class="mt-6 max-w-2xl"> 568 - <h2 class={sectionLabel}>Email</h2> 569 - <div class={card}> 570 - <div class="mb-3 flex items-center gap-2.5"> 571 - <RouteChip route="email" size="md" /> 572 - <span class="text-sm font-semibold text-fg">Email</span> 573 - </div> 574 - 575 - {#if data.emails.length > 0} 576 - <ul class="mb-3 divide-y divide-line-2"> 577 - {#each data.emails as e (e.id)} 578 - <li class="py-3 first:pt-0"> 579 - {#if renaming === e.id} 580 - <div class="flex items-center justify-between gap-3"> 581 - <input 582 - class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent" 583 - bind:value={renameLabel} 584 - aria-label="Email name" 585 - onkeydown={(ev) => { 586 - if (ev.key === 'Enter') saveRename(e.id); 587 - else if (ev.key === 'Escape') renaming = null; 588 - }} 589 - /> 590 - <div class="flex shrink-0 items-center gap-3"> 591 - <button 592 - class="text-xs font-medium text-accent" 593 - onclick={() => saveRename(e.id)} 594 - > 595 - Save 596 - </button> 597 - <button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button> 598 - </div> 599 - </div> 600 - {:else} 601 - <div class="flex items-center justify-between gap-3"> 602 - <div class="flex min-w-0 items-center gap-2"> 603 - {#if e.verified} 604 - <Icon name="check" size={16} stroke={2.4} class="shrink-0 text-accent" /> 605 - {/if} 606 - <span class="truncate text-sm font-medium text-fg">{e.label}</span> 607 - </div> 608 - <div class="flex shrink-0 items-center gap-3"> 609 - <button class={rowBtn} onclick={() => startRename(e.id, e.label)} 610 - >Rename</button 611 - > 612 - <button 613 - class={dangerBtn} 614 - disabled={busy[`rm:${e.id}`]} 615 - onclick={() => run(`rm:${e.id}`, () => removeTarget({ id: e.id }))} 616 - > 617 - Remove 618 - </button> 619 - </div> 620 - </div> 621 - {#if e.label !== e.address} 622 - <div class="mt-0.5 truncate font-mono text-[0.65rem] text-muted-2"> 623 - {e.address} 624 - </div> 625 - {/if} 626 - {#if !e.verified} 627 - <div class="mt-1 text-xs text-warn"> 628 - Pending — enter the 6-digit code we emailed you. 629 - </div> 630 - <div class="mt-2 flex gap-2"> 631 - <input 632 - inputmode="numeric" 633 - maxlength="6" 634 - bind:value={codeInputs[e.id]} 635 - placeholder="123456" 636 - class="w-28 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm tracking-widest text-fg placeholder:text-muted-2 focus:border-accent" 637 - /> 638 - <button 639 - class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50" 640 - disabled={busy[`email-verify:${e.id}`]} 641 - onclick={() => submitCode(e.id)} 642 - > 643 - {busy[`email-verify:${e.id}`] ? 'Verifying…' : 'Verify'} 644 - </button> 645 - </div> 646 - {/if} 647 - {/if} 648 - </li> 649 - {/each} 650 - </ul> 651 - {/if} 652 - 653 - <div class={data.emails.length > 0 ? 'border-t border-line-2 pt-3' : ''}> 654 - {#if data.emails.length === 0} 655 - <p class="mb-2 text-xs text-muted">Get notifications by email.</p> 656 - {/if} 657 - <div class="flex flex-col gap-2 sm:flex-row"> 658 - <input 659 - bind:value={emailLabel} 660 - placeholder="Name (optional)" 661 - class="min-w-0 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent sm:w-40" 662 - onkeydown={(e) => { 663 - if (e.key === 'Enter') submitEmail(); 664 - }} 665 - /> 666 - <input 667 - type="email" 668 - bind:value={emailInput} 669 - placeholder="you@example.com" 670 - class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent" 671 - onkeydown={(e) => { 672 - if (e.key === 'Enter') submitEmail(); 673 - }} 674 - /> 675 - <button 676 - class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50" 677 - disabled={busy['email']} 678 - onclick={submitEmail} 679 - > 680 - {busy['email'] ? 'Sending…' : data.emails.length > 0 ? 'Add' : 'Send code'} 681 - </button> 682 - </div> 683 - </div> 684 - </div> 685 - </section> 686 - 687 - <!-- Webhooks --> 688 - <section class="mt-6 max-w-2xl"> 689 - <h2 class={sectionLabel}>Webhooks</h2> 690 - <div class={card}> 691 - <div class="mb-3 flex items-center gap-2.5"> 692 - <RouteChip route="webhook" size="md" /> 693 - <span class="text-sm font-semibold text-fg">Webhook</span> 694 - </div> 695 - 696 - {#if data.webhooks.length > 0} 697 - <ul class="mb-3 divide-y divide-line-2"> 698 - {#each data.webhooks as w (w.id)} 699 - <li class="py-3 first:pt-0"> 700 - {#if renaming === w.id} 701 - <div class="flex items-center justify-between gap-3"> 702 - <input 703 - class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent" 704 - bind:value={renameLabel} 705 - aria-label="Webhook name" 706 - onkeydown={(ev) => { 707 - if (ev.key === 'Enter') saveRename(w.id); 708 - else if (ev.key === 'Escape') renaming = null; 709 - }} 710 - /> 711 - <div class="flex shrink-0 items-center gap-3"> 712 - <button class="text-xs font-medium text-accent" onclick={() => saveRename(w.id)}> 713 - Save 714 - </button> 715 - <button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button> 716 - </div> 717 - </div> 718 - {:else} 719 - <div class="flex items-center justify-between gap-3"> 720 - <span class="truncate text-sm font-medium text-fg">{w.label}</span> 721 - <div class="flex shrink-0 items-center gap-3"> 722 - <button class={rowBtn} onclick={() => startRename(w.id, w.label)}>Rename</button> 723 - <button 724 - class={dangerBtn} 725 - disabled={busy[`rm:${w.id}`]} 726 - onclick={() => run(`rm:${w.id}`, () => removeTarget({ id: w.id }))} 727 - > 728 - Remove 729 - </button> 730 - </div> 731 - </div> 732 - <div class="mt-0.5 truncate font-mono text-[0.65rem] text-muted-2">{w.url}</div> 733 - {/if} 734 - </li> 735 - {/each} 736 - </ul> 737 - {/if} 738 - 739 - <div class={data.webhooks.length > 0 ? 'border-t border-line-2 pt-3' : ''}> 740 - {#if data.webhooks.length === 0} 741 - <p class="mb-2 text-xs text-muted"> 742 - Send notifications to your own server. We POST JSON ({'{'} title, body, uri, sender, sentAt 743 - {'}'}) to the URL over HTTPS. 744 - </p> 745 - {/if} 746 - <div class="flex flex-col gap-2 sm:flex-row"> 747 - <input 748 - bind:value={webhookLabel} 749 - placeholder="Label (e.g. My server)" 750 - class="min-w-0 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent sm:w-44" 751 - onkeydown={(e) => { 752 - if (e.key === 'Enter') submitWebhook(); 753 - }} 754 - /> 755 - <input 756 - type="url" 757 - inputmode="url" 758 - bind:value={webhookUrl} 759 - placeholder="https://example.com/hook" 760 - class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent" 761 - onkeydown={(e) => { 762 - if (e.key === 'Enter') submitWebhook(); 763 - }} 764 - /> 765 - <button 766 - class="shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50" 767 - disabled={busy['webhook']} 768 - onclick={submitWebhook} 769 - > 770 - {busy['webhook'] ? 'Adding…' : 'Add'} 771 - </button> 772 - </div> 773 - </div> 774 - </div> 775 - </section> 46 + <ChannelsTab {data} /> 776 47 {:else if tab === 'routing'} 777 - <!-- Default routing --> 778 - <section class="mt-6 max-w-2xl"> 779 - <h2 class={sectionLabel}>Default routing</h2> 780 - <div class={card}> 781 - <div class="text-sm font-medium text-fg">Where notifications go by default</div> 782 - <p class="mt-1 text-xs text-muted"> 783 - Pick where notifications go by default. <span class="font-medium text-fg">Inbox only</span 784 - > 785 - saves them with no alerts; <span class="font-medium text-fg">Off</span> drops them 786 - entirely. Apps set to “Account default” use this; override per-app (and per-category) from 787 - <a href="/apps" class="text-accent hover:underline">Apps</a>. 788 - </p> 789 - <div class="mt-3"> 790 - <ChannelRoutePicker 791 - value={data.defaultRoute} 792 - instances={data.channels} 793 - disabled={busy['defaultRoute']} 794 - onchange={(route) => run('defaultRoute', () => setDefaultRoute({ route }))} 795 - /> 796 - </div> 797 - </div> 798 - </section> 799 - {:else if tab === 'auto-allow'} 800 - <!-- Automatic approval --> 801 - <section class="mt-6 max-w-2xl"> 802 - <h2 class={sectionLabel}>Allow apps automatically</h2> 803 - <p class="mb-2 text-sm text-muted"> 804 - When an app asks to notify you, decide whether it's approved automatically or waits for you 805 - in Apps. 806 - </p> 807 - <div class="overflow-hidden rounded-card border border-line bg-surface"> 808 - {#each autoAllowOptions as opt, i (opt.value)} 809 - <button 810 - type="button" 811 - disabled={busy['autoAllow']} 812 - onclick={() => run('autoAllow', () => setAutoAllow({ autoAllow: opt.value }))} 813 - class="flex w-full items-start justify-between gap-3 p-4 text-left transition-colors hover:bg-surface-2 disabled:opacity-50 {i < 814 - autoAllowOptions.length - 1 815 - ? 'border-b border-line-2' 816 - : ''}" 817 - > 818 - <div class="min-w-0"> 819 - <div class="text-sm font-semibold text-fg">{opt.label}</div> 820 - <div class="mt-0.5 text-xs text-muted">{opt.desc}</div> 821 - </div> 822 - {#if data.autoAllow === opt.value} 823 - <Icon name="check" size={18} stroke={2.4} class="shrink-0 text-accent" /> 824 - {/if} 825 - </button> 826 - {/each} 827 - </div> 828 - </section> 48 + <RoutingTab {data} /> 49 + {:else if tab === 'requests'} 50 + <RequestsTab {data} /> 829 51 {:else if tab === 'appearance'} 830 - <!-- Appearance --> 831 - <section class="mt-6 max-w-2xl"> 832 - <h2 class={sectionLabel}>Theme</h2> 833 - <div class="overflow-hidden rounded-card border border-line bg-surface"> 834 - {#each themeOptions as opt, i (opt.value)} 835 - <button 836 - type="button" 837 - onclick={() => setTheme(opt.value)} 838 - class="flex w-full items-start justify-between gap-3 p-4 text-left transition-colors hover:bg-surface-2 {i < 839 - themeOptions.length - 1 840 - ? 'border-b border-line-2' 841 - : ''}" 842 - > 843 - <div class="min-w-0"> 844 - <div class="text-sm font-semibold text-fg">{opt.label}</div> 845 - <div class="mt-0.5 text-xs text-muted">{opt.desc}</div> 846 - </div> 847 - {#if theme === opt.value} 848 - <Icon name="check" size={18} stroke={2.4} class="shrink-0 text-accent" /> 849 - {/if} 850 - </button> 851 - {/each} 852 - </div> 853 - </section> 52 + <AppearanceTab /> 854 53 {:else if tab === 'developer'} 855 - <!-- Developer docs --> 856 - <section class="mt-6 max-w-2xl"> 857 - <h2 class={sectionLabel}>Developer docs</h2> 858 - <div class={card}> 859 - <p class="text-sm text-muted"> 860 - Building an app? Send notifications through atmo.pub — request permission, send, and let 861 - users manage routing — over a small XRPC API. The full guide, with code, lives on the docs 862 - site. 863 - </p> 864 - <a 865 - href={DOCS_URL} 866 - target="_blank" 867 - rel="noreferrer" 868 - class="mt-4 inline-flex items-center gap-2 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90" 869 - > 870 - Open developer docs 871 - <Icon name="arrow-right" size={16} stroke={2} /> 872 - </a> 873 - </div> 874 - </section> 54 + <DeveloperTab /> 875 55 {/if} 876 56 </div>
+52
apps/web/src/routes/(app)/settings/AppearanceTab.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import Icon from '$lib/components/Icon.svelte'; 4 + 5 + type Theme = 'system' | 'light' | 'dark'; 6 + let theme = $state<Theme>('system'); 7 + const themeOptions = [ 8 + { value: 'system', label: 'System', desc: 'Match your device appearance.' }, 9 + { value: 'light', label: 'Light', desc: 'Always use the light theme.' }, 10 + { value: 'dark', label: 'Dark', desc: 'Always use the dark theme.' } 11 + ] as const; 12 + 13 + onMount(() => { 14 + const stored = localStorage.getItem('theme'); 15 + theme = stored === 'light' || stored === 'dark' ? stored : 'system'; 16 + }); 17 + 18 + function setTheme(value: Theme) { 19 + theme = value; 20 + if (value === 'system') { 21 + localStorage.removeItem('theme'); 22 + delete document.documentElement.dataset.theme; 23 + } else { 24 + localStorage.setItem('theme', value); 25 + document.documentElement.dataset.theme = value; 26 + } 27 + } 28 + </script> 29 + 30 + <section class="mt-6 max-w-2xl"> 31 + <h2 class="mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase">Theme</h2> 32 + <div class="overflow-hidden rounded-card border border-line bg-surface"> 33 + {#each themeOptions as opt, i (opt.value)} 34 + <button 35 + type="button" 36 + onclick={() => setTheme(opt.value)} 37 + class="flex w-full items-start justify-between gap-3 p-4 text-left transition-colors hover:bg-surface-2 {i < 38 + themeOptions.length - 1 39 + ? 'border-b border-line-2' 40 + : ''}" 41 + > 42 + <div class="min-w-0"> 43 + <div class="text-sm font-semibold text-fg">{opt.label}</div> 44 + <div class="mt-0.5 text-xs text-muted">{opt.desc}</div> 45 + </div> 46 + {#if theme === opt.value} 47 + <Icon name="check" size={18} stroke={2.4} class="shrink-0 text-accent" /> 48 + {/if} 49 + </button> 50 + {/each} 51 + </div> 52 + </section>
+599
apps/web/src/routes/(app)/settings/ChannelsTab.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { invalidateAll } from '$app/navigation'; 4 + import DefaultRoutePrompt from './DefaultRoutePrompt.svelte'; 5 + import Icon from '$lib/components/Icon.svelte'; 6 + import RelativeTime from '$lib/components/RelativeTime.svelte'; 7 + import RouteChip from '$lib/components/RouteChip.svelte'; 8 + import { currentSubscription, pushSupported, subscribe, unsubscribe } from '$lib/push'; 9 + import { 10 + addWebhook, 11 + enableDM, 12 + linkEmail, 13 + linkTelegram, 14 + registerPush, 15 + removeTarget, 16 + renameTarget, 17 + unregisterPush, 18 + verifyEmail 19 + } from '$lib/remote/notifs.remote'; 20 + import type { PageData } from './$types'; 21 + 22 + let { data }: { data: PageData } = $props(); 23 + 24 + let busy = $state<Record<string, boolean>>({}); 25 + let errorMsg = $state(''); 26 + 27 + async function run(key: string, fn: () => Promise<unknown>) { 28 + busy[key] = true; 29 + errorMsg = ''; 30 + try { 31 + await fn(); 32 + await invalidateAll(); 33 + } catch (err) { 34 + errorMsg = err instanceof Error ? err.message : 'Something went wrong'; 35 + } finally { 36 + busy[key] = false; 37 + } 38 + } 39 + 40 + // Rename any delivery target (device / chat / email / webhook) inline. 41 + let renaming = $state<string | null>(null); 42 + let renameLabel = $state(''); 43 + function startRename(id: string, label: string) { 44 + renaming = id; 45 + renameLabel = label; 46 + } 47 + async function saveRename(id: string) { 48 + const label = renameLabel.trim(); 49 + if (!label) { 50 + renaming = null; 51 + return; 52 + } 53 + await run(`rename:${id}`, () => renameTarget({ id, label })); 54 + renaming = null; 55 + } 56 + 57 + // Email: add a new address (+ optional name) + verify pending ones. 58 + let emailInput = $state(''); 59 + let emailLabel = $state(''); 60 + let codeInputs = $state<Record<string, string>>({}); 61 + 62 + function submitEmail() { 63 + const address = emailInput.trim(); 64 + const label = emailLabel.trim() || undefined; 65 + if (address) 66 + run('email', async () => { 67 + await linkEmail({ address, label }); 68 + emailInput = ''; 69 + emailLabel = ''; 70 + }); 71 + } 72 + 73 + function submitCode(emailId: string) { 74 + const code = (codeInputs[emailId] ?? '').trim(); 75 + if (!/^\d{6}$/.test(code)) return; 76 + run(`email-verify:${emailId}`, async () => { 77 + const { verified } = await verifyEmail({ code }); 78 + if (!verified) throw new Error('That code is invalid or expired.'); 79 + codeInputs[emailId] = ''; 80 + }); 81 + } 82 + 83 + // Webhook: enter a URL + label; the relay POSTs notifications to it. 84 + let webhookUrl = $state(''); 85 + let webhookLabel = $state(''); 86 + 87 + function submitWebhook() { 88 + const url = webhookUrl.trim(); 89 + const label = webhookLabel.trim(); 90 + if (!url || !label) return; 91 + run('webhook', async () => { 92 + await addWebhook({ url, label }); 93 + webhookUrl = ''; 94 + webhookLabel = ''; 95 + }); 96 + } 97 + 98 + // Telegram: optional name typed before linking, carried through the handshake. 99 + let telegramLabel = $state(''); 100 + 101 + async function connectTelegram() { 102 + busy['link'] = true; 103 + errorMsg = ''; 104 + try { 105 + const label = telegramLabel.trim() || undefined; 106 + const { deepLink } = await linkTelegram({ label }); 107 + telegramLabel = ''; 108 + window.open(deepLink, '_blank', 'noopener,noreferrer'); 109 + } catch (err) { 110 + errorMsg = err instanceof Error ? err.message : 'Could not start linking'; 111 + } finally { 112 + busy['link'] = false; 113 + } 114 + } 115 + 116 + // Web push for THIS browser. The device list comes from the server; we track 117 + // this browser's own subscription endpoint to mark it as "this device". 118 + type PushState = 'loading' | 'unsupported' | 'ready'; 119 + let pushState = $state<PushState>('loading'); 120 + let currentEndpoint = $state<string | null>(null); 121 + let deviceName = $state(''); 122 + 123 + const thisDeviceConnected = $derived( 124 + currentEndpoint !== null && data.devices.some((d) => d.endpoint === currentEndpoint) 125 + ); 126 + 127 + onMount(async () => { 128 + if (!pushSupported()) { 129 + pushState = 'unsupported'; 130 + return; 131 + } 132 + try { 133 + currentEndpoint = (await currentSubscription())?.endpoint ?? null; 134 + } catch { 135 + currentEndpoint = null; 136 + } 137 + pushState = 'ready'; 138 + }); 139 + 140 + async function enablePush() { 141 + busy['push'] = true; 142 + errorMsg = ''; 143 + try { 144 + const sub = await subscribe(deviceName.trim() || undefined); 145 + await registerPush(sub); 146 + currentEndpoint = sub.endpoint; 147 + deviceName = ''; 148 + await invalidateAll(); 149 + } catch (err) { 150 + errorMsg = err instanceof Error ? err.message : 'Could not enable push'; 151 + } finally { 152 + busy['push'] = false; 153 + } 154 + } 155 + 156 + async function disableCurrent() { 157 + busy['push'] = true; 158 + errorMsg = ''; 159 + try { 160 + const endpoint = await unsubscribe(); 161 + if (endpoint) await unregisterPush({ endpoint }); 162 + currentEndpoint = null; 163 + await invalidateAll(); 164 + } catch (err) { 165 + errorMsg = err instanceof Error ? err.message : 'Could not disable push'; 166 + } finally { 167 + busy['push'] = false; 168 + } 169 + } 170 + 171 + const sectionLabel = 'mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase'; 172 + const card = 'rounded-card border border-line bg-surface p-4'; 173 + const rowBtn = 'text-xs font-medium text-muted hover:text-fg'; 174 + const dangerBtn = 'text-xs font-medium text-danger hover:underline disabled:opacity-50'; 175 + const nameInput = 176 + 'min-w-0 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg placeholder:text-muted-2 focus:border-accent'; 177 + const addBtn = 178 + 'shrink-0 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50'; 179 + </script> 180 + 181 + <!-- A reusable inline-rename row: shows the rename editor for `id`, else `display`. --> 182 + {#snippet renameEditor(id: string, ariaLabel: string)} 183 + <input 184 + class="min-w-0 flex-1 rounded-md border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent" 185 + bind:value={renameLabel} 186 + aria-label={ariaLabel} 187 + onkeydown={(e) => { 188 + if (e.key === 'Enter') saveRename(id); 189 + else if (e.key === 'Escape') renaming = null; 190 + }} 191 + /> 192 + <div class="flex shrink-0 items-center gap-3"> 193 + <button class="text-xs font-medium text-accent" onclick={() => saveRename(id)}>Save</button> 194 + <button class={rowBtn} onclick={() => (renaming = null)}>Cancel</button> 195 + </div> 196 + {/snippet} 197 + 198 + <DefaultRoutePrompt defaultRoute={data.defaultRoute} channels={data.channels} /> 199 + 200 + <p class="mt-6 max-w-2xl text-sm text-muted"> 201 + Where atmo.pub can reach you. You can connect several of each and give them names; apps and 202 + categories route to one or more of these — everything also lands in your inbox. 203 + </p> 204 + 205 + <div 206 + class="mt-4 flex max-w-2xl items-start gap-2.5 rounded-card border border-line bg-surface-2 px-3 py-2.5" 207 + > 208 + <Icon name="info" size={16} stroke={2} class="mt-0.5 shrink-0 text-muted-2" /> 209 + <p class="text-xs text-muted"> 210 + Apps you connect can see the <span class="font-medium text-fg">names</span> you give these channels 211 + (so they can show you a routing picker) — but never the email address, phone number, or device behind 212 + them. 213 + </p> 214 + </div> 215 + 216 + {#if errorMsg} 217 + <p 218 + class="mt-4 max-w-2xl rounded-card border border-line bg-danger/10 px-3 py-2 text-sm text-danger" 219 + role="alert" 220 + > 221 + {errorMsg} 222 + </p> 223 + {/if} 224 + 225 + <!-- Push devices --> 226 + <section class="mt-4 max-w-2xl"> 227 + <div class="flex items-center justify-between"> 228 + <h2 class={sectionLabel}>Push devices</h2> 229 + {#if data.devices.length > 0} 230 + <span class="font-mono text-[0.7rem] text-muted-2"> 231 + {data.devices.length} device{data.devices.length === 1 ? '' : 's'} 232 + </span> 233 + {/if} 234 + </div> 235 + <div class={card}> 236 + <div class="mb-3 flex items-center gap-2.5"> 237 + <RouteChip route="push" size="md" /> 238 + <span class="text-sm font-semibold text-fg">Web push</span> 239 + </div> 240 + 241 + {#if data.devices.length > 0} 242 + <ul class="divide-y divide-line-2"> 243 + {#each data.devices as d (d.id)} 244 + <li class="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"> 245 + {#if renaming === d.id} 246 + {@render renameEditor(d.id, 'Device name')} 247 + {:else} 248 + <div class="min-w-0 flex-1"> 249 + <div class="flex items-center gap-2"> 250 + <span class="truncate text-sm font-medium text-fg">{d.label}</span> 251 + {#if d.endpoint === currentEndpoint} 252 + <span 253 + class="rounded bg-accent-soft px-1.5 py-0.5 font-mono text-[0.6rem] font-bold tracking-wide text-accent uppercase" 254 + > 255 + This device 256 + </span> 257 + {/if} 258 + </div> 259 + <div class="mt-0.5 font-mono text-[0.65rem] text-muted-2"> 260 + added <RelativeTime date={d.createdAt} /> 261 + </div> 262 + </div> 263 + <div class="flex shrink-0 items-center gap-3"> 264 + <button class={rowBtn} onclick={() => startRename(d.id, d.label)}>Rename</button> 265 + {#if d.endpoint === currentEndpoint} 266 + <button class={dangerBtn} disabled={busy['push']} onclick={disableCurrent}> 267 + Disable 268 + </button> 269 + {:else} 270 + <button 271 + class={dangerBtn} 272 + disabled={busy[`rm:${d.id}`]} 273 + onclick={() => run(`rm:${d.id}`, () => removeTarget({ id: d.id }))} 274 + > 275 + Remove 276 + </button> 277 + {/if} 278 + </div> 279 + {/if} 280 + </li> 281 + {/each} 282 + </ul> 283 + {/if} 284 + 285 + {#if pushState === 'loading'} 286 + <p class="text-xs text-muted-2">Checking this browser…</p> 287 + {:else if pushState === 'unsupported'} 288 + <p class="text-xs text-muted">Push isn't available in this browser.</p> 289 + {:else if !thisDeviceConnected} 290 + {@const reconnect = currentEndpoint !== null} 291 + <div class={data.devices.length > 0 ? 'mt-3 border-t border-line-2 pt-3' : ''}> 292 + <p class="mb-2 text-xs text-muted"> 293 + {#if reconnect} 294 + This browser was subscribed but isn't linked to your account anymore — reconnect it. 295 + {:else} 296 + Get notifications in this browser, even when it's closed. 297 + {/if} 298 + </p> 299 + <div class="flex flex-col gap-2 sm:flex-row"> 300 + {#if !reconnect} 301 + <input 302 + bind:value={deviceName} 303 + placeholder="Device name (optional)" 304 + class="{nameInput} sm:w-52" 305 + onkeydown={(e) => { 306 + if (e.key === 'Enter') enablePush(); 307 + }} 308 + /> 309 + {/if} 310 + <button class={addBtn} disabled={busy['push']} onclick={enablePush}> 311 + {busy['push'] 312 + ? 'Enabling…' 313 + : reconnect 314 + ? 'Reconnect this device' 315 + : 'Enable on this device'} 316 + </button> 317 + </div> 318 + </div> 319 + {/if} 320 + </div> 321 + </section> 322 + 323 + <!-- Telegram --> 324 + <section class="mt-6 max-w-2xl"> 325 + <h2 class={sectionLabel}>Telegram</h2> 326 + <div class={card}> 327 + <div class="mb-3 flex items-center gap-2.5"> 328 + <RouteChip route="telegram" size="md" /> 329 + <span class="text-sm font-semibold text-fg">Telegram DM</span> 330 + </div> 331 + 332 + {#if data.telegrams.length > 0} 333 + <ul class="mb-3 divide-y divide-line-2"> 334 + {#each data.telegrams as t (t.id)} 335 + <li class="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"> 336 + {#if renaming === t.id} 337 + {@render renameEditor(t.id, 'Chat name')} 338 + {:else} 339 + <div class="min-w-0 flex-1"> 340 + <div class="truncate text-sm font-medium text-fg">{t.label}</div> 341 + <div class="mt-0.5 font-mono text-[0.65rem] text-muted-2"> 342 + linked <RelativeTime date={t.createdAt} /> 343 + </div> 344 + </div> 345 + <div class="flex shrink-0 items-center gap-3"> 346 + <button class={rowBtn} onclick={() => startRename(t.id, t.label)}>Rename</button> 347 + <button 348 + class={dangerBtn} 349 + disabled={busy[`rm:${t.id}`]} 350 + onclick={() => run(`rm:${t.id}`, () => removeTarget({ id: t.id }))} 351 + > 352 + Unlink 353 + </button> 354 + </div> 355 + {/if} 356 + </li> 357 + {/each} 358 + </ul> 359 + {/if} 360 + 361 + <div class={data.telegrams.length > 0 ? 'border-t border-line-2 pt-3' : ''}> 362 + {#if data.telegrams.length === 0} 363 + <p class="mb-2 text-xs text-muted">Link a Telegram chat to get notifications there.</p> 364 + {/if} 365 + <div class="flex flex-col gap-2 sm:flex-row"> 366 + <input 367 + bind:value={telegramLabel} 368 + placeholder="Name (optional)" 369 + class="{nameInput} sm:w-52" 370 + onkeydown={(e) => { 371 + if (e.key === 'Enter') connectTelegram(); 372 + }} 373 + /> 374 + <button class={addBtn} disabled={busy['link']} onclick={connectTelegram}> 375 + {busy['link'] ? 'Opening…' : data.telegrams.length > 0 ? 'Link another' : 'Link'} 376 + </button> 377 + </div> 378 + </div> 379 + </div> 380 + </section> 381 + 382 + <!-- Bluesky DM --> 383 + <section class="mt-6 max-w-2xl"> 384 + <h2 class={sectionLabel}>Bluesky DM</h2> 385 + <div class={card}> 386 + <div class="mb-3 flex items-center gap-2.5"> 387 + <RouteChip route="dm" size="md" /> 388 + <span class="text-sm font-semibold text-fg">Bluesky DM</span> 389 + </div> 390 + 391 + {#if data.dms.length > 0} 392 + {#each data.dms as dm (dm.id)} 393 + {#if renaming === dm.id} 394 + <div class="flex items-center justify-between gap-3"> 395 + {@render renameEditor(dm.id, 'DM name')} 396 + </div> 397 + {:else} 398 + <div class="flex items-center justify-between gap-3"> 399 + <div class="flex min-w-0 items-center gap-2"> 400 + <Icon name="check" size={16} stroke={2.4} class="shrink-0 text-accent" /> 401 + <span class="truncate text-sm font-medium text-fg">{dm.label}</span> 402 + </div> 403 + <div class="flex shrink-0 items-center gap-3"> 404 + <button class={rowBtn} onclick={() => startRename(dm.id, dm.label)}>Rename</button> 405 + <button 406 + class={dangerBtn} 407 + disabled={busy[`rm:${dm.id}`]} 408 + onclick={() => run(`rm:${dm.id}`, () => removeTarget({ id: dm.id }))} 409 + > 410 + Disable 411 + </button> 412 + </div> 413 + </div> 414 + <p class="mt-2 text-xs text-muted-2"> 415 + The atmo.pub bot will DM you on Bluesky. Make sure your Bluesky chat settings allow 416 + messages from it. 417 + </p> 418 + {/if} 419 + {/each} 420 + {:else} 421 + <p class="mb-2 text-xs text-muted"> 422 + Get notifications as a Bluesky direct message from the atmo.pub bot. 423 + </p> 424 + <button class={addBtn} disabled={busy['dm']} onclick={() => run('dm', () => enableDM())}> 425 + {busy['dm'] ? 'Enabling…' : 'Enable'} 426 + </button> 427 + {/if} 428 + </div> 429 + </section> 430 + 431 + <!-- Email (allowlisted — hidden unless the user's DID is whitelisted) --> 432 + {#if data.emailEnabled} 433 + <section class="mt-6 max-w-2xl"> 434 + <h2 class={sectionLabel}>Email</h2> 435 + <div class={card}> 436 + <div class="mb-3 flex items-center gap-2.5"> 437 + <RouteChip route="email" size="md" /> 438 + <span class="text-sm font-semibold text-fg">Email</span> 439 + </div> 440 + 441 + {#if data.emails.length > 0} 442 + <ul class="mb-3 divide-y divide-line-2"> 443 + {#each data.emails as e (e.id)} 444 + <li class="py-3 first:pt-0"> 445 + {#if renaming === e.id} 446 + <div class="flex items-center justify-between gap-3"> 447 + {@render renameEditor(e.id, 'Email name')} 448 + </div> 449 + {:else} 450 + <div class="flex items-center justify-between gap-3"> 451 + <div class="flex min-w-0 items-center gap-2"> 452 + {#if e.verified} 453 + <Icon name="check" size={16} stroke={2.4} class="shrink-0 text-accent" /> 454 + {/if} 455 + <span class="truncate text-sm font-medium text-fg">{e.label}</span> 456 + </div> 457 + <div class="flex shrink-0 items-center gap-3"> 458 + <button class={rowBtn} onclick={() => startRename(e.id, e.label)}>Rename</button> 459 + <button 460 + class={dangerBtn} 461 + disabled={busy[`rm:${e.id}`]} 462 + onclick={() => run(`rm:${e.id}`, () => removeTarget({ id: e.id }))} 463 + > 464 + Remove 465 + </button> 466 + </div> 467 + </div> 468 + {#if e.label !== e.address} 469 + <div class="mt-0.5 truncate font-mono text-[0.65rem] text-muted-2">{e.address}</div> 470 + {/if} 471 + {#if !e.verified} 472 + <div class="mt-1 text-xs text-warn"> 473 + Pending — enter the 6-digit code we emailed you. 474 + </div> 475 + <div class="mt-2 flex gap-2"> 476 + <input 477 + inputmode="numeric" 478 + maxlength="6" 479 + bind:value={codeInputs[e.id]} 480 + placeholder="123456" 481 + class="w-28 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm tracking-widest text-fg placeholder:text-muted-2 focus:border-accent" 482 + /> 483 + <button 484 + class={addBtn} 485 + disabled={busy[`email-verify:${e.id}`]} 486 + onclick={() => submitCode(e.id)} 487 + > 488 + {busy[`email-verify:${e.id}`] ? 'Verifying…' : 'Verify'} 489 + </button> 490 + </div> 491 + {/if} 492 + {/if} 493 + </li> 494 + {/each} 495 + </ul> 496 + {/if} 497 + 498 + <div class={data.emails.length > 0 ? 'border-t border-line-2 pt-3' : ''}> 499 + {#if data.emails.length === 0} 500 + <p class="mb-2 text-xs text-muted">Get notifications by email.</p> 501 + {/if} 502 + <div class="flex flex-col gap-2 sm:flex-row"> 503 + <input 504 + bind:value={emailLabel} 505 + placeholder="Name (optional)" 506 + class="{nameInput} sm:w-40" 507 + onkeydown={(e) => { 508 + if (e.key === 'Enter') submitEmail(); 509 + }} 510 + /> 511 + <input 512 + type="email" 513 + bind:value={emailInput} 514 + placeholder="you@example.com" 515 + class="{nameInput} flex-1" 516 + onkeydown={(e) => { 517 + if (e.key === 'Enter') submitEmail(); 518 + }} 519 + /> 520 + <button class={addBtn} disabled={busy['email']} onclick={submitEmail}> 521 + {busy['email'] ? 'Sending…' : data.emails.length > 0 ? 'Add' : 'Send code'} 522 + </button> 523 + </div> 524 + </div> 525 + </div> 526 + </section> 527 + {/if} 528 + 529 + <!-- Webhooks --> 530 + <section class="mt-6 max-w-2xl"> 531 + <h2 class={sectionLabel}>Webhooks</h2> 532 + <div class={card}> 533 + <div class="mb-3 flex items-center gap-2.5"> 534 + <RouteChip route="webhook" size="md" /> 535 + <span class="text-sm font-semibold text-fg">Webhook</span> 536 + </div> 537 + 538 + {#if data.webhooks.length > 0} 539 + <ul class="mb-3 divide-y divide-line-2"> 540 + {#each data.webhooks as w (w.id)} 541 + <li class="py-3 first:pt-0"> 542 + {#if renaming === w.id} 543 + <div class="flex items-center justify-between gap-3"> 544 + {@render renameEditor(w.id, 'Webhook name')} 545 + </div> 546 + {:else} 547 + <div class="flex items-center justify-between gap-3"> 548 + <span class="truncate text-sm font-medium text-fg">{w.label}</span> 549 + <div class="flex shrink-0 items-center gap-3"> 550 + <button class={rowBtn} onclick={() => startRename(w.id, w.label)}>Rename</button> 551 + <button 552 + class={dangerBtn} 553 + disabled={busy[`rm:${w.id}`]} 554 + onclick={() => run(`rm:${w.id}`, () => removeTarget({ id: w.id }))} 555 + > 556 + Remove 557 + </button> 558 + </div> 559 + </div> 560 + <div class="mt-0.5 truncate font-mono text-[0.65rem] text-muted-2">{w.url}</div> 561 + {/if} 562 + </li> 563 + {/each} 564 + </ul> 565 + {/if} 566 + 567 + <div class={data.webhooks.length > 0 ? 'border-t border-line-2 pt-3' : ''}> 568 + {#if data.webhooks.length === 0} 569 + <p class="mb-2 text-xs text-muted"> 570 + Send notifications to your own server. We POST JSON ({'{'} title, body, uri, sender, sentAt 571 + {'}'}) to the URL over HTTPS. 572 + </p> 573 + {/if} 574 + <div class="flex flex-col gap-2 sm:flex-row"> 575 + <input 576 + bind:value={webhookLabel} 577 + placeholder="Label (e.g. My server)" 578 + class="{nameInput} sm:w-44" 579 + onkeydown={(e) => { 580 + if (e.key === 'Enter') submitWebhook(); 581 + }} 582 + /> 583 + <input 584 + type="url" 585 + inputmode="url" 586 + bind:value={webhookUrl} 587 + placeholder="https://example.com/hook" 588 + class="{nameInput} flex-1" 589 + onkeydown={(e) => { 590 + if (e.key === 'Enter') submitWebhook(); 591 + }} 592 + /> 593 + <button class={addBtn} disabled={busy['webhook']} onclick={submitWebhook}> 594 + {busy['webhook'] ? 'Adding…' : 'Add'} 595 + </button> 596 + </div> 597 + </div> 598 + </div> 599 + </section>
+101
apps/web/src/routes/(app)/settings/DefaultRoutePrompt.svelte
··· 1 + <script lang="ts"> 2 + // Nudge the user to pick a real default route when their account default would 3 + // reach NONE of their connected channels — i.e. a fresh 'inbox' default, or a 4 + // stale one like 'push' when they have no push device. Shows on the Channels 5 + // tab once at least one channel is connected. Only the explicit "Not now" 6 + // persists (localStorage); backdrop/Escape just close it for this session, so a 7 + // stray click can't hide it forever. 8 + import { onMount } from 'svelte'; 9 + import { invalidateAll } from '$app/navigation'; 10 + import { CHANNELS, channelsRoute, routeChannels, type RouteInstances } from '@atmo/notifs-lexicons'; 11 + import { channelLabel } from '$lib/routes'; 12 + import { setDefaultRoute } from '$lib/remote/notifs.remote'; 13 + 14 + let { defaultRoute, channels }: { defaultRoute: string; channels: RouteInstances } = $props(); 15 + 16 + const DISMISS_KEY = 'atmo:defaultRoutePrompt:dismissed:v2'; 17 + 18 + let mounted = $state(false); 19 + let dismissed = $state(false); // persisted "Not now" 20 + let closed = $state(false); // session-only (backdrop / Escape) 21 + let busy = $state(false); 22 + 23 + onMount(() => { 24 + dismissed = localStorage.getItem(DISMISS_KEY) === '1'; 25 + mounted = true; 26 + }); 27 + 28 + // Channels the user has at least one (verified) target for. 29 + const available = $derived(CHANNELS.filter((c) => (channels[c]?.length ?? 0) > 0)); 30 + // The default delivers nowhere useful: none of its channels is one they have. 31 + // (Empty 'inbox'/'' → reaches none; 'off' is a deliberate "drop", so leave it.) 32 + const reachesNone = $derived( 33 + defaultRoute !== 'off' && routeChannels(defaultRoute).every((c) => !available.includes(c)) 34 + ); 35 + const visible = $derived( 36 + mounted && !dismissed && !closed && available.length > 0 && reachesNone 37 + ); 38 + 39 + const label = $derived( 40 + available.length === 1 ? channelLabel(available[0]) : 'your connected channels' 41 + ); 42 + 43 + function notNow() { 44 + dismissed = true; 45 + localStorage.setItem(DISMISS_KEY, '1'); 46 + } 47 + 48 + async function setAsDefault() { 49 + busy = true; 50 + try { 51 + await setDefaultRoute({ route: channelsRoute(available) }); 52 + await invalidateAll(); // default now reaches a channel → `visible` becomes false 53 + } finally { 54 + busy = false; 55 + } 56 + } 57 + </script> 58 + 59 + <svelte:window onkeydown={(e) => visible && e.key === 'Escape' && (closed = true)} /> 60 + 61 + {#if visible} 62 + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> 63 + <div 64 + class="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center" 65 + onclick={() => (closed = true)} 66 + > 67 + <div 68 + role="dialog" 69 + aria-modal="true" 70 + aria-labelledby="drp-title" 71 + tabindex="-1" 72 + class="w-full max-w-sm rounded-card border border-line bg-surface p-5 shadow-lg" 73 + onclick={(e) => e.stopPropagation()} 74 + > 75 + <h2 id="drp-title" class="text-base font-semibold text-fg">Set a default route?</h2> 76 + <p class="mt-2 text-sm leading-relaxed text-muted"> 77 + Right now notifications won't alert you anywhere — they only land in your inbox. Send them to 78 + <span class="font-medium text-fg">{label}</span> by default? You can change this anytime in 79 + Settings&nbsp;→&nbsp;Routing. 80 + </p> 81 + <div class="mt-4 flex justify-end gap-2"> 82 + <button 83 + type="button" 84 + onclick={notNow} 85 + disabled={busy} 86 + class="rounded-md border border-line px-3 py-1.5 text-sm font-medium text-fg hover:bg-surface-2 disabled:opacity-50" 87 + > 88 + Not now 89 + </button> 90 + <button 91 + type="button" 92 + onclick={setAsDefault} 93 + disabled={busy} 94 + class="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50" 95 + > 96 + {busy ? 'Saving…' : `Send to ${label}`} 97 + </button> 98 + </div> 99 + </div> 100 + </div> 101 + {/if}
+23
apps/web/src/routes/(app)/settings/DeveloperTab.svelte
··· 1 + <script lang="ts"> 2 + import Icon from '$lib/components/Icon.svelte'; 3 + import { DOCS_URL } from '$lib/config'; 4 + </script> 5 + 6 + <section class="mt-6 max-w-2xl"> 7 + <h2 class="mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase">Developer docs</h2> 8 + <div class="rounded-card border border-line bg-surface p-4"> 9 + <p class="text-sm text-muted"> 10 + Building an app? Send notifications through atmo.pub — request permission, send, and let users 11 + manage routing — over a small XRPC API. The full guide, with code, lives on the docs site. 12 + </p> 13 + <a 14 + href={DOCS_URL} 15 + target="_blank" 16 + rel="noreferrer" 17 + class="mt-4 inline-flex items-center gap-2 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90" 18 + > 19 + Open developer docs 20 + <Icon name="arrow-right" size={16} stroke={2} /> 21 + </a> 22 + </div> 23 + </section>
+100
apps/web/src/routes/(app)/settings/RequestsTab.svelte
··· 1 + <script lang="ts"> 2 + import { invalidateAll } from '$app/navigation'; 3 + import ChannelRoutePicker from '$lib/components/ChannelRoutePicker.svelte'; 4 + import Icon from '$lib/components/Icon.svelte'; 5 + import { setAutoAllow, setPendingRoute } from '$lib/remote/notifs.remote'; 6 + import type { PageData } from './$types'; 7 + 8 + let { data }: { data: PageData } = $props(); 9 + 10 + let busy = $state<Record<string, boolean>>({}); 11 + let errorMsg = $state(''); 12 + 13 + async function run(key: string, fn: () => Promise<unknown>) { 14 + busy[key] = true; 15 + errorMsg = ''; 16 + try { 17 + await fn(); 18 + await invalidateAll(); 19 + } catch (err) { 20 + errorMsg = err instanceof Error ? err.message : 'Something went wrong'; 21 + } finally { 22 + busy[key] = false; 23 + } 24 + } 25 + 26 + const autoAllowOptions = [ 27 + { value: 'all', label: 'All apps', desc: 'Any app that asks is approved automatically.' }, 28 + { 29 + value: 'trusted', 30 + label: 'Trusted apps only', 31 + desc: 'A small built-in allowlist is auto-approved; everything else waits for you.' 32 + }, 33 + { value: 'none', label: 'No apps', desc: 'Every request waits for your approval in Apps.' } 34 + ] as const; 35 + 36 + const sectionLabel = 'mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase'; 37 + </script> 38 + 39 + <div class="mt-6 flex max-w-2xl flex-col gap-8"> 40 + {#if errorMsg} 41 + <p 42 + class="rounded-card border border-line bg-danger/10 px-3 py-2 text-sm text-danger" 43 + role="alert" 44 + > 45 + {errorMsg} 46 + </p> 47 + {/if} 48 + 49 + <!-- Automatic approval --> 50 + <section> 51 + <h2 class={sectionLabel}>Allow apps automatically</h2> 52 + <p class="mb-2 text-sm text-muted"> 53 + When an app asks to notify you, decide whether it's approved automatically or waits for you in 54 + Apps. 55 + </p> 56 + <div class="overflow-hidden rounded-card border border-line bg-surface"> 57 + {#each autoAllowOptions as opt, i (opt.value)} 58 + <button 59 + type="button" 60 + disabled={busy['autoAllow']} 61 + onclick={() => run('autoAllow', () => setAutoAllow({ autoAllow: opt.value }))} 62 + class="flex w-full items-start justify-between gap-3 p-4 text-left transition-colors hover:bg-surface-2 disabled:opacity-50 {i < 63 + autoAllowOptions.length - 1 64 + ? 'border-b border-line-2' 65 + : ''}" 66 + > 67 + <div class="min-w-0"> 68 + <div class="text-sm font-semibold text-fg">{opt.label}</div> 69 + <div class="mt-0.5 text-xs text-muted">{opt.desc}</div> 70 + </div> 71 + {#if data.autoAllow === opt.value} 72 + <Icon name="check" size={18} stroke={2.4} class="shrink-0 text-accent" /> 73 + {/if} 74 + </button> 75 + {/each} 76 + </div> 77 + </section> 78 + 79 + <!-- Where permission requests are sent --> 80 + <section> 81 + <h2 class={sectionLabel}>Alert me about requests</h2> 82 + <div class="rounded-card border border-line bg-surface p-4"> 83 + <div class="text-sm font-medium text-fg">Where new permission requests are sent</div> 84 + <p class="mt-1 mb-3 text-xs text-muted"> 85 + Pick the channels to ping when an app asks for permission (<span class="font-medium text-fg" 86 + >Off</span 87 + > 88 + for none). You can always review and approve them in 89 + <a href="/apps" class="text-accent hover:underline">Apps</a>. 90 + </p> 91 + <ChannelRoutePicker 92 + value={data.pendingRoute} 93 + instances={data.channels} 94 + allowInbox={false} 95 + disabled={busy['pending']} 96 + onchange={(route) => run('pending', () => setPendingRoute({ route }))} 97 + /> 98 + </div> 99 + </section> 100 + </div>
+53
apps/web/src/routes/(app)/settings/RoutingTab.svelte
··· 1 + <script lang="ts"> 2 + import { invalidateAll } from '$app/navigation'; 3 + import ChannelRoutePicker from '$lib/components/ChannelRoutePicker.svelte'; 4 + import { setDefaultRoute } from '$lib/remote/notifs.remote'; 5 + import type { PageData } from './$types'; 6 + 7 + let { data }: { data: PageData } = $props(); 8 + 9 + let busy = $state(false); 10 + let errorMsg = $state(''); 11 + 12 + async function save(route: string) { 13 + busy = true; 14 + errorMsg = ''; 15 + try { 16 + await setDefaultRoute({ route }); 17 + await invalidateAll(); 18 + } catch (err) { 19 + errorMsg = err instanceof Error ? err.message : 'Something went wrong'; 20 + } finally { 21 + busy = false; 22 + } 23 + } 24 + </script> 25 + 26 + <section class="mt-6 max-w-2xl"> 27 + <h2 class="mb-2 font-mono text-[0.7rem] tracking-wide text-muted-2 uppercase">Default routing</h2> 28 + {#if errorMsg} 29 + <p 30 + class="mb-3 rounded-card border border-line bg-danger/10 px-3 py-2 text-sm text-danger" 31 + role="alert" 32 + > 33 + {errorMsg} 34 + </p> 35 + {/if} 36 + <div class="rounded-card border border-line bg-surface p-4"> 37 + <div class="text-sm font-medium text-fg">Where notifications go by default</div> 38 + <p class="mt-1 text-xs text-muted"> 39 + Pick where notifications go by default. <span class="font-medium text-fg">Inbox only</span> 40 + saves them with no alerts; <span class="font-medium text-fg">Off</span> drops them entirely. 41 + Apps set to “Account default” use this; override per-app (and per-category) from 42 + <a href="/apps" class="text-accent hover:underline">Apps</a>. 43 + </p> 44 + <div class="mt-3"> 45 + <ChannelRoutePicker 46 + value={data.defaultRoute} 47 + instances={data.channels} 48 + disabled={busy} 49 + onchange={save} 50 + /> 51 + </div> 52 + </div> 53 + </section>
+7 -4
docs/DEVELOPMENT.md
··· 16 16 │ ├── relay/ 17 17 │ │ ├── wrangler.toml 18 18 │ │ ├── vitest.config.ts 19 - │ │ ├── migrations/ # 0001_init.sql, 0002_request_metadata.sql 19 + │ │ ├── migrations/ # 0001_init.sql (consolidated canonical schema) 20 20 │ │ ├── src/ 21 21 │ │ │ ├── index.ts # default export { fetch, queue, scheduled } 22 22 │ │ │ ├── env.ts # Env + DispatchJob + AppContext types 23 23 │ │ │ ├── router.ts # builds the XRPCRouter, wires handlers 24 24 │ │ │ ├── auth/ # verifier.ts, sender.ts, user.ts 25 25 │ │ │ ├── xrpc/ # one file per procedure/query 26 - │ │ │ ├── delivery/ # telegram.ts (API), dispatcher.ts (queue consumer) 26 + │ │ │ ├── delivery/ # dispatcher.ts (queue consumer) + one module per channel 27 + │ │ │ │ # (webpush, telegram, email, bluesky-dm, webhook) + channel/limits 27 28 │ │ │ ├── telegram/ # webhook.ts, commands.ts, callbacks.ts 28 - │ │ │ ├── db/ # schema.sql, queries.ts 29 + │ │ │ ├── db/ # queries.ts (schema: migrations/0001_init.sql) 29 30 │ │ │ ├── identity/resolve.ts 30 31 │ │ │ ├── profile/fetch.ts 31 32 │ │ │ ├── ratelimit.ts ··· 40 41 │ │ ├── hooks.server.ts # = atproto.handle (@svelte-atproto/oauth) 41 42 │ │ ├── lib/atproto/ # OAuth client config + oauth.remote.ts (login/logout) 42 43 │ │ ├── lib/server/relay.ts # calls the relay as the signed-in user 43 - │ │ └── routes/ # landing, /dashboard, /docs 44 + │ │ └── routes/ # (app)/ dashboard: apps, settings, inbox 45 + │ ├── homepage/ # marketing landing + developer docs (docs.atmo.pub) 46 + │ │ └── static/llms.txt # LLM-oriented integration guide 44 47 │ └── example-sender/ # one-page sender demo (example.atmo.pub) 45 48 │ ├── wrangler.jsonc 46 49 │ ├── scripts/generate-keys.js # P-256 keypair for `send` (sender:keygen)
+23 -15
docs/MANAGEMENT-AUTH.md
··· 124 124 125 125 | Capability | Methods | 126 126 |---|---| 127 - | **`self`-read** | `getRouting` (own slice), `listNotifications` (own) | 128 - | **`self`-write** | `setRouting`, `setAppRouting`, `markRead` (own), `revokeSelf`, `muteSelf` | 129 - | **`full`-read** | `listGrants`, `listPending`, `listChannels`, `getSettings`, `listDevices`, `getRouting` (full config), `listNotifications` (all) | 130 - | **`full`-write** | `grant`, `revoke` (any), `denyPending`, `muteGrant` (any), `linkChannel`, `unlinkChannel`, `updateSettings`, `registerWebPush`, `unregisterWebPush`, `renameDevice`, `setDefaultRoute` | 127 + | **`self`-read** | `getRouting` (own slice + target catalog), `getCategories`, `listNotifications` (own) | 128 + | **`self`-write** | `setRouting`, `setCategories`, `addCategory`, `removeCategory`, `markRead` (own), `revokeSelf`, `muteSelf` | 129 + | **`full`-read** | `listGrants`, `listPending`, `getSettings`, `listTargets`, `getRouting` (full config), `listNotifications` (all) | 130 + | **`full`-write** | `grant`, `revoke` (any), `denyPending`, `muteGrant` (any), `linkChannel`, `linkEmail`, `verifyEmail`, `renameTarget`, `removeTarget`, `registerWebPush`, `unregisterWebPush`, `updateSettings`, `clearNotificationsFromSender`, `setRouting`/`setAppRouting`/`setDefaultRoute` (any) | 131 131 | **infrastructure** (not user-data-scoped) | `verifyAppLogin` (token is self-authorizing), `listApps` (static catalog) | 132 132 133 133 Notes: 134 134 - `getRouting` / `listNotifications` exist at **both** scopes — they're distinct 135 135 lexicons/handlers: the `self` ones return only the calling app's slice (already 136 136 built); the `full` ones return the whole account. 137 - - `revokeSelf` / `muteSelf` are **new** `self`-write methods so an app can let the 138 - user turn it off / mute it from inside the app (target is implicitly the caller — 139 - safe under fact #2). Whole-account `revoke`/`muteGrant` (any target) stay `full`. 140 - - `registerWebPush` is `full`: a device belongs to the user, not to one app, so 141 - registering it is a dashboard action. 137 + - `revokeSelf` / `muteSelf` let an app turn itself off / mute itself from inside the 138 + app (target is implicitly the caller — safe under fact #2). Whole-account 139 + `revoke`/`muteGrant` (any target) stay `full`. 140 + - **Category management** (`setCategories`/`addCategory`/`removeCategory`/`getCategories`) 141 + is `self`-only — an app declares *its own* categories, never another app's — so it 142 + is intentionally **not** in the `full` (`manage`) envelope. 143 + - A delivery target (push device / Telegram chat / email / DM / webhook) belongs to 144 + the user, not one app: listing/renaming/removing/registering them is `full` 145 + (`listTargets`, `renameTarget`, `removeTarget`, `registerWebPush`). `getRouting`'s 146 + target catalog exposes only privacy-safe labels + opaque ids (no raw address/handle). 147 + - First-party only (service binding, never federated): `addWebhook`, `enableDM`, 148 + `setGrantManage` (the user designates capabilities; an app can't grant itself one). 142 149 143 150 ## Why third-party `full` is safe despite fact #2 144 151 ··· 219 226 - `full` requires **manager designation**; never reachable by scope or per-call 220 227 consent alone. 221 228 - `self` is **own-slice forever** — sender from the app token, not the body. 222 - - **Vouch** (no user token) demands standing designation; that's also the *only* 223 - management path that works for lite sessions. Undesignated apps need a real 224 - session (dual-auth). 229 + - **Every XRPC management call is dual-auth** — app token *and* a fresh user token, 230 + both scoped to the method. There is no token-less vouch path over XRPC (→ 403); 231 + the only vouch is the first-party service binding (atmo.pub's own UI). 225 232 - Manager-key compromise = an attacker manages that manager's users — bounded and 226 233 rotatable via the DID doc; the same exposure atmo.pub already carries. 227 234 228 235 ## Build checklist 229 236 230 - - [x] grants: `manage` column (`0008`); `apps.ts` `manage?: 'self'|'full'` + `relayManageFor`; 237 + - [x] grants: `manage` column (now in the consolidated `0001_init.sql`); `apps.ts` 238 + `manage?: 'self'|'full'` + `relayManageFor`; 231 239 `MANAGEMENT_SELF_READ_POLICY` + `MANAGEMENT_SELF_WRITE_POLICY` env *(Slice 1)* 232 - - [x] `verifyManagementCall` helper (app-auth + user-token/vouch + capability resolution) *(Slice 1)* 240 + - [x] `verifyManagementCall` helper (app-auth + required user token + capability resolution) *(Slice 1)* 233 241 - [x] new `self`-write ops `revokeSelf` + `muteSelf`; retrofit the 4 self methods onto the helper *(Slice 1)* 234 242 - [x] `full` surface: the `pub.atmo.notify.manage` envelope → `ops.*` (binding kept as fast path) *(Slice 2)* 235 - - [x] tests: capability matrix (self read/write/designated; full vouch + dual-auth + unknown method) *(Slices 1–2)* 243 + - [x] tests: capability matrix (self read/write/designated; full dual-auth + missing-token 403 + unknown method) *(Slices 1–2)* 236 244 - [x] dashboard: per-app capability control on the app detail page (`manage` on 237 245 `RoutingApp` + `setGrantManage` binding/command + selector UI) *(Slice 3)* 238 246 - [~] dashboard: dedicated "your managers" view (list apps where `manage` != none) — optional; per-app control above already covers designation
+11 -3
docs/SELF-HOSTING.md
··· 1 1 # Run your own relay 2 2 3 3 Want notifications for your own app(s) without depending on atmo.pub? Run the 4 - relay yourself. You get web push, Telegram, an inbox, and per-app/category 5 - routing — for whatever apps you register. **It runs on Cloudflare Workers** (D1 + 4 + relay yourself. You get web push, Telegram, email, Bluesky DM, webhooks, an inbox, 5 + and per-app/category routing — for whatever apps you register. **It runs on 6 + Cloudflare Workers** (D1 + 6 7 KV + Queues); that's the only supported target for now. 7 8 8 9 For deeper details see [DEVELOPMENT.md](DEVELOPMENT.md). ··· 53 54 # Telegram (optional — skip if you only want web push): 54 55 pnpm exec wrangler secret put TELEGRAM_BOT_TOKEN 55 56 pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET 57 + # Email (optional — via comail.at): 58 + pnpm exec wrangler secret put COMAIL_API_KEY # atmos_… from comail.at 56 59 ``` 57 - If you use Telegram, also set `BOT_USERNAME` in `[vars]`. 60 + If you use Telegram, also set `BOT_USERNAME` in `[vars]`. If you use email, set 61 + `COMAIL_DID` (the account DID for the `X-Atmos-DID` header) and `COMAIL_FROM` (an 62 + enrolled sender address) in `[vars]`; the `EMAIL_DAILY_*` caps there default to 63 + 10 / recipient / day and 100 global / day — keep them under your comail plan. 64 + Bluesky DM and webhook channels need no extra config (DM uses the relay's own 65 + identity; webhooks are user-supplied URLs). 58 66 59 67 6. **Apply migrations & deploy** 60 68 ```bash
+39
packages/lexicons/lexicons/pub/atmo/notify/addCategory.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.atmo.notify.addCategory", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Declare (add or update) one of the calling app's categories for a user, without disturbing the others. Self-scoped management write (app JWT + a fresh `userToken`, like setRouting). Requires an active grant.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["userToken", "id"], 13 + "properties": { 14 + "userToken": { 15 + "type": "string", 16 + "description": "A user-issued service-auth JWT (aud = relay DID, lxm = pub.atmo.notify.addCategory)." 17 + }, 18 + "id": { "type": "string", "maxLength": 64, "description": "The category key (matches `category` in send)." }, 19 + "title": { "type": "string", "maxLength": 128, "description": "Human display name (e.g. a webhook's name)." }, 20 + "description": { "type": "string", "maxLength": 200 }, 21 + "route": { 22 + "type": "string", 23 + "description": "Optional initial route: a '+'-joined token set, 'off', 'inbox', or 'app' to inherit the app-wide route. Omit to leave routing unchanged." 24 + } 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "application/json", 30 + "schema": { 31 + "type": "object", 32 + "required": ["ok"], 33 + "properties": { "ok": { "type": "boolean" } } 34 + } 35 + }, 36 + "errors": [{ "name": "NotAuthorized" }] 37 + } 38 + } 39 + }
+50
packages/lexicons/lexicons/pub/atmo/notify/getCategories.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.atmo.notify.getCategories", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "List the calling app's categories for a user (its own catalog only — never another app's, never another user's), with each category's current route. Self-scoped management read (app JWT + a fresh `userToken`). Requires an active grant.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["userToken"], 13 + "properties": { 14 + "userToken": { 15 + "type": "string", 16 + "description": "A user-issued service-auth JWT (aud = relay DID, lxm = pub.atmo.notify.getCategories)." 17 + } 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": ["categories"], 26 + "properties": { 27 + "categories": { 28 + "type": "array", 29 + "items": { "type": "ref", "ref": "#category" } 30 + } 31 + } 32 + } 33 + }, 34 + "errors": [{ "name": "NotAuthorized" }] 35 + }, 36 + "category": { 37 + "type": "object", 38 + "required": ["id", "route"], 39 + "properties": { 40 + "id": { "type": "string", "description": "The category key." }, 41 + "title": { "type": "string", "description": "Human display name, if set." }, 42 + "description": { "type": "string", "description": "Description, if set." }, 43 + "route": { 44 + "type": "string", 45 + "description": "Current route for this category: a '+'-joined token set, 'off', 'inbox', or 'app' (inherit the app-wide route)." 46 + } 47 + } 48 + } 49 + } 50 + }
+4
packages/lexicons/lexicons/pub/atmo/notify/getRouting.json
··· 67 67 "required": ["id", "route"], 68 68 "properties": { 69 69 "id": { "type": "string", "description": "The category key." }, 70 + "title": { 71 + "type": "string", 72 + "description": "Human display name for this category, if the app set one." 73 + }, 70 74 "description": { 71 75 "type": "string", 72 76 "description": "Human description last seen for this category, if any."
+5 -2
packages/lexicons/lexicons/pub/atmo/notify/getSettings.json
··· 9 9 "encoding": "application/json", 10 10 "schema": { 11 11 "type": "object", 12 - "required": ["notifyPendingViaTelegram", "autoAllow", "pushDevices"], 12 + "required": ["pendingRoute", "autoAllow", "pushDevices"], 13 13 "properties": { 14 - "notifyPendingViaTelegram": { "type": "boolean" }, 14 + "pendingRoute": { 15 + "type": "string", 16 + "description": "Where permission-request alerts go: a '+'-joined token set (push|telegram|email|dm|webhook, optionally channel:<id>) or 'off'." 17 + }, 15 18 "autoAllow": { 16 19 "type": "string", 17 20 "enum": ["all", "trusted", "none"],
-32
packages/lexicons/lexicons/pub/atmo/notify/listChannels.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "pub.atmo.notify.listChannels", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "description": "List the delivery channels linked by the authenticated user. User-authenticated.", 8 - "output": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["channels"], 13 - "properties": { 14 - "channels": { 15 - "type": "array", 16 - "items": { "type": "ref", "ref": "#channelView" } 17 - } 18 - } 19 - } 20 - } 21 - }, 22 - "channelView": { 23 - "type": "object", 24 - "required": ["platform", "linkedAt"], 25 - "properties": { 26 - "platform": { "type": "string" }, 27 - "linkedAt": { "type": "string", "format": "datetime" }, 28 - "displayName": { "type": "string" } 29 - } 30 - } 31 - } 32 - }
+33
packages/lexicons/lexicons/pub/atmo/notify/removeCategory.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.atmo.notify.removeCategory", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove one of the calling app's categories for a user, along with its per-category routing. Self-scoped management write (app JWT + a fresh `userToken`). Requires an active grant. Past notifications keep their category string (they are historical).", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["userToken", "id"], 13 + "properties": { 14 + "userToken": { 15 + "type": "string", 16 + "description": "A user-issued service-auth JWT (aud = relay DID, lxm = pub.atmo.notify.removeCategory)." 17 + }, 18 + "id": { "type": "string", "maxLength": 64, "description": "The category key to remove." } 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["removed"], 27 + "properties": { "removed": { "type": "boolean" } } 28 + } 29 + }, 30 + "errors": [{ "name": "NotAuthorized" }] 31 + } 32 + } 33 + }
+51
packages/lexicons/lexicons/pub/atmo/notify/setCategories.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.atmo.notify.setCategories", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Replace the calling app's category catalog for a user (full sync). Listed categories are upserted (key + optional title/description/initial route); any the app previously had that are NOT listed are removed, along with their per-category routing. Categories are per (user, app) — never shared across users. Self-scoped management write: app JWT in the Authorization header + a fresh user-issued `userToken` (like setRouting). Requires an active grant.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["userToken", "categories"], 13 + "properties": { 14 + "userToken": { 15 + "type": "string", 16 + "description": "A user-issued service-auth JWT (aud = relay DID, lxm = pub.atmo.notify.setCategories)." 17 + }, 18 + "categories": { 19 + "type": "array", 20 + "maxLength": 500, 21 + "description": "The app's complete category set for this user. Anything omitted is removed.", 22 + "items": { "type": "ref", "ref": "#categoryInput" } 23 + } 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "required": ["ok"], 32 + "properties": { "ok": { "type": "boolean" } } 33 + } 34 + }, 35 + "errors": [{ "name": "NotAuthorized" }] 36 + }, 37 + "categoryInput": { 38 + "type": "object", 39 + "required": ["id"], 40 + "properties": { 41 + "id": { "type": "string", "maxLength": 64, "description": "The category key (matches `category` in send)." }, 42 + "title": { "type": "string", "maxLength": 128, "description": "Human display name (e.g. a webhook's name)." }, 43 + "description": { "type": "string", "maxLength": 200 }, 44 + "route": { 45 + "type": "string", 46 + "description": "Optional initial route: a '+'-joined token set, 'off', 'inbox', or 'app' to inherit the app-wide route. Omit to leave this category's routing unchanged." 47 + } 48 + } 49 + } 50 + } 51 + }
-30
packages/lexicons/lexicons/pub/atmo/notify/unlinkChannel.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "pub.atmo.notify.unlinkChannel", 4 - "defs": { 5 - "main": { 6 - "type": "procedure", 7 - "description": "Unlink a delivery channel from the authenticated user. User-authenticated.", 8 - "input": { 9 - "encoding": "application/json", 10 - "schema": { 11 - "type": "object", 12 - "required": ["platform"], 13 - "properties": { 14 - "platform": { "type": "string", "enum": ["telegram"] } 15 - } 16 - } 17 - }, 18 - "output": { 19 - "encoding": "application/json", 20 - "schema": { 21 - "type": "object", 22 - "required": ["unlinked"], 23 - "properties": { 24 - "unlinked": { "type": "boolean" } 25 - } 26 - } 27 - } 28 - } 29 - } 30 - }
+6 -3
packages/lexicons/lexicons/pub/atmo/notify/updateSettings.json
··· 10 10 "schema": { 11 11 "type": "object", 12 12 "properties": { 13 - "notifyPendingViaTelegram": { "type": "boolean" }, 13 + "pendingRoute": { 14 + "type": "string", 15 + "description": "Where permission-request alerts go: a '+'-joined token set or 'off'." 16 + }, 14 17 "autoAllow": { 15 18 "type": "string", 16 19 "enum": ["all", "trusted", "none"], ··· 23 26 "encoding": "application/json", 24 27 "schema": { 25 28 "type": "object", 26 - "required": ["notifyPendingViaTelegram"], 29 + "required": ["pendingRoute"], 27 30 "properties": { 28 - "notifyPendingViaTelegram": { "type": "boolean" } 31 + "pendingRoute": { "type": "string" } 29 32 } 30 33 } 31 34 }
+14 -2
packages/lexicons/src/rpc.ts
··· 108 108 instance?: string; 109 109 } 110 110 111 - /** Instance-id format: lowercase hex/alphanumeric, kept short. */ 112 - const INSTANCE_ID_RE = /^[a-z0-9]+$/i; 111 + /** Instance-id format: nanoid's url-safe alphabet (A–Z a–z 0–9 _ -); see lib/ids newTargetId. */ 112 + const INSTANCE_ID_RE = /^[A-Za-z0-9_-]+$/; 113 113 114 114 /** True for routes that carry no channel tokens: the inherit sentinels, plus 115 115 * 'off' (drop entirely) and 'inbox' (inbox only, no alerts), and ''. */ ··· 202 202 }); 203 203 } 204 204 205 + /** Valid app-wide route value: a concrete route, or 'default' (inherit account default). */ 206 + export function isAppRoute(route: string): boolean { 207 + return route === 'default' || isConcreteRoute(route); 208 + } 209 + 210 + /** Valid per-category route value: a concrete route, or 'app' (inherit app-wide). */ 211 + export function isCategoryRoute(route: string): boolean { 212 + return route === 'app' || isConcreteRoute(route); 213 + } 214 + 205 215 /** Management capability the user designated for an app. See MANAGEMENT-AUTH.md. */ 206 216 export type Capability = 'none' | 'self' | 'full'; 207 217 208 218 export interface RoutingCategory { 209 219 category: string; 220 + /** Human display name (e.g. a webhook's name), if the app set one. */ 221 + title?: string; 210 222 description?: string; 211 223 route: CategoryRoute; 212 224 }