From fd363f07298037ca4c53adf97e99b107fa38f48a Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sat, 23 May 2026 05:28:59 +0200 Subject: [PATCH] some cleanup, small fixes --- README.md | 17 +---- apps/relay/src/identity/resolve.ts | 15 +++++ apps/relay/src/telegram/commands.ts | 17 +++-- apps/relay/src/well-known.ts | 4 -- apps/web/README.md | 30 ++++++--- apps/web/package.json | 3 +- apps/web/src/lib/components/Logomark.svelte | 63 +++++++++++++++++-- apps/web/src/lib/config.ts | 17 ++--- apps/web/src/routes/+page.server.ts | 11 ++++ apps/web/src/routes/+page.svelte | 13 ++-- apps/web/src/routes/docs/+page.server.ts | 9 +-- apps/web/src/routes/docs/+page.svelte | 5 +- apps/web/svelte.config.js | 7 +-- apps/web/wrangler.jsonc | 37 +++++++---- .../tools/atmo/notifs/authSender.json | 19 ------ .../lexicons/tools/atmo/notifs/authUser.json | 31 --------- 16 files changed, 167 insertions(+), 131 deletions(-) create mode 100644 apps/web/src/routes/+page.server.ts delete mode 100644 packages/lexicons/lexicons/tools/atmo/notifs/authSender.json delete mode 100644 packages/lexicons/lexicons/tools/atmo/notifs/authUser.json diff --git a/README.md b/README.md index 5d946fe..1368704 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ re-home the relay, change them everywhere listed below. | Relay domain | `notifs.atmo.tools` | `apps/relay/wrangler.toml` (`routes`, derived `RELAY_DID`); dashboard links in `apps/relay/src/telegram/commands.ts` (`DASHBOARD_URL`, `NOT_LINKED`); `apps/relay/test/helpers.ts` | | Relay DID | `did:web:notifs.atmo.tools` | `apps/relay/wrangler.toml` (`[vars].RELAY_DID`); consumed by `apps/relay/src/auth/verifier.ts` and `apps/relay/src/well-known.ts` (service endpoint derived from it); `apps/relay/test/helpers.ts` (`RELAY_DID`) | | Relay service id | `#notif_relay` | `apps/relay/src/well-known.ts` (DID-doc `service[].id`); `apps/relay/src/auth/verifier.ts` (`acceptAudiences` fragment) | -| Lexicon NSID prefix | `tools.atmo.notifs` | Every file under `packages/lexicons/lexicons/` (each `id`); regenerated types under `packages/lexicons/src/lexicons/`; the `LXM` constant in every `apps/relay/src/xrpc/*.ts`; the map + imports in `apps/relay/src/well-known.ts`; the permission-set `lxm` arrays | +| Lexicon NSID prefix | `tools.atmo.notifs` | Every file under `packages/lexicons/lexicons/` (each `id`); regenerated types under `packages/lexicons/src/lexicons/`; the `LXM` constant in every `apps/relay/src/xrpc/*.ts`; the map + imports in `apps/relay/src/well-known.ts` | | Pending request TTL | 7 days | `apps/relay/src/xrpc/requestPermission.ts` (`addDays(createdAt, 7)`) | | Link token TTL | 10 minutes | `apps/relay/src/xrpc/linkChannel.ts` (`addMinutes(now(), 10)`) | | DID-doc cache TTL (KV) | 5 minutes | `apps/relay/src/identity/resolve.ts` (`DID_DOC_CACHE_TTL_SECONDS`) | @@ -38,7 +38,7 @@ re-home the relay, change them everywhere listed below. | Bot username | `REPLACE_ME` | `apps/relay/wrangler.toml` (`[vars].BOT_USERNAME`) → deep links in `linkChannel` | > **Auth model:** `requestPermission` is **user-authenticated** (the user OAuths -> into the requesting app with the `tools.atmo.notifs.authSender` permission set); +> into the requesting app, granting the relay's `requestPermission` rpc scope); > the sender DID + display metadata are in the request body. `send` stays > **sender-authenticated** (the sender's own DID key). See "For sender developers". @@ -226,9 +226,7 @@ Then: atproto rpc?lxm=tools.atmo.notifs.requestPermission&aud=* ``` - (Once the `tools.atmo.notifs.authSender` permission set is published, the - tidier `include:tools.atmo.notifs.authSender?aud=did:web:notifs.atmo.tools%23notif_relay` - works too.) Then, on the user's PDS, mint a service-auth JWT via + Then, on the user's PDS, mint a service-auth JWT via `com.atproto.server.getServiceAuth` (`aud = did:web:notifs.atmo.tools`, `lxm = tools.atmo.notifs.requestPermission`) and call: @@ -283,12 +281,3 @@ Then: `send` returns `403 NotAuthorized` if there is no grant, and `429 RateLimitExceeded` (with `Retry-After`) when limits are hit. A muted grant is accepted silently with `delivered: 0`. - -### Permission sets - -The relay publishes two OAuth permission sets so the website can request scopes: - -- `tools.atmo.notifs.authSender` — `requestPermission` only (for sender apps; - `send` is authenticated by the app's own DID, not via this grant). -- `tools.atmo.notifs.authUser` — all user-facing management methods (for the - dashboard acting on a user's behalf). diff --git a/apps/relay/src/identity/resolve.ts b/apps/relay/src/identity/resolve.ts index c796edb..89c85f1 100644 --- a/apps/relay/src/identity/resolve.ts +++ b/apps/relay/src/identity/resolve.ts @@ -54,3 +54,18 @@ export function makeResolver(cache: KVNamespace): DidDocumentResolver { }); return new CachedDidDocumentResolver(composite, cache); } + +/** + * Best-effort handle for a DID, read from the DID document's `alsoKnownAs` + * (`at://`). For display only (the claimed handle is not bidirectionally + * verified). Returns null on any failure. + */ +export async function resolveHandle(cache: KVNamespace, did: Did): Promise { + try { + const doc = await makeResolver(cache).resolve(did); + const aka = doc.alsoKnownAs?.find((uri) => uri.startsWith('at://')); + return aka !== undefined ? aka.slice('at://'.length) : null; + } catch { + return null; + } +} diff --git a/apps/relay/src/telegram/commands.ts b/apps/relay/src/telegram/commands.ts index 6ad84b7..6d6e270 100644 --- a/apps/relay/src/telegram/commands.ts +++ b/apps/relay/src/telegram/commands.ts @@ -7,6 +7,7 @@ import { type InlineKeyboardMarkup, sendMessage, } from '../delivery/telegram'; +import { resolveHandle } from '../identity/resolve'; import { now } from '../lib/time'; import type { TelegramMessage } from './webhook'; @@ -65,13 +66,17 @@ async function handleStart(env: Env, message: TelegramMessage, token: string): P did, platform: PLATFORM, platformUserId: String(chatId), - displayName: username, + displayName: username, // the Telegram account label (for the channels list) linkedAt: now(), }); await q.deleteLinkToken(env.DB, token); - const label = username !== null ? `@${username}` : did; - await replyText(env, chatId, `✅ Linked to ${label}`); + // Confirm with the atproto identity being linked (their handle), not the + // Telegram username. Falls back to the DID if the handle can't be resolved. + // Rendered as a code span so Telegram doesn't linkify it as an @username. + const handle = await resolveHandle(env.CACHE, did); + const label = handle !== null ? `@${handle}` : did; + await replyMarkdown(env, chatId, `✅ Linked to \`${label}\``); } async function handleList(env: Env, chatId: number): Promise { @@ -88,9 +93,11 @@ async function handleList(env: Env, chatId: number): Promise { } const lines = grants.map((grant) => { - const name = grant.handle !== null ? `@${grant.handle}` : grant.sender_did; + // Prefer the app's display title; identifier goes in a code span so Telegram + // doesn't linkify it as an @username/URL. + const name = grant.title ?? grant.handle ?? grant.sender_did; const muted = grant.muted === 1 ? ' \\(muted\\)' : ''; - return `• ${escapeMd(name)}${muted}`; + return `• *${escapeMd(name)}*${muted}\n \`${grant.sender_did}\``; }); await replyMarkdown(env, chatId, `*Authorized apps*\n${lines.join('\n')}`); } diff --git a/apps/relay/src/well-known.ts b/apps/relay/src/well-known.ts index 2fe2aa0..26ec695 100644 --- a/apps/relay/src/well-known.ts +++ b/apps/relay/src/well-known.ts @@ -1,5 +1,3 @@ -import authSender from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/authSender.json'; -import authUser from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/authUser.json'; import denyPending from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/denyPending.json'; import getSettings from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/getSettings.json'; import grant from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/grant.json'; @@ -32,8 +30,6 @@ const LEXICONS: Record = { 'tools.atmo.notifs.listChannels': listChannels, 'tools.atmo.notifs.getSettings': getSettings, 'tools.atmo.notifs.updateSettings': updateSettings, - 'tools.atmo.notifs.authSender': authSender, - 'tools.atmo.notifs.authUser': authUser, }; /** diff --git a/apps/web/README.md b/apps/web/README.md index 781bbcb..d534896 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -60,21 +60,31 @@ pnpm check # svelte-check (type check) pnpm build # production build ``` -## Deployment (Cloudflare) +## Deployment (Cloudflare Workers) -The OAuth session store uses Cloudflare KV, so this deploys to Cloudflare. +Uses `@sveltejs/adapter-cloudflare` (Workers + static assets). Run these from +`apps/web/` in a Cloudflare-authenticated terminal. -1. Install the adapter: `pnpm add -D @sveltejs/adapter-cloudflare` and use it in - `svelte.config.js` (or rely on `adapter-auto`, which selects it on CF Pages). -2. Create the KV namespaces and paste the ids into `wrangler.jsonc`: +1. **Pick the domain.** In `wrangler.jsonc`, set `name` and `vars.ORIGIN` + (e.g. `https://notifs-web.atmo.tools`). `ORIGIN` must equal the served URL — + the atproto OAuth `client_id` is derived from it. +2. **Create the KV namespaces** and paste the ids into `wrangler.jsonc`: ```sh pnpm exec wrangler kv namespace create OAUTH_SESSIONS pnpm exec wrangler kv namespace create OAUTH_STATES ``` -3. Set secrets (`ORIGIN`, `COOKIE_SECRET`, `CLIENT_ASSERTION_KEY`) via - `wrangler secret put` (or the dashboard). -4. Pick a real subdomain (placeholder: `notifs-web.atmo.tools`), update - `wrangler.jsonc` `name`, deploy, and bind the custom domain. +3. **Set the secrets** (the `atproto-oauth` CLI generates them): + ```sh + pnpm exec atproto-oauth secret | pnpm exec wrangler secret put COOKIE_SECRET + pnpm exec atproto-oauth keygen | pnpm exec wrangler secret put CLIENT_ASSERTION_KEY + ``` +4. **Deploy** (builds, then `wrangler deploy`): + ```sh + pnpm run deploy + ``` +5. **Bind the custom domain** in the CF dashboard (Workers → the worker → + Settings → Domains & Routes), matching `ORIGIN`. The OAuth client metadata is served dynamically by `@svelte-atproto/oauth` — no -static file to write. +static file to write. The dashboard needs the relay reachable at the domain in +`src/lib/config.ts` (`notifs.atmo.tools`), so deploy the relay too. diff --git a/apps/web/package.json b/apps/web/package.json index 3692fb2..99fb2c0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", + "deploy": "vite build && wrangler deploy", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", @@ -19,7 +20,7 @@ "devDependencies": { "@eslint/compat": "^2.0.4", "@eslint/js": "^10.0.1", - "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-cloudflare": "^7.2.8", "@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/forms": "^0.5.11", diff --git a/apps/web/src/lib/components/Logomark.svelte b/apps/web/src/lib/components/Logomark.svelte index 3cd9a7b..b548842 100644 --- a/apps/web/src/lib/components/Logomark.svelte +++ b/apps/web/src/lib/components/Logomark.svelte @@ -1,16 +1,67 @@ - -
@@ -82,7 +87,7 @@

- Enter your handle or DID. You'll approve access on your own server. + Enter your handle or DID.

{#if errorMsg} diff --git a/apps/web/src/routes/docs/+page.server.ts b/apps/web/src/routes/docs/+page.server.ts index 7b406d5..9c6bd49 100644 --- a/apps/web/src/routes/docs/+page.server.ts +++ b/apps/web/src/routes/docs/+page.server.ts @@ -3,10 +3,7 @@ import { highlight, type CodeLang } from '$lib/server/highlight'; import type { PageServerLoad } from './$types'; -const requestExample = `# $USER_JWT is minted on the user's PDS via com.atproto.server.getServiceAuth -# (aud=${RELAY_DID}, lxm=${LEXICON_PREFIX}.requestPermission) after the user -# OAuths into your app with the authSender scope. -curl -X POST ${RELAY_ORIGIN}/xrpc/${LEXICON_PREFIX}.requestPermission \\ +const requestExample = `curl -X POST ${RELAY_ORIGIN}/xrpc/${LEXICON_PREFIX}.requestPermission \\ -H "Authorization: Bearer $USER_JWT" \\ -H "Content-Type: application/json" \\ -d '{ @@ -40,9 +37,7 @@ await client.post('${LEXICON_PREFIX}.send', { body: 'alice replied to your post', uri: 'https://yourapp.example/thread/123' } -}); -// typing tip: \`import '@atmo/notifs-lexicons'\` to register the lexicon, -// or use client.call(ToolsAtmoNotifsSend.mainSchema, { ... }).`; +});`; const sendCurlExample = `curl -X POST ${RELAY_ORIGIN}/xrpc/${LEXICON_PREFIX}.send \\ -H "Authorization: Bearer $JWT" \\ diff --git a/apps/web/src/routes/docs/+page.svelte b/apps/web/src/routes/docs/+page.svelte index 4f2cd90..3b18573 100644 --- a/apps/web/src/routes/docs/+page.svelte +++ b/apps/web/src/routes/docs/+page.svelte @@ -89,10 +89,7 @@ atproto rpc?lxm=tools.atmo.notifs.requestPermission&aud=*

- Once the tools.atmo.notifs.authSender permission set is - published you can use the tidier - include:tools.atmo.notifs.authSender?aud=did:web:notifs.atmo.tools#notif_relay - instead. Then mint a service-auth JWT on the user's PDS via + Then mint a service-auth JWT on the user's PDS via com.atproto.server.getServiceAuth and call:

{@render codeblock(data.code.request)} diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js index 840f835..a366107 100644 --- a/apps/web/svelte.config.js +++ b/apps/web/svelte.config.js @@ -1,4 +1,4 @@ -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-cloudflare'; /** @type {import('@sveltejs/kit').Config} */ const config = { @@ -11,9 +11,8 @@ const config = { } }, kit: { - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. + // Cloudflare Workers (with static assets). The Worker output goes to + // `.svelte-kit/cloudflare`, which `wrangler.jsonc` points `main`/`assets` at. adapter: adapter(), // Mutations are written as remote `command` functions. experimental: { diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 2a2954c..e40900f 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -1,22 +1,37 @@ { "$schema": "node_modules/wrangler/config-schema.json", - // Working default — pick a real subdomain and update this + the custom domain. - "name": "notifs-web", + // Working default — pick a real subdomain and update this, ORIGIN, and the custom domain. + "name": "notify-atmo", + "main": ".svelte-kit/cloudflare/_worker.js", "compatibility_date": "2026-04-01", "compatibility_flags": ["nodejs_compat"], - + "assets": { + "binding": "ASSETS", + "directory": ".svelte-kit/cloudflare" + }, + // Public origin of the deployed app (NOT secret). Must equal the domain you + // serve from — the atproto OAuth client_id is derived from it. + "vars": { + "ORIGIN": "https://notify.atmo.tools" + }, // KV namespaces required by @svelte-atproto/oauth's `cloudflareKV` stores // (see src/lib/atproto/index.ts). Create them and paste the ids: // pnpm exec wrangler kv namespace create OAUTH_SESSIONS // pnpm exec wrangler kv namespace create OAUTH_STATES "kv_namespaces": [ - { "binding": "OAUTH_SESSIONS", "id": "REPLACE_ME" }, - { "binding": "OAUTH_STATES", "id": "REPLACE_ME" } + { + "binding": "OAUTH_SESSIONS", + "id": "6559fa117ec041a1a5fabda5b6526f83" + }, + { + "binding": "OAUTH_STATES", + "id": "a0f731098f5d4bdea2c3b9e622b13153" + } ] - - // Deploying to Cloudflare: install `@sveltejs/adapter-cloudflare` and use it in - // svelte.config.js (adapter-auto also selects it automatically on CF Pages). - // The adapter injects `main` and `assets`; this file supplies the bindings. - // Bind the custom domain (e.g. notifs-web.atmo.tools) in the CF dashboard, or: + // Secrets (set via `wrangler secret put`, never commit): + // COOKIE_SECRET (atproto-oauth secret | wrangler secret put COOKIE_SECRET) + // CLIENT_ASSERTION_KEY (atproto-oauth keygen | wrangler secret put CLIENT_ASSERTION_KEY) + // + // Custom domain: bind it in the CF dashboard, or add: // "routes": [{ "pattern": "notifs-web.atmo.tools", "custom_domain": true }] -} +} \ No newline at end of file diff --git a/packages/lexicons/lexicons/tools/atmo/notifs/authSender.json b/packages/lexicons/lexicons/tools/atmo/notifs/authSender.json deleted file mode 100644 index b9dec1a..0000000 --- a/packages/lexicons/lexicons/tools/atmo/notifs/authSender.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.notifs.authSender", - "defs": { - "main": { - "type": "permission-set", - "title": "Request notification permission", - "detail": "Allow this app to ask you for permission to send you notifications. (Sending itself is authenticated by the app's own identity, not this grant.)", - "permissions": [ - { - "type": "permission", - "resource": "rpc", - "inheritAud": true, - "lxm": ["tools.atmo.notifs.requestPermission"] - } - ] - } - } -} diff --git a/packages/lexicons/lexicons/tools/atmo/notifs/authUser.json b/packages/lexicons/lexicons/tools/atmo/notifs/authUser.json deleted file mode 100644 index 38b2f4e..0000000 --- a/packages/lexicons/lexicons/tools/atmo/notifs/authUser.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "lexicon": 1, - "id": "tools.atmo.notifs.authUser", - "defs": { - "main": { - "type": "permission-set", - "title": "Manage your notifications", - "detail": "Allow this app to view and manage which apps can send you notifications, link delivery channels, and adjust your notification settings.", - "permissions": [ - { - "type": "permission", - "resource": "rpc", - "inheritAud": true, - "lxm": [ - "tools.atmo.notifs.grant", - "tools.atmo.notifs.revoke", - "tools.atmo.notifs.denyPending", - "tools.atmo.notifs.muteGrant", - "tools.atmo.notifs.listGrants", - "tools.atmo.notifs.listPending", - "tools.atmo.notifs.linkChannel", - "tools.atmo.notifs.unlinkChannel", - "tools.atmo.notifs.listChannels", - "tools.atmo.notifs.getSettings", - "tools.atmo.notifs.updateSettings" - ] - } - ] - } - } -} -- 2.51.2