diff --git a/PROMPT-2-website.md b/PROMPT-2-website.md new file mode 100644 index 0000000..da82d45 --- /dev/null +++ b/PROMPT-2-website.md @@ -0,0 +1,262 @@ +# Build the atproto notifs SvelteKit website + +You're filling in `apps/web` in an existing pnpm monorepo. The relay Worker (in `apps/relay`) and the shared lexicons package (in `packages/lexicons`) already exist. Refer to them when you need types or behavior details — they are the source of truth. + +The SvelteKit project at `apps/web` has **already** been scaffolded by the user with `npx sv add @svelte-atproto=storage:cloudflare+demo:none+demoStyle:form` followed by `pnpm install` and `pnpm atproto:setup`. So the OAuth infrastructure (handle, locals, session storage, env scripts) is in place. Do not re-scaffold it. Do not touch `src/hooks.server.ts`, `src/app.d.ts`, or anything generated by `@svelte-atproto/sv`. + +Your job: configure the OAuth scope, write a relay client helper, and build the dashboard + docs UI. + +--- + +## Configuration constants + +- **Relay domain**: `notifs.atmo.tools` +- **Relay DID**: `did:web:notifs.atmo.tools` +- **Relay service ref**: `did:web:notifs.atmo.tools#notif_relay` +- **Lexicon NSID prefix**: `tools.atmo.notifs` +- **Permission set requested**: `tools.atmo.notifs.authUser` + +Put these in a single `src/lib/config.ts` so they live in one place. + +--- + +## Tasks + +### 1. Wire the lexicons package as a workspace dep + +Add `"@atmo/notifs-lexicons": "workspace:*"` to `apps/web/package.json` dependencies. Import the generated types from there where useful (mostly for the response shapes when calling the relay). + +### 2. Configure the OAuth scope + +Edit `src/lib/atproto/index.ts` (the config file scaffolded by `@svelte-atproto/sv`) so the `scope` passed to `createAtprotoAuth` is: + +``` +atproto include:tools.atmo.notifs.authUser?aud=did:web:notifs.atmo.tools%23notif_relay +``` + +This requests the `authUser` permission set scoped to the relay's service ref. The user will see the title/detail strings from the lexicon's permission-set definition in the OAuth consent screen. + +Do not change anything else in this file beyond what's necessary to set the scope. + +### 3. Relay client helper + +Create `src/lib/server/relay.ts` (server-only). It exports one function: + +```ts +import type { OAuthSession } from '@svelte-atproto/oauth'; // or whatever the type is — match the package +// import generated request/response types from '@atmo/notifs-lexicons' + +export async function callRelay( + client: App.Locals['client'], + lxm: string, + body: object | null, + method: 'GET' | 'POST', +): Promise; +``` + +Behavior: + +1. Call `com.atproto.server.getServiceAuth` on the user's PDS via the provided `client`, with params: + - `aud: 'did:web:notifs.atmo.tools'` + - `lxm: ` +2. Take the returned `token` and call the relay: + - URL: `https://notifs.atmo.tools/xrpc/` + - Method: per argument + - Headers: `Authorization: Bearer `, `Content-Type: application/json` + - Body: for GET, null. For POST, JSON-stringified `body`. +3. Parse the JSON response. On non-2xx, throw an `Error` with the body's `error` and `message` fields (the relay throws atproto-style XRPCError responses). +4. Return the parsed body, typed via `T`. + +Also expose narrower typed wrappers: + +```ts +export const relay = { + listGrants: (client) => callRelay(client, 'tools.atmo.notifs.listGrants', null, 'GET'), + listPending: (client) => callRelay(client, 'tools.atmo.notifs.listPending', null, 'GET'), + listChannels: (client) => callRelay(client, 'tools.atmo.notifs.listChannels', null, 'GET'), + getSettings: (client) => callRelay(client, 'tools.atmo.notifs.getSettings', null, 'GET'), + grant: (client, input) => callRelay(client, 'tools.atmo.notifs.grant', input, 'POST'), + revoke: (client, input) => callRelay(client, 'tools.atmo.notifs.revoke', input, 'POST'), + denyPending: (client, input) => callRelay(client, 'tools.atmo.notifs.denyPending', input, 'POST'), + muteGrant: (client, input) => callRelay(client, 'tools.atmo.notifs.muteGrant', input, 'POST'), + linkChannel: (client, input) => callRelay(client, 'tools.atmo.notifs.linkChannel', input, 'POST'), + unlinkChannel: (client, input) => callRelay(client, 'tools.atmo.notifs.unlinkChannel', input, 'POST'), + updateSettings: (client, input) => callRelay(client, 'tools.atmo.notifs.updateSettings', input, 'POST'), +}; +``` + +These are typed using the generated types from `@atmo/notifs-lexicons`. + +### 4. Routes and pages + +Use SvelteKit form actions (the package was scaffolded with `demoStyle:form`). All relay calls happen server-side via these actions; never expose JWT minting to the client. + +#### `/` — Landing page (`src/routes/+page.svelte`) + +A simple marketing-style page: + +- Project name "Atmo Notifs" (placeholder — call it out as such in the README so the user can rename). +- One-paragraph explainer: "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." +- "Sign in with Bluesky" button → invokes the `@svelte-atproto/oauth/client` `login` function. +- Two link buttons below: "Dashboard" (only shown if signed in) and "Developer docs" (always). + +If `event.locals.did` is set (user signed in), include a small banner at the top of the landing page linking to the dashboard. + +#### `/dashboard` — Main dashboard (`src/routes/dashboard/+page.svelte` + `+page.server.ts`) + +Server load: in parallel, call `relay.listPending`, `relay.listGrants`, `relay.listChannels`, `relay.getSettings`. If `event.locals.did` is unset, redirect to `/`. + +Page renders four sections, in this order: + +**a) Pending requests** — only shown if there are any. + +For each pending request, display: + +- Sender avatar (or a placeholder if `senderAvatar` is undefined) +- Sender display name (fallback: handle, fallback: shortened DID) +- Sender handle (`@handle` style) +- `reason` if present, in italics +- `createdAt` as relative time ("3 hours ago") +- Two buttons: **Approve** (form action `?/approve` with hidden inputs for `sender` and `requestId`) and **Deny** (form action `?/deny` with hidden input for `requestId`). + +On approve action: call `relay.grant({ sender, requestId })`, then `invalidateAll()` (or use form action's natural reload). + +On deny action: call `relay.denyPending({ requestId })`. + +**b) Active grants** — section header "Apps you've authorized". + +For each grant: + +- Sender avatar + display name + handle as above +- `grantedAt` relative time +- A small "muted" badge if `muted` is true +- Buttons: **Mute / Unmute** (toggles via `?/toggleMute`), **Revoke** (form action `?/revoke`) + +Revoke should require confirmation — use a small in-page confirm pattern (a confirmation button replacing the revoke button), not `window.confirm`. + +**c) Delivery channels** — section header "Where your notifications go". + +For each channel: platform name (e.g. "Telegram"), `displayName` if present (e.g. "@yourhandle"), linked-since relative time, and an **Unlink** button. + +Below the list, an **+ Connect Telegram** button (only shown if no telegram channel is linked). Form action `?/linkTelegram`: + +1. Call `relay.linkChannel({ platform: 'telegram' })` +2. Returns `{ token, deepLink }` +3. Use SvelteKit's redirect helper to send the user to the `deepLink` (`throw redirect(303, deepLink)`) + +On returning to the dashboard (e.g. after `/start ` in Telegram), the channels section will reflect the new link. + +**d) Settings** — section header "Settings". + +For now, just one toggle: **"Notify me on Telegram when an app requests permission"** — backed by `notifyPendingViaTelegram`. Default off. Form action `?/toggleSetting` calls `relay.updateSettings({ notifyPendingViaTelegram: })`. + +Render this as a labeled switch/toggle, not a checkbox. Include a one-line caption below: "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." + +#### `/dashboard/pending/[id]` — Deep-linkable single pending view + +Some bot messages link directly here. Server load: + +- Fetch `relay.listPending`, find the request with matching id. +- If not found (expired or doesn't exist), render a "This request is no longer available" message with a button back to the dashboard. +- Otherwise render exactly the same pending-request card as in the dashboard, just larger, with the same Approve/Deny actions. + +#### `/docs` — Developer docs + +A static markdown-style page with these sections (write them as actual Svelte markup, or use mdsvex if convenient; if mdsvex isn't already in the scaffold, just use Svelte components and don't add the dep): + +1. **Overview** — one paragraph: any atproto app with its own DID can ask users to receive notifications via Atmo. Users approve in the dashboard; the relay delivers via Telegram. + +2. **Get a DID for your app** — short steps for `did:web`: + - Host `/.well-known/did.json` on your app's domain. + - Generate a P-256 keypair, put the public key in the DID doc as a `verificationMethod`. + - Reference: link to atproto's DID spec page. + +3. **Mint a service-auth JWT** — show a code example using `@atcute/xrpc-server/auth`'s `createServiceJwt`: + + ```ts + import { createServiceJwt } from '@atcute/xrpc-server/auth'; + + const jwt = await createServiceJwt({ + keypair: yourKeypair, + issuer: 'did:web:yourapp.example', + audience: 'did:web:notifs.atmo.tools', + lxm: 'tools.atmo.notifs.requestPermission', + }); + ``` + +4. **Request permission** — show the curl/fetch call to `/xrpc/tools.atmo.notifs.requestPermission` with the JWT in `Authorization: Bearer`. + +5. **Send a notification** — same shape, calling `tools.atmo.notifs.send`. Note the `title` / `body` / `uri` field constraints. + +6. **Rate limits** — list current limits: max 1 outstanding pending request per (sender, recipient); 100 pending requests per hour per sender; 1 send/sec, 100 sends/day per (sender, recipient). + +7. **Error handling** — list common XRPC errors: `AuthenticationRequired`, `NotAuthorized` (no grant), `RateLimitExceeded`. + +Keep this page deliberately simple — it's docs, not marketing. + +### 5. Components + +Extract these into `src/lib/components/`: + +- `SenderCard.svelte` — avatar + display name + handle, used by both pending and grants lists. Props: `sender: string` (did), `senderHandle?: string`, `senderDisplayName?: string`, `senderAvatar?: string`. +- `RelativeTime.svelte` — takes a date string, renders relative time, updates every minute. Use Intl.RelativeTimeFormat. +- `Toggle.svelte` — accessible labeled switch component. +- `EmptyState.svelte` — for "no pending requests", "no grants yet", etc. Props: `title`, `description`, optional `cta` slot. + +### 6. Styling + +- Use whatever CSS approach is already in the scaffolded project. If Tailwind isn't already set up, **don't** add it — write plain `