diff --git a/web/src/app.d.ts b/web/src/app.d.ts index 86364211e..9f8db5f41 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -5,6 +5,7 @@ declare global { bobbinUrl: string; knotMirrorUrl: string; apiUrl: string; + deliberiUrl: string; sitesDomain: string; camoEnabled: boolean; }; diff --git a/web/src/lib/api/deliberi.ts b/web/src/lib/api/deliberi.ts new file mode 100644 index 000000000..c315a5e08 --- /dev/null +++ b/web/src/lib/api/deliberi.ts @@ -0,0 +1,79 @@ +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { mintServiceAuth, serviceDidForHost } from "$lib/auth/agent"; +import { buildUrl, toResponseError } from "./_request"; +import type { QueryValue, XrpcRequestInit } from "./client"; + +// Authenticated client for deliberi's org.tangled.temp.* xrpc methods. +// Calls are authed with an atproto service-auth jwt: the browser mints a +// per-call token via the user's pds (getServiceAuth), scoped to the method +// (lxm) and deliberi's did:web (aud). +export interface DeliberiContext { + readonly serviceUrl: string; + readonly aud: string; + readonly agent: OAuthUserAgent; + readonly fetch: typeof globalThis.fetch; +} + +export interface CreateDeliberiOptions { + deliberiUrl: string; + agent: OAuthUserAgent; + fetch?: typeof globalThis.fetch; +} + +export const createDeliberiClient = ({ + deliberiUrl, + agent, + fetch +}: CreateDeliberiOptions): DeliberiContext => { + const serviceUrl = deliberiUrl.replace(/\/+$/, ""); + // aud is derived from the url hostname so it can't drift from + // deliberi's DELIBERI_HOSTNAME — both must match for service auth. + const aud = serviceDidForHost(new URL(serviceUrl).host); + return { serviceUrl, aud, agent, fetch: fetch ?? globalThis.fetch }; +}; + +const authHeader = async (ctx: DeliberiContext, nsid: string): Promise => { + const token = await mintServiceAuth(ctx.agent, { aud: ctx.aud, lxm: nsid }); + return `Bearer ${token}`; +}; + +export const authedGet = async ( + ctx: DeliberiContext, + nsid: string, + params?: Record, + init?: XrpcRequestInit +): Promise => { + const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid, params), { + headers: { + accept: "application/json", + authorization: await authHeader(ctx, nsid), + ...init?.headers + }, + signal: init?.signal + }); + if (!response.ok) throw await toResponseError(response); + return (await response.json()) as T; +}; + +export const authedPost = async ( + ctx: DeliberiContext, + nsid: string, + body: unknown, + init?: XrpcRequestInit +): Promise => { + const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid), { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json", + authorization: await authHeader(ctx, nsid), + ...init?.headers + }, + body: JSON.stringify(body ?? {}), + signal: init?.signal + }); + if (!response.ok) throw await toResponseError(response); + // most temp procedures return no body (200 with empty payload) + const text = await response.text(); + return text ? (JSON.parse(text) as T) : null; +}; diff --git a/web/src/lib/api/notifications.ts b/web/src/lib/api/notifications.ts index 1e4facd58..66e3880ee 100644 --- a/web/src/lib/api/notifications.ts +++ b/web/src/lib/api/notifications.ts @@ -1,4 +1,4 @@ -import { authedGet, authedPost, type AppviewContext } from "./appview"; +import { authedGet, authedPost, type DeliberiContext } from "./deliberi"; import type { XrpcRequestInit } from "./client"; // mirrors org.tangled.temp.notification.getPreferences#preferences @@ -19,14 +19,14 @@ const GET_PREFERENCES = "org.tangled.temp.notification.getPreferences"; const UPDATE_PREFERENCES = "org.tangled.temp.notification.updatePreferences"; export const getNotificationPreferences = ( - ctx: AppviewContext, + ctx: DeliberiContext, init?: XrpcRequestInit ): Promise => authedGet(ctx, GET_PREFERENCES, undefined, init); // only the provided fields are updated server-side. export const updateNotificationPreferences = ( - ctx: AppviewContext, + ctx: DeliberiContext, patch: Partial, init?: XrpcRequestInit ): Promise => authedPost(ctx, UPDATE_PREFERENCES, patch, init).then(() => undefined); diff --git a/web/src/lib/components/settings/tabs/NotificationsTab.svelte b/web/src/lib/components/settings/tabs/NotificationsTab.svelte index 489195c7b..0904d42b3 100644 --- a/web/src/lib/components/settings/tabs/NotificationsTab.svelte +++ b/web/src/lib/components/settings/tabs/NotificationsTab.svelte @@ -3,7 +3,7 @@ import type { SvelteHTMLElements } from "svelte/elements"; import { page } from "$app/state"; import { getAuth } from "$lib/auth.svelte"; - import { createAppviewClient } from "$lib/api/appview"; + import { createDeliberiClient } from "$lib/api/deliberi"; import { getNotificationPreferences, updateNotificationPreferences, @@ -38,7 +38,7 @@ } const auth = getAuth(); - const apiUrl = $derived(page.data.publicConfig?.apiUrl as string | undefined); + const deliberiUrl = $derived(page.data.publicConfig?.deliberiUrl as string | undefined); // row definitions map the design-system UI onto the api's preference keys. let prefs = $state([ @@ -121,9 +121,9 @@ const loaded = createLoad(async () => { const agent = auth.agent; - const url = apiUrl; + const url = deliberiUrl; if (!agent || !url) return; - const ctx = createAppviewClient({ apiUrl: url, agent }); + const ctx = createDeliberiClient({ deliberiUrl: url, agent }); const values = await getNotificationPreferences(ctx); for (const pref of prefs) pref.enabled = values[pref.key]; baseline = values; @@ -131,14 +131,14 @@ const save = createAction(async () => { const agent = auth.agent; - const url = apiUrl; + const url = deliberiUrl; if (!agent || !url || !baseline || !dirty) return; // send only the toggles that changed since the last save. const patch: Partial = {}; for (const pref of prefs) { if (pref.enabled !== baseline[pref.key]) patch[pref.key] = pref.enabled; } - const ctx = createAppviewClient({ apiUrl: url, agent }); + const ctx = createDeliberiClient({ deliberiUrl: url, agent }); await updateNotificationPreferences(ctx, patch); baseline = { ...baseline, ...patch }; }); diff --git a/web/src/lib/server/config.ts b/web/src/lib/server/config.ts index 4da678053..681291237 100644 --- a/web/src/lib/server/config.ts +++ b/web/src/lib/server/config.ts @@ -9,6 +9,7 @@ export type WebConfig = { bobbinUrl: string; knotMirrorUrl: string; apiUrl: string; + deliberiUrl: string; /** the domain user sites are served under, e.g. "tngl.io" */ sitesDomain: string; camoUrl: string; @@ -20,7 +21,7 @@ export type WebConfig = { export type PublicWebConfig = Pick< WebConfig, - "bobbinUrl" | "knotMirrorUrl" | "apiUrl" | "sitesDomain" + "bobbinUrl" | "knotMirrorUrl" | "apiUrl" | "deliberiUrl" | "sitesDomain" > & { /** camo has a secret, so markup can route images through it */ camoEnabled: boolean; @@ -31,6 +32,7 @@ type WebConfigEnv = { KNOTMIRROR_URL?: string; TANGLED_API_URL?: string; API_URL?: string; + DELIBERI_URL?: string; KNOT_RESOLVER_URL?: string; SITES_DOMAIN?: string; CAMO_URL?: string; @@ -43,6 +45,7 @@ export const resolveConfig = (values: WebConfigEnv): WebConfig => ({ bobbinUrl: cleanUrl(values.BOBBIN_URL, "http://127.0.0.1:8090"), knotMirrorUrl: cleanUrl(values.KNOTMIRROR_URL, ""), apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, "http://127.0.0.1:8080"), + deliberiUrl: cleanUrl(values.DELIBERI_URL, "https://notifs-dev.tangled.network"), sitesDomain: values.SITES_DOMAIN?.trim() || "tngl.io", camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh"), avatarUrl: cleanUrl(values.AVATAR_URL, "https://avatar.tangled.sh"), @@ -60,6 +63,7 @@ export const getPublicConfig = (): PublicWebConfig => { bobbinUrl: config.bobbinUrl, knotMirrorUrl: config.knotMirrorUrl, apiUrl: config.apiUrl, + deliberiUrl: config.deliberiUrl, sitesDomain: config.sitesDomain, camoEnabled: config.camoSecret !== "" }; diff --git a/web/wrangler.dev.jsonc b/web/wrangler.dev.jsonc index e7bc3f388..e98bcf919 100644 --- a/web/wrangler.dev.jsonc +++ b/web/wrangler.dev.jsonc @@ -32,6 +32,7 @@ "BOBBIN_URL": "https://next.tangled.org", "KNOTMIRROR_URL": "https://mirror-fsn.tangled.network", "TANGLED_API_URL": "https://tangled.org", + "DELIBERI_URL": "https://notifs-dev.tangled.network", "CAMO_URL": "https://camo-dev.tangled.org", "AVATAR_URL": "https://avatar-dev.tangled.org" }