"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
- Navigate to your project's Bridges tab
- Click Create Bridge
- Select the target project and environment to bridge to
- Set an alias — this is what you'll use in your function code (e.g.
"studio") - Configure permissions — what you're exposing to the other project
- If you own both projects, the bridge is automatically accepted
- 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:
- Select the environment to link to
- Set your alias for the source project
- Configure table permissions — what the source can access on your side
- 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 thestreamsmodule"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.userIdto 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>;
}