diff --git a/apps/relay/package.json b/apps/relay/package.json index 1552704..5f511a2 100644 --- a/apps/relay/package.json +++ b/apps/relay/package.json @@ -9,6 +9,7 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc -b", + "deploy": "wrangler deploy", "db:migrate": "wrangler d1 migrations apply notifs-relay --remote", "db:migrate:local": "wrangler d1 migrations apply notifs-relay --local" }, diff --git a/apps/relay/src/index.ts b/apps/relay/src/index.ts index 496f004..138b2d2 100644 --- a/apps/relay/src/index.ts +++ b/apps/relay/src/index.ts @@ -10,6 +10,10 @@ import { buildRouter } from './router'; import { handleTelegramWebhook } from './telegram/webhook'; import { handleLexicon, handleWellKnownDid } from './well-known'; +// First-party management API, exposed only over a service binding (never as +// public XRPC). The web app's server is the sole caller. See ./rpc/entrypoint.ts. +export { RelayRpc } from './rpc/entrypoint'; + const TELEGRAM_WEBHOOK_RE = /^\/telegram\/webhook\/(.+)$/; const DELIVERY_LOG_RETENTION_MS = 30 * DAY_MS; diff --git a/apps/relay/src/router.ts b/apps/relay/src/router.ts index c75db35..654f787 100644 --- a/apps/relay/src/router.ts +++ b/apps/relay/src/router.ts @@ -1,40 +1,22 @@ -import { - ToolsAtmoNotifsDenyPending, - ToolsAtmoNotifsGetSettings, - ToolsAtmoNotifsGrant, - ToolsAtmoNotifsLinkChannel, - ToolsAtmoNotifsListChannels, - ToolsAtmoNotifsListGrants, - ToolsAtmoNotifsListPending, - ToolsAtmoNotifsMuteGrant, - ToolsAtmoNotifsRequestPermission, - ToolsAtmoNotifsRevoke, - ToolsAtmoNotifsSend, - ToolsAtmoNotifsUnlinkChannel, - ToolsAtmoNotifsUpdateSettings, -} from '@atmo/notifs-lexicons'; +import { ToolsAtmoNotifsRequestPermission, ToolsAtmoNotifsSend } from '@atmo/notifs-lexicons'; import { XRPCRouter } from '@atcute/xrpc-server'; import { getVerifier } from './auth/verifier'; import type { AppContext, Env } from './env'; -import { makeDenyPending } from './xrpc/denyPending'; -import { makeGetSettings } from './xrpc/getSettings'; -import { makeGrant } from './xrpc/grant'; -import { makeLinkChannel } from './xrpc/linkChannel'; -import { makeListChannels } from './xrpc/listChannels'; -import { makeListGrants } from './xrpc/listGrants'; -import { makeListPending } from './xrpc/listPending'; -import { makeMuteGrant } from './xrpc/muteGrant'; import { makeRequestPermission } from './xrpc/requestPermission'; -import { makeRevoke } from './xrpc/revoke'; import { makeSend } from './xrpc/send'; -import { makeUnlinkChannel } from './xrpc/unlinkChannel'; -import { makeUpdateSettings } from './xrpc/updateSettings'; /** - * Build the XRPC router for a single request. Auth + rate limiting live inside - * each handler (sender path vs user path differ), so there is no global - * middleware. The router is cheap to construct; the verifier is memoized per Env. + * Build the XRPC router for a single request. This is the relay's *federated* + * surface — the only methods third-party atproto apps interact with: + * - `requestPermission` (user-OAuth): an app asks a user for notify permission. + * - `send` (sender-DID): an approved sender delivers a notification. + * + * Every first-party user-management method (grant, revoke, the list/get + * queries, settings, and so on) lives behind the `RelayRpc` service-binding + * entrypoint instead (see ./rpc/), so it is unreachable from the public + * internet. Auth + rate limiting live inside each handler; the verifier is + * memoized per Env. */ export function buildRouter(env: Env, ctx: ExecutionContext): XRPCRouter { const app: AppContext = { env, ctx, verifier: getVerifier(env) }; @@ -48,22 +30,8 @@ export function buildRouter(env: Env, ctx: ExecutionContext): XRPCRouter { }, }); - // Sender path. router.addProcedure(ToolsAtmoNotifsRequestPermission.mainSchema, makeRequestPermission(app)); router.addProcedure(ToolsAtmoNotifsSend.mainSchema, makeSend(app)); - // User path. - router.addProcedure(ToolsAtmoNotifsGrant.mainSchema, makeGrant(app)); - router.addProcedure(ToolsAtmoNotifsRevoke.mainSchema, makeRevoke(app)); - router.addProcedure(ToolsAtmoNotifsDenyPending.mainSchema, makeDenyPending(app)); - router.addProcedure(ToolsAtmoNotifsMuteGrant.mainSchema, makeMuteGrant(app)); - router.addProcedure(ToolsAtmoNotifsLinkChannel.mainSchema, makeLinkChannel(app)); - router.addProcedure(ToolsAtmoNotifsUnlinkChannel.mainSchema, makeUnlinkChannel(app)); - router.addProcedure(ToolsAtmoNotifsUpdateSettings.mainSchema, makeUpdateSettings(app)); - router.addQuery(ToolsAtmoNotifsListGrants.mainSchema, makeListGrants(app)); - router.addQuery(ToolsAtmoNotifsListPending.mainSchema, makeListPending(app)); - router.addQuery(ToolsAtmoNotifsListChannels.mainSchema, makeListChannels(app)); - router.addQuery(ToolsAtmoNotifsGetSettings.mainSchema, makeGetSettings(app)); - return router; } diff --git a/apps/relay/src/rpc/entrypoint.ts b/apps/relay/src/rpc/entrypoint.ts new file mode 100644 index 0000000..12e85a1 --- /dev/null +++ b/apps/relay/src/rpc/entrypoint.ts @@ -0,0 +1,66 @@ +import { WorkerEntrypoint } from 'cloudflare:workers'; + +import type { Did } from '@atcute/lexicons'; +import type { + NotifsRpc, + ToolsAtmoNotifsDenyPending, + ToolsAtmoNotifsGetSettings, + ToolsAtmoNotifsGrant, + ToolsAtmoNotifsLinkChannel, + ToolsAtmoNotifsListChannels, + ToolsAtmoNotifsListGrants, + ToolsAtmoNotifsListPending, + ToolsAtmoNotifsMuteGrant, + ToolsAtmoNotifsRevoke, + ToolsAtmoNotifsUnlinkChannel, + ToolsAtmoNotifsUpdateSettings, +} from '@atmo/notifs-lexicons'; + +import type { Env } from '../env'; +import * as ops from './ops'; + +/** + * Private, first-party management API: the relay's 11 user-management methods, + * reachable ONLY via a Cloudflare service binding from the web app's server + * (apps/web/wrangler.jsonc → services[].entrypoint = "RelayRpc"). There is no + * public route and no JWT — only bound Workers can call this, so the binding is + * the security boundary and `did` is the authenticated user the caller vouches + * for. The federated surface (`requestPermission`, `send`) stays on the public + * XRPC router (../router.ts). `implements NotifsRpc` keeps this in lockstep with + * the shared contract the web app types its binding against. + */ +export class RelayRpc extends WorkerEntrypoint implements NotifsRpc { + grant(did: Did, input: ToolsAtmoNotifsGrant.$input) { + return ops.grant(this.env, did, input); + } + revoke(did: Did, input: ToolsAtmoNotifsRevoke.$input) { + return ops.revoke(this.env, did, input); + } + denyPending(did: Did, input: ToolsAtmoNotifsDenyPending.$input) { + return ops.denyPending(this.env, did, input); + } + muteGrant(did: Did, input: ToolsAtmoNotifsMuteGrant.$input) { + return ops.muteGrant(this.env, did, input); + } + linkChannel(did: Did, input: ToolsAtmoNotifsLinkChannel.$input) { + return ops.linkChannel(this.env, did, input); + } + unlinkChannel(did: Did, input: ToolsAtmoNotifsUnlinkChannel.$input) { + return ops.unlinkChannel(this.env, did, input); + } + updateSettings(did: Did, input: ToolsAtmoNotifsUpdateSettings.$input) { + return ops.updateSettings(this.env, did, input); + } + listGrants(did: Did): Promise { + return ops.listGrants(this.env, did); + } + listPending(did: Did): Promise { + return ops.listPending(this.env, did); + } + listChannels(did: Did): Promise { + return ops.listChannels(this.env, did); + } + getSettings(did: Did): Promise { + return ops.getSettings(this.env, did); + } +} diff --git a/apps/relay/src/rpc/ops.ts b/apps/relay/src/rpc/ops.ts new file mode 100644 index 0000000..085fa79 --- /dev/null +++ b/apps/relay/src/rpc/ops.ts @@ -0,0 +1,209 @@ +// First-party management operations — the logic formerly in src/xrpc/*.ts for +// every user method except `requestPermission`. These run ONLY behind the +// `RelayRpc` service-binding entrypoint (./entrypoint.ts), so there is no auth, +// no `Request`, and no `json()` wrapper here: the binding is the security +// boundary and `did` is the already-authenticated user the caller vouches for. +import type { Did } from '@atcute/lexicons'; + +import type { + ToolsAtmoNotifsDenyPending, + ToolsAtmoNotifsGetSettings, + ToolsAtmoNotifsGrant, + ToolsAtmoNotifsLinkChannel, + ToolsAtmoNotifsListChannels, + ToolsAtmoNotifsListGrants, + ToolsAtmoNotifsListPending, + ToolsAtmoNotifsMuteGrant, + ToolsAtmoNotifsRevoke, + ToolsAtmoNotifsUnlinkChannel, + ToolsAtmoNotifsUpdateSettings, +} from '@atmo/notifs-lexicons'; + +import * as q from '../db/queries'; +import type { Env } from '../env'; +import { newLinkToken } from '../lib/ids'; +import { addMinutes, now, toIsoDatetime } from '../lib/time'; + +export async function grant( + env: Env, + did: Did, + input: ToolsAtmoNotifsGrant.$input, +): Promise { + await q.ensureUser(env.DB, did, 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(env.DB, input.requestId) : null; + const fromPending = pending !== null && pending.recipient_did === did ? pending : null; + + await q.upsertGrant(env.DB, { + recipientDid: did, + 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(env.DB, input.requestId, did); + } + + return { granted: true }; +} + +export async function revoke( + env: Env, + did: Did, + input: ToolsAtmoNotifsRevoke.$input, +): Promise { + const revoked = await q.deleteGrant(env.DB, did, input.sender); + // The pending request (if any) for this pair is now irrelevant. + await q.deletePendingByPair(env.DB, did, input.sender); + + return { revoked }; +} + +export async function denyPending( + env: Env, + did: Did, + input: ToolsAtmoNotifsDenyPending.$input, +): Promise { + // Delete the pending request without granting. Does not blocklist the sender; + // they may request again once rate limits allow. + const denied = await q.deletePendingById(env.DB, input.requestId, did); + + return { denied }; +} + +export async function muteGrant( + env: Env, + did: Did, + input: ToolsAtmoNotifsMuteGrant.$input, +): Promise { + await q.setGrantMuted(env.DB, did, input.sender, input.muted); + + return { muted: input.muted }; +} + +export async function linkChannel( + env: Env, + did: Did, + input: ToolsAtmoNotifsLinkChannel.$input, +): Promise { + await q.ensureUser(env.DB, did, now()); + const token = newLinkToken(); + await q.insertLinkToken(env.DB, { + token, + did, + platform: input.platform, + expiresAt: addMinutes(now(), 10), + }); + + const deepLink = `https://t.me/${env.BOT_USERNAME}?start=${token}`; + return { token, deepLink }; +} + +export async function unlinkChannel( + env: Env, + did: Did, + input: ToolsAtmoNotifsUnlinkChannel.$input, +): Promise { + const unlinked = await q.deleteChannel(env.DB, did, input.platform); + + return { unlinked }; +} + +export async function updateSettings( + env: Env, + did: Did, + input: ToolsAtmoNotifsUpdateSettings.$input, +): Promise { + await q.ensureUser(env.DB, did, now()); + + // Partial PATCH: only touch fields present in the input. + if (input.notifyPendingViaTelegram !== undefined) { + await q.setNotifyPending(env.DB, did, input.notifyPendingViaTelegram); + } + + const user = await q.getUser(env.DB, did); + return { + notifyPendingViaTelegram: (user?.notify_pending_via_telegram ?? 0) === 1, + }; +} + +export async function listGrants( + env: Env, + did: Did, +): Promise { + const rows = await q.listGrantsForRecipient(env.DB, did); + + return { + grants: rows.map((row) => ({ + 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, + senderBskyDisplayName: row.display_name ?? undefined, + senderBskyAvatar: row.avatar_url ?? undefined, + grantedAt: toIsoDatetime(row.granted_at), + muted: row.muted === 1, + })), + }; +} + +export async function listPending( + env: Env, + did: Did, +): Promise { + const rows = await q.listPendingForRecipient(env.DB, did, now()); + + return { + 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, + senderBskyDisplayName: row.display_name ?? undefined, + senderBskyAvatar: row.avatar_url ?? undefined, + createdAt: toIsoDatetime(row.created_at), + expiresAt: toIsoDatetime(row.expires_at), + })), + }; +} + +export async function listChannels( + env: Env, + did: Did, +): Promise { + const rows = await q.listChannelsForDid(env.DB, did); + + return { + channels: rows.map((row) => ({ + platform: row.platform, + linkedAt: toIsoDatetime(row.linked_at), + displayName: row.display_name ?? undefined, + })), + }; +} + +export async function getSettings( + env: Env, + did: Did, +): Promise { + // Ensure the row exists so we return stored defaults rather than guessing. + await q.ensureUser(env.DB, did, now()); + const user = await q.getUser(env.DB, did); + + return { + notifyPendingViaTelegram: (user?.notify_pending_via_telegram ?? 0) === 1, + }; +} diff --git a/apps/relay/src/well-known.ts b/apps/relay/src/well-known.ts index 26ec695..23c83aa 100644 --- a/apps/relay/src/well-known.ts +++ b/apps/relay/src/well-known.ts @@ -1,35 +1,15 @@ -import denyPending from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/denyPending.json'; -import getSettings from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/getSettings.json'; -import grant from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/grant.json'; -import linkChannel from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/linkChannel.json'; -import listChannels from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/listChannels.json'; -import listGrants from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/listGrants.json'; -import listPending from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/listPending.json'; -import muteGrant from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/muteGrant.json'; import requestPermission from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/requestPermission.json'; -import revoke from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/revoke.json'; import send from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/send.json'; -import unlinkChannel from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/unlinkChannel.json'; -import updateSettings from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/updateSettings.json'; import type { Env } from './env'; -// Lexicon JSONs are bundled into the Worker (imported as JSON modules) and served -// statically from `/lexicons/:nsid`, keyed by NSID. +// Only the *federated* lexicons are published here. The user-management methods +// are no longer public XRPC — they live behind the `RelayRpc` service binding — +// so their lexicon JSON is intentionally NOT served (the files remain in the +// package purely as type-generation input). See src/rpc/entrypoint.ts. const LEXICONS: Record = { 'tools.atmo.notifs.requestPermission': requestPermission, 'tools.atmo.notifs.send': send, - 'tools.atmo.notifs.grant': grant, - 'tools.atmo.notifs.revoke': revoke, - 'tools.atmo.notifs.denyPending': denyPending, - 'tools.atmo.notifs.muteGrant': muteGrant, - 'tools.atmo.notifs.listGrants': listGrants, - 'tools.atmo.notifs.listPending': listPending, - 'tools.atmo.notifs.linkChannel': linkChannel, - 'tools.atmo.notifs.unlinkChannel': unlinkChannel, - 'tools.atmo.notifs.listChannels': listChannels, - 'tools.atmo.notifs.getSettings': getSettings, - 'tools.atmo.notifs.updateSettings': updateSettings, }; /** diff --git a/apps/relay/src/xrpc/denyPending.ts b/apps/relay/src/xrpc/denyPending.ts deleted file mode 100644 index afb3802..0000000 --- a/apps/relay/src/xrpc/denyPending.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ToolsAtmoNotifsDenyPending } from '@atmo/notifs-lexicons'; -import { json, type ProcedureConfig } from '@atcute/xrpc-server'; - -import { verifyUserRequest } from '../auth/user'; -import * as q from '../db/queries'; -import type { AppContext } from '../env'; - -const LXM = 'tools.atmo.notifs.denyPending'; - -export function makeDenyPending( - app: AppContext, -): ProcedureConfig { - return { - handler: async ({ request, input }) => { - const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - - // Delete the pending request without granting. Does not blocklist the - // sender; they may request again once rate limits allow. - const denied = await q.deletePendingById(app.env.DB, input.requestId, userDid); - - return json({ denied }); - }, - }; -} diff --git a/apps/relay/src/xrpc/getSettings.ts b/apps/relay/src/xrpc/getSettings.ts deleted file mode 100644 index 1dc8e79..0000000 --- a/apps/relay/src/xrpc/getSettings.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ToolsAtmoNotifsGetSettings } from '@atmo/notifs-lexicons'; -import { json, type QueryConfig } from '@atcute/xrpc-server'; - -import { verifyUserRequest } from '../auth/user'; -import * as q from '../db/queries'; -import type { AppContext } from '../env'; -import { now } from '../lib/time'; - -const LXM = 'tools.atmo.notifs.getSettings'; - -export function makeGetSettings( - app: AppContext, -): QueryConfig { - return { - handler: async ({ request }) => { - const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - - // Ensure the row exists so we return stored defaults rather than guessing. - await q.ensureUser(app.env.DB, userDid, now()); - const user = await q.getUser(app.env.DB, userDid); - - return json({ - notifyPendingViaTelegram: (user?.notify_pending_via_telegram ?? 0) === 1, - }); - }, - }; -} diff --git a/apps/relay/src/xrpc/grant.ts b/apps/relay/src/xrpc/grant.ts deleted file mode 100644 index 5a32796..0000000 --- a/apps/relay/src/xrpc/grant.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ToolsAtmoNotifsGrant } from '@atmo/notifs-lexicons'; -import { json, type ProcedureConfig } from '@atcute/xrpc-server'; - -import { verifyUserRequest } from '../auth/user'; -import * as q from '../db/queries'; -import type { AppContext } from '../env'; -import { now } from '../lib/time'; - -const LXM = 'tools.atmo.notifs.grant'; - -export function makeGrant(app: AppContext): ProcedureConfig { - return { - handler: async ({ request, input }) => { - const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - await q.ensureUser(app.env.DB, userDid, 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); - } - - return json({ granted: true }); - }, - }; -} diff --git a/apps/relay/src/xrpc/linkChannel.ts b/apps/relay/src/xrpc/linkChannel.ts deleted file mode 100644 index 6c47147..0000000 --- a/apps/relay/src/xrpc/linkChannel.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ToolsAtmoNotifsLinkChannel } from '@atmo/notifs-lexicons'; -import { json, type ProcedureConfig } from '@atcute/xrpc-server'; - -import { verifyUserRequest } from '../auth/user'; -import * as q from '../db/queries'; -import type { AppContext } from '../env'; -import { newLinkToken } from '../lib/ids'; -import { addMinutes, now } from '../lib/time'; - -const LXM = 'tools.atmo.notifs.linkChannel'; - -export function makeLinkChannel( - app: AppContext, -): ProcedureConfig { - return { - handler: async ({ request, input }) => { - const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - - await q.ensureUser(app.env.DB, userDid, now()); - const token = newLinkToken(); - await q.insertLinkToken(app.env.DB, { - token, - did: userDid, - platform: input.platform, - expiresAt: addMinutes(now(), 10), - }); - - const deepLink = `https://t.me/${app.env.BOT_USERNAME}?start=${token}`; - return json({ token, deepLink }); - }, - }; -} diff --git a/apps/relay/src/xrpc/listChannels.ts b/apps/relay/src/xrpc/listChannels.ts deleted file mode 100644 index 464c3c7..0000000 --- a/apps/relay/src/xrpc/listChannels.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ToolsAtmoNotifsListChannels } from '@atmo/notifs-lexicons'; -import { json, type QueryConfig } from '@atcute/xrpc-server'; - -import { verifyUserRequest } from '../auth/user'; -import * as q from '../db/queries'; -import type { AppContext } from '../env'; -import { toIsoDatetime } from '../lib/time'; - -const LXM = 'tools.atmo.notifs.listChannels'; - -export function makeListChannels( - app: AppContext, -): QueryConfig { - return { - handler: async ({ request }) => { - const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - const rows = await q.listChannelsForDid(app.env.DB, userDid); - - return json({ - channels: rows.map((row) => ({ - platform: row.platform, - linkedAt: toIsoDatetime(row.linked_at), - displayName: row.display_name ?? undefined, - })), - }); - }, - }; -} diff --git a/apps/relay/src/xrpc/listGrants.ts b/apps/relay/src/xrpc/listGrants.ts deleted file mode 100644 index 49ea4a3..0000000 --- a/apps/relay/src/xrpc/listGrants.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ToolsAtmoNotifsListGrants } from '@atmo/notifs-lexicons'; -import { json, type QueryConfig } from '@atcute/xrpc-server'; - -import { verifyUserRequest } from '../auth/user'; -import * as q from '../db/queries'; -import type { AppContext } from '../env'; -import { toIsoDatetime } from '../lib/time'; - -const LXM = 'tools.atmo.notifs.listGrants'; - -export function makeListGrants(app: AppContext): QueryConfig { - return { - handler: async ({ request }) => { - const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - const rows = await q.listGrantsForRecipient(app.env.DB, userDid); - - return json({ - grants: rows.map((row) => ({ - 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, - 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 deleted file mode 100644 index 84c7117..0000000 --- a/apps/relay/src/xrpc/listPending.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ToolsAtmoNotifsListPending } from '@atmo/notifs-lexicons'; -import { json, type QueryConfig } from '@atcute/xrpc-server'; - -import { verifyUserRequest } from '../auth/user'; -import * as q from '../db/queries'; -import type { AppContext } from '../env'; -import { now, toIsoDatetime } from '../lib/time'; - -const LXM = 'tools.atmo.notifs.listPending'; - -export function makeListPending( - app: AppContext, -): QueryConfig { - return { - handler: async ({ request }) => { - const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - const rows = await q.listPendingForRecipient(app.env.DB, userDid, now()); - - return json({ - 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, - 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/muteGrant.ts b/apps/relay/src/xrpc/muteGrant.ts deleted file mode 100644 index 8a05a8a..0000000 --- a/apps/relay/src/xrpc/muteGrant.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ToolsAtmoNotifsMuteGrant } from '@atmo/notifs-lexicons'; -import { json, type ProcedureConfig } from '@atcute/xrpc-server'; - -import { verifyUserRequest } from '../auth/user'; -import * as q from '../db/queries'; -import type { AppContext } from '../env'; - -const LXM = 'tools.atmo.notifs.muteGrant'; - -export function makeMuteGrant( - app: AppContext, -): ProcedureConfig { - return { - handler: async ({ request, input }) => { - const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - - await q.setGrantMuted(app.env.DB, userDid, input.sender, input.muted); - - return json({ muted: input.muted }); - }, - }; -} diff --git a/apps/relay/src/xrpc/revoke.ts b/apps/relay/src/xrpc/revoke.ts deleted file mode 100644 index 7d531dd..0000000 --- a/apps/relay/src/xrpc/revoke.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ToolsAtmoNotifsRevoke } from '@atmo/notifs-lexicons'; -import { json, type ProcedureConfig } from '@atcute/xrpc-server'; - -import { verifyUserRequest } from '../auth/user'; -import * as q from '../db/queries'; -import type { AppContext } from '../env'; - -const LXM = 'tools.atmo.notifs.revoke'; - -export function makeRevoke(app: AppContext): ProcedureConfig { - return { - handler: async ({ request, input }) => { - const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - - const revoked = await q.deleteGrant(app.env.DB, userDid, input.sender); - // The pending request (if any) for this pair is now irrelevant. - await q.deletePendingByPair(app.env.DB, userDid, input.sender); - - return json({ revoked }); - }, - }; -} diff --git a/apps/relay/src/xrpc/unlinkChannel.ts b/apps/relay/src/xrpc/unlinkChannel.ts deleted file mode 100644 index 505cf75..0000000 --- a/apps/relay/src/xrpc/unlinkChannel.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ToolsAtmoNotifsUnlinkChannel } from '@atmo/notifs-lexicons'; -import { json, type ProcedureConfig } from '@atcute/xrpc-server'; - -import { verifyUserRequest } from '../auth/user'; -import * as q from '../db/queries'; -import type { AppContext } from '../env'; - -const LXM = 'tools.atmo.notifs.unlinkChannel'; - -export function makeUnlinkChannel( - app: AppContext, -): ProcedureConfig { - return { - handler: async ({ request, input }) => { - const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - - const unlinked = await q.deleteChannel(app.env.DB, userDid, input.platform); - - return json({ unlinked }); - }, - }; -} diff --git a/apps/relay/src/xrpc/updateSettings.ts b/apps/relay/src/xrpc/updateSettings.ts deleted file mode 100644 index 4340c72..0000000 --- a/apps/relay/src/xrpc/updateSettings.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ToolsAtmoNotifsUpdateSettings } from '@atmo/notifs-lexicons'; -import { json, type ProcedureConfig } from '@atcute/xrpc-server'; - -import { verifyUserRequest } from '../auth/user'; -import * as q from '../db/queries'; -import type { AppContext } from '../env'; -import { now } from '../lib/time'; - -const LXM = 'tools.atmo.notifs.updateSettings'; - -export function makeUpdateSettings( - app: AppContext, -): ProcedureConfig { - return { - handler: async ({ request, input }) => { - const { userDid } = await verifyUserRequest(app.verifier, request, LXM); - await q.ensureUser(app.env.DB, userDid, now()); - - // Partial PATCH: only touch fields present in the input. - if (input.notifyPendingViaTelegram !== undefined) { - await q.setNotifyPending(app.env.DB, userDid, input.notifyPendingViaTelegram); - } - - const user = await q.getUser(app.env.DB, userDid); - return json({ - notifyPendingViaTelegram: (user?.notify_pending_via_telegram ?? 0) === 1, - }); - }, - }; -} diff --git a/apps/relay/test/auth.test.ts b/apps/relay/test/auth.test.ts index 9e2888d..1563a0a 100644 --- a/apps/relay/test/auth.test.ts +++ b/apps/relay/test/auth.test.ts @@ -1,15 +1,32 @@ +import type { Did } from '@atcute/lexicons'; import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; import { beforeAll, expect, it } from 'vitest'; import worker from '../src/index'; -import { installFetchMock, makeIdentity, makeJwt, mockPlc, mockPlcNotFound, xrpcGet } from './helpers'; +import { + installFetchMock, + makeBskyProfileMock, + makeIdentity, + makeJwt, + mockPlc, + mockPlcNotFound, + xrpcPost, +} from './helpers'; beforeAll(() => { installFetchMock(); + // requestPermission fires a best-effort sender-profile refresh; stub it. + makeBskyProfileMock(); }); -const GET_SETTINGS = 'tools.atmo.notifs.getSettings'; +// requestPermission is the remaining user-OAuth XRPC endpoint, so it's what +// exercises the user-JWT verifier now that the management methods moved to the +// service binding. A valid body is sent in every case so the request reaches the +// auth check rather than tripping schema validation first. +const REQ = 'tools.atmo.notifs.requestPermission'; +const SENDER: Did = 'did:plc:authsender'; +const BODY = { senderDid: SENDER, title: 'Test' }; async function call(req: Request): Promise { const ctx = createExecutionContext(); @@ -21,20 +38,19 @@ async function call(req: Request): Promise { it('accepts a valid user JWT (happy path)', async () => { const user = await makeIdentity('did:plc:authhappy'); mockPlc(user); - const jwt = await makeJwt(user, { lxm: GET_SETTINGS }); + const jwt = await makeJwt(user, { lxm: REQ }); - const res = await call(xrpcGet(GET_SETTINGS, jwt)); + const res = await call(xrpcPost(REQ, jwt, BODY)); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ notifyPendingViaTelegram: false }); }); it('rejects an expired JWT', async () => { const user = await makeIdentity('did:plc:authexpired'); mockPlc(user); - const jwt = await makeJwt(user, { lxm: GET_SETTINGS, expiresIn: -120 }); + const jwt = await makeJwt(user, { lxm: REQ, expiresIn: -120 }); - const res = await call(xrpcGet(GET_SETTINGS, jwt)); + const res = await call(xrpcPost(REQ, jwt, BODY)); expect(res.status).toBe(401); }); @@ -42,9 +58,9 @@ it('rejects an expired JWT', async () => { it('rejects a JWT addressed to the wrong audience', async () => { const user = await makeIdentity('did:plc:authaud'); mockPlc(user); - const jwt = await makeJwt(user, { lxm: GET_SETTINGS, audience: 'did:web:evil.example' }); + const jwt = await makeJwt(user, { lxm: REQ, audience: 'did:web:evil.example' }); - const res = await call(xrpcGet(GET_SETTINGS, jwt)); + const res = await call(xrpcPost(REQ, jwt, BODY)); expect(res.status).toBe(401); }); @@ -52,10 +68,10 @@ it('rejects a JWT addressed to the wrong audience', async () => { it('rejects a JWT scoped to a different lexicon method', async () => { const user = await makeIdentity('did:plc:authlxm'); mockPlc(user); - // Token authorizes `send`, but we call `getSettings`. + // Token authorizes `send`, but we call `requestPermission`. const jwt = await makeJwt(user, { lxm: 'tools.atmo.notifs.send' }); - const res = await call(xrpcGet(GET_SETTINGS, jwt)); + const res = await call(xrpcPost(REQ, jwt, BODY)); expect(res.status).toBe(401); }); @@ -63,9 +79,9 @@ it('rejects a JWT scoped to a different lexicon method', async () => { it('rejects a JWT from an unresolvable DID', async () => { const user = await makeIdentity('did:plc:authunknown'); mockPlcNotFound(user.did); - const jwt = await makeJwt(user, { lxm: GET_SETTINGS }); + const jwt = await makeJwt(user, { lxm: REQ }); - const res = await call(xrpcGet(GET_SETTINGS, jwt)); + const res = await call(xrpcPost(REQ, jwt, BODY)); expect(res.status).toBe(401); }); diff --git a/apps/relay/test/grant.test.ts b/apps/relay/test/grant.test.ts index 69a7d46..098dd2f 100644 --- a/apps/relay/test/grant.test.ts +++ b/apps/relay/test/grant.test.ts @@ -1,31 +1,20 @@ import type { Did } from '@atcute/lexicons'; -import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; -import { beforeAll, expect, it } from 'vitest'; +import { env } from 'cloudflare:test'; +import { expect, it } from 'vitest'; import * as q from '../src/db/queries'; -import worker from '../src/index'; - -import { installFetchMock, makeIdentity, makeJwt, mockPlc, xrpcPost } from './helpers'; - -beforeAll(() => { - installFetchMock(); -}); +import * as ops from '../src/rpc/ops'; +// grant/revoke are no longer XRPC endpoints — they're first-party management ops +// behind the service binding. The binding is the auth boundary, so these tests +// exercise the operation logic directly (the `did` is the authenticated user). const SENDER: Did = 'did:plc:grantsender'; -async function call(req: Request): Promise { - const ctx = createExecutionContext(); - const res = await worker.fetch(req, env, ctx); - await waitOnExecutionContext(ctx); - return res; -} - it('grant consumes the pending request and copies its metadata onto the grant', async () => { - const user = await makeIdentity('did:plc:grantuser'); - mockPlc(user); + const user: Did = 'did:plc:grantuser'; await q.insertPending(env.DB, { id: 'req-1', - recipientDid: user.did, + recipientDid: user, senderDid: SENDER, title: 'Bookhive', description: 'New comments on your books', @@ -34,14 +23,12 @@ it('grant consumes the pending request and copies its metadata onto the grant', expiresAt: Date.now() + 1_000_000, }); - const jwt = await makeJwt(user, { lxm: 'tools.atmo.notifs.grant' }); - const res = await call(xrpcPost('tools.atmo.notifs.grant', jwt, { sender: SENDER, requestId: 'req-1' })); + const out = await ops.grant(env, user, { sender: SENDER, requestId: 'req-1' }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ granted: true }); + expect(out).toEqual({ granted: true }); expect(await q.getPendingById(env.DB, 'req-1')).toBeNull(); - const grant = await q.getGrant(env.DB, user.did, SENDER); + const grant = await q.getGrant(env.DB, user, SENDER); expect(grant).not.toBeNull(); expect(grant?.title).toBe('Bookhive'); expect(grant?.description).toBe('New comments on your books'); @@ -49,10 +36,9 @@ it('grant consumes the pending request and copies its metadata onto the grant', }); it('revoke removes the grant', async () => { - const user = await makeIdentity('did:plc:revokeuser'); - mockPlc(user); + const user: Did = 'did:plc:revokeuser'; await q.upsertGrant(env.DB, { - recipientDid: user.did, + recipientDid: user, senderDid: SENDER, grantedAt: Date.now(), title: null, @@ -60,10 +46,8 @@ it('revoke removes the grant', async () => { iconUrl: null, }); - const jwt = await makeJwt(user, { lxm: 'tools.atmo.notifs.revoke' }); - const res = await call(xrpcPost('tools.atmo.notifs.revoke', jwt, { sender: SENDER })); + const out = await ops.revoke(env, user, { sender: SENDER }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ revoked: true }); - expect(await q.getGrant(env.DB, user.did, SENDER)).toBeNull(); + expect(out).toEqual({ revoked: true }); + expect(await q.getGrant(env.DB, user, SENDER)).toBeNull(); }); diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index 3a78063..20e079e 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -2,6 +2,7 @@ import type { OAuthSession } from '@atcute/oauth-node-client'; import type { Client } from '@atcute/client'; import type { Did } from '@atcute/lexicons'; +import type { NotifsRpc } from '@atmo/notifs-lexicons'; declare global { namespace App { @@ -13,7 +14,16 @@ declare global { } // interface PageData {} // interface PageState {} - // interface Platform {} + interface Platform { + // Cloudflare bindings on `event.platform`. `RELAY` is the relay Worker's + // `RelayRpc` entrypoint (wrangler.jsonc → services + src/lib/server/relay.ts). + // KV/ASSETS/vars are resolved by the OAuth lib via runtime binding names, so + // they're covered by the index signature rather than enumerated here. + env: { + RELAY: NotifsRpc; + [binding: string]: unknown; + }; + } } } diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts index 652c677..395d280 100644 --- a/apps/web/src/lib/config.ts +++ b/apps/web/src/lib/config.ts @@ -13,33 +13,19 @@ 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. */ +/** Lexicon NSID prefix for relay methods (still used by the /docs examples). */ export const LEXICON_PREFIX = 'tools.atmo.notifs'; /** - * The user-management methods the website calls on the relay, requested directly - * as individual `rpc` scopes (rather than a published permission set). + * OAuth scope: identity only. + * + * The website talks to the relay's management methods over a private Cloudflare + * service binding (see src/lib/server/relay.ts), passing the signed-in user's + * DID directly — it never mints service-auth JWTs on the user's behalf. So it + * needs no `rpc?lxm=…` scopes; plain `atproto` (identity) is sufficient, which + * also means a phished web OAuth grant yields nothing but the user's identity. */ -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=`. `aud=*` (any audience) so - * service-auth tokens can be minted for the relay. - */ -export const OAUTH_SCOPE = `atproto rpc?${USER_LXMS.map((lxm) => `lxm=${lxm}`).join('&')}&aud=*`; +export const OAUTH_SCOPE = 'atproto'; // --- Branding (placeholders — rename freely) ------------------------------- diff --git a/apps/web/src/lib/remote/notifs.remote.ts b/apps/web/src/lib/remote/notifs.remote.ts index 5a3e431..ab22c8b 100644 --- a/apps/web/src/lib/remote/notifs.remote.ts +++ b/apps/web/src/lib/remote/notifs.remote.ts @@ -6,15 +6,15 @@ import { command, getRequestEvent } from '$app/server'; import { error } from '@sveltejs/kit'; import * as v from 'valibot'; -import { relay } from '$lib/server/relay'; +import { relayFor } from '$lib/server/relay'; -/** Resolve the signed-in user's authenticated client, or 401. */ -function requireClient() { - const { locals } = getRequestEvent(); - if (!locals.client) { +/** Resolve the relay bound to the signed-in user, or 401. */ +function requireRelay() { + const { locals, platform } = getRequestEvent(); + if (!locals.did) { error(401, 'Not signed in'); } - return locals.client; + return relayFor(platform, locals.did); } const didSchema = v.pipe(v.string(), v.startsWith('did:')); @@ -22,35 +22,35 @@ 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 }); + await requireRelay().grant({ sender: sender as Did, requestId }); } ); export const deny = command(v.object({ requestId: v.string() }), async ({ requestId }) => { - await relay.denyPending(requireClient(), { requestId }); + await requireRelay().denyPending({ requestId }); }); export const revoke = command(v.object({ sender: didSchema }), async ({ sender }) => { - await relay.revoke(requireClient(), { sender: sender as Did }); + await requireRelay().revoke({ 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 }); + await requireRelay().muteGrant({ sender: sender as Did, muted }); } ); export const setNotifyPending = command(v.object({ value: v.boolean() }), async ({ value }) => { - await relay.updateSettings(requireClient(), { notifyPendingViaTelegram: value }); + await requireRelay().updateSettings({ 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' }); + const { deepLink } = await requireRelay().linkChannel({ platform: 'telegram' }); return { deepLink }; }); export const unlinkTelegram = command(async () => { - await relay.unlinkChannel(requireClient(), { platform: 'telegram' }); + await requireRelay().unlinkChannel({ platform: 'telegram' }); }); diff --git a/apps/web/src/lib/server/relay.ts b/apps/web/src/lib/server/relay.ts index 4667886..703f0b7 100644 --- a/apps/web/src/lib/server/relay.ts +++ b/apps/web/src/lib/server/relay.ts @@ -1,144 +1,52 @@ -// 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'; +// Server-only helper for calling the notification relay over a Cloudflare +// service binding (apps/web/wrangler.jsonc → services[].binding = "RELAY", +// entrypoint "RelayRpc"). The relay's management methods are NOT public XRPC: +// only bound Workers (this app's server) can reach them, so the binding itself +// is the security boundary and we pass the signed-in user's DID directly — no +// service-auth JWT, and the web app needs no `rpc` OAuth scope. +// +// In local `vite dev` the binding is provided by the cloudflare adapter's +// platform proxy, which needs the relay running via `wrangler dev`; if it isn't, +// `relayFor` throws a clear error rather than failing cryptically. +import type { Did } 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. + * Bind the relay service to the signed-in user. The `RELAY` binding is declared + * on `App.Platform` (app.d.ts) as the shared `NotifsRpc` contract, which the + * relay's `RelayRpc` entrypoint implements — so every method below is + * type-checked end-to-end off one source. Throws if the binding is missing + * (e.g. local `vite dev` without a running relay) or the user is not signed in. */ -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'); +export function relayFor(platform: App.Platform | undefined, did: Did | null) { + const svc = platform?.env.RELAY; + if (!svc) { + throw new Error('Relay service binding (RELAY) is unavailable'); } - - 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); + if (!did) { + throw new Error('Not signed in'); } - return parsed as T; + return { + listGrants: () => svc.listGrants(did), + listPending: () => svc.listPending(did), + listChannels: () => svc.listChannels(did), + getSettings: () => svc.getSettings(did), + grant: (input: ToolsAtmoNotifsGrant.$input) => svc.grant(did, input), + revoke: (input: ToolsAtmoNotifsRevoke.$input) => svc.revoke(did, input), + denyPending: (input: ToolsAtmoNotifsDenyPending.$input) => svc.denyPending(did, input), + muteGrant: (input: ToolsAtmoNotifsMuteGrant.$input) => svc.muteGrant(did, input), + linkChannel: (input: ToolsAtmoNotifsLinkChannel.$input) => svc.linkChannel(did, input), + unlinkChannel: (input: ToolsAtmoNotifsUnlinkChannel.$input) => svc.unlinkChannel(did, input), + updateSettings: (input: ToolsAtmoNotifsUpdateSettings.$input) => + svc.updateSettings(did, input) + }; } - -/** 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/dashboard/+page.server.ts b/apps/web/src/routes/dashboard/+page.server.ts index f537731..1d74e95 100644 --- a/apps/web/src/routes/dashboard/+page.server.ts +++ b/apps/web/src/routes/dashboard/+page.server.ts @@ -1,20 +1,20 @@ import { redirect } from '@sveltejs/kit'; -import { relay } from '$lib/server/relay'; +import { relayFor } from '$lib/server/relay'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ locals }) => { - if (!locals.did || !locals.client) { +export const load: PageServerLoad = async ({ locals, platform }) => { + if (!locals.did) { redirect(303, '/'); } - const client = locals.client; + const relay = relayFor(platform, locals.did); const [pending, grants, channels, settings] = await Promise.all([ - relay.listPending(client), - relay.listGrants(client), - relay.listChannels(client), - relay.getSettings(client) + relay.listPending(), + relay.listGrants(), + relay.listChannels(), + relay.getSettings() ]); // Treat relay responses defensively — render fallbacks rather than crash. diff --git a/apps/web/src/routes/dashboard/pending/[id]/+page.server.ts b/apps/web/src/routes/dashboard/pending/[id]/+page.server.ts index 2309046..40bfa6f 100644 --- a/apps/web/src/routes/dashboard/pending/[id]/+page.server.ts +++ b/apps/web/src/routes/dashboard/pending/[id]/+page.server.ts @@ -1,14 +1,14 @@ import { redirect } from '@sveltejs/kit'; -import { relay } from '$lib/server/relay'; +import { relayFor } from '$lib/server/relay'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ locals, params }) => { - if (!locals.did || !locals.client) { +export const load: PageServerLoad = async ({ locals, platform, params }) => { + if (!locals.did) { redirect(303, '/'); } - const res = await relay.listPending(locals.client); + const res = await relayFor(platform, locals.did).listPending(); const request = (res?.pending ?? []).find((p) => p.id === params.id) ?? null; return { request }; }; diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index e40900f..7d35d83 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -27,6 +27,19 @@ "binding": "OAUTH_STATES", "id": "a0f731098f5d4bdea2c3b9e622b13153" } + ], + // Private binding to the relay Worker's `RelayRpc` entrypoint. The relay's + // first-party management methods are reachable only this way (no public + // XRPC). `service` must match the relay's deployed Worker name (its + // wrangler.toml `name`); both Workers must be in the same Cloudflare account. + // For local `vite dev`, run the relay via `wrangler dev` so the adapter's + // platform proxy can wire this up. + "services": [ + { + "binding": "RELAY", + "service": "notifs-relay", + "entrypoint": "RelayRpc" + } ] // Secrets (set via `wrangler secret put`, never commit): // COOKIE_SECRET (atproto-oauth secret | wrangler secret put COOKIE_SECRET) diff --git a/packages/lexicons/src/index.ts b/packages/lexicons/src/index.ts index b279bcb..4dab56c 100644 --- a/packages/lexicons/src/index.ts +++ b/packages/lexicons/src/index.ts @@ -9,3 +9,7 @@ // Importing these modules also augments `@atcute/lexicons/ambient`'s // `XRPCQueries` / `XRPCProcedures` registries with our NSIDs. export * from './lexicons/index.js'; + +// Hand-written: the first-party management contract shared by the relay's +// service-binding entrypoint and the web app (see ./rpc.ts). +export * from './rpc.js'; diff --git a/packages/lexicons/src/rpc.ts b/packages/lexicons/src/rpc.ts new file mode 100644 index 0000000..c69be8c --- /dev/null +++ b/packages/lexicons/src/rpc.ts @@ -0,0 +1,57 @@ +// Hand-written (NOT generated by lex-cli, which only writes ./lexicons/). +// +// The relay's first-party management API. These 11 methods are deliberately NOT +// public XRPC: the relay exposes them ONLY over a Cloudflare service binding to +// the web app's server (see apps/relay/src/rpc/entrypoint.ts). This interface is +// the single contract both ends share — the relay's `RelayRpc` entrypoint +// `implements NotifsRpc`, and the web app types its `platform.env.RELAY` binding +// as `NotifsRpc`, so every call is type-checked end-to-end off the same source. +// +// Shapes reuse the lex-cli-generated `$input`/`$output` types, so the lexicon +// JSON stays the source of truth for request/response bodies even though these +// methods are no longer served as XRPC. The leading `did` argument is the +// authenticated user the caller (the trusted web server) vouches for. +import type { Did } from '@atcute/lexicons'; + +import type { + ToolsAtmoNotifsDenyPending, + ToolsAtmoNotifsGetSettings, + ToolsAtmoNotifsGrant, + ToolsAtmoNotifsLinkChannel, + ToolsAtmoNotifsListChannels, + ToolsAtmoNotifsListGrants, + ToolsAtmoNotifsListPending, + ToolsAtmoNotifsMuteGrant, + ToolsAtmoNotifsRevoke, + ToolsAtmoNotifsUnlinkChannel, + ToolsAtmoNotifsUpdateSettings, +} from './lexicons/index.js'; + +export interface NotifsRpc { + grant(did: Did, input: ToolsAtmoNotifsGrant.$input): Promise; + revoke(did: Did, input: ToolsAtmoNotifsRevoke.$input): Promise; + denyPending( + did: Did, + input: ToolsAtmoNotifsDenyPending.$input, + ): Promise; + muteGrant( + did: Did, + input: ToolsAtmoNotifsMuteGrant.$input, + ): Promise; + linkChannel( + did: Did, + input: ToolsAtmoNotifsLinkChannel.$input, + ): Promise; + unlinkChannel( + did: Did, + input: ToolsAtmoNotifsUnlinkChannel.$input, + ): Promise; + updateSettings( + did: Did, + input: ToolsAtmoNotifsUpdateSettings.$input, + ): Promise; + listGrants(did: Did): Promise; + listPending(did: Did): Promise; + listChannels(did: Did): Promise; + getSettings(did: Did): Promise; +}