Add or extend a consumer-facing API
The browser never speaks gRPC and never holds a bearer token. Everything the SaaS
app does — list your library, create a generation, read your balance — is an HTTP
call to /api/* on its own origin, which a thin backend-for-frontend (BFF)
proxies to core-gateway-consumer,
the browser-safe face of the whole system. Adding a new capability to the browser
means editing two seams that must move together:
- The gateway (
core-gateway-consumer, Python) — a new RPC + a…Viewprojection that drops internal fields, plus a servicer that enforces per-user access. - The BFF (
soundverse-saas-2.0, TypeScript) — aroute.tshandler that turns a plain HTTP request into a typed Connect call and serializes the reply to JSON.
The plumbing you’ll touch
Section titled “The plumbing you’ll touch”Directorysoundverse-proto/proto/soundverse_proto/consumer/v1/
- consumer.proto add the RPC + the
…Viewmessage
- consumer.proto add the RPC + the
Directorycore-gateway-consumer/app/
Directorygrpc_server/
- mappers.py internal message → consumer
…View Directoryservicers/ one file per service — implement the method here
- …
- mappers.py internal message → consumer
- main.py register the servicer on the gRPC server
- update-proto.sh pull the regenerated Python SDK
Directorysoundverse-saas-2.0/
Directoryapp/api/ your-route/route.ts the BFF handler (
runProxy)- …
Directorylib/grpc/ client.ts · proxy.ts · endpoints.ts · auth-metadata.ts
- …
- lib/api/consumer.ts browser-side wrapper (
ApiError)
-
Add the RPC + a
…Viewmessage toconsumer.proto— plain proto, no SQL annotations. Model the response on an existing…View(e.g.GenerationView,ToolView,LibraryItemView): it deliberately omits internal-only fields — owner/creator ids, billing ids, raw payloads, blob URLs. The consumer gateway serves five services today —GenerationService,WorkspaceProjectService,AccountService,LibraryService, andNotificationService— so add your RPC to whichever one fits. -
Add a mapper in
mappers.pythat projects the internal message onto your view, dropping internal fields by omission. This is the one place a field is allowed to cross the trust boundary:app/grpc_server/mappers.py def tool_view(tool: tool_pb2.Tool) -> consumer_pb2.ToolView:"""Consumer-safe projection — no credentials, pricing, or rate-limitinternals; input_schema_json is forwarded so the AI Tools panel canbuild its form from the registered schema."""return consumer_pb2.ToolView(id=tool.id,name=tool.name,operation=tool.operation,model=tool.model,description=tool.description,cost_unit=tool.cost_unit,input_schema_json=tool.input_schema_json,) -
Implement the servicer method under
app/grpc_server/servicers/and enforce access yourself.core-databasedoes no per-user authz — the servicer is the guard. Resolve the caller from the auth interceptor, build aRequestContext, call the internal client withmetadata=self._clients.auth_metadata, and map the result:app/grpc_server/servicers/workspace.py async def GetWorkspace(self, request, context):caller = current_caller() # from AuthInterceptorws = await self._assert_workspace_access( # PERMISSION_DENIED / NOT_FOUNDrequest.workspace_id, caller.user_id, context)return consumer_pb2.GetWorkspaceResponse(workspace=mappers.workspace_view(ws))If your RPC is a servicer on a new service, register it in
app/main.pywithconsumer_pb2_grpc.add_<Name>ServiceServicer_to_server(...). Adding a method to an existing service needs no registration change. -
Refresh stubs and redeploy the gateway. A
consumer.protochange is a proto change, so it rides the proto → deploy cascade: it republishes bothsoundverse-proto-python(for the gateway) andsoundverse-proto-web(for the BFF). Incore-gateway-consumerrun./update-proto.shto pull the new Python SDK (how to refresh stubs), then deploy the gateway. -
Add the BFF route. Create
app/api/<your-route>/route.ts(Node runtime,force-dynamic). It reads a typed Connect client fromlib/grpc/client.ts, wraps the call inrunProxy, and serializes withtoJson(<Response>Schema, resp). A whole route is one line of intent:app/api/workspaces/route.ts import { toJson } from "@bufbuild/protobuf";import { ListWorkspacesResponseSchema }from "@soundversegit/soundverse-proto-web/soundverse_proto/consumer/v1/consumer_pb";import { getWorkspaceProjectClient } from "@/lib/grpc/client";import { runProxy } from "@/lib/grpc/proxy";export const runtime = "nodejs";export const dynamic = "force-dynamic";export async function GET(req: Request) {return runProxy(req, async (opts) => {const resp = await getWorkspaceProjectClient().listWorkspaces({ /* … */ }, opts);return toJson(ListWorkspacesResponseSchema, resp); // int64-safe JSON});}If you added a new consumer service (not just a method), also add a
get<Name>Client()accessor inlib/grpc/client.ts— it resolves the endpoint from theCORE_GATEWAY_CONSUMER_GRPCenv var (name only; the deploy pipeline injects the value) vialib/grpc/endpoints.ts. -
Expose it to the UI. Add a thin wrapper in
lib/api/consumer.tsso components calllistWorkspaces(), never a rawfetch. That module is the single seam the SPA imports; it also throwsApiError(carryingfriendlyMessage+traceId) so every call site gets a clean, copyable error.
The runProxy pattern
Section titled “The runProxy pattern”Every /api/* handler funnels through one wrapper —
lib/grpc/proxy.ts —
which does three things so each route stays a one-liner:
- Authenticate.
authCallOptions(req)resolves the upstream bearer — from an explicitAuthorization: Bearerheader (native/mobile clients, forwarded verbatim), else off the encrypted Auth.js session cookie — and returns the call options, or throwsUnauthenticatedError→ HTTP 401. The bearer stays server-side; the browser never sees it (see Identity & auth). - Continue the trace. It extracts the inbound W3C
traceparentand runs the call under it, and the gRPC OTel interceptor injects it into the outbound metadata — linking browser → BFF → gateway → worker into one trace. - Map the reply. A success becomes
Response.json(...); aConnectErroris translated to a real HTTP status plus a structured JSON body.
Connect code → HTTP status
Section titled “Connect code → HTTP status”runProxy faithfully maps each Connect Code to a real HTTP status (the error-handling
overhaul stopped collapsing everything to a blanket 500), so the browser sees the true
outcome. Genuinely-unknown codes fall back to 500.
Connect Code |
HTTP | Connect Code |
HTTP |
|---|---|---|---|
InvalidArgument |
400 | ResourceExhausted |
429 |
Unauthenticated |
401 | FailedPrecondition |
412 |
PermissionDenied |
403 | Unimplemented |
501 |
NotFound |
404 | Unavailable |
503 |
AlreadyExists |
409 | DeadlineExceeded |
504 |
Canceled |
499 | (unknown) | 500 |
The error body unpacks the gateway’s structured ErrorDetail into
{ error, code, friendlyMessage, retryable, requestId, traceId }. The browser-side
ApiError class in lib/api/consumer.ts reads that same shape back out, so a component
gets a friendly message and a copyable trace id that opens the exact SigNoz trace. For
the upstream half of this map — the DB-error → Connect-code translation — see the
proto contract reference.
Representative routes → RPCs
Section titled “Representative routes → RPCs”A verified sample of the current map (the full table lives in the BFF routes reference):
| HTTP route | Verb(s) | consumer.v1 RPC |
|---|---|---|
/api/account |
GET | AccountService.GetCurrentUser |
/api/account/balance |
GET | AccountService.GetTokenBalance |
/api/generation |
POST | GenerationService.CreateGeneration |
/api/generation/[taskId]/stream |
GET (SSE) | GenerationService.StreamGeneration |
/api/tools |
GET | GenerationService.ListTools |
/api/library |
GET | LibraryService.ListLibrary |
/api/library/upload |
POST | LibraryService.UploadFile (client-streaming) |
/api/library/mint-url, /api/library/stream-url |
POST | LibraryService.MintDownloadUrl |
/api/workspaces |
GET / POST | WorkspaceProjectService.ListWorkspaces / CreateWorkspace |
/api/projects |
GET / POST | WorkspaceProjectService.ListProjects / CreateProject |
/api/projects/[projectId]/messages |
GET / POST / PATCH | ListProjectMessages / CreateMessage / UpdateMessage |
/api/notifications |
GET | NotificationService.ListNotifications |
The traps that will actually bite you
Section titled “The traps that will actually bite you”Related
Section titled “Related”- BFF routes → RPC map — the full route catalog
- core-gateway-consumer — the trust boundary in depth
- Frontend architecture (SPA + BFF) — why the browser never speaks gRPC
- Drive the proto → deploy cascade — shipping the contract change
- Add a field or RPC to a data service — the codegen-driven other half
- Identity & auth — how the bearer stays server-side