diff --git a/apps/example-sender/src/lib/config.ts b/apps/example-sender/src/lib/config.ts index fae4c34..ae7d51b 100644 --- a/apps/example-sender/src/lib/config.ts +++ b/apps/example-sender/src/lib/config.ts @@ -19,15 +19,36 @@ export const APP_DESCRIPTION = 'Demo of how to integrate with atmo.pub.'; export const RELAY_ORIGIN = dev ? 'http://localhost:8787' : 'https://relay.atmo.pub'; export const RELAY_DID = 'did:web:relay.atmo.pub'; -/** The web dashboard where users approve apps + link Telegram (note: no "s"). */ -export const DASHBOARD_ORIGIN = 'https://atmo.pub'; +/** + * The web dashboard where users approve apps + link Telegram. In dev the webapp + * runs on Vite's default 5173, so cross-app login links point there. + */ +export const DASHBOARD_ORIGIN = dev ? 'http://localhost:5173' : 'https://atmo.pub'; + +/** lxm for cross-app login (a convention NSID, not a published lexicon). */ +export const APPLOGIN_LXM = 'pub.atmo.auth'; /** - * OAuth scope: the sender app only needs `requestPermission` via the user's - * session (`send` uses this app's own DID key, not the user's OAuth). Requested - * as an individual `rpc` scope. + * OAuth scope. Each `rpc?lxm=…` entry lets this app mint a user service-auth + * token (via `com.atproto.server.getServiceAuth`) for that method: + * - `requestPermission` — ask the user for notify permission. + * - `pub.atmo.auth` — one-time login token to jump into atmo.pub signed in. + * - `setRouting`/`getRouting`/`listNotifications`/`markRead` — the dual-auth + * methods, where this user token rides in the body alongside our own app JWT. + * `send` is NOT here — it uses this app's own DID key, not the user's OAuth. + * + * Changing this scope means already-signed-in users must sign out and back in + * before the new tokens can be minted. */ -export const OAUTH_SCOPE = 'atproto rpc?lxm=pub.atmo.notify.requestPermission&aud=*'; +export const OAUTH_SCOPE = [ + 'atproto', + 'rpc?lxm=pub.atmo.notify.requestPermission&aud=*', + 'rpc?lxm=pub.atmo.auth&aud=*', + 'rpc?lxm=pub.atmo.notify.setRouting&aud=*', + 'rpc?lxm=pub.atmo.notify.getRouting&aud=*', + 'rpc?lxm=pub.atmo.notify.listNotifications&aud=*', + 'rpc?lxm=pub.atmo.notify.markRead&aud=*' +].join(' '); /** Branding. */ export const PROJECT_NAME = 'atmo.pub · example sender'; diff --git a/apps/example-sender/src/lib/relay.remote.ts b/apps/example-sender/src/lib/relay.remote.ts index 44b342e..c28d76b 100644 --- a/apps/example-sender/src/lib/relay.remote.ts +++ b/apps/example-sender/src/lib/relay.remote.ts @@ -1,18 +1,36 @@ // Remote command functions the page calls. requestNotifications goes through the -// user's OAuth session; sendTest signs with this app's own key. +// user's OAuth session; sendTest signs with this app's own key; the routing/inbox +// commands carry both tokens (dual-auth). import { command, getRequestEvent } from '$app/server'; import { error } from '@sveltejs/kit'; import * as v from 'valibot'; import { APP_DOMAIN } from '$lib/config'; -import { requestPermissionForUser, sendAsSender } from '$lib/server/relay'; +import { + getRoutingForUser, + listNotificationsForUser, + markAllReadForUser, + mintAppLoginUrl, + requestPermissionForUser, + sendAsSender, + setRoutingForUser +} from '$lib/server/relay'; +import type { AppRoute, NotificationView, RoutingView } from '$lib/types'; -export const requestNotifications = command(async () => { +/** The user's OAuth client, or a 401 if they're not signed in. */ +function requireClient() { const { locals } = getRequestEvent(); if (!locals.client) { error(401, 'Not signed in'); } - return requestPermissionForUser(locals.client); + return locals.client; +} + +export const requestNotifications = command(() => requestPermissionForUser(requireClient())); + +/** Build a one-time link that signs the user into atmo.pub (cross-app login). */ +export const openInAtmo = command(async (): Promise<{ url: string }> => { + return { url: await mintAppLoginUrl(requireClient()) }; }); export type SendResult = @@ -43,3 +61,22 @@ export const sendTest = command( } } ); + +// --- Dual-auth: manage this app's own routing + inbox for the signed-in user --- + +export const getRouting = command((): Promise => getRoutingForUser(requireClient())); + +export const setRouting = command( + v.object({ route: v.picklist(['push', 'telegram', 'push+telegram', 'off', 'default']) }), + ({ route }): Promise<{ ok: boolean }> => + setRoutingForUser(requireClient(), { route: route as AppRoute }) +); + +export const listNotifications = command( + (): Promise<{ notifications: NotificationView[]; cursor?: string }> => + listNotificationsForUser(requireClient(), { limit: 25 }) +); + +export const markAllRead = command((): Promise<{ marked: number }> => + markAllReadForUser(requireClient()) +); diff --git a/apps/example-sender/src/lib/server/relay.ts b/apps/example-sender/src/lib/server/relay.ts index 2076b6e..ffe6f37 100644 --- a/apps/example-sender/src/lib/server/relay.ts +++ b/apps/example-sender/src/lib/server/relay.ts @@ -1,10 +1,20 @@ // The actual relay calls. Each endpoint uses a different auth path: -// requestPermission → user-OAuth (a JWT minted by the user's PDS) -// send → sender-DID (a JWT signed by this app's own key) +// requestPermission → user-OAuth (JWT minted by the user's PDS) +// send → sender-DID (JWT signed by this app's key) +// setRouting/getRouting/list…/mark… → dual-auth (BOTH of the above per call) import '@atcute/atproto'; // ambient com.atproto.* types (getServiceAuth) import type { Did, Nsid } from '@atcute/lexicons'; -import { APP_DESCRIPTION, APP_TITLE, RELAY_DID, RELAY_ORIGIN, SENDER_DID } from '$lib/config'; +import { + APP_DESCRIPTION, + APP_TITLE, + APPLOGIN_LXM, + DASHBOARD_ORIGIN, + RELAY_DID, + RELAY_ORIGIN, + SENDER_DID +} from '$lib/config'; +import type { AppRoute, CategoryRoute, NotificationView, RoutingView } from '$lib/types'; import { mintSenderJwt } from './sender-auth'; @@ -33,6 +43,22 @@ async function postRelay(jwt: string, lxm: string, body: object): Promise, lxm: string): Promise { + const auth = await client.get('com.atproto.server.getServiceAuth', { + params: { aud: RELAY_DID as Did, lxm: lxm as Nsid } + }); + if (!auth.ok) { + throw new Error('Failed to mint a service-auth token from your PDS'); + } + return auth.data.token; +} + /** * User-OAuth path. Mint a service-auth JWT on the user's PDS (proves the user * authorized this request), then call `requestPermission` naming this app as the @@ -42,19 +68,26 @@ export async function requestPermissionForUser( client: NonNullable ): Promise<{ id: string; status: 'pending' | 'alreadyGranted' }> { const lxm = 'pub.atmo.notify.requestPermission'; - const auth = await client.get('com.atproto.server.getServiceAuth', { - params: { aud: RELAY_DID as Did, lxm: lxm as Nsid } - }); - if (!auth.ok) { - throw new Error('Failed to mint a service-auth token from your PDS'); - } - return postRelay(auth.data.token, lxm, { + const token = await mintUserToken(client, lxm); + return postRelay(token, lxm, { senderDid: SENDER_DID, title: APP_TITLE, description: APP_DESCRIPTION }) as Promise<{ id: string; status: 'pending' | 'alreadyGranted' }>; } +/** + * Cross-app login. Mint a one-time `pub.atmo.auth` service-auth token on the + * user's PDS and build a link that drops them into atmo.pub already signed in, + * deep-linked to THIS app's settings page. No login form, no PDS round-trip on + * atmo.pub's side. See CROSS-APP-AUTH.md. + */ +export async function mintAppLoginUrl(client: NonNullable): Promise { + const token = encodeURIComponent(await mintUserToken(client, APPLOGIN_LXM)); + const redirect = `/apps/${SENDER_DID}`; + return `${DASHBOARD_ORIGIN}/applogin?token=${token}&redirect=${encodeURIComponent(redirect)}`; +} + /** * Sender-DID path. Sign with this app's own key (no user OAuth) and send a * notification to `recipient`. @@ -69,3 +102,49 @@ export async function sendAsSender(input: { const jwt = await mintSenderJwt(lxm); return postRelay(jwt, lxm, input) as Promise<{ id: string; delivered: number }>; } + +/** + * Dual-auth path (setRouting / getRouting / listNotifications / markRead). + * Carries TWO service-auth tokens: this app's own in the Authorization header + * (proves which app), and a fresh user-issued one as `userToken` in the body + * (proves the user consented to this exact call). The relay verifies both and + * scopes the effect to (user, this app) — it can't touch the account default, + * other apps, or channels. + */ +async function dualAuthCall( + client: NonNullable, + lxm: string, + body: object = {} +): Promise { + const [userToken, appJwt] = await Promise.all([mintUserToken(client, lxm), mintSenderJwt(lxm)]); + return postRelay(appJwt, lxm, { userToken, ...body }); +} + +/** Read how this app's notifications are currently routed for the user. */ +export function getRoutingForUser(client: NonNullable): Promise { + return dualAuthCall(client, 'pub.atmo.notify.getRouting') as Promise; +} + +/** Change how this app's notifications are routed for the user. */ +export function setRoutingForUser( + client: NonNullable, + input: { route?: AppRoute; categories?: { id: string; route: CategoryRoute }[] } +): Promise<{ ok: boolean }> { + return dualAuthCall(client, 'pub.atmo.notify.setRouting', input) as Promise<{ ok: boolean }>; +} + +/** List the notifications this app has sent the user (read state + delivery). */ +export function listNotificationsForUser( + client: NonNullable, + input: { limit?: number; cursor?: string } = {} +): Promise<{ notifications: NotificationView[]; cursor?: string }> { + return dualAuthCall(client, 'pub.atmo.notify.listNotifications', input) as Promise<{ + notifications: NotificationView[]; + cursor?: string; + }>; +} + +/** Mark all of this app's notifications to the user as read. */ +export function markAllReadForUser(client: NonNullable): Promise<{ marked: number }> { + return dualAuthCall(client, 'pub.atmo.notify.markRead') as Promise<{ marked: number }>; +} diff --git a/apps/example-sender/src/lib/types.ts b/apps/example-sender/src/lib/types.ts new file mode 100644 index 0000000..8e5fa55 --- /dev/null +++ b/apps/example-sender/src/lib/types.ts @@ -0,0 +1,28 @@ +// Response shapes for the relay's dual-auth routing/inbox methods. Kept out of +// `$lib/server` so the component can import them too. A real integrator would +// likely codegen these from the published lexicons; hand-typing keeps the demo +// dependency-light. + +export type AlertRoute = 'push' | 'telegram' | 'push+telegram' | 'off'; +/** App-wide route; 'default' inherits the user's account default. */ +export type AppRoute = AlertRoute | 'default'; +/** Per-category route; 'app' inherits the app-wide route. */ +export type CategoryRoute = AlertRoute | 'app'; + +export interface RoutingView { + route: AppRoute; + defaultRoute: AlertRoute; + categories: { id: string; description?: string; route: CategoryRoute }[]; +} + +export interface NotificationView { + id: string; + title: string; + body: string; + uri?: string; + category?: string; + createdAt: string; + read: boolean; + /** Number of channels this notification fanned out to (0 = recorded only). */ + delivered?: number; +} diff --git a/apps/example-sender/src/routes/+page.svelte b/apps/example-sender/src/routes/+page.svelte index b8fcae0..dc2ba24 100644 --- a/apps/example-sender/src/routes/+page.svelte +++ b/apps/example-sender/src/routes/+page.svelte @@ -8,7 +8,17 @@ PROJECT_NAME, SENDER_DID } from '$lib/config'; - import { requestNotifications, sendTest, type SendResult } from '$lib/relay.remote'; + import { + getRouting, + listNotifications, + markAllRead, + openInAtmo, + requestNotifications, + sendTest, + setRouting, + type SendResult + } from '$lib/relay.remote'; + import type { AppRoute, NotificationView, RoutingView } from '$lib/types'; import type { PageServerData } from './$types'; let { data }: { data: PageServerData } = $props(); @@ -57,6 +67,28 @@ } } + // --- cross-app login: jump into atmo.pub already signed in ------------- + let openBusy = $state(false); + let openError = $state(''); + + async function openAtmo() { + openBusy = true; + openError = ''; + // Open the tab synchronously on the click so the browser keeps the user + // gesture (no popup blocker), then point it at the link once minted. + const w = window.open('about:blank'); + try { + const { url } = await openInAtmo(); + if (w) w.location.href = url; + else window.location.href = url; + } catch (err) { + w?.close(); + openError = err instanceof Error ? err.message : 'Failed to open atmo.pub'; + } finally { + openBusy = false; + } + } + // --- step 2: send a test ---------------------------------------------- let title = $state(''); let body = $state(''); @@ -77,11 +109,69 @@ } } + // --- step 3: manage routing + inbox (dual-auth) ----------------------- + let manageBusy = $state(false); + let manageError = $state(''); + let routing = $state(null); + let notifs = $state([]); + let routeChoice = $state('default'); + let loaded = $state(false); + + // Remote-command errors arrive as { body: { message } }, not Error instances. + function errMsg(err: unknown, fallback: string): string { + const e = err as { body?: { message?: string }; message?: string }; + return e?.body?.message ?? e?.message ?? fallback; + } + + async function loadSettings() { + manageBusy = true; + manageError = ''; + try { + const [r, list] = await Promise.all([getRouting(), listNotifications()]); + routing = r; + routeChoice = r.route; + notifs = list.notifications; + loaded = true; + } catch (err) { + manageError = errMsg(err, 'Failed to load settings'); + } finally { + manageBusy = false; + } + } + + async function changeRoute() { + manageBusy = true; + manageError = ''; + try { + await setRouting({ route: routeChoice }); + routing = await getRouting(); + } catch (err) { + manageError = errMsg(err, 'Failed to update routing'); + } finally { + manageBusy = false; + } + } + + async function markRead() { + manageBusy = true; + manageError = ''; + try { + await markAllRead(); + notifs = (await listNotifications()).notifications; + } catch (err) { + manageError = errMsg(err, 'Failed to mark read'); + } finally { + manageBusy = false; + } + } + const card = 'rounded-card border border-line bg-surface p-5'; const btnPrimary = 'rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-opacity hover:opacity-90 disabled:opacity-50'; const inputCls = 'w-full rounded-md border border-line bg-surface px-3 py-2 text-sm text-fg placeholder:text-muted-2 focus:border-accent'; + const selectCls = + 'w-full rounded-md border border-line bg-surface px-3 py-2 text-sm text-fg focus:border-accent'; const noticeOk = 'mt-3 rounded-md border border-line bg-accent-soft px-3 py-2 text-sm text-fg'; const noticeWarn = 'mt-3 rounded-md border border-line px-3 py-2 text-sm text-warn'; @@ -200,6 +290,84 @@ {/if} {#if sendError}{/if} + +
+

Step 3 — Manage your settings (dual-auth)

+

+ Read and change how this app's notifications reach you — from inside this app. Each + call carries two service-auth tokens: one signed by this app + (proves which app), and a fresh one minted by your PDS (proves you consented). The relay + scopes every change to you + this app — it can't touch your account default or + other apps. Requires an approved grant (Step 1). +

+ + + {#if routing} +
+ + +
+
+

Notifications we've sent you

+ +
+ {#if notifs.length === 0} +

None yet — send one in Step 2, then Refresh.

+ {:else} +
    + {#each notifs as n (n.id)} +
  • + {n.read ? '' : '• '}{n.title} + + delivered to {n.delivered ?? 0} · {n.read ? 'read' : 'unread'} + +
  • + {/each} +
+ {/if} +
+
+ {/if} + {#if manageError}{/if} +
+ +
+

Cross-app login

+

+ Mints a one-time pub.atmo.auth token on your PDS and + opens + atmo.pub + already signed in — deep-linked to this app's settings. No login form. +

+ + {#if openError}{/if} +
{/if}