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 @@
-
-
- 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"
- ]
- }
- ]
- }
- }
-}