diff --git a/components/notifications/notifications-provider.tsx b/components/notifications/notifications-provider.tsx index 435616c5..7a3b26d1 100644 --- a/components/notifications/notifications-provider.tsx +++ b/components/notifications/notifications-provider.tsx @@ -5,13 +5,18 @@ * WebSocket connection to our Soketi (Pusher-compatible) server so the * bell badge + a toast update in realtime. * - * Transport: subscribes to the PUBLIC channel `notifications.{userId}` and - * binds the `notification` event the BFF triggers on each follow-request / + * Transport: subscribes to the PRIVATE channel `private-notifications.{userId}` + * and binds the `notification` event the BFF triggers on each follow-request / * follow-accept. The event is a lightweight nudge — we bump the unread * count locally and toast — while the authoritative list/count still come - * from the REST endpoints. (Public channel for now; once Soketi App auth - * is configured, switch to a `private-` channel + a /broadcasting/auth - * endpoint — only the channel name and a Pusher `authEndpoint` change.) + * from the REST endpoints. + * + * The channel is private so a subscription requires a signature the BFF only + * issues to that user; on the old public channel anyone who knew a user's id + * could listen in. Signing goes through `channelAuthorizer` (see + * lib/realtime.ts). The BFF still publishes to the old public channel too, so + * a service-worker-cached bundle running this file's previous version keeps + * working until it updates. * * Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent * realtime silently disables and the bell falls back to its fetched count. @@ -26,6 +31,7 @@ import { } from "react"; import { toast } from "sonner"; import { useAuth } from "@/components/auth/auth-provider"; +import { channelAuthorizer } from "@/lib/realtime"; import { feed, notifications } from "@/lib/social"; import { sadhna } from "@/lib/sadhna"; @@ -145,7 +151,7 @@ export function NotificationsProvider({ const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443"); const forceTLS = (process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false"; - const channelName = `notifications.${userId}`; + const channelName = `private-notifications.${userId}`; let cancelled = false; // Dynamic import keeps pusher-js out of SSR (it touches window) and the @@ -162,8 +168,16 @@ export function NotificationsProvider({ disableStats: true, // Ignored when wsHost is set, but required by the type. cluster: "", + // Private channels are refused without a signature from the BFF. + channelAuthorization: { customHandler: channelAuthorizer }, }); const channel = pusher.subscribe(channelName); + // Authorization can legitimately fail (expired session, realtime not + // configured). Realtime is a nudge, not the source of truth, so degrade + // quietly — the bell keeps working off its fetched count. + channel.bind("pusher:subscription_error", (status: unknown) => { + console.warn("notifications: realtime subscription failed", status); + }); channel.bind("notification", (data: IncomingEvent) => { setUnread((u) => u + 1); setLastEventAt(Date.now()); diff --git a/lib/realtime.ts b/lib/realtime.ts new file mode 100644 index 00000000..b510f59f --- /dev/null +++ b/lib/realtime.ts @@ -0,0 +1,54 @@ +/** + * Realtime channel authorization. + * + * Our Soketi channels are `private-`/`presence-` prefixed, so Soketi refuses a + * subscription without a signature. Only the BFF holds the app secret, so the + * client asks it to sign each subscription at + * POST /api/v1/broadcasting/auth — which first checks the session cookie + * identifies someone allowed on that channel. + * + * We supply a `customHandler` rather than pusher-js's built-in `authEndpoint` + * because that built-in uses XMLHttpRequest without setting + * `withCredentials`, so it would never send our httpOnly session cookie + * cross-origin (prabhupadasays.org → api.prabhupadasays.org) and every + * subscription would 401. A plain fetch with `credentials: "include"` matches + * how the rest of lib/ talks to the BFF. + * + * The endpoint answers with a BARE {auth, channel_data} body rather than the + * usual {success, data} envelope, because pusher-js reads `auth` off the top + * level of the response. + */ + +import type { ChannelAuthorizationHandler } from "pusher-js"; + +const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; + +/** + * Signs private/presence subscriptions. Shared by every realtime consumer, so + * new channel families need no client-side change — only a rule on the BFF. + */ +export const channelAuthorizer: ChannelAuthorizationHandler = ( + { socketId, channelName }, + callback, +) => { + fetch(`${BFF_BASE}/api/v1/broadcasting/auth`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ socket_id: socketId, channel_name: channelName }), + }) + .then(async (res) => { + if (!res.ok) { + // 403 is a normal outcome (not a participant / not your channel), so + // carry the status rather than presenting it as a network fault. + throw new Error( + `channel authorization failed for ${channelName}: ${res.status}`, + ); + } + return (await res.json()) as { auth: string; channel_data?: string }; + }) + .then((data) => callback(null, data)) + .catch((err: unknown) => { + callback(err instanceof Error ? err : new Error(String(err)), null); + }); +};