diff --git a/README.md b/README.md index c944ab8..5d946fe 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,15 @@ re-home the relay, change them everywhere listed below. | 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`) | | Profile cache TTL (`senders`) | 24 hours | `apps/relay/src/profile/fetch.ts` (`PROFILE_TTL_MS`) | -| `requestPermission` rate limit | 100 / hour / sender | `apps/relay/src/xrpc/requestPermission.ts` (`REQ_LIMIT`, `REQ_WINDOW_SECONDS`) | +| `requestPermission` rate limits | 50 / hour / recipient & 100 / hour / sender | `apps/relay/src/xrpc/requestPermission.ts` (`PER_RECIPIENT_LIMIT`, `PER_SENDER_LIMIT`, `WINDOW_SECONDS`) | | `send` rate limits | 1 / sec & 100 / day / pair | `apps/relay/src/xrpc/send.ts` (`PER_SECOND_*`, `PER_DAY_*`) | | 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); +> the sender DID + display metadata are in the request body. `send` stays +> **sender-authenticated** (the sender's own DID key). See "For sender developers". + > **Note on `lex.config.js`** — the prompt's tree named this `lex.config.json`, > but `@atcute/lex-cli` loads its config via dynamic `import()`, which only > resolves `lex.config.js`/`.ts`. We use `.js` so `pnpm generate` works without @@ -199,64 +204,72 @@ Then: ## For sender developers -To send notifications from your own app you authenticate as **your app's DID** -using atproto service-auth JWTs. There is no registration step — the relay -verifies your signature against your DID document. - -1. **Set up a `did:web` (or `did:plc`) for your app** with an atproto signing key. - For `did:web:yourapp.example`, host a DID document at - `https://yourapp.example/.well-known/did.json` containing a - `verificationMethod` whose id ends in `#atproto` (a `Multikey` with your - public key in `publicKeyMultibase`). The relay resolves plc + web DIDs. +**Two endpoints, two different auth mechanisms:** -2. **Mint a service-auth JWT** for each call. The `aud` must be the relay - (`did:web:notifs.atmo.tools` or `did:web:notifs.atmo.tools#notif_relay`) and - the `lxm` must match the method you're calling. Use atcute's - [`createServiceJwt`](https://www.npmjs.com/package/@atcute/xrpc-server): +- **`requestPermission`** proves *the user authorized this request* — it's + authenticated by the **user** (via OAuth into your app with the `authSender` + permission set). The sender DID and display info are passed in the body. +- **`send`** proves *the sender identity* — it's authenticated by **your app's + DID** (a service-auth JWT signed with your app's key). Unchanged. - ```ts - import { P256PrivateKeyExportable } from '@atcute/crypto'; - import { createServiceJwt } from '@atcute/xrpc-server/auth'; +1. **Set up a `did:web` (or `did:plc`) for your app** with an atproto signing key + (needed for `send`). For `did:web:yourapp.example`, host a DID document at + `https://yourapp.example/.well-known/did.json` containing a + `verificationMethod` whose id ends in `#atproto` (a `Multikey` with your public + key in `publicKeyMultibase`). The relay resolves plc + web DIDs. - const keypair = await P256PrivateKeyExportable.importRaw(yourPrivateKeyBytes); +2. **Request permission** — the user signs into your app via atproto OAuth. Your + app's scope needs only `requestPermission` (`send` uses your app's own key, not + the user's session): - async function jwt(lxm: string) { - return createServiceJwt({ - keypair, - issuer: 'did:web:yourapp.example', - audience: 'did:web:notifs.atmo.tools', - lxm, - expiresIn: 60, - }); - } + ``` + atproto rpc?lxm=tools.atmo.notifs.requestPermission&aud=* ``` -3. **Request permission** (the user approves it in the dashboard or Telegram): + (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 + `com.atproto.server.getServiceAuth` (`aud = did:web:notifs.atmo.tools`, + `lxm = tools.atmo.notifs.requestPermission`) and call: ```ts await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.requestPermission', { method: 'POST', headers: { - authorization: `Bearer ${await jwt('tools.atmo.notifs.requestPermission')}`, + authorization: `Bearer ${userServiceAuthJwt}`, // issued by the USER's PDS 'content-type': 'application/json', }, body: JSON.stringify({ - recipient: 'did:plc:therecipient', - reason: 'Get notified when someone replies to you', + senderDid: 'did:web:yourapp.example', // what the user approves & what `send` uses + title: 'Bookhive', // shown to the user at approval + description: 'New comments on your books', + iconUrl: 'https://yourapp.example/icon.png', }), }); // -> { id, status: "pending" | "alreadyGranted" } ``` -4. **Send a notification** once granted: + The user approves it in the dashboard or Telegram. + +3. **Send a notification** once granted — authenticated with **your app's own + key** (`aud` = the relay, `lxm = tools.atmo.notifs.send`): ```ts + import { P256PrivateKeyExportable } from '@atcute/crypto'; + import { createServiceJwt } from '@atcute/xrpc-server/auth'; + + const keypair = await P256PrivateKeyExportable.importRaw(yourPrivateKeyBytes); + const jwt = await createServiceJwt({ + keypair, + issuer: 'did:web:yourapp.example', + audience: 'did:web:notifs.atmo.tools', + lxm: 'tools.atmo.notifs.send', + expiresIn: 60, + }); + await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.send', { method: 'POST', - headers: { - authorization: `Bearer ${await jwt('tools.atmo.notifs.send')}`, - 'content-type': 'application/json', - }, + headers: { authorization: `Bearer ${jwt}`, 'content-type': 'application/json' }, body: JSON.stringify({ recipient: 'did:plc:therecipient', title: 'New reply', @@ -275,6 +288,7 @@ verifies your signature against your DID document. The relay publishes two OAuth permission sets so the website can request scopes: -- `tools.atmo.notifs.authSender` — `requestPermission` + `send` (for apps). +- `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/migrations/0002_request_metadata.sql b/apps/relay/migrations/0002_request_metadata.sql new file mode 100644 index 0000000..f8247e7 --- /dev/null +++ b/apps/relay/migrations/0002_request_metadata.sql @@ -0,0 +1,12 @@ +-- Revised requestPermission: the requester supplies user-facing display metadata +-- (title/description/icon) for the sender, stored per pending request and copied +-- onto the grant on approval. The legacy `pending_requests.reason` column stays +-- (nullable, no longer written/read) to avoid a risky DROP COLUMN. + +ALTER TABLE pending_requests ADD COLUMN title TEXT; +ALTER TABLE pending_requests ADD COLUMN description TEXT; +ALTER TABLE pending_requests ADD COLUMN icon_url TEXT; + +ALTER TABLE grants ADD COLUMN title TEXT; +ALTER TABLE grants ADD COLUMN description TEXT; +ALTER TABLE grants ADD COLUMN icon_url TEXT; diff --git a/apps/relay/package.json b/apps/relay/package.json index a12287d..1552704 100644 --- a/apps/relay/package.json +++ b/apps/relay/package.json @@ -9,7 +9,8 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc -b", - "db:migrate": "wrangler d1 migrations apply notifs-relay" + "db:migrate": "wrangler d1 migrations apply notifs-relay --remote", + "db:migrate:local": "wrangler d1 migrations apply notifs-relay --local" }, "dependencies": { "@atcute/client": "^5.0.0", diff --git a/apps/relay/src/db/queries.ts b/apps/relay/src/db/queries.ts index 7f632c8..73ef05d 100644 --- a/apps/relay/src/db/queries.ts +++ b/apps/relay/src/db/queries.ts @@ -37,7 +37,10 @@ export interface PendingRequestRow { id: string; recipient_did: Did; sender_did: Did; - reason: string | null; + reason: string | null; // legacy (migration 0001); unused + title: string | null; + description: string | null; + icon_url: string | null; created_at: number; expires_at: number; } @@ -47,16 +50,19 @@ export interface GrantRow { sender_did: Did; granted_at: number; muted: number; + title: string | null; + description: string | null; + icon_url: string | null; } -/** A grant joined with the (optional) cached sender profile. */ +/** A grant joined with the (optional) cached Bluesky profile (`s.*`). */ export interface GrantWithSenderRow extends GrantRow { handle: string | null; display_name: string | null; avatar_url: string | null; } -/** A pending request joined with the (optional) cached sender profile. */ +/** A pending request joined with the (optional) cached Bluesky profile (`s.*`). */ export interface PendingWithSenderRow extends PendingRequestRow { handle: string | null; display_name: string | null; @@ -244,7 +250,9 @@ export interface InsertPendingInput { id: string; recipientDid: Did; senderDid: Did; - reason: string | null; + title: string; + description: string | null; + iconUrl: string | null; createdAt: number; expiresAt: number; } @@ -252,9 +260,20 @@ export interface InsertPendingInput { export async function insertPending(db: D1Database, input: InsertPendingInput): Promise { await db .prepare( - 'INSERT INTO pending_requests (id, recipient_did, sender_did, reason, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)', + `INSERT INTO pending_requests + (id, recipient_did, sender_did, title, description, icon_url, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + input.id, + input.recipientDid, + input.senderDid, + input.title, + input.description, + input.iconUrl, + input.createdAt, + input.expiresAt, ) - .bind(input.id, input.recipientDid, input.senderDid, input.reason, input.createdAt, input.expiresAt) .run(); } @@ -315,22 +334,41 @@ export function getGrant(db: D1Database, recipientDid: Did, senderDid: Did): Pro .first(); } -/** Insert or refresh a grant. Re-granting resets `muted` to 0. */ -export async function upsertGrant( - db: D1Database, - recipientDid: Did, - senderDid: Did, - grantedAt: number, -): Promise { +export interface UpsertGrantInput { + recipientDid: Did; + senderDid: Did; + grantedAt: number; + /** Display metadata copied from the pending request; null for manual grants. */ + title: string | null; + description: string | null; + iconUrl: string | null; +} + +/** + * Insert or refresh a grant. Re-granting resets `muted` to 0. Metadata is copied + * from the pending request; `COALESCE` keeps any previously-stored metadata when + * a re-grant provides none (e.g. a manual grant with no requestId). + */ +export async function upsertGrant(db: D1Database, input: UpsertGrantInput): Promise { await db .prepare( - `INSERT INTO grants (recipient_did, sender_did, granted_at, muted) - VALUES (?, ?, ?, 0) + `INSERT INTO grants (recipient_did, sender_did, granted_at, muted, title, description, icon_url) + VALUES (?, ?, ?, 0, ?, ?, ?) ON CONFLICT(recipient_did, sender_did) DO UPDATE SET granted_at = excluded.granted_at, - muted = 0`, + muted = 0, + title = COALESCE(excluded.title, grants.title), + description = COALESCE(excluded.description, grants.description), + icon_url = COALESCE(excluded.icon_url, grants.icon_url)`, + ) + .bind( + input.recipientDid, + input.senderDid, + input.grantedAt, + input.title, + input.description, + input.iconUrl, ) - .bind(recipientDid, senderDid, grantedAt) .run(); } diff --git a/apps/relay/src/db/schema.sql b/apps/relay/src/db/schema.sql index 5d8893c..5c38c02 100644 --- a/apps/relay/src/db/schema.sql +++ b/apps/relay/src/db/schema.sql @@ -43,7 +43,10 @@ CREATE TABLE pending_requests ( id TEXT PRIMARY KEY, recipient_did TEXT NOT NULL, sender_did TEXT NOT NULL, - reason TEXT, + reason TEXT, -- legacy (migration 0001); no longer written/read + title TEXT, -- migration 0002: user-supplied display name + description TEXT, -- migration 0002 + icon_url TEXT, -- migration 0002 created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, UNIQUE (recipient_did, sender_did) @@ -56,6 +59,9 @@ CREATE TABLE grants ( sender_did TEXT NOT NULL, granted_at INTEGER NOT NULL, muted INTEGER NOT NULL DEFAULT 0, + title TEXT, -- migration 0002: copied from the pending request + description TEXT, -- migration 0002 + icon_url TEXT, -- migration 0002 PRIMARY KEY (recipient_did, sender_did) ); CREATE INDEX grants_by_recipient ON grants (recipient_did); diff --git a/apps/relay/src/delivery/dispatcher.ts b/apps/relay/src/delivery/dispatcher.ts index 1333b94..bbfda45 100644 --- a/apps/relay/src/delivery/dispatcher.ts +++ b/apps/relay/src/delivery/dispatcher.ts @@ -60,14 +60,13 @@ async function dispatch(env: Env, job: DispatchJob): Promise { return; } - // pendingRequest - const handle = `@${escapeMd(job.senderHandle)}`; - const name = - job.senderDisplayName !== undefined - ? `${escapeMd(job.senderDisplayName)} \\(${handle}\\)` - : handle; - const reasonLine = job.reason !== undefined ? `\n\n_${escapeMd(job.reason)}_` : ''; - const text = `🔔 *${name}* wants to send you notifications${reasonLine}`; + // pendingRequest: title is the bold header, description the body line, and the + // sender DID is shown in small/monospace so the user can verify it. + const descriptionLine = + job.senderDescription !== undefined ? `\n\n_${escapeMd(job.senderDescription)}_` : ''; + const text = + `🔔 *${escapeMd(job.senderTitle)}* wants to send you notifications${descriptionLine}` + + `\n\n\`${escapeMd(job.senderDid)}\``; const replyMarkup: InlineKeyboardMarkup = { inline_keyboard: [ [ diff --git a/apps/relay/src/env.ts b/apps/relay/src/env.ts index 195a07b..dd37fb1 100644 --- a/apps/relay/src/env.ts +++ b/apps/relay/src/env.ts @@ -24,9 +24,10 @@ export type DispatchJob = kind: 'pendingRequest'; channel: TelegramChannel; requestId: string; - senderHandle: string; - senderDisplayName?: string; - reason?: string; + senderTitle: string; + senderDescription?: string; + senderIconUrl?: string; + senderDid: string; }; /** Cloudflare bindings + vars + secrets, as declared in `wrangler.toml`. */ diff --git a/apps/relay/src/telegram/callbacks.ts b/apps/relay/src/telegram/callbacks.ts index c869a0b..7958e28 100644 --- a/apps/relay/src/telegram/callbacks.ts +++ b/apps/relay/src/telegram/callbacks.ts @@ -58,7 +58,14 @@ async function handleApprove( return; } - await q.upsertGrant(env.DB, pending.recipient_did, pending.sender_did, now()); + await q.upsertGrant(env.DB, { + recipientDid: pending.recipient_did, + senderDid: pending.sender_did, + grantedAt: now(), + title: pending.title, + description: pending.description, + iconUrl: pending.icon_url, + }); await q.deletePendingById(env.DB, requestId, recipientDid); if (messageId !== undefined) { await editMessageText(env, { diff --git a/apps/relay/src/xrpc/grant.ts b/apps/relay/src/xrpc/grant.ts index cd1f6d2..5a32796 100644 --- a/apps/relay/src/xrpc/grant.ts +++ b/apps/relay/src/xrpc/grant.ts @@ -12,9 +12,26 @@ export function makeGrant(app: AppContext): ProcedureConfig { const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - await q.ensureUser(app.env.DB, userDid, now()); - await q.upsertGrant(app.env.DB, userDid, input.sender, now()); + + // When granting from a pending request, copy its display metadata onto the + // grant so listGrants can show it later. For a manual grant (no requestId) + // the metadata stays null and listGrants falls back to Bluesky-resolved info. + const pending = + input.requestId !== undefined + ? await q.getPendingById(app.env.DB, input.requestId) + : null; + const fromPending = pending !== null && pending.recipient_did === userDid ? pending : null; + + await q.upsertGrant(app.env.DB, { + recipientDid: userDid, + senderDid: input.sender, + grantedAt: now(), + title: fromPending?.title ?? null, + description: fromPending?.description ?? null, + iconUrl: fromPending?.icon_url ?? null, + }); + if (input.requestId !== undefined) { await q.deletePendingById(app.env.DB, input.requestId, userDid); } diff --git a/apps/relay/src/xrpc/listGrants.ts b/apps/relay/src/xrpc/listGrants.ts index e66d915..49ea4a3 100644 --- a/apps/relay/src/xrpc/listGrants.ts +++ b/apps/relay/src/xrpc/listGrants.ts @@ -17,9 +17,13 @@ export function makeListGrants(app: AppContext): QueryConfig ({ sender: row.sender_did, + // user-supplied title; fall back to the Bluesky name/handle, then the DID + title: row.title ?? row.display_name ?? row.handle ?? row.sender_did, + description: row.description ?? undefined, + iconUrl: row.icon_url ?? undefined, senderHandle: row.handle ?? undefined, - senderDisplayName: row.display_name ?? undefined, - senderAvatar: row.avatar_url ?? undefined, + senderBskyDisplayName: row.display_name ?? undefined, + senderBskyAvatar: row.avatar_url ?? undefined, grantedAt: toIsoDatetime(row.granted_at), muted: row.muted === 1, })), diff --git a/apps/relay/src/xrpc/listPending.ts b/apps/relay/src/xrpc/listPending.ts index a0ae740..84c7117 100644 --- a/apps/relay/src/xrpc/listPending.ts +++ b/apps/relay/src/xrpc/listPending.ts @@ -20,10 +20,14 @@ export function makeListPending( pending: rows.map((row) => ({ id: row.id, sender: row.sender_did, + // user-supplied display metadata (fall back to the DID for title) + title: row.title ?? row.sender_did, + description: row.description ?? undefined, + iconUrl: row.icon_url ?? undefined, + // best-effort Bluesky profile (informational "verified on Bluesky") senderHandle: row.handle ?? undefined, - senderDisplayName: row.display_name ?? undefined, - senderAvatar: row.avatar_url ?? undefined, - reason: row.reason ?? undefined, + senderBskyDisplayName: row.display_name ?? undefined, + senderBskyAvatar: row.avatar_url ?? undefined, createdAt: toIsoDatetime(row.created_at), expiresAt: toIsoDatetime(row.expires_at), })), diff --git a/apps/relay/src/xrpc/requestPermission.ts b/apps/relay/src/xrpc/requestPermission.ts index f22e734..7b45898 100644 --- a/apps/relay/src/xrpc/requestPermission.ts +++ b/apps/relay/src/xrpc/requestPermission.ts @@ -1,8 +1,9 @@ import { ToolsAtmoNotifsRequestPermission } from '@atmo/notifs-lexicons'; import type { Did } from '@atcute/lexicons'; -import { json, type ProcedureConfig } from '@atcute/xrpc-server'; +import { isDid } from '@atcute/lexicons/syntax'; +import { InvalidRequestError, json, type ProcedureConfig } from '@atcute/xrpc-server'; -import { verifySenderRequest } from '../auth/sender'; +import { verifyUserRequest } from '../auth/user'; import * as q from '../db/queries'; import type { AppContext } from '../env'; import { rateLimited } from '../lib/errors'; @@ -13,62 +14,89 @@ import { checkAndIncrement } from '../ratelimit'; const LXM = 'tools.atmo.notifs.requestPermission'; -// Per-sender cap on new pending requests: 100 per rolling hour. -const REQ_LIMIT = 100; -const REQ_WINDOW_SECONDS = 60 * 60; +// New pending-request caps, both per rolling hour. +const PER_RECIPIENT_LIMIT = 50; // NEW: stops one OAuth'd app spamming a user +const PER_SENDER_LIMIT = 100; // unchanged from the original design +const WINDOW_SECONDS = 60 * 60; export function makeRequestPermission( app: AppContext, ): ProcedureConfig { return { handler: async ({ request, input }) => { - const { senderDid } = await verifySenderRequest(app.verifier, request, LXM); - const recipient = input.recipient; + // Auth flipped to the user path: the JWT issuer is the recipient (the user + // who'd receive notifications). The sender DID is supplied in the body. + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); + const senderDid = input.senderDid; - // 2. Already granted? Return a stable id and the alreadyGranted status. - const existingGrant = await q.getGrant(app.env.DB, recipient, senderDid); + // 1. Validate the sender DID (router already enforces `format: did`; this + // keeps the contract explicit and surfaces InvalidRequest). + if (!isDid(senderDid)) { + throw new InvalidRequestError({ message: 'senderDid is not a valid DID' }); + } + + // 2. Ensure the user row exists. + await q.ensureUser(app.env.DB, userDid, now()); + + // 3. Already granted? Short-circuit. + const existingGrant = await q.getGrant(app.env.DB, userDid, senderDid); if (existingGrant !== null) { - return json({ id: pseudoGrantId(recipient, senderDid), status: 'alreadyGranted' }); + return json({ id: pseudoGrantId(userDid, senderDid), status: 'alreadyGranted' }); } - // 3. Per-pair pending cap: reuse a live pending request instead of inserting a duplicate. - const existingPending = await q.getPendingByPair(app.env.DB, recipient, senderDid); + // 4. Per-pair pending cap: reuse a live pending request instead of duplicating. + const existingPending = await q.getPendingByPair(app.env.DB, userDid, senderDid); if (existingPending !== null && existingPending.expires_at > now()) { return json({ id: existingPending.id, status: 'pending' }); } - // 4. Per-sender global rate limit. - const rl = await checkAndIncrement( + // 5. Rate limits: per recipient (new) and per sender DID (unchanged). + const perRecipient = await checkAndIncrement( app.env.CACHE, - `rl:req:${senderDid}`, - REQ_LIMIT, - REQ_WINDOW_SECONDS, + `rl:req:recipient:${userDid}`, + PER_RECIPIENT_LIMIT, + WINDOW_SECONDS, ); - if (!rl.allowed) { - throw rateLimited(rl.resetIn, 'Too many permission requests; try again later'); + if (!perRecipient.allowed) { + throw rateLimited(perRecipient.resetIn, 'Too many permission requests for this account'); + } + const perSender = await checkAndIncrement( + app.env.CACHE, + `rl:req:sender:${senderDid}`, + PER_SENDER_LIMIT, + WINDOW_SECONDS, + ); + if (!perSender.allowed) { + throw rateLimited(perSender.resetIn, 'Too many permission requests from this sender'); } - // 5. Insert the pending request (replacing any stale/expired row for the pair - // so the UNIQUE(recipient_did, sender_did) constraint holds). + // 6. Insert the pending request (replacing any stale row for the pair so the + // UNIQUE(recipient_did, sender_did) constraint holds). if (existingPending !== null) { - await q.deletePendingByPair(app.env.DB, recipient, senderDid); + await q.deletePendingByPair(app.env.DB, userDid, senderDid); } const id = newId(); - const reason = input.reason ?? null; const createdAt = now(); + const description = input.description ?? null; + const iconUrl = input.iconUrl ?? null; await q.insertPending(app.env.DB, { id, - recipientDid: recipient, + recipientDid: userDid, senderDid, - reason, + title: input.title, + description, + iconUrl, createdAt, expiresAt: addDays(createdAt, 7), }); - // 6. Fire-and-forget: refresh the sender profile cache. + // 7. Fire-and-forget: refresh the sender's Bluesky profile cache (informational + // "verified on Bluesky" fallback for the dashboard). app.ctx.waitUntil(ensureSenderProfile(app.env, senderDid)); - // 7. Fire-and-forget: optionally notify the recipient on Telegram. - app.ctx.waitUntil(maybeNotifyPending(app, recipient, senderDid, id, reason)); + // 8. Fire-and-forget: optionally notify the recipient on Telegram. + app.ctx.waitUntil( + maybeNotifyPending(app, userDid, senderDid, id, input.title, description, iconUrl), + ); return json({ id, status: 'pending' }); }, @@ -89,7 +117,9 @@ async function maybeNotifyPending( recipient: Did, senderDid: Did, requestId: string, - reason: string | null, + title: string, + description: string | null, + iconUrl: string | null, ): Promise { const user = await q.getUser(app.env.DB, recipient); if (user === null || user.notify_pending_via_telegram !== 1) { @@ -101,13 +131,13 @@ async function maybeNotifyPending( return; } - const sender = await q.getSender(app.env.DB, senderDid); await app.env.DISPATCH_QUEUE.send({ kind: 'pendingRequest', channel: { platform: 'telegram', platformUserId: telegram.platform_user_id }, requestId, - senderHandle: sender?.handle ?? senderDid, - senderDisplayName: sender?.display_name ?? undefined, - reason: reason ?? undefined, + senderTitle: title, + senderDescription: description ?? undefined, + senderIconUrl: iconUrl ?? undefined, + senderDid, }); } diff --git a/apps/relay/test/grant.test.ts b/apps/relay/test/grant.test.ts index ab2d38c..69a7d46 100644 --- a/apps/relay/test/grant.test.ts +++ b/apps/relay/test/grant.test.ts @@ -20,14 +20,16 @@ async function call(req: Request): Promise { return res; } -it('grant inserts a row and consumes the pending request', async () => { +it('grant consumes the pending request and copies its metadata onto the grant', async () => { const user = await makeIdentity('did:plc:grantuser'); mockPlc(user); await q.insertPending(env.DB, { id: 'req-1', recipientDid: user.did, senderDid: SENDER, - reason: null, + title: 'Bookhive', + description: 'New comments on your books', + iconUrl: 'https://bookhive.example/icon.png', createdAt: Date.now(), expiresAt: Date.now() + 1_000_000, }); @@ -37,14 +39,26 @@ it('grant inserts a row and consumes the pending request', async () => { expect(res.status).toBe(200); expect(await res.json()).toEqual({ granted: true }); - expect(await q.getGrant(env.DB, user.did, SENDER)).not.toBeNull(); expect(await q.getPendingById(env.DB, 'req-1')).toBeNull(); + + const grant = await q.getGrant(env.DB, user.did, SENDER); + expect(grant).not.toBeNull(); + expect(grant?.title).toBe('Bookhive'); + expect(grant?.description).toBe('New comments on your books'); + expect(grant?.icon_url).toBe('https://bookhive.example/icon.png'); }); it('revoke removes the grant', async () => { const user = await makeIdentity('did:plc:revokeuser'); mockPlc(user); - await q.upsertGrant(env.DB, user.did, SENDER, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: user.did, + senderDid: SENDER, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null, + }); const jwt = await makeJwt(user, { lxm: 'tools.atmo.notifs.revoke' }); const res = await call(xrpcPost('tools.atmo.notifs.revoke', jwt, { sender: SENDER })); diff --git a/apps/relay/test/requestPermission.test.ts b/apps/relay/test/requestPermission.test.ts index 69f2d33..1ddaddc 100644 --- a/apps/relay/test/requestPermission.test.ts +++ b/apps/relay/test/requestPermission.test.ts @@ -5,23 +5,17 @@ import { beforeAll, expect, it } from 'vitest'; import * as q from '../src/db/queries'; import worker from '../src/index'; -import { - installFetchMock, - makeBskyProfileMock, - makeIdentity, - makeJwt, - mockPlc, - xrpcPost, -} from './helpers'; +import { installFetchMock, makeBskyProfileMock, makeIdentity, makeJwt, mockPlc, xrpcPost } from './helpers'; beforeAll(() => { installFetchMock(); - // Sender profile refresh runs fire-and-forget; stub it so it never hits network. + // The sender profile refresh runs fire-and-forget; stub it so it never hits network. makeBskyProfileMock(); }); const REQ = 'tools.atmo.notifs.requestPermission'; -const RECIPIENT: Did = 'did:plc:reqrecipient'; +// requestPermission is now user-authenticated; the sender is a plain DID in the body. +const SENDER: Did = 'did:plc:somesender'; async function call(req: Request): Promise { const ctx = createExecutionContext(); @@ -35,60 +29,73 @@ interface RequestResult { status: string; } -it('first request creates a pending request', async () => { - const sender = await makeIdentity('did:plc:reqfirst'); - mockPlc(sender); - const jwt = await makeJwt(sender, { lxm: REQ }); +it('first request creates a pending request with the supplied metadata', async () => { + const user = await makeIdentity('did:plc:requser1'); + mockPlc(user); + const jwt = await makeJwt(user, { lxm: REQ }); - const res = await call(xrpcPost(REQ, jwt, { recipient: RECIPIENT, reason: 'because' })); + const res = await call( + xrpcPost(REQ, jwt, { senderDid: SENDER, title: 'Bookhive', description: 'New comments' }), + ); expect(res.status).toBe(200); const data = (await res.json()) as RequestResult; expect(data.status).toBe('pending'); - expect(typeof data.id).toBe('string'); - expect(await q.getPendingByPair(env.DB, RECIPIENT, sender.did)).not.toBeNull(); + + const pending = await q.getPendingByPair(env.DB, user.did, SENDER); + expect(pending).not.toBeNull(); + expect(pending?.title).toBe('Bookhive'); + expect(pending?.description).toBe('New comments'); }); it('a duplicate within the window returns the same pending request', async () => { - const sender = await makeIdentity('did:plc:reqdup'); - mockPlc(sender); - const jwt = await makeJwt(sender, { lxm: REQ }); + const user = await makeIdentity('did:plc:requser2'); + mockPlc(user); + const jwt = await makeJwt(user, { lxm: REQ }); + const body = { senderDid: SENDER, title: 'Bookhive' }; - const first = (await (await call(xrpcPost(REQ, jwt, { recipient: RECIPIENT }))).json()) as RequestResult; - const second = (await (await call(xrpcPost(REQ, jwt, { recipient: RECIPIENT }))).json()) as RequestResult; + const first = (await (await call(xrpcPost(REQ, jwt, body))).json()) as RequestResult; + const second = (await (await call(xrpcPost(REQ, jwt, body))).json()) as RequestResult; expect(second.id).toBe(first.id); const count = await env.DB.prepare( 'SELECT COUNT(*) AS c FROM pending_requests WHERE recipient_did = ? AND sender_did = ?', ) - .bind(RECIPIENT, sender.did) + .bind(user.did, SENDER) .first<{ c: number }>(); expect(count?.c).toBe(1); }); it('returns alreadyGranted when a grant exists', async () => { - const sender = await makeIdentity('did:plc:reqgranted'); - mockPlc(sender); - await q.upsertGrant(env.DB, RECIPIENT, sender.did, Date.now()); - const jwt = await makeJwt(sender, { lxm: REQ }); + const user = await makeIdentity('did:plc:requser3'); + mockPlc(user); + await q.upsertGrant(env.DB, { + recipientDid: user.did, + senderDid: SENDER, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null, + }); + const jwt = await makeJwt(user, { lxm: REQ }); - const res = await call(xrpcPost(REQ, jwt, { recipient: RECIPIENT })); + const res = await call(xrpcPost(REQ, jwt, { senderDid: SENDER, title: 'Bookhive' })); const data = (await res.json()) as RequestResult; expect(data.status).toBe('alreadyGranted'); }); -it('returns 429 when the per-sender rate limit is exceeded', async () => { - const sender = await makeIdentity('did:plc:reqratelimited'); - mockPlc(sender); - // Seed the hourly counter at the limit so the next request trips it. - await env.CACHE.put(`rl:req:${sender.did}`, '100', { +it('returns 429 when the per-recipient rate limit is exceeded', async () => { + const user = await makeIdentity('did:plc:requser4'); + mockPlc(user); + // Seed the per-recipient hourly counter at its limit (50) so the next trips it. + await env.CACHE.put(`rl:req:recipient:${user.did}`, '50', { expirationTtl: 3600, metadata: { expiresAt: Date.now() + 3_600_000 }, }); - const jwt = await makeJwt(sender, { lxm: REQ }); + const jwt = await makeJwt(user, { lxm: REQ }); - const res = await call(xrpcPost(REQ, jwt, { recipient: RECIPIENT })); + const res = await call(xrpcPost(REQ, jwt, { senderDid: SENDER, title: 'Bookhive' })); expect(res.status).toBe(429); }); diff --git a/apps/relay/test/send.test.ts b/apps/relay/test/send.test.ts index 5e6b766..5112bd6 100644 --- a/apps/relay/test/send.test.ts +++ b/apps/relay/test/send.test.ts @@ -39,7 +39,14 @@ it('returns 403 when no grant exists', async () => { it('accepts but delivers to nobody when there is a grant but no channel', async () => { const sender = await makeIdentity('did:plc:sendnochannel'); mockPlc(sender); - await q.upsertGrant(env.DB, RECIPIENT, sender.did, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: RECIPIENT, + senderDid: sender.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null + }); const jwt = await makeJwt(sender, { lxm: SEND }); const res = await call(send(jwt)); @@ -51,7 +58,14 @@ it('accepts but delivers to nobody when there is a grant but no channel', async it('enqueues and reports delivered=1 with a linked channel', async () => { const sender = await makeIdentity('did:plc:sendchannel'); mockPlc(sender); - await q.upsertGrant(env.DB, RECIPIENT, sender.did, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: RECIPIENT, + senderDid: sender.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null + }); await q.upsertChannel(env.DB, { did: RECIPIENT, platform: 'telegram', @@ -75,7 +89,14 @@ it('enqueues and reports delivered=1 with a linked channel', async () => { it('accepts silently with delivered=0 when the grant is muted', async () => { const sender = await makeIdentity('did:plc:sendmuted'); mockPlc(sender); - await q.upsertGrant(env.DB, RECIPIENT, sender.did, Date.now()); + await q.upsertGrant(env.DB, { + recipientDid: RECIPIENT, + senderDid: sender.did, + grantedAt: Date.now(), + title: null, + description: null, + iconUrl: null + }); await q.setGrantMuted(env.DB, RECIPIENT, sender.did, true); await q.upsertChannel(env.DB, { did: RECIPIENT, diff --git a/apps/relay/wrangler.toml b/apps/relay/wrangler.toml index 139174c..b9b53a8 100644 --- a/apps/relay/wrangler.toml +++ b/apps/relay/wrangler.toml @@ -18,7 +18,9 @@ binding = "CACHE" id = "8b1baa80a28c468c83da20f3f6dec3b6" [[queues.producers]] -binding = "notifs_dispatch" +# `binding` is the code-side name (env.DISPATCH_QUEUE); `queue` is the real +# Cloudflare queue you created (`notifs-dispatch`). The binding name is arbitrary. +binding = "DISPATCH_QUEUE" queue = "notifs-dispatch" [[queues.consumers]] diff --git a/apps/web/README.md b/apps/web/README.md index e36afc9..781bbcb 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,42 +1,80 @@ -# sv +# Atmo Notifs — website -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +The user-facing dashboard for the [atproto notifications relay](../relay). Users +sign in with Bluesky (atproto OAuth), approve/revoke which apps may notify them, +link Telegram, and manage settings. Built with SvelteKit + Tailwind v4. -## Creating a project +> **Names are placeholders.** The product name (`PROJECT_NAME`) and footer repo +> link (`GITHUB_URL`) live in [`src/lib/config.ts`](src/lib/config.ts). Relay +> constants (domain, DID, scope) are in the same file. -If you're seeing this, you've probably already done this step. Congrats! +## How it fits together -```sh -# create a new project -npx sv create my-app -``` +- **Auth** — provided by `@svelte-atproto/oauth` (scaffolded; do not modify + `src/hooks.server.ts`, `src/app.d.ts`, or `src/lib/atproto/index.ts` beyond the + OAuth scope). The hook populates `locals.did` / `locals.session` / `locals.client`. +- **Reads** — page `load` functions (`+page.server.ts`) call the relay in parallel. +- **Writes** — SvelteKit **remote functions** (`command`s in + `src/lib/remote/notifs.remote.ts`); the page calls `invalidateAll()` after each. +- **Relay calls** — `src/lib/server/relay.ts` mints a per-request service-auth JWT + via the user's PDS (`com.atproto.server.getServiceAuth`) and calls the relay. + The JWT never reaches the browser. +- **Types** — request/response shapes come from the workspace package + `@atmo/notifs-lexicons`. -To recreate this project with the same configuration: +Remote functions are enabled via `kit.experimental.remoteFunctions` + +`compilerOptions.experimental.async` in `svelte.config.js`. -```sh -# recreate this project -pnpm dlx sv@0.15.3 create --template minimal --types ts --add prettier eslint tailwindcss="plugins:typography,forms" --install pnpm web -``` +## Routes -## Developing +| Route | Purpose | +| ------------------------- | ----------------------------------------------------- | +| `/` | Landing + "Sign in with Bluesky" | +| `/dashboard` | Pending requests, granted apps, channels, settings | +| `/dashboard/pending/[id]` | Deep-linkable single pending request (bot links here) | +| `/docs` | Developer docs for sending notifications | -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +## Theming -```sh -npm run dev +Light/dark via semantic CSS variables in `src/routes/layout.css` (exposed to +Tailwind through `@theme inline`, so `bg-surface`, `text-muted`, etc. switch +automatically). Defaults to the OS preference; the header toggle overrides it +(persisted in `localStorage`, applied before paint by a small script in +`src/app.html` to avoid a flash). -# or start the server and open the app in a new browser tab -npm run dev -- --open -``` +## Local development -## Building +```sh +pnpm install +pnpm atproto:setup # generates dev OAuth keys / .env (from @svelte-atproto) +pnpm dev +``` -To create a production version of your app: +Set the env vars in `.env` (see `.env.example`): `ORIGIN`, `COOKIE_SECRET`, +`CLIENT_ASSERTION_KEY`. The relay must be reachable at the domain in +`src/lib/config.ts` (`notifs.atmo.tools`) for dashboard data to load — point it at +a local relay during development if needed. ```sh -npm run build +pnpm check # svelte-check (type check) +pnpm build # production build ``` -You can preview the production build with `npm run preview`. +## Deployment (Cloudflare) + +The OAuth session store uses Cloudflare KV, so this deploys to Cloudflare. + +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`: + ```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. -> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. +The OAuth client metadata is served dynamically by `@svelte-atproto/oauth` — no +static file to write. diff --git a/apps/web/package.json b/apps/web/package.json index 1832d91..3692fb2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -42,6 +42,12 @@ }, "dependencies": { "@atcute/atproto": "^3.1.10", - "@svelte-atproto/oauth": "^0.1.0" + "@atcute/client": "^4.2.1", + "@atcute/identity": "^1.1.5", + "@atcute/lexicons": "^1.2.9", + "@atmo/notifs-lexicons": "workspace:*", + "@svelte-atproto/oauth": "^0.1.0", + "shiki": "^4.1.0", + "valibot": "^1.4.0" } } diff --git a/apps/web/src/app.html b/apps/web/src/app.html index 6a2bb58..38da952 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -4,6 +4,25 @@ + + + + + + + + %sveltekit.head% diff --git a/apps/web/src/lib/atproto/index.ts b/apps/web/src/lib/atproto/index.ts index 555eb72..26e84a4 100644 --- a/apps/web/src/lib/atproto/index.ts +++ b/apps/web/src/lib/atproto/index.ts @@ -3,13 +3,15 @@ import '@atcute/atproto'; import { createAtprotoAuth } from '@svelte-atproto/oauth/server'; import { cloudflareKV } from '@svelte-atproto/oauth/server/stores/cloudflare'; import { env } from '$env/dynamic/private'; +import { OAUTH_SCOPE } from '$lib/config'; // To enable signup, add: signupPDS: 'https://your-pds.example/' export const atproto = createAtprotoAuth({ origin: env.ORIGIN, cookieSecret: env.COOKIE_SECRET, clientAssertionKey: env.CLIENT_ASSERTION_KEY, - scope: 'atproto repo:xyz.statusphere.status', + // Requests the `tools.atmo.notifs.authUser` permission set scoped to the relay. + scope: OAUTH_SCOPE, sessions: cloudflareKV('OAUTH_SESSIONS'), states: cloudflareKV('OAUTH_STATES', { ttl: 600 }) }); diff --git a/apps/web/src/lib/components/EmptyState.svelte b/apps/web/src/lib/components/EmptyState.svelte new file mode 100644 index 0000000..410ff99 --- /dev/null +++ b/apps/web/src/lib/components/EmptyState.svelte @@ -0,0 +1,22 @@ + + +
+

{title}

+ {#if description} +

{description}

+ {/if} + {#if cta} +
{@render cta()}
+ {/if} +
diff --git a/apps/web/src/lib/components/Logomark.svelte b/apps/web/src/lib/components/Logomark.svelte new file mode 100644 index 0000000..3cd9a7b --- /dev/null +++ b/apps/web/src/lib/components/Logomark.svelte @@ -0,0 +1,16 @@ + + + + diff --git a/apps/web/src/lib/components/RelativeTime.svelte b/apps/web/src/lib/components/RelativeTime.svelte new file mode 100644 index 0000000..18ec621 --- /dev/null +++ b/apps/web/src/lib/components/RelativeTime.svelte @@ -0,0 +1,43 @@ + + + diff --git a/apps/web/src/lib/components/SenderCard.svelte b/apps/web/src/lib/components/SenderCard.svelte new file mode 100644 index 0000000..eec3bae --- /dev/null +++ b/apps/web/src/lib/components/SenderCard.svelte @@ -0,0 +1,72 @@ + + +
+ {#if icon} + + {:else} + + {/if} +
+
{title}
+
{shortDid}
+ {#if description} +

{description}

+ {/if} + {#if senderHandle} +

+ + Verified on Bluesky: @{senderHandle} +

+ {/if} +
+
diff --git a/apps/web/src/lib/components/ThemeToggle.svelte b/apps/web/src/lib/components/ThemeToggle.svelte new file mode 100644 index 0000000..8c69b9b --- /dev/null +++ b/apps/web/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,83 @@ + + + diff --git a/apps/web/src/lib/components/Toggle.svelte b/apps/web/src/lib/components/Toggle.svelte new file mode 100644 index 0000000..53e3daa --- /dev/null +++ b/apps/web/src/lib/components/Toggle.svelte @@ -0,0 +1,30 @@ + + + diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts new file mode 100644 index 0000000..f8f75d0 --- /dev/null +++ b/apps/web/src/lib/config.ts @@ -0,0 +1,55 @@ +// Single source of truth for relay-facing constants. See the relay's README +// "Configuration" section to keep these in sync if the relay is re-homed. + +/** The notification relay's domain. */ +export const RELAY_DOMAIN = 'notifs.atmo.tools'; + +/** `https://notifs.atmo.tools` — base for relay XRPC calls. */ +export const RELAY_ORIGIN = `https://${RELAY_DOMAIN}`; + +/** The relay's `did:web` identity (the `aud` for service-auth JWTs). */ +export const RELAY_DID = 'did:web:notifs.atmo.tools'; + +/** Service-ref form (the relay's `#notif_relay` service). */ +export const RELAY_SERVICE_REF = `${RELAY_DID}#notif_relay`; + +/** Lexicon NSID prefix for all relay methods. */ +export const LEXICON_PREFIX = 'tools.atmo.notifs'; + +/** + * The user-management methods the website calls on the relay. These mirror the + * `tools.atmo.notifs.authUser` permission set, but we request them directly as + * individual `rpc` scopes instead of `include:`-ing the permission set — the + * permission-set lexicon isn't published to the network yet, and `include:` + * makes the authorization server resolve it ("Could not resolve Lexicon for + * NSID"). Requesting the methods directly avoids that. + */ +export const USER_LXMS = [ + 'grant', + 'revoke', + 'denyPending', + 'muteGrant', + 'listGrants', + 'listPending', + 'linkChannel', + 'unlinkChannel', + 'listChannels', + 'getSettings', + 'updateSettings' +].map((method) => `${LEXICON_PREFIX}.${method}`); + +/** + * OAuth scope: base `atproto` plus an `rpc` permission for the relay methods. + * Format is `rpc?lxm=&lxm=…&aud=`. We use `aud=*` for now (any + * audience) so service-auth tokens can be minted for the relay; tighten to the + * relay's service ref once the permission set is published. + */ +export const OAUTH_SCOPE = `atproto rpc?${USER_LXMS.map((lxm) => `lxm=${lxm}`).join('&')}&aud=*`; + +// --- Branding (placeholders — rename freely) ------------------------------- + +/** Product name shown in the UI. Placeholder; rename to taste. */ +export const PROJECT_NAME = 'Atmo Notifs'; + +/** GitHub repo link in the footer. */ +export const GITHUB_URL = 'https://github.com/flo-bit/atproto-notify'; diff --git a/apps/web/src/lib/remote/notifs.remote.ts b/apps/web/src/lib/remote/notifs.remote.ts new file mode 100644 index 0000000..5a3e431 --- /dev/null +++ b/apps/web/src/lib/remote/notifs.remote.ts @@ -0,0 +1,56 @@ +// Remote `command` functions for dashboard mutations. Reads live in the page +// `load` functions; after a command runs, the client calls `invalidateAll()` to +// refresh the page data. +import type { Did } from '@atcute/lexicons'; +import { command, getRequestEvent } from '$app/server'; +import { error } from '@sveltejs/kit'; +import * as v from 'valibot'; + +import { relay } from '$lib/server/relay'; + +/** Resolve the signed-in user's authenticated client, or 401. */ +function requireClient() { + const { locals } = getRequestEvent(); + if (!locals.client) { + error(401, 'Not signed in'); + } + return locals.client; +} + +const didSchema = v.pipe(v.string(), v.startsWith('did:')); + +export const approve = command( + v.object({ sender: didSchema, requestId: v.optional(v.string()) }), + async ({ sender, requestId }) => { + await relay.grant(requireClient(), { sender: sender as Did, requestId }); + } +); + +export const deny = command(v.object({ requestId: v.string() }), async ({ requestId }) => { + await relay.denyPending(requireClient(), { requestId }); +}); + +export const revoke = command(v.object({ sender: didSchema }), async ({ sender }) => { + await relay.revoke(requireClient(), { sender: sender as Did }); +}); + +export const setMuted = command( + v.object({ sender: didSchema, muted: v.boolean() }), + async ({ sender, muted }) => { + await relay.muteGrant(requireClient(), { sender: sender as Did, muted }); + } +); + +export const setNotifyPending = command(v.object({ value: v.boolean() }), async ({ value }) => { + await relay.updateSettings(requireClient(), { notifyPendingViaTelegram: value }); +}); + +/** Returns the Telegram deep link; the client navigates to it. */ +export const linkTelegram = command(async () => { + const { deepLink } = await relay.linkChannel(requireClient(), { platform: 'telegram' }); + return { deepLink }; +}); + +export const unlinkTelegram = command(async () => { + await relay.unlinkChannel(requireClient(), { platform: 'telegram' }); +}); diff --git a/apps/web/src/lib/server/highlight.ts b/apps/web/src/lib/server/highlight.ts new file mode 100644 index 0000000..6995d76 --- /dev/null +++ b/apps/web/src/lib/server/highlight.ts @@ -0,0 +1,37 @@ +// Server-only syntax highlighting via Shiki. Uses the fine-grained core + the +// JavaScript regex engine (no WASM) so it runs on Cloudflare Workers, and a +// single lazily-created highlighter is reused across requests. +import { createHighlighterCore, type HighlighterCore } from 'shiki/core'; +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; +import bash from 'shiki/langs/bash.mjs'; +import typescript from 'shiki/langs/typescript.mjs'; +import githubDark from 'shiki/themes/github-dark.mjs'; +import githubLight from 'shiki/themes/github-light.mjs'; + +export type CodeLang = 'bash' | 'ts'; + +const LANG_ID: Record = { bash: 'bash', ts: 'typescript' }; + +let highlighterPromise: Promise | undefined; + +function getHighlighter(): Promise { + highlighterPromise ??= createHighlighterCore({ + themes: [githubLight, githubDark], + langs: [bash, typescript], + engine: createJavaScriptRegexEngine() + }); + return highlighterPromise; +} + +/** + * Render code to themed HTML. Emits dual-theme output (light inline + a + * `--shiki-dark` CSS variable); `layout.css` swaps to the dark variable under our + * dark theme. + */ +export async function highlight(code: string, lang: CodeLang): Promise { + const highlighter = await getHighlighter(); + return highlighter.codeToHtml(code, { + lang: LANG_ID[lang], + themes: { light: 'github-light', dark: 'github-dark' } + }); +} diff --git a/apps/web/src/lib/server/relay.ts b/apps/web/src/lib/server/relay.ts new file mode 100644 index 0000000..4667886 --- /dev/null +++ b/apps/web/src/lib/server/relay.ts @@ -0,0 +1,144 @@ +// Server-only helper for calling the notification relay on behalf of the +// signed-in user. Mints a short-lived service-auth JWT via the user's PDS, then +// calls the relay's XRPC endpoint with it. The JWT never leaves the server. +import '@atcute/atproto'; // side-effect: registers com.atproto.* lexicon types +import type { Did, Nsid } from '@atcute/lexicons'; +import type { + ToolsAtmoNotifsDenyPending, + ToolsAtmoNotifsGetSettings, + ToolsAtmoNotifsGrant, + ToolsAtmoNotifsLinkChannel, + ToolsAtmoNotifsListChannels, + ToolsAtmoNotifsListGrants, + ToolsAtmoNotifsListPending, + ToolsAtmoNotifsMuteGrant, + ToolsAtmoNotifsRevoke, + ToolsAtmoNotifsUnlinkChannel, + ToolsAtmoNotifsUpdateSettings +} from '@atmo/notifs-lexicons'; + +import { RELAY_DID, RELAY_ORIGIN } from '$lib/config'; + +type AppClient = App.Locals['client']; + +interface RelayErrorBody { + error?: string; + message?: string; +} + +/** + * Call a relay XRPC method as the signed-in user. + * + * 1. Ask the user's PDS for a service-auth token (`com.atproto.server.getServiceAuth`) + * scoped to `aud = ` and `lxm = `. + * 2. Call `https:///xrpc/` with `Authorization: Bearer `. + * 3. Throw on non-2xx with the relay's `error: message`; otherwise return the body. + */ +export async function callRelay( + client: AppClient, + lxm: string, + body: object | null, + method: 'GET' | 'POST' +): Promise { + if (!client) { + throw new Error('Not signed in'); + } + + const authRes = await client.get('com.atproto.server.getServiceAuth', { + params: { aud: RELAY_DID as Did, lxm: lxm as Nsid } + }); + if (!authRes.ok) { + throw new Error('Failed to obtain a service-auth token from your PDS'); + } + + const res = await fetch(`${RELAY_ORIGIN}/xrpc/${lxm}`, { + method, + headers: { + authorization: `Bearer ${authRes.data.token}`, + 'content-type': 'application/json' + }, + body: method === 'POST' && body !== null ? JSON.stringify(body) : undefined + }); + + const text = await res.text(); + const parsed: unknown = text ? JSON.parse(text) : {}; + + if (!res.ok) { + const err = parsed as RelayErrorBody; + const name = err.error ?? `RelayError(${res.status})`; + throw new Error(err.message ? `${name}: ${err.message}` : name); + } + + return parsed as T; +} + +/** Narrow, typed wrappers around {@link callRelay}, using the generated lexicon types. */ +export const relay = { + listGrants: (client: AppClient) => + callRelay( + client, + 'tools.atmo.notifs.listGrants', + null, + 'GET' + ), + listPending: (client: AppClient) => + callRelay( + client, + 'tools.atmo.notifs.listPending', + null, + 'GET' + ), + listChannels: (client: AppClient) => + callRelay( + client, + 'tools.atmo.notifs.listChannels', + null, + 'GET' + ), + getSettings: (client: AppClient) => + callRelay( + client, + 'tools.atmo.notifs.getSettings', + null, + 'GET' + ), + grant: (client: AppClient, input: ToolsAtmoNotifsGrant.$input) => + callRelay(client, 'tools.atmo.notifs.grant', input, 'POST'), + revoke: (client: AppClient, input: ToolsAtmoNotifsRevoke.$input) => + callRelay(client, 'tools.atmo.notifs.revoke', input, 'POST'), + denyPending: (client: AppClient, input: ToolsAtmoNotifsDenyPending.$input) => + callRelay( + client, + 'tools.atmo.notifs.denyPending', + input, + 'POST' + ), + muteGrant: (client: AppClient, input: ToolsAtmoNotifsMuteGrant.$input) => + callRelay( + client, + 'tools.atmo.notifs.muteGrant', + input, + 'POST' + ), + linkChannel: (client: AppClient, input: ToolsAtmoNotifsLinkChannel.$input) => + callRelay( + client, + 'tools.atmo.notifs.linkChannel', + input, + 'POST' + ), + unlinkChannel: (client: AppClient, input: ToolsAtmoNotifsUnlinkChannel.$input) => + callRelay( + client, + 'tools.atmo.notifs.unlinkChannel', + input, + 'POST' + ), + updateSettings: (client: AppClient, input: ToolsAtmoNotifsUpdateSettings.$input) => + callRelay( + client, + 'tools.atmo.notifs.updateSettings', + input, + 'POST' + ) +}; diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts new file mode 100644 index 0000000..2040bd0 --- /dev/null +++ b/apps/web/src/routes/+layout.server.ts @@ -0,0 +1,6 @@ +import type { LayoutServerLoad } from './$types'; + +// Expose the signed-in DID to the whole app (header nav, landing banner). +export const load: LayoutServerLoad = ({ locals }) => { + return { did: locals.did }; +}; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 0d8eb03..db3ab87 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -1,9 +1,74 @@ -{@render children()} + +
+
+
+ + + {PROJECT_NAME} + + +
+
+ +
+ {@render children()} +
+ + +
diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index cc88df0..c4bea5d 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,2 +1,118 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + + + {PROJECT_NAME} — notifications for the atmosphere + + +{#if data.did} +
+ You're signed in. + + Go to dashboard → + +
+{/if} + +
+
+ +
+ +

+ {PROJECT_NAME} +

+

+ Lets any AT Protocol app send you notifications via Telegram (and soon more). You stay in + control: every app must ask permission, and you can revoke any time. +

+ + {#if !data.did} +
+ +
+ + +
+

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

+ {#if errorMsg} + + {/if} +
+ {/if} + +
+ {#if data.did} + + Dashboard + + {/if} + + Developer docs + +
+
+ +
+ {#each [{ t: 'Permission first', d: 'Apps request access; nothing is sent until you approve.' }, { t: 'Delivered to Telegram', d: 'Link your Telegram once and get notifications in chat.' }, { t: 'Revoke anytime', d: 'Mute or revoke any app from your dashboard.' }] as item (item.t)} +
+

{item.t}

+

{item.d}

+
+ {/each} +
diff --git a/apps/web/src/routes/dashboard/+page.server.ts b/apps/web/src/routes/dashboard/+page.server.ts new file mode 100644 index 0000000..f537731 --- /dev/null +++ b/apps/web/src/routes/dashboard/+page.server.ts @@ -0,0 +1,27 @@ +import { redirect } from '@sveltejs/kit'; + +import { relay } from '$lib/server/relay'; + +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.did || !locals.client) { + redirect(303, '/'); + } + const client = locals.client; + + const [pending, grants, channels, settings] = await Promise.all([ + relay.listPending(client), + relay.listGrants(client), + relay.listChannels(client), + relay.getSettings(client) + ]); + + // Treat relay responses defensively — render fallbacks rather than crash. + return { + pending: pending?.pending ?? [], + grants: grants?.grants ?? [], + channels: channels?.channels ?? [], + notifyPendingViaTelegram: settings?.notifyPendingViaTelegram ?? false + }; +}; diff --git a/apps/web/src/routes/dashboard/+page.svelte b/apps/web/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..563a5d1 --- /dev/null +++ b/apps/web/src/routes/dashboard/+page.svelte @@ -0,0 +1,259 @@ + + +Dashboard + +

Dashboard

+ +{#if errorMsg} + +{/if} + +
{errorMsg}
+ + +{#if data.pending.length > 0} +
+

Pending requests

+
    + {#each data.pending as p (p.id)} +
  • +
    + + +
    +
    + + + + Open ↗ + +
    +
  • + {/each} +
+
+{/if} + + +
+

+ Apps you've authorized +

+ {#if data.grants.length === 0} + + {:else} +
    + {#each data.grants as g (g.sender)} +
  • +
    +
    + + {#if g.muted} + muted + {/if} +
    + + authorized + +
    +
    + + {#if confirming[g.sender]} + + + {:else} + + {/if} +
    +
  • + {/each} +
+ {/if} +
+ + +
+

+ Where your notifications go +

+ {#if data.channels.length === 0} + + {#snippet cta()} + + {/snippet} + + {:else} +
    + {#each data.channels as channel (channel.platform)} +
  • +
    +
    {channel.platform}
    +
    + {#if channel.displayName}{channel.displayName} ·{/if} + linked +
    +
    + +
  • + {/each} +
+ {#if !telegramChannel} + + {/if} + {/if} +
+ + +
+

Settings

+
+
+ + run('setting', () => setNotifyPending({ value }))} + /> +
+

+ When on, you'll get a Telegram message with Approve/Deny buttons whenever an app asks to + notify you. When off (the default), permission requests only appear here. +

+
+
diff --git a/apps/web/src/routes/dashboard/pending/[id]/+page.server.ts b/apps/web/src/routes/dashboard/pending/[id]/+page.server.ts new file mode 100644 index 0000000..2309046 --- /dev/null +++ b/apps/web/src/routes/dashboard/pending/[id]/+page.server.ts @@ -0,0 +1,14 @@ +import { redirect } from '@sveltejs/kit'; + +import { relay } from '$lib/server/relay'; + +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, params }) => { + if (!locals.did || !locals.client) { + redirect(303, '/'); + } + const res = await relay.listPending(locals.client); + const request = (res?.pending ?? []).find((p) => p.id === params.id) ?? null; + return { request }; +}; diff --git a/apps/web/src/routes/dashboard/pending/[id]/+page.svelte b/apps/web/src/routes/dashboard/pending/[id]/+page.svelte new file mode 100644 index 0000000..6ad0182 --- /dev/null +++ b/apps/web/src/routes/dashboard/pending/[id]/+page.svelte @@ -0,0 +1,152 @@ + + +Permission request + +← Dashboard + +{#if !data.request} +
+

This request is no longer available

+

+ It may have expired, been approved, or been denied already. +

+ + Back to dashboard + +
+{:else} + {@const request = data.request} + {@const docUrl = didDocUrl(request.sender)} +
+

An app is asking to send you notifications.

+ + + +

+ Requested · expires + +

+ + +
+ + {#if showAbout} +
+

+ You're approving this exact DID. Only its key can send you notifications. +

+ {request.sender} +
+ + {#if docUrl} + + View DID document ↗ + + {/if} +
+
+ {/if} +
+ + {#if errorMsg} + + {/if} + +
+ + +
+
+{/if} diff --git a/apps/web/src/routes/demo/atproto/+page.server.ts b/apps/web/src/routes/demo/atproto/+page.server.ts deleted file mode 100644 index 1a385c6..0000000 --- a/apps/web/src/routes/demo/atproto/+page.server.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { redirect, fail } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -import { atproto } from '$lib/atproto'; -import { - loadHandle, - createTID, - recentRecords, - loadHandles, - listRecords, - parseUri -} from '@svelte-atproto/oauth/helper'; -import { memory } from '@svelte-atproto/oauth/server/stores/memory'; - -// In-memory cache for handle lookups — fine for dev. For prod, swap in -// cloudflareKV or upstashRedis (any `Store` works). -const profileCache = memory(); -const COLLECTION = 'xyz.statusphere.status'; - -export const load: PageServerLoad = async ({ locals, url }) => { - if (!locals.did) { - const returnTo = encodeURIComponent(url.pathname + url.search); - redirect(302, `/demo/atproto/login?returnTo=${returnTo}`); - } - - // Lightweight: just resolve the handle from the user's PDS. - // For richer Bluesky profile data (display name, avatar) swap to: - // import { loadBskyProfile } from '@svelte-atproto/oauth/bsky'; - // const profile = await loadBskyProfile(locals.did, { cache: profileCache }); - const handle = await loadHandle(locals.did, { cache: profileCache }); - - // Recent statuses globally, from the firehose via UFO. UFO is slightly - // behind the firehose, so we also pull the user's own records and merge - // them in front so just-published statuses show up immediately. - const [globalRecent, own] = await Promise.all([ - recentRecords(COLLECTION), - locals.client - ? listRecords({ did: locals.did, collection: COLLECTION, client: locals.client, limit: 10 }) - : Promise.resolve([]) - ]); - - const ownAsItems = own.map((r) => { - const parts = parseUri(r.uri); - const record = r.value as { $type: string; createdAt?: string; [k: string]: unknown }; - const parsed = - typeof record.createdAt === 'string' ? new Date(record.createdAt).getTime() : NaN; - const time_us = (Number.isFinite(parsed) ? parsed : Date.now()) * 1000; - return { - did: parts?.repo ?? locals.did, - collection: parts?.collection ?? COLLECTION, - rkey: parts?.rkey ?? '', - record, - time_us - }; - }); - - // Own records first so they win the dedupe (UFO can be stale on a record - // the user just published). Then sort by time_us so the merged list is - // in true reverse-chronological order regardless of source. - const seen = new Set(); - const merged = []; - for (const item of [...ownAsItems, ...globalRecent]) { - const key = `${item.did}/${item.rkey}`; - if (seen.has(key)) continue; - seen.add(key); - merged.push(item); - } - const recent = merged.sort((a, b) => b.time_us - a.time_us); - - // Resolve the author handles in parallel (cached). - // For richer profile data, swap `loadHandles` for: - // import { loadBskyProfiles } from '@svelte-atproto/oauth/bsky'; - const authorDids = [...new Set(recent.map((r) => r.did))]; - const authors = await loadHandles(authorDids, { cache: profileCache }); - return { did: locals.did, handle, recent, authors }; -}; - -export const actions: Actions = { - setStatus: async ({ request, locals }) => { - if (!locals.client || !locals.did) return fail(401, { message: 'Not signed in' }); - const fd = await request.formData(); - const status = fd.get('status')?.toString(); - if (!status) return fail(400, { message: 'Missing status' }); - - await locals.client.post('com.atproto.repo.putRecord', { - input: { - repo: locals.did, - collection: COLLECTION, - rkey: createTID(), - record: { - $type: COLLECTION, - status, - createdAt: new Date().toISOString() - } - } - }); - return { ok: true }; - }, - signOut: async () => { - await atproto.api.logout(); - redirect(303, '/demo/atproto/login'); - } -}; diff --git a/apps/web/src/routes/demo/atproto/+page.svelte b/apps/web/src/routes/demo/atproto/+page.svelte deleted file mode 100644 index ff3da73..0000000 --- a/apps/web/src/routes/demo/atproto/+page.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -
-

What's your status?

-

Hi {data.handle ?? data.did}.

- -
- {#each emojis as emoji} - - {/each} -
- - {#if data.recent?.length} -

Recent statuses (firehose)

-
    - {#each data.recent as item} -
  • - {item.record.status} - @{data.authors[item.did] ?? item.did} - {item.record.createdAt} -
  • - {/each} -
- {/if} - -
- -
-
diff --git a/apps/web/src/routes/demo/atproto/login/+page.server.ts b/apps/web/src/routes/demo/atproto/login/+page.server.ts deleted file mode 100644 index c1b52eb..0000000 --- a/apps/web/src/routes/demo/atproto/login/+page.server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { fail, redirect } from '@sveltejs/kit'; -import type { Actions, PageServerLoad } from './$types'; -import { atproto } from '$lib/atproto'; - -const DEFAULT_RETURN_TO = '/demo/atproto'; - -function safeReturnTo(value: string | null | undefined): string { - if (!value) return DEFAULT_RETURN_TO; - try { - const decoded = decodeURIComponent(value); - if (decoded.startsWith('/') && !decoded.startsWith('//')) return decoded; - } catch {} - return DEFAULT_RETURN_TO; -} - -export const load: PageServerLoad = ({ locals, url }) => { - if (locals.did) redirect(302, safeReturnTo(url.searchParams.get('returnTo'))); - return { returnTo: safeReturnTo(url.searchParams.get('returnTo')) }; -}; - -export const actions: Actions = { - signIn: async ({ request }) => { - const fd = await request.formData(); - const handle = fd.get('handle')?.toString().trim(); - const returnTo = safeReturnTo(fd.get('returnTo')?.toString()); - if (!handle) return fail(400, { message: 'Handle or DID is required' }); - - try { - const { url } = await atproto.api.startLogin({ handle, returnTo }); - redirect(303, url); - } catch (e) { - if (e && typeof e === 'object' && 'status' in e && 'location' in e) throw e; - return fail(400, { message: e instanceof Error ? e.message : 'Sign-in failed' }); - } - } -}; diff --git a/apps/web/src/routes/demo/atproto/login/+page.svelte b/apps/web/src/routes/demo/atproto/login/+page.svelte deleted file mode 100644 index 4a032b0..0000000 --- a/apps/web/src/routes/demo/atproto/login/+page.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -
-

Sign in with atproto

- -
- - - - - {#if form?.message} -

{form.message}

- {/if} - - -
-
diff --git a/apps/web/src/routes/docs/+page.server.ts b/apps/web/src/routes/docs/+page.server.ts new file mode 100644 index 0000000..7b406d5 --- /dev/null +++ b/apps/web/src/routes/docs/+page.server.ts @@ -0,0 +1,84 @@ +import { LEXICON_PREFIX, RELAY_DID, RELAY_ORIGIN } from '$lib/config'; +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 \\ + -H "Authorization: Bearer $USER_JWT" \\ + -H "Content-Type: application/json" \\ + -d '{ + "senderDid": "did:web:yourapp.example", + "title": "Bookhive", + "description": "New comments on your books" + }'`; + +const sendJwtExample = `import { createServiceJwt } from '@atcute/xrpc-server/auth'; + +// Signed with YOUR app's key — this proves the sender identity. +const jwt = await createServiceJwt({ + keypair: yourKeypair, + issuer: 'did:web:yourapp.example', + audience: '${RELAY_DID}', + lxm: '${LEXICON_PREFIX}.send' +});`; + +// Easier: use @atcute/client instead of hand-rolling fetch (pass the JWT per call). +const sendAtcuteExample = `import { Client, simpleFetchHandler } from '@atcute/client'; + +const client = new Client({ + handler: simpleFetchHandler({ service: '${RELAY_ORIGIN}' }) +}); + +await client.post('${LEXICON_PREFIX}.send', { + headers: { authorization: \`Bearer \${jwt}\` }, + input: { + recipient: 'did:plc:recipient', + title: 'New reply', + 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" \\ + -H "Content-Type: application/json" \\ + -d '{ + "recipient": "did:plc:recipient", + "title": "New reply", + "body": "alice replied to your post", + "uri": "https://yourapp.example/thread/123" + }'`; + +export interface CodeBlock { + lang: CodeLang; + raw: string; + html: string; +} + +// Highlight the (static) examples once per server instance, then reuse. +let cached: Promise> | undefined; +function buildBlocks() { + cached ??= (async () => { + const make = async (raw: string, lang: CodeLang): Promise => ({ + lang, + raw, + html: await highlight(raw, lang) + }); + return { + request: await make(requestExample, 'bash'), + sendJwt: await make(sendJwtExample, 'ts'), + sendAtcute: await make(sendAtcuteExample, 'ts'), + sendCurl: await make(sendCurlExample, 'bash') + }; + })(); + return cached; +} + +export const load: PageServerLoad = async () => { + return { code: await buildBlocks() }; +}; diff --git a/apps/web/src/routes/docs/+page.svelte b/apps/web/src/routes/docs/+page.svelte new file mode 100644 index 0000000..4f2cd90 --- /dev/null +++ b/apps/web/src/routes/docs/+page.svelte @@ -0,0 +1,145 @@ + + +Developer docs — {PROJECT_NAME} + +{#snippet codeblock(block: Block)} +
+
+ {block.lang} + +
+
+ + {@html block.html} +
+
+{/snippet} + +
+
+

Developer docs

+

+ Any atproto app can ask users to receive notifications via {PROJECT_NAME}. Users approve in + the dashboard; the relay delivers via Telegram. +

+

+ Two endpoints, two auth mechanisms. + requestPermission proves + the user authorized this request (user OAuth); + send proves + the sender identity (your app's own DID key). +

+
+ +
+

1. Get a DID for your app

+

+ Needed for send. The simplest option is + did:web: +

+
    +
  • Host /.well-known/did.json on your app's domain.
  • +
  • + Generate a P-256 keypair and put the public key in the DID document as a + verificationMethod whose id ends in + #atproto. +
  • +
  • + Reference: + atproto DID spec ↗ +
  • +
+
+ +
+

2. Request permission (user OAuth)

+

+ The user signs into your app via atproto OAuth. Add just the + requestPermission method to your app's OAuth scope — + send uses your app's own key, not the user's session, + so it doesn't belong here: +

+

+ 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 + com.atproto.server.getServiceAuth and call: +

+ {@render codeblock(data.code.request)} +

+ Returns { id, status } + (pending or + alreadyGranted). The user approves in their dashboard or + via Telegram. title ≤ 50 chars, + description ≤ 200 chars, optional + iconUrl. +

+
+ +
+

3. Send a notification (your app's key)

+

+ Once granted, sign with your app's own key (no user involved) and send. Field limits: + title ≤ 100, body + ≤ 500, optional uri and + threadKey. +

+ {@render codeblock(data.code.sendJwt)} +

+ Easiest with @atcute/client (pass the JWT per call): +

+ {@render codeblock(data.code.sendAtcute)} +

…or any HTTP client:

+ {@render codeblock(data.code.sendCurl)} +
+ +
+

4. Rate limits

+
    +
  • At most 1 outstanding pending request per (sender, recipient).
  • +
  • requestPermission: 50 / hour per recipient and 100 / hour per sender.
  • +
  • send: 1 / second and 100 / day per (sender, recipient).
  • +
+
+ +
+

5. Error handling

+

Common XRPC errors:

+
    +
  • AuthenticationRequired — missing/invalid JWT.
  • +
  • NotAuthorized — no active grant for this recipient.
  • +
  • RateLimitExceeded — slow down (see Retry-After).
  • +
  • InvalidRequest — malformed body (e.g. bad senderDid).
  • +
+
+
diff --git a/apps/web/src/routes/layout.css b/apps/web/src/routes/layout.css index cd67023..4188ea5 100644 --- a/apps/web/src/routes/layout.css +++ b/apps/web/src/routes/layout.css @@ -1,3 +1,131 @@ @import 'tailwindcss'; @plugin '@tailwindcss/forms'; @plugin '@tailwindcss/typography'; + +/* + * Semantic design tokens (palette adapted from the design bundle). + * Colors are exposed to Tailwind via `@theme inline`, so utilities like + * `bg-surface`, `text-muted`, `border-line` resolve to the live CSS variables + * and switch automatically between light and dark. + */ +@theme inline { + --color-bg: var(--bg); + --color-surface: var(--surface); + --color-surface-2: var(--surface-2); + --color-line: var(--line); + --color-line-2: var(--line-2); + --color-fg: var(--fg); + --color-muted: var(--muted); + --color-muted-2: var(--muted-2); + --color-accent: var(--accent); + --color-accent-fg: var(--accent-fg); + --color-accent-soft: var(--accent-soft); + --color-success: var(--success); + --color-warn: var(--warn); + --color-danger: var(--danger); + + --font-sans: 'Geist', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, monospace; + + --radius-card: 0.75rem; +} + +/* Light theme (default). */ +:root { + color-scheme: light; + --bg: oklch(0.985 0.003 80); + --surface: oklch(1 0 0); + --surface-2: oklch(0.975 0.003 80); + --line: oklch(0.92 0.005 80); + --line-2: oklch(0.95 0.004 80); + --fg: oklch(0.22 0.01 60); + --muted: oklch(0.5 0.01 60); + --muted-2: oklch(0.68 0.01 60); + --accent: oklch(0.55 0.18 250); + --accent-fg: oklch(1 0 0); + --accent-soft: oklch(0.55 0.18 250 / 0.1); + --success: oklch(0.6 0.14 150); + --warn: oklch(0.7 0.14 80); + --danger: oklch(0.58 0.18 28); +} + +/* Dark theme values, shared by the explicit toggle and the system fallback. */ +:root[data-theme='dark'], +:root.theme-dark { + color-scheme: dark; + --bg: oklch(0.18 0.005 60); + --surface: oklch(0.22 0.005 60); + --surface-2: oklch(0.25 0.005 60); + --line: oklch(0.32 0.005 60); + --line-2: oklch(0.28 0.005 60); + --fg: oklch(0.97 0.005 80); + --muted: oklch(0.72 0.01 60); + --muted-2: oklch(0.55 0.01 60); + --accent: oklch(0.72 0.18 250); + --accent-fg: oklch(0.18 0.005 60); + --accent-soft: oklch(0.72 0.18 250 / 0.16); + --success: oklch(0.78 0.14 150); + --warn: oklch(0.78 0.14 80); + --danger: oklch(0.72 0.18 28); +} + +/* No-JS / pre-hydration fallback: honor the OS preference when no explicit + theme has been chosen (the inline script in app.html sets data-theme). */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']):not([data-theme='dark']) { + color-scheme: dark; + --bg: oklch(0.18 0.005 60); + --surface: oklch(0.22 0.005 60); + --surface-2: oklch(0.25 0.005 60); + --line: oklch(0.32 0.005 60); + --line-2: oklch(0.28 0.005 60); + --fg: oklch(0.97 0.005 80); + --muted: oklch(0.72 0.01 60); + --muted-2: oklch(0.55 0.01 60); + --accent: oklch(0.72 0.18 250); + --accent-fg: oklch(0.18 0.005 60); + --accent-soft: oklch(0.72 0.18 250 / 0.16); + --success: oklch(0.78 0.14 150); + --warn: oklch(0.78 0.14 80); + --danger: oklch(0.72 0.18 28); + } +} + +html { + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Consistent, visible focus ring for keyboard users. */ +:where(a, button, input, select, textarea):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; +} + +/* + * Shiki code blocks (rendered via {@html} in /docs). Shiki emits dual-theme + * output: light colors inline + a `--shiki-dark` variable per token. We keep the + * card's own surface background (transparent
) and swap to the dark tokens
+ * under our dark theme.
+ */
+.codeblock :where(pre.shiki) {
+	margin: 0;
+	background-color: transparent !important;
+	font-family: var(--font-mono);
+}
+:root[data-theme='dark'] .codeblock :where(pre.shiki, pre.shiki span) {
+	color: var(--shiki-dark) !important;
+	background-color: transparent !important;
+}
+@media (prefers-color-scheme: dark) {
+	:root:not([data-theme='light']):not([data-theme='dark'])
+		.codeblock
+		:where(pre.shiki, pre.shiki span) {
+		color: var(--shiki-dark) !important;
+		background-color: transparent !important;
+	}
+}
diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js
index 0c3412e..840f835 100644
--- a/apps/web/svelte.config.js
+++ b/apps/web/svelte.config.js
@@ -4,13 +4,21 @@ import adapter from '@sveltejs/adapter-auto';
 const config = {
 	compilerOptions: {
 		// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
-		runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
+		runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true),
+		// Required for remote functions (enables `await` in components).
+		experimental: {
+			async: true
+		}
 	},
 	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.
-		adapter: adapter()
+		adapter: adapter(),
+		// Mutations are written as remote `command` functions.
+		experimental: {
+			remoteFunctions: true
+		}
 	}
 };
 
diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc
new file mode 100644
index 0000000..2a2954c
--- /dev/null
+++ b/apps/web/wrangler.jsonc
@@ -0,0 +1,22 @@
+{
+	"$schema": "node_modules/wrangler/config-schema.json",
+	// Working default — pick a real subdomain and update this + the custom domain.
+	"name": "notifs-web",
+	"compatibility_date": "2026-04-01",
+	"compatibility_flags": ["nodejs_compat"],
+
+	// 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" }
+	]
+
+	// 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:
+	//   "routes": [{ "pattern": "notifs-web.atmo.tools", "custom_domain": true }]
+}
diff --git a/packages/lexicons/lexicons/tools/atmo/notifs/authSender.json b/packages/lexicons/lexicons/tools/atmo/notifs/authSender.json
index 2cb5152..b9dec1a 100644
--- a/packages/lexicons/lexicons/tools/atmo/notifs/authSender.json
+++ b/packages/lexicons/lexicons/tools/atmo/notifs/authSender.json
@@ -4,17 +4,14 @@
   "defs": {
     "main": {
       "type": "permission-set",
-      "title": "Send notifications",
-      "detail": "Allow this app to request permission to send you notifications and to deliver them to your linked channels (like Telegram).",
+      "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",
-            "tools.atmo.notifs.send"
-          ]
+          "lxm": ["tools.atmo.notifs.requestPermission"]
         }
       ]
     }
diff --git a/packages/lexicons/lexicons/tools/atmo/notifs/listGrants.json b/packages/lexicons/lexicons/tools/atmo/notifs/listGrants.json
index 2256a1e..78b7543 100644
--- a/packages/lexicons/lexicons/tools/atmo/notifs/listGrants.json
+++ b/packages/lexicons/lexicons/tools/atmo/notifs/listGrants.json
@@ -21,12 +21,21 @@
     },
     "grantView": {
       "type": "object",
-      "required": ["sender", "grantedAt", "muted"],
+      "required": ["sender", "title", "grantedAt", "muted"],
       "properties": {
         "sender": { "type": "string", "format": "did" },
-        "senderHandle": { "type": "string" },
-        "senderDisplayName": { "type": "string" },
-        "senderAvatar": { "type": "string" },
+        "title": {
+          "type": "string",
+          "description": "Display name for the sender (user-supplied at request time, falling back to Bluesky info or the DID)."
+        },
+        "description": { "type": "string" },
+        "iconUrl": { "type": "string" },
+        "senderHandle": {
+          "type": "string",
+          "description": "Best-effort Bluesky handle for the sender DID, if it has a profile."
+        },
+        "senderBskyDisplayName": { "type": "string" },
+        "senderBskyAvatar": { "type": "string" },
         "grantedAt": { "type": "string", "format": "datetime" },
         "muted": { "type": "boolean" }
       }
diff --git a/packages/lexicons/lexicons/tools/atmo/notifs/listPending.json b/packages/lexicons/lexicons/tools/atmo/notifs/listPending.json
index 7d3b510..85b4efc 100644
--- a/packages/lexicons/lexicons/tools/atmo/notifs/listPending.json
+++ b/packages/lexicons/lexicons/tools/atmo/notifs/listPending.json
@@ -21,14 +21,22 @@
     },
     "pendingView": {
       "type": "object",
-      "required": ["id", "sender", "createdAt", "expiresAt"],
+      "required": ["id", "sender", "title", "createdAt", "expiresAt"],
       "properties": {
         "id": { "type": "string" },
         "sender": { "type": "string", "format": "did" },
-        "senderHandle": { "type": "string" },
-        "senderDisplayName": { "type": "string" },
-        "senderAvatar": { "type": "string" },
-        "reason": { "type": "string" },
+        "title": {
+          "type": "string",
+          "description": "User-supplied display name from the request."
+        },
+        "description": { "type": "string" },
+        "iconUrl": { "type": "string" },
+        "senderHandle": {
+          "type": "string",
+          "description": "Best-effort Bluesky handle for the sender DID, if it has a profile."
+        },
+        "senderBskyDisplayName": { "type": "string" },
+        "senderBskyAvatar": { "type": "string" },
         "createdAt": { "type": "string", "format": "datetime" },
         "expiresAt": { "type": "string", "format": "datetime" }
       }
diff --git a/packages/lexicons/lexicons/tools/atmo/notifs/requestPermission.json b/packages/lexicons/lexicons/tools/atmo/notifs/requestPermission.json
index c3d50d0..10855e3 100644
--- a/packages/lexicons/lexicons/tools/atmo/notifs/requestPermission.json
+++ b/packages/lexicons/lexicons/tools/atmo/notifs/requestPermission.json
@@ -4,22 +4,32 @@
   "defs": {
     "main": {
       "type": "procedure",
-      "description": "Ask a recipient for permission to send them notifications. Sender-authenticated.",
+      "description": "Ask the authenticated user for permission for a sender DID to notify them. User-authenticated (via the authSender permission set); the sender DID and display metadata are supplied in the body.",
       "input": {
         "encoding": "application/json",
         "schema": {
           "type": "object",
-          "required": ["recipient"],
+          "required": ["senderDid", "title"],
           "properties": {
-            "recipient": {
+            "senderDid": {
               "type": "string",
               "format": "did",
-              "description": "DID of the user the sender wants to notify."
+              "description": "The DID that will send notifications. This is what the user approves and what `send` calls authenticate as."
             },
-            "reason": {
+            "title": {
+              "type": "string",
+              "maxLength": 50,
+              "description": "Short display name shown to the user during approval (e.g. \"Bookhive\")."
+            },
+            "description": {
               "type": "string",
               "maxLength": 200,
-              "description": "Human-readable reason shown to the user when approving."
+              "description": "Optional one-line context shown during approval."
+            },
+            "iconUrl": {
+              "type": "string",
+              "format": "uri",
+              "description": "Optional HTTPS URL to a small square icon shown during approval."
             }
           }
         }
@@ -33,14 +43,15 @@
             "id": { "type": "string" },
             "status": {
               "type": "string",
-              "enum": ["pending", "alreadyGranted"]
+              "knownValues": ["pending", "alreadyGranted"]
             }
           }
         }
       },
       "errors": [
         { "name": "NotAuthorized" },
-        { "name": "RateLimitExceeded" }
+        { "name": "RateLimitExceeded" },
+        { "name": "InvalidRequest" }
       ]
     }
   }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 89c72be..9ef74e8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -56,10 +56,28 @@ importers:
     dependencies:
       '@atcute/atproto':
         specifier: ^3.1.10
-        version: 3.1.12(@atcute/lexicons@2.0.0)
+        version: 3.1.12(@atcute/lexicons@1.3.1)
+      '@atcute/client':
+        specifier: ^4.2.1
+        version: 4.2.2(@atcute/lexicons@1.3.1)
+      '@atcute/identity':
+        specifier: ^1.1.5
+        version: 1.1.5(@atcute/lexicons@1.3.1)
+      '@atcute/lexicons':
+        specifier: ^1.2.9
+        version: 1.3.1
+      '@atmo/notifs-lexicons':
+        specifier: workspace:*
+        version: link:../../packages/lexicons
       '@svelte-atproto/oauth':
         specifier: ^0.1.0
-        version: 0.1.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)
+        version: 0.1.0(@atcute/identity@1.1.5(@atcute/lexicons@1.3.1))(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)
+      shiki:
+        specifier: ^4.1.0
+        version: 4.1.0
+      valibot:
+        specifier: ^1.4.0
+        version: 1.4.0(typescript@6.0.3)
     devDependencies:
       '@eslint/compat':
         specifier: ^2.0.4
@@ -887,6 +905,37 @@ packages:
   '@rolldown/pluginutils@1.0.1':
     resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
 
+  '@shikijs/core@4.1.0':
+    resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
+    engines: {node: '>=20'}
+
+  '@shikijs/engine-javascript@4.1.0':
+    resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
+    engines: {node: '>=20'}
+
+  '@shikijs/engine-oniguruma@4.1.0':
+    resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
+    engines: {node: '>=20'}
+
+  '@shikijs/langs@4.1.0':
+    resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
+    engines: {node: '>=20'}
+
+  '@shikijs/primitive@4.1.0':
+    resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
+    engines: {node: '>=20'}
+
+  '@shikijs/themes@4.1.0':
+    resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
+    engines: {node: '>=20'}
+
+  '@shikijs/types@4.1.0':
+    resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
+    engines: {node: '>=20'}
+
+  '@shikijs/vscode-textmate@10.0.2':
+    resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+
   '@sindresorhus/is@7.2.0':
     resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
     engines: {node: '>=18'}
@@ -1059,15 +1108,24 @@ packages:
   '@types/estree@1.0.9':
     resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
 
+  '@types/hast@3.0.4':
+    resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
   '@types/json-schema@7.0.15':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
+  '@types/mdast@4.0.4':
+    resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
   '@types/node@22.19.19':
     resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
 
   '@types/trusted-types@2.0.7':
     resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
 
+  '@types/unist@3.0.3':
+    resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
   '@typescript-eslint/eslint-plugin@8.59.4':
     resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1127,6 +1185,9 @@ packages:
     resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
+  '@ungap/structured-clone@1.3.1':
+    resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
+
   '@vitest/expect@4.1.7':
     resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==}
 
@@ -1192,10 +1253,19 @@ packages:
     resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
     engines: {node: 18 || 20 || >=22}
 
+  ccount@2.0.1:
+    resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
   chai@6.2.2:
     resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
     engines: {node: '>=18'}
 
+  character-entities-html4@2.1.0:
+    resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+  character-entities-legacy@3.0.0:
+    resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
   chokidar@4.0.3:
     resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
     engines: {node: '>= 14.16.0'}
@@ -1207,6 +1277,9 @@ packages:
     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
     engines: {node: '>=6'}
 
+  comma-separated-tokens@2.0.3:
+    resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
   convert-source-map@2.0.0:
     resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
 
@@ -1243,6 +1316,10 @@ packages:
     resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
     engines: {node: '>=0.10.0'}
 
+  dequal@2.0.3:
+    resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+    engines: {node: '>=6'}
+
   detect-libc@2.1.2:
     resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
     engines: {node: '>=8'}
@@ -1250,6 +1327,9 @@ packages:
   devalue@5.8.1:
     resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
 
+  devlop@1.1.0:
+    resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
   enhanced-resolve@5.21.6:
     resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
     engines: {node: '>=10.13.0'}
@@ -1410,6 +1490,15 @@ packages:
   graceful-fs@4.2.11:
     resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
 
+  hast-util-to-html@9.0.5:
+    resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
+
+  hast-util-whitespace@3.0.0:
+    resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
+  html-void-elements@3.0.0:
+    resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+
   ignore@5.3.2:
     resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
     engines: {node: '>= 4'}
@@ -1551,6 +1640,24 @@ packages:
   magic-string@0.30.21:
     resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
 
+  mdast-util-to-hast@13.2.1:
+    resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+
+  micromark-util-character@2.1.1:
+    resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+  micromark-util-encode@2.0.1:
+    resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+  micromark-util-sanitize-uri@2.0.1:
+    resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+  micromark-util-symbol@2.0.1:
+    resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+  micromark-util-types@2.0.2:
+    resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
   mini-svg-data-uri@1.4.4:
     resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
     hasBin: true
@@ -1591,6 +1698,12 @@ packages:
   obug@2.1.1:
     resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
 
+  oniguruma-parser@0.12.2:
+    resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
+
+  oniguruma-to-es@4.3.6:
+    resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
+
   optionator@0.9.4:
     resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
     engines: {node: '>= 0.8.0'}
@@ -1730,6 +1843,9 @@ packages:
     engines: {node: '>=14'}
     hasBin: true
 
+  property-information@7.1.0:
+    resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+
   punycode@2.3.1:
     resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
     engines: {node: '>=6'}
@@ -1738,6 +1854,15 @@ packages:
     resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
     engines: {node: '>= 14.18.0'}
 
+  regex-recursion@6.0.2:
+    resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
+
+  regex-utilities@2.3.0:
+    resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
+
+  regex@6.1.0:
+    resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
+
   rolldown@1.0.2:
     resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
     engines: {node: ^20.19.0 || >=22.12.0}
@@ -1787,6 +1912,10 @@ packages:
     resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
     engines: {node: '>=8'}
 
+  shiki@4.1.0:
+    resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
+    engines: {node: '>=20'}
+
   siginfo@2.0.0:
     resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
 
@@ -1798,12 +1927,18 @@ packages:
     resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
     engines: {node: '>=0.10.0'}
 
+  space-separated-tokens@2.0.2:
+    resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
   stackback@0.0.2:
     resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
 
   std-env@4.1.0:
     resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
 
+  stringify-entities@4.0.4:
+    resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
   supports-color@10.2.2:
     resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
     engines: {node: '>=18'}
@@ -1855,6 +1990,9 @@ packages:
     resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
     engines: {node: '>=6'}
 
+  trim-lines@3.0.1:
+    resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
   ts-api-utils@2.5.0:
     resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
     engines: {node: '>=18.12'}
@@ -1898,6 +2036,21 @@ packages:
   unicode-segmenter@0.14.5:
     resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==}
 
+  unist-util-is@6.0.1:
+    resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+  unist-util-position@5.0.0:
+    resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+  unist-util-stringify-position@4.0.0:
+    resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+  unist-util-visit-parents@6.0.2:
+    resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+  unist-util-visit@5.1.0:
+    resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
   uri-js@4.4.1:
     resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
 
@@ -1912,6 +2065,12 @@ packages:
       typescript:
         optional: true
 
+  vfile-message@4.0.3:
+    resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+  vfile@6.0.3:
+    resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
   vite@8.0.14:
     resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==}
     engines: {node: ^20.19.0 || >=22.12.0}
@@ -2065,16 +2224,15 @@ packages:
   zod@3.25.76:
     resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
 
+  zwitch@2.0.4:
+    resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
 snapshots:
 
   '@atcute/atproto@3.1.12(@atcute/lexicons@1.3.1)':
     dependencies:
       '@atcute/lexicons': 1.3.1
 
-  '@atcute/atproto@3.1.12(@atcute/lexicons@2.0.0)':
-    dependencies:
-      '@atcute/lexicons': 2.0.0
-
   '@atcute/bluesky@3.3.5(@atcute/lexicons@1.3.1)':
     dependencies:
       '@atcute/atproto': 3.1.12(@atcute/lexicons@1.3.1)
@@ -2116,10 +2274,10 @@ snapshots:
       '@atcute/uint8array': 1.1.2
       '@noble/secp256k1': 3.1.0
 
-  '@atcute/identity-resolver@1.2.3(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)':
+  '@atcute/identity-resolver@1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)':
     dependencies:
-      '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3)
-      '@atcute/lexicons': 2.0.0
+      '@atcute/identity': 1.1.5(@atcute/lexicons@1.3.1)
+      '@atcute/lexicons': 1.3.1
       '@atcute/util-fetch': 1.0.5
       '@badrap/valita': 0.4.6
 
@@ -2144,13 +2302,6 @@ snapshots:
     transitivePeerDependencies:
       - typescript
 
-  '@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3)':
-    dependencies:
-      '@atcute/lexicons': 2.0.0
-      valibot: 1.4.0(typescript@6.0.3)
-    transitivePeerDependencies:
-      - typescript
-
   '@atcute/lex-cli@3.1.0(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(typescript@5.9.3)':
     dependencies:
       '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3)
@@ -2217,10 +2368,10 @@ snapshots:
     dependencies:
       '@atcute/uint8array': 1.1.2
 
-  '@atcute/oauth-browser-client@3.0.1(@atcute/identity-resolver@1.2.3(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)(typescript@6.0.3)':
+  '@atcute/oauth-browser-client@3.0.1(@atcute/identity-resolver@1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)(typescript@6.0.3)':
     dependencies:
       '@atcute/client': 4.2.2(@atcute/lexicons@1.3.1)
-      '@atcute/identity-resolver': 1.2.3(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)
+      '@atcute/identity-resolver': 1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)
       '@atcute/lexicons': 1.3.1
       '@atcute/multibase': 1.2.0
       '@atcute/oauth-crypto': 0.1.0
@@ -2251,11 +2402,11 @@ snapshots:
     transitivePeerDependencies:
       - typescript
 
-  '@atcute/oauth-node-client@1.1.1(@atcute/identity-resolver@1.2.3(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)(typescript@6.0.3)':
+  '@atcute/oauth-node-client@1.1.1(@atcute/identity-resolver@1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)(typescript@6.0.3)':
     dependencies:
       '@atcute/client': 4.2.2(@atcute/lexicons@1.3.1)
       '@atcute/identity': 1.1.5(@atcute/lexicons@1.3.1)
-      '@atcute/identity-resolver': 1.2.3(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)
+      '@atcute/identity-resolver': 1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)
       '@atcute/lexicons': 1.3.1
       '@atcute/oauth-crypto': 0.1.0
       '@atcute/oauth-keyset': 0.1.1(typescript@6.0.3)
@@ -2722,21 +2873,61 @@ snapshots:
 
   '@rolldown/pluginutils@1.0.1': {}
 
+  '@shikijs/core@4.1.0':
+    dependencies:
+      '@shikijs/primitive': 4.1.0
+      '@shikijs/types': 4.1.0
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+      hast-util-to-html: 9.0.5
+
+  '@shikijs/engine-javascript@4.1.0':
+    dependencies:
+      '@shikijs/types': 4.1.0
+      '@shikijs/vscode-textmate': 10.0.2
+      oniguruma-to-es: 4.3.6
+
+  '@shikijs/engine-oniguruma@4.1.0':
+    dependencies:
+      '@shikijs/types': 4.1.0
+      '@shikijs/vscode-textmate': 10.0.2
+
+  '@shikijs/langs@4.1.0':
+    dependencies:
+      '@shikijs/types': 4.1.0
+
+  '@shikijs/primitive@4.1.0':
+    dependencies:
+      '@shikijs/types': 4.1.0
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+
+  '@shikijs/themes@4.1.0':
+    dependencies:
+      '@shikijs/types': 4.1.0
+
+  '@shikijs/types@4.1.0':
+    dependencies:
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+
+  '@shikijs/vscode-textmate@10.0.2': {}
+
   '@sindresorhus/is@7.2.0': {}
 
   '@speed-highlight/core@1.2.15': {}
 
   '@standard-schema/spec@1.1.0': {}
 
-  '@svelte-atproto/oauth@0.1.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)':
+  '@svelte-atproto/oauth@0.1.0(@atcute/identity@1.1.5(@atcute/lexicons@1.3.1))(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)':
     dependencies:
       '@atcute/atproto': 3.1.12(@atcute/lexicons@1.3.1)
       '@atcute/bluesky': 3.3.5(@atcute/lexicons@1.3.1)
       '@atcute/client': 4.2.2(@atcute/lexicons@1.3.1)
-      '@atcute/identity-resolver': 1.2.3(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@2.0.0)
+      '@atcute/identity-resolver': 1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)
       '@atcute/lexicons': 1.3.1
-      '@atcute/oauth-browser-client': 3.0.1(@atcute/identity-resolver@1.2.3(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)(typescript@6.0.3)
-      '@atcute/oauth-node-client': 1.1.1(@atcute/identity-resolver@1.2.3(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@6.0.3))(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)(typescript@6.0.3)
+      '@atcute/oauth-browser-client': 3.0.1(@atcute/identity-resolver@1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)(typescript@6.0.3)
+      '@atcute/oauth-node-client': 1.1.1(@atcute/identity-resolver@1.2.3(@atcute/identity@1.1.5(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1))(@atcute/lexicons@1.3.1)(typescript@6.0.3)
       '@atcute/tid': 1.1.2
       '@sveltejs/kit': 2.60.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.9(@typescript-eslint/types@8.59.4))(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0)))(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0))
       svelte: 5.55.9(@typescript-eslint/types@8.59.4)
@@ -2877,14 +3068,24 @@ snapshots:
 
   '@types/estree@1.0.9': {}
 
+  '@types/hast@3.0.4':
+    dependencies:
+      '@types/unist': 3.0.3
+
   '@types/json-schema@7.0.15': {}
 
+  '@types/mdast@4.0.4':
+    dependencies:
+      '@types/unist': 3.0.3
+
   '@types/node@22.19.19':
     dependencies:
       undici-types: 6.21.0
 
   '@types/trusted-types@2.0.7': {}
 
+  '@types/unist@3.0.3': {}
+
   '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
     dependencies:
       '@eslint-community/regexpp': 4.12.2
@@ -2976,6 +3177,8 @@ snapshots:
       '@typescript-eslint/types': 8.59.4
       eslint-visitor-keys: 5.0.1
 
+  '@ungap/structured-clone@1.3.1': {}
+
   '@vitest/expect@4.1.7':
     dependencies:
       '@standard-schema/spec': 1.1.0
@@ -3044,8 +3247,14 @@ snapshots:
     dependencies:
       balanced-match: 4.0.4
 
+  ccount@2.0.1: {}
+
   chai@6.2.2: {}
 
+  character-entities-html4@2.1.0: {}
+
+  character-entities-legacy@3.0.0: {}
+
   chokidar@4.0.3:
     dependencies:
       readdirp: 4.1.2
@@ -3054,6 +3263,8 @@ snapshots:
 
   clsx@2.1.1: {}
 
+  comma-separated-tokens@2.0.3: {}
+
   convert-source-map@2.0.0: {}
 
   cookie@0.6.0: {}
@@ -3076,10 +3287,16 @@ snapshots:
 
   deepmerge@4.3.1: {}
 
+  dequal@2.0.3: {}
+
   detect-libc@2.1.2: {}
 
   devalue@5.8.1: {}
 
+  devlop@1.1.0:
+    dependencies:
+      dequal: 2.0.3
+
   enhanced-resolve@5.21.6:
     dependencies:
       graceful-fs: 4.2.11
@@ -3274,6 +3491,26 @@ snapshots:
 
   graceful-fs@4.2.11: {}
 
+  hast-util-to-html@9.0.5:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/unist': 3.0.3
+      ccount: 2.0.1
+      comma-separated-tokens: 2.0.3
+      hast-util-whitespace: 3.0.0
+      html-void-elements: 3.0.0
+      mdast-util-to-hast: 13.2.1
+      property-information: 7.1.0
+      space-separated-tokens: 2.0.2
+      stringify-entities: 4.0.4
+      zwitch: 2.0.4
+
+  hast-util-whitespace@3.0.0:
+    dependencies:
+      '@types/hast': 3.0.4
+
+  html-void-elements@3.0.0: {}
+
   ignore@5.3.2: {}
 
   ignore@7.0.5: {}
@@ -3374,6 +3611,35 @@ snapshots:
     dependencies:
       '@jridgewell/sourcemap-codec': 1.5.5
 
+  mdast-util-to-hast@13.2.1:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/mdast': 4.0.4
+      '@ungap/structured-clone': 1.3.1
+      devlop: 1.1.0
+      micromark-util-sanitize-uri: 2.0.1
+      trim-lines: 3.0.1
+      unist-util-position: 5.0.0
+      unist-util-visit: 5.1.0
+      vfile: 6.0.3
+
+  micromark-util-character@2.1.1:
+    dependencies:
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-util-encode@2.0.1: {}
+
+  micromark-util-sanitize-uri@2.0.1:
+    dependencies:
+      micromark-util-character: 2.1.1
+      micromark-util-encode: 2.0.1
+      micromark-util-symbol: 2.0.1
+
+  micromark-util-symbol@2.0.1: {}
+
+  micromark-util-types@2.0.2: {}
+
   mini-svg-data-uri@1.4.4: {}
 
   miniflare@4.20260521.0:
@@ -3406,6 +3672,14 @@ snapshots:
 
   obug@2.1.1: {}
 
+  oniguruma-parser@0.12.2: {}
+
+  oniguruma-to-es@4.3.6:
+    dependencies:
+      oniguruma-parser: 0.12.2
+      regex: 6.1.0
+      regex-recursion: 6.0.2
+
   optionator@0.9.4:
     dependencies:
       deep-is: 0.1.4
@@ -3481,10 +3755,22 @@ snapshots:
 
   prettier@3.8.3: {}
 
+  property-information@7.1.0: {}
+
   punycode@2.3.1: {}
 
   readdirp@4.1.2: {}
 
+  regex-recursion@6.0.2:
+    dependencies:
+      regex-utilities: 2.3.0
+
+  regex-utilities@2.3.0: {}
+
+  regex@6.1.0:
+    dependencies:
+      regex-utilities: 2.3.0
+
   rolldown@1.0.2:
     dependencies:
       '@oxc-project/types': 0.132.0
@@ -3566,6 +3852,17 @@ snapshots:
 
   shebang-regex@3.0.0: {}
 
+  shiki@4.1.0:
+    dependencies:
+      '@shikijs/core': 4.1.0
+      '@shikijs/engine-javascript': 4.1.0
+      '@shikijs/engine-oniguruma': 4.1.0
+      '@shikijs/langs': 4.1.0
+      '@shikijs/themes': 4.1.0
+      '@shikijs/types': 4.1.0
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+
   siginfo@2.0.0: {}
 
   sirv@3.0.2:
@@ -3576,10 +3873,17 @@ snapshots:
 
   source-map-js@1.2.1: {}
 
+  space-separated-tokens@2.0.2: {}
+
   stackback@0.0.2: {}
 
   std-env@4.1.0: {}
 
+  stringify-entities@4.0.4:
+    dependencies:
+      character-entities-html4: 2.1.0
+      character-entities-legacy: 3.0.0
+
   supports-color@10.2.2: {}
 
   svelte-check@4.4.8(picomatch@4.0.4)(svelte@5.55.9(@typescript-eslint/types@8.59.4))(typescript@6.0.3):
@@ -3644,6 +3948,8 @@ snapshots:
 
   totalist@3.0.1: {}
 
+  trim-lines@3.0.1: {}
+
   ts-api-utils@2.5.0(typescript@6.0.3):
     dependencies:
       typescript: 6.0.3
@@ -3680,6 +3986,29 @@ snapshots:
 
   unicode-segmenter@0.14.5: {}
 
+  unist-util-is@6.0.1:
+    dependencies:
+      '@types/unist': 3.0.3
+
+  unist-util-position@5.0.0:
+    dependencies:
+      '@types/unist': 3.0.3
+
+  unist-util-stringify-position@4.0.0:
+    dependencies:
+      '@types/unist': 3.0.3
+
+  unist-util-visit-parents@6.0.2:
+    dependencies:
+      '@types/unist': 3.0.3
+      unist-util-is: 6.0.1
+
+  unist-util-visit@5.1.0:
+    dependencies:
+      '@types/unist': 3.0.3
+      unist-util-is: 6.0.1
+      unist-util-visit-parents: 6.0.2
+
   uri-js@4.4.1:
     dependencies:
       punycode: 2.3.1
@@ -3694,6 +4023,16 @@ snapshots:
     optionalDependencies:
       typescript: 6.0.3
 
+  vfile-message@4.0.3:
+    dependencies:
+      '@types/unist': 3.0.3
+      unist-util-stringify-position: 4.0.0
+
+  vfile@6.0.3:
+    dependencies:
+      '@types/unist': 3.0.3
+      vfile-message: 4.0.3
+
   vite@8.0.14(@types/node@22.19.19)(esbuild@0.27.3)(jiti@2.7.0):
     dependencies:
       lightningcss: 1.32.0
@@ -3797,3 +4136,5 @@ snapshots:
   zimmerframe@1.1.4: {}
 
   zod@3.25.76: {}
+
+  zwitch@2.0.4: {}