"Bridges"

Tether Bridge enables cross-project communication, allowing deployed functions in one project to call functions and access data in another project — securely, through Tether's function runtime.

Overview

A Bridge is a bilateral trust link between two project environments. Once established, functions on either side can:

  • Call deployed functions on the bridged project
  • Access table data with granular read/write/admin permissions
  • Pass user auth context through (when both projects share the same auth provider)

Each side of a bridge independently controls what the other project can access. Bridges can be bidirectional (both sides can call each other) or one-way (only the initiator can call the target).

Creating a Bridge

Via Dashboard

  1. Navigate to your project's Bridges tab
  2. Click Create Bridge
  3. Select the target project and environment to bridge to
  4. Set an alias — this is what you'll use in your function code (e.g. "studio")
  5. Configure permissions — what you're exposing to the other project
  6. If you own both projects, the bridge is automatically accepted
  7. If the target belongs to another user, a request is sent for their approval

Via CLI

tether bridge create \
  --alias studio \
  --source-env production \
  --target-project <project-id> \
  --target-env production \
  --bidirectional

Accepting a Bridge

When another user's project requests a bridge to yours, you'll see a pending request in your dashboard. When accepting, you must:

  1. Select the environment to link to
  2. Set your alias for the source project
  3. Configure table permissions — what the source can access on your side
  4. Configure function permissions — which functions the source can call

Permissions

Table Permissions

Each side independently sets per-table access levels:

Level Operations Allowed
read findMany, findFirst, findById, count
read_write All read operations + create, update, delete, deleteMany, upsert
admin Full access including deleteAll and schema introspection

Example: If studio.letsstre.am needs to read your api_keys table but shouldn't modify it, set it to read.

Function Permissions

Control which deployed functions the bridged project can call using patterns:

{
  "allowedFunctions": ["streams.*", "users.getProfile"],
  "deniedFunctions": ["admin.*"]
}
  • "streams.*" — allow all functions in the streams module
  • "users.getProfile" — allow a specific function
  • "*" — allow all functions (use with caution)
  • Denied patterns take precedence over allowed patterns

Updating Permissions

Permissions can be changed at any time by each side:

tether bridge update-permissions <bridge-id> \
  --table users=read \
  --table streams=read_write \
  --allow-function "streams.*" \
  --deny-function "admin.*"

Using Bridges in Functions

Calling Functions

import { query, mutation } from "@tthr/server";

export const syncStreams = query({
  handler: async ({ bridge, ctx }) => {
    // Call a query on the bridged project
    const streams = await bridge.query("studio", "streams.getActive", {
      userId: ctx.auth.userId,
    });

    // Call a mutation on the bridged project
    await bridge.mutate("studio", "renders.queue", {
      streamId: streams[0].id,
    });

    return { synced: streams.length };
  },
});

Transactions and errors

Each project has its own database, so a call to a bridged project runs in that project's own transaction and commits when the bridged function returns. If the calling function throws afterwards, the bridged project's writes stay. A bridged call that comes back to the environment of a mutation further up the chain joins that mutation's transaction instead.

Two mutations can each lock their own environment and then write into the other's through a bridge. Neither could ever get the second lock, so instead of both waiting out their 30 seconds, the write that would close the loop fails at once with a TetherError coded DEADLOCK (its data names the two environments). The other mutation then carries on. The failed call is safe to retry.

A TetherError thrown by the bridged function reaches the caller as a TetherError with the same code and data:

try {
  await bridge.mutate("studio", "renders.queue", { streamId });
} catch (e) {
  if (e instanceof TetherError && e.code === "QUEUE_FULL") {
    return { queued: false };
  }
  throw e;
}

Accessing Bridged Data Directly

export const getStudioProjects = query({
  handler: async ({ bridge }) => {
    const studio = bridge.project("studio");

    // Read from the bridged project's tables (respects table permissions)
    const projects = await studio.db.projects.findMany({
      where: { status: "active" },
      limit: 10,
    });

    const count = await studio.db.projects.count();

    return { projects, count };
  },
});

Writing to Bridged Tables

export const createRender = mutation({
  handler: async ({ bridge, args }) => {
    const studio = bridge.project("studio");

    // Write to bridged tables (requires read_write or admin permission)
    const render = await studio.db.renders.create({
      data: {
        streamId: args.streamId,
        status: "pending",
      },
    });

    return render;
  },
});

Auth Propagation

When both environments use the same auth provider (Strands Accounts for the same app, by its client ID, or the same provider and issuer), the calling user's auth context is automatically forwarded to the target function. Otherwise the target runs with no user. Users picked with Run as in the function playground are never forwarded. The target function sees the same ctx.auth.userId and ctx.auth.claims as the source.

This means:

  • Row-Level Security (RLS) policies on the target project still apply
  • The target function can check ctx.auth.userId to authorise the request
  • No separate authentication is needed for bridge calls

Security

  • Only deployed functions are callable via bridge (same as publishable key access)
  • No transitive trust: A→B→C calls are blocked unless B has an explicit bridge to C
  • Per-bridge rate limiting: 100 requests/second per bridge (configurable)
  • Audit trail: Every bridge call is logged in both projects' execution logs
  • Revocable: Either side can revoke a bridge at any time, immediately blocking all calls

Revoking a Bridge

Either side can revoke a bridge at any time:

tether bridge revoke <bridge-id>

Or via the dashboard, click the Revoke button on the bridge entry. Revoked bridges can be deleted permanently, or left in place for audit purposes.

API Reference

Endpoints

Method Endpoint Description
GET /api/v1/projects/{id}/bridges List all bridges
POST /api/v1/projects/{id}/bridges Create a bridge
GET /api/v1/projects/{id}/bridges/{bridgeId} Get bridge details
POST /api/v1/projects/{id}/bridges/{bridgeId}/accept Accept a pending bridge
PATCH /api/v1/projects/{id}/bridges/{bridgeId}/permissions Update permissions
POST /api/v1/projects/{id}/bridges/{bridgeId}/revoke Revoke a bridge
DELETE /api/v1/projects/{id}/bridges/{bridgeId} Delete a bridge
POST /api/v1/projects/{id}[/env/{env}]/bridges/{alias}/call Call a function on the bridged project

Calling a bridge from your server

Server-side code can make the same call a function makes with ctx.bridge, using a secret API key (from the key's environment) or a dashboard session:

POST /api/v1/projects/{id}/bridges/studio/call
Authorization: Bearer tthr_prod_...
Content-Type: application/json

{ "function": "streams.getActive", "type": "query", "args": { "limit": 10 } }

The response is { "data": ... }, or an error body with code and data when the bridged function threw a TetherError. type is query or mutation. The bridge's function permissions apply as they do for ctx.bridge, and the caller needs the same role as for deploying functions. An x-auth-token header passes the end user on when both projects use the same auth provider.

The SDKs' bridge.query() and bridge.mutate() call this endpoint. Publishable keys are refused, so a browser can't call a bridge directly: call one of your own functions that uses ctx.bridge instead.

Runtime Context

interface BridgeProxy {
  /** Get a project proxy by alias */
  project(alias: string): BridgeProjectProxy;
  /** Shorthand: call a query on a bridged project */
  query(alias: string, fn: string, args?: Record<string, unknown>): Promise<unknown>;
  /** Shorthand: call a mutation on a bridged project */
  mutate(alias: string, fn: string, args?: Record<string, unknown>): Promise<unknown>;
  /** Shorthand: call an action on a bridged project */
  exec(alias: string, fn: string, args?: Record<string, unknown>): Promise<unknown>;
}

interface BridgeProjectProxy {
  query(fn: string, args?: Record<string, unknown>): Promise<unknown>;
  mutate(fn: string, args?: Record<string, unknown>): Promise<unknown>;
  exec(fn: string, args?: Record<string, unknown>): Promise<unknown>;
  /** Direct table access (respects bridge table permissions) */
  db: Record<string, BridgeTableProxy>;
}