"Bridges"

Tether Bridge enables cross-project communication, allowing deployed functions in one project to call functions and access data in another project — securely, through the Deno 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 };
  },
});

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 projects use the same auth provider (e.g. both use Strands Accounts), the calling user's auth context is automatically forwarded to the target function. 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

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>;
}