Skip to content

BFF routes → RPC map

The browser never talks gRPC and never sees a backend hostname. The SaaS frontend ships as a client-only SPA whose every backend call goes to a same-origin /api/* route. Each of those routes is a Node-runtime handler in soundverse-saas-2.0 that authenticates the caller, makes one typed Connect/gRPC call to core-gateway-consumer, and translates the result (or a Connect error) back to JSON. This is the BFF (backend-for-frontend) seam.

Deployed — the SPA + BFF are live. A handful of routes proxy to services that are code-complete but not yet deployed (billing, DNA); those are flagged in the passthrough table below.

Every gRPC-backed route reads as one line of intent. runProxy (lib/grpc/proxy.ts) does the auth, the trace threading, and the error mapping; the route body just names the RPC:

app/api/library/route.ts
import { getLibraryClient } from "@/lib/grpc/client";
import { runProxy } from "@/lib/grpc/proxy";
export const runtime = "nodejs"; // never Edge — connect-node needs node:http2
export const dynamic = "force-dynamic"; // per-user, never cached
/** GET /api/library → LibraryService.ListLibrary */
export async function GET(req: Request) {
return runProxy(req, async (opts) => {
const resp = await getLibraryClient().listLibrary({ /* … */ }, opts);
return toJson(ListLibraryResponseSchema, resp); // toJson keeps int64/bigint safe
});
}

The full mechanics of adding one — plus the mapper and the Connect-code translation — are in Add or extend a consumer-facing API.

All of these go through runProxy to core-gateway-consumer. The RPC column is the soundverse_proto.consumer.v1 method (consumer.proto).

Route Method RPC
/api/generation POST CreateGeneration
/api/generation/:taskId GET GetGeneration
/api/generation/:taskId/stream GET (SSE) StreamGeneration (relayed as text/event-stream)
/api/tools GET ListTools (active tool registry for the AI Tools panel)
Route Method RPC
/api/account GET GetCurrentUser
/api/account/balance GET GetTokenBalance
Route Method RPC
/api/library GET ListLibrary (query: workspaceId, projectId, …)
/api/library/upload POST UploadFile (client-streaming; first message is metadata, rest are chunks)
/api/library/info/:fileId GET GetFileInfo
/api/library/provenance/:fileId GET GetFileProvenanceGraph
/api/library/mint-url POST MintDownloadUrl
/api/library/stream-url POST MintDownloadUrl (chooses HLS vs single-file — see below)
/api/library/rename POST RenameLibraryFile
/api/library/cover POST SetLibraryCover
Route Method RPC
/api/workspaces GET / POST ListWorkspaces / CreateWorkspace
/api/workspaces/:workspaceId/members GET / POST ListWorkspaceMembers / AddWorkspaceMember
/api/workspaces/:workspaceId/members/:userId DELETE RemoveWorkspaceMember
/api/projects GET / POST ListProjects / CreateProject
/api/projects/:projectId GET GetProject
/api/projects/:projectId/messages GET / POST / PATCH ListProjectMessages / CreateMessage / UpdateMessage
/api/projects/recent-with-messages GET ListProjects + ListProjectMessages (one combined round-trip)
Route Method RPC
/api/notifications GET / POST ListNotifications / MarkNotificationRead | MarkAllNotificationsRead
/api/notifications/stream GET (SSE) StreamNotifications (relayed as text/event-stream)

Reverse-proxy & passthrough routes (not consumer.v1)

Section titled “Reverse-proxy & passthrough routes (not consumer.v1)”

Not every /api/* route is a gRPC proxy. These forward raw HTTP, run OIDC, or relay to a legacy or sibling backend. They do not go through runProxy.

Route Kind Upstream / behaviour
/api/auth/[...nextauth] Auth.js Logto OIDC callback + session handlers
/api/federated-logout Redirect builder Builds the Logto end_session URL for federated sign-out
/api/diag-auth Diagnostic Reports the resolved bearer/session state (no token material)
/api/billing/[...path] REST reverse-proxy → core-billing /v1/billing/* via CORE_BILLING_GRPC upstream in code, not deployed
/api/stripe/connect/start, /status Stripe SDK Stripe Connect onboarding / status for creator payouts
/api/dna/[...path], /api/dna/proxy/[...path], /api/dna/identity REST reverse-proxy → core-dna-api /dna/* via CORE_DNA_API_URL / CORE_DNA_API_GRPC; injects the server-verified user id + INTERNAL_RPC_SECRET upstream in code, not deployed
/api/collaborative-studio/[...path] REST reverse-proxy → legacy monolith /collaborative-studio/* (NEXT_PUBLIC_BACKEND_URL)
/api/sparks/[...path] REST reverse-proxy → legacy monolith public /sparks/v1* feed (admin paths blocked)
/api/home/* REST reverse-proxy → legacy monolith /home/* (recent tracks, staff picks, prompts, workflows, …)
/api/compat/chat-history, /api/compat/chat-history/projects Legacy read Read-only legacy chat history, owner-gated by session email — see legacy bridge
/api/library/stream/:token Same-origin media proxy Streams the sealed SAS URL back to <audio>, hiding the blob URL
/api/otel/[...signal] OTLP proxy Forwards browser traces to OTEL_EXPORTER_OTLP_ENDPOINT (accept-and-drop when unset)
/api/docs, /api/openapi Static Scalar API docs + the OpenAPI document

The whole gRPC seam lives in six small files under lib/grpc/. Each is server-only, so none can leak into a client bundle.

Module Responsibility
client.ts getClient() + the per-service accessors (getAccountClient, getGenerationClient, getLibraryClient, getNotificationClient, getWorkspaceProjectClient)
endpoints.ts Resolves upstream FQDNs from CI-injected env vars: resolveEndpoint("<service>")<SERVICE>_GRPC, the UPSTREAM map, resolveCoreBillingUrl, resolveCoreDnaApiUrl, normalizeBaseUrl (:443 → https, else h2c)
transport.ts Cached HTTP/2 gRPC transports, one per base URL (connect-node, node:http2)
proxy.ts runProxy — auth, trace continuation, and the Connect-code → HTTP-status + friendly-message mapping
auth-metadata.ts resolveBearer (header-first, cookie fallback), authCallOptions, getUpstreamBearer, getVerifiedUserId
otel-interceptor.ts Injects the active span’s W3C traceparent into the outgoing gRPC metadata

runProxy maps every Connect Code the gateway emits to a faithful HTTP status (the error-handling overhaul stopped collapsing everything to 500). Unmapped codes fall back to 500.

Connect Code HTTP Connect Code HTTP
Canceled 499 PermissionDenied 403
InvalidArgument 400 ResourceExhausted 429
DeadlineExceeded 504 FailedPrecondition 412
NotFound 404 Unimplemented 501
AlreadyExists 409 Unavailable 503
Unauthenticated 401 (everything else) 500

The JSON error body carries the gateway’s structured ErrorDetail when present — friendlyMessage, retryable, requestId, and the traceId that opens the exact SigNoz trace — so the browser can render a real message instead of a bare status.