Technical docs

Transport & Communication

How the frontend communicates with backend services.

Communication Overview

The frontend uses two protocols to communicate with the backend, depending on the rendering context.

gRPC-Web

Used by client components. Binary protocol through Envoy proxy. Efficient for real-time updates and streaming.

REST API

Used by server components and API routes. JSON over HTTP via gRPC-Gateway. Required for SSR data fetching.

gRPC-Web (Client Components)

Client components use Connect-RPC with generated TypeScript clients.

Setting up the transport

// lib/grpc-transport.ts
import { createGrpcWebTransport } from "@connectrpc/connect-web"

export const transport = createGrpcWebTransport({
  baseUrl: "http://localhost:8081", // Envoy gRPC-Web port
})

Making a request

// In a client component
"use client"

import { createPromiseClient } from "@connectrpc/connect"
import { AccountingService } from "@/gen/accounting_connect"
import { transport } from "@/lib/grpc-transport"

const client = createPromiseClient(AccountingService, transport)

// Make the call
const response = await client.listTransactions({
  tenantId: workspaceId,
  pageSize: 50,
  pageToken: "",
})

With React Query

// Using TanStack Query for caching
import { useQuery } from "@tanstack/react-query"

function TransactionList({ tenantId }: { tenantId: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ["transactions", tenantId],
    queryFn: () => client.listTransactions({ tenantId, pageSize: 50 }),
  })
  
  if (isLoading) return <Loading />
  if (error) return <Error error={error} />
  
  return <Table data={data.transactions} />
}

REST API (Server Components)

Server components use the REST client for data fetching during SSR.

REST client usage

// In a server component or API route
import { rest } from "@/lib/rest-client"

// The client automatically includes auth token from cookies
const data = await rest.get<TransactionListResponse>(
  `/v1/tenants/${tenantId}/transactions?page_size=50`
)

// POST request
const created = await rest.post<Transaction>(
  `/v1/tenants/${tenantId}/transactions`,
  { description: "New transaction", amount: 1000 }
)

REST endpoints

gRPC-Gateway generates REST endpoints from proto files. The mapping follows these patterns:

# Transactions
GET    /v1/tenants/{tenant_id}/transactions
POST   /v1/tenants/{tenant_id}/transactions
GET    /v1/tenants/{tenant_id}/transactions/{id}
PATCH  /v1/tenants/{tenant_id}/transactions/{id}
DELETE /v1/tenants/{tenant_id}/transactions/{id}

# Dashboard
GET    /v1/tenants/{tenant_id}/dashboard/stats
GET    /v1/tenants/{tenant_id}/dashboard/widgets

# Categories
GET    /v1/tenants/{tenant_id}/categories
POST   /v1/tenants/{tenant_id}/categories

URL Parameter Naming

REST uses snake_case for query parameters (page_size), while gRPC uses camelCase (pageSize). The rest-client helper handles this conversion automatically.

Proto Files

API contracts are defined in Protocol Buffer files.

Proto file locations

proto/
├── accounting.proto        # Dashboard, reports, journals
├── organization.proto      # Orgs, workspaces, teams
├── payment_v3.proto       # Transactions
├── tax.proto              # Tax calculations
├── captable.proto         # Cap table (equity)
├── forensics.proto        # Document verification
├── invoice_extraction.proto  # OCR extraction
└── buf.gen.yaml           # Code generation config

Generated code locations

# Go server code
backend/proto/*.pb.go
backend/proto/*.pb.gw.go  # REST gateway

# TypeScript clients
frontend/src/gen/*_pb.ts
frontend/src/gen/*_connect.ts

Regenerating Proto Code

After modifying any .proto file:

make proto

This runs buf generate and creates both Go and TypeScript code.

Example proto definition

// payment_v3.proto
service PaymentV3Service {
  rpc ListTransactions(ListTransactionsRequest) returns (ListTransactionsResponse) {
    option (google.api.http) = {
      get: "/v1/tenants/{tenant_id}/transactions"
    };
  }
  
  rpc CreateTransaction(CreateTransactionRequest) returns (Transaction) {
    option (google.api.http) = {
      post: "/v1/tenants/{tenant_id}/transactions"
      body: "*"
    };
  }
}

message Transaction {
  string id = 1;
  string tenant_id = 2;
  string description = 3;
  int64 amount = 4;  // In cents
  string currency = 5;
  TransactionType type = 6;
  // ...
}

Authentication Flow

Every request passes through authentication middleware.

JWT Token Structure

{
  "user_id": "uuid",
  "email": "user@example.com",
  "full_name": "John Doe",
  "is_super_admin": false,
  "organizations": [
    { "organization_id": "uuid", "role": "owner" }
  ],
  "workspaces": [
    { "workspace_id": "uuid", "workspace_name": "My Business", "role": "admin" }
  ],
  "exp": 1234567890,
  "iat": 1234567800
}

Including Auth Token

The token is stored in an HTTP-only cookie and included automatically:

// Client-side: token sent via cookie
// The browser automatically includes cookies

// Server-side: REST client reads from cookies
import { cookies } from "next/headers"
const token = cookies().get("access_token")?.value

Backend Auth Context

// In a gRPC handler
func (s *Server) ListTransactions(ctx context.Context, req *pb.Request) (*pb.Response, error) {
    // Get auth context (injected by AuthInterceptor)
    authCtx, ok := middleware.GetAuthContext(ctx)
    if !ok {
        return nil, status.Error(codes.Unauthenticated, "not authenticated")
    }
    
    // Check tenant access
    if !authCtx.HasTenantAccess(req.TenantId) {
        return nil, status.Error(codes.PermissionDenied, "no access to tenant")
    }
    
    // Proceed with request...
}

Error Handling

gRPC status codes are mapped to HTTP status codes for REST responses.

Status code mapping

gRPC CodeHTTP StatusMeaning
OK (0)200Success
INVALID_ARGUMENT (3)400Bad request
NOT_FOUND (5)404Resource not found
ALREADY_EXISTS (6)409Conflict
PERMISSION_DENIED (7)403Forbidden
RESOURCE_EXHAUSTED (8)429Rate limited
UNAUTHENTICATED (16)401Not logged in
INTERNAL (13)500Server error

Frontend error handling

// Handling gRPC errors
import { ConnectError, Code } from "@connectrpc/connect"

try {
  await client.createTransaction(request)
} catch (error) {
  if (error instanceof ConnectError) {
    switch (error.code) {
      case Code.InvalidArgument:
        toast.error("Invalid input: " + error.message)
        break
      case Code.PermissionDenied:
        toast.error("You don't have permission")
        break
      case Code.ResourceExhausted:
        toast.error("Rate limited. Try again later.")
        break
      default:
        toast.error("Something went wrong")
    }
  }
}