Skip to content

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 …View projection that drops internal fields, plus a servicer that enforces per-user access.
  • The BFF (soundverse-saas-2.0, TypeScript) — a route.ts handler that turns a plain HTTP request into a typed Connect call and serializes the reply to JSON.
  • Directorysoundverse-proto/proto/soundverse_proto/consumer/v1/
    • consumer.proto add the RPC + the …View message
  • Directorycore-gateway-consumer/app/
    • Directorygrpc_server/
      • mappers.py internal message → consumer …View
      • Directoryservicers/ one file per service — implement the method here
    • 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)
  1. Add the RPC + a …View message to consumer.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, and NotificationService — so add your RPC to whichever one fits.

  2. Add a mapper in mappers.py that 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-limit
    internals; input_schema_json is forwarded so the AI Tools panel can
    build 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,
    )
  3. Implement the servicer method under app/grpc_server/servicers/ and enforce access yourself. core-database does no per-user authz — the servicer is the guard. Resolve the caller from the auth interceptor, build a RequestContext, call the internal client with metadata=self._clients.auth_metadata, and map the result:

    app/grpc_server/servicers/workspace.py
    async def GetWorkspace(self, request, context):
    caller = current_caller() # from AuthInterceptor
    ws = await self._assert_workspace_access( # PERMISSION_DENIED / NOT_FOUND
    request.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.py with consumer_pb2_grpc.add_<Name>ServiceServicer_to_server(...). Adding a method to an existing service needs no registration change.

  4. Refresh stubs and redeploy the gateway. A consumer.proto change is a proto change, so it rides the proto → deploy cascade: it republishes both soundverse-proto-python (for the gateway) and soundverse-proto-web (for the BFF). In core-gateway-consumer run ./update-proto.sh to pull the new Python SDK (how to refresh stubs), then deploy the gateway.

  5. Add the BFF route. Create app/api/<your-route>/route.ts (Node runtime, force-dynamic). It reads a typed Connect client from lib/grpc/client.ts, wraps the call in runProxy, and serializes with toJson(<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 in lib/grpc/client.ts — it resolves the endpoint from the CORE_GATEWAY_CONSUMER_GRPC env var (name only; the deploy pipeline injects the value) via lib/grpc/endpoints.ts.

  6. Expose it to the UI. Add a thin wrapper in lib/api/consumer.ts so components call listWorkspaces(), never a raw fetch. That module is the single seam the SPA imports; it also throws ApiError (carrying friendlyMessage + traceId) so every call site gets a clean, copyable error.

Every /api/* handler funnels through one wrapper — lib/grpc/proxy.ts — which does three things so each route stays a one-liner:

  1. Authenticate. authCallOptions(req) resolves the upstream bearer — from an explicit Authorization: Bearer header (native/mobile clients, forwarded verbatim), else off the encrypted Auth.js session cookie — and returns the call options, or throws UnauthenticatedError → HTTP 401. The bearer stays server-side; the browser never sees it (see Identity & auth).
  2. Continue the trace. It extracts the inbound W3C traceparent and runs the call under it, and the gRPC OTel interceptor injects it into the outbound metadata — linking browser → BFF → gateway → worker into one trace.
  3. Map the reply. A success becomes Response.json(...); a ConnectError is translated to a real HTTP status plus a structured JSON body.

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.

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