|
| 1 | +# cc-bridge Hosted Relay — Design Spec |
| 2 | + |
| 3 | +**Date:** 2026-06-21 |
| 4 | +**Status:** Approved (design), pending implementation plan |
| 5 | +**Scope of this doc:** Sub-project #1 of 3 — the cross-machine relay core. |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +`cc-bridge` v0.1.0 is a local, single-machine JSONL message bridge between Claude |
| 10 | +Code sessions (MIT, shipped to npm + global bin). `send` appends to a per-room |
| 11 | +JSONL file; `listen` tails it. Everything lives on one machine's disk. |
| 12 | + |
| 13 | +The goal is to **sell cc-bridge as a hosted relay SaaS** (recurring revenue). A |
| 14 | +local single-machine MIT tool has no moat — it is trivially copyable. The moat is |
| 15 | +a thing customers can't clone: rooms that live in *our* authed database, syncing |
| 16 | +sessions across **different machines** (and later, teammates), behind a paywall. |
| 17 | + |
| 18 | +This is three sub-projects. Each gets its own spec → plan → build: |
| 19 | + |
| 20 | +| # | Sub-project | Delivers | |
| 21 | +|---|---|---| |
| 22 | +| **1 (this doc)** | **Cross-machine relay core** | One account's sessions sync in real time across machines + history | |
| 23 | +| 2 | Billing / entitlement | Stripe subscription gates relay access | |
| 24 | +| 3 | Distribution | `/b` command + `cc-bridge login` onboarding + pricing page | |
| 25 | + |
| 26 | +2 and 3 are meaningless without 1, so 1 is designed and built first. |
| 27 | + |
| 28 | +## Decisions locked (from brainstorm) |
| 29 | + |
| 30 | +- **Relay model:** real-time (both sessions live), latency sub-second — fine for hand-offs, not an interrupt of a working agent. |
| 31 | +- **Backend:** Supabase open-core. Postgres + Realtime + Auth + RLS in one managed service. **No custom server to run.** |
| 32 | +- **Auth:** Supabase email OTP (6-digit code), CLI-friendly. |
| 33 | +- **Default backend:** logged in → cloud (the point of logging in); `--local` / `CC_BRIDGE_LOCAL=1` forces today's JSONL files. |
| 34 | +- **Open-core:** local JSONL mode stays MIT/free and untouched. |
| 35 | + |
| 36 | +## Architecture |
| 37 | + |
| 38 | +``` |
| 39 | +A's machine: cc-bridge send → INSERT into messages (room, body) ┐ |
| 40 | + ├─ Supabase |
| 41 | +B's laptop: cc-bridge listen → SUBSCRIBE realtime where room = X ◀─────┘ |
| 42 | + (RLS: owner = auth.uid() — you only ever see your own rooms) |
| 43 | +``` |
| 44 | + |
| 45 | +The existing CLI already routes all storage through exactly two functions — |
| 46 | +`sendMessage()` and `listen()`. The code above them (`src/bin/cc-bridge.ts`) is |
| 47 | +transport-agnostic: it consumes a `ListenController { iterator, close }` and |
| 48 | +prints. Cloud mode is therefore a **backend swap of two functions**, not a |
| 49 | +rewrite. One new dependency, `@supabase/supabase-js`, covers auth + insert + |
| 50 | +realtime. |
| 51 | + |
| 52 | +A dedicated **new** Supabase project hosts this (isolation + clean billing later). |
| 53 | +Free tier. |
| 54 | + |
| 55 | +- **Account:** incultnitopeng@gmail.com |
| 56 | +- **Project name:** cc-bridge |
| 57 | +- **Project ref:** lyktygwrmhfdxqoqzdxb |
| 58 | +- **Project URL:** https://lyktygwrmhfdxqoqzdxb.supabase.co |
| 59 | +- **Anon key:** _pending from user (Settings → API → anon public)._ Plugged into `CC_BRIDGE_SUPABASE_URL` + `CC_BRIDGE_SUPABASE_ANON_KEY` (env override) with baked defaults in `src/lib/config.ts`. |
| 60 | + |
| 61 | +## Data model — one table, one migration |
| 62 | + |
| 63 | +```sql |
| 64 | +create table messages ( |
| 65 | + id text primary key, -- reuse existing ULID |
| 66 | + v int not null, -- schema version (reuse SCHEMA_VERSION) |
| 67 | + ts timestamptz not null, |
| 68 | + room text not null, |
| 69 | + sender text not null, -- "from" (reserved word → sender) |
| 70 | + recipient text, -- "to" |
| 71 | + reply_to text, |
| 72 | + kind text not null default 'text',-- text | event |
| 73 | + msg text not null, |
| 74 | + owner uuid not null default auth.uid(), |
| 75 | + created_at timestamptz not null default now() |
| 76 | +); |
| 77 | + |
| 78 | +create index on messages (owner, room, id); -- replay + per-room queries |
| 79 | + |
| 80 | +alter table messages enable row level security; |
| 81 | +create policy own_select on messages for select using (owner = auth.uid()); |
| 82 | +create policy own_insert on messages for insert with check (owner = auth.uid()); |
| 83 | + |
| 84 | +-- Realtime, RLS-aware |
| 85 | +alter publication supabase_realtime add table messages; |
| 86 | +``` |
| 87 | + |
| 88 | +Rooms stay implicit (a string column). No `rooms` table — YAGNI. |
| 89 | + |
| 90 | +Column-name mapping in the CLI: `from → sender`, `to → recipient`. The wire |
| 91 | +`Message` shape (validated by the existing zod schema) is unchanged; mapping |
| 92 | +happens only at the DB boundary. |
| 93 | + |
| 94 | +## Auth — Supabase email OTP |
| 95 | + |
| 96 | +``` |
| 97 | +cc-bridge login you@email.com → Supabase emails a 6-digit code |
| 98 | + → paste code → tokens cached at |
| 99 | + ~/.cc-bridge/credentials (mode 0600) |
| 100 | +cc-bridge whoami → prints account email |
| 101 | +cc-bridge logout → clears creds |
| 102 | +``` |
| 103 | + |
| 104 | +The cached **user JWT** is used for both the REST insert and the Realtime |
| 105 | +subscribe, so RLS isolation is automatic — no custom auth server. The refresh |
| 106 | +token keeps the session alive across CLI invocations. |
| 107 | + |
| 108 | +Alternative considered and deferred: dashboard-generated API keys. More to build |
| 109 | +(a web dashboard, a keys table, custom JWT minting). OTP needs none of that. |
| 110 | + |
| 111 | +## CLI seam |
| 112 | + |
| 113 | +New dependency: `@supabase/supabase-js`. |
| 114 | + |
| 115 | +- **`sendRemote(input)`** — calls `buildMessage()` (reused, unchanged) to produce |
| 116 | + the validated `Message`, then INSERTs the mapped row. Returns the same `Message` |
| 117 | + the local path returns. |
| 118 | +- **`listenRemote(opts)`** — subscribes to `postgres_changes` INSERT on `messages` |
| 119 | + filtered by `room=eq.X`, yields the **same `ListenController { iterator, close }`** |
| 120 | + the bin already consumes. Bin code unchanged. |
| 121 | +- **Backend switch** — a small resolver: logged in and not `--local`/`CC_BRIDGE_LOCAL` |
| 122 | + → cloud; else → existing JSONL path. Both `send` and `listen` consult it. |
| 123 | + |
| 124 | +New commands: `login`, `logout`, `whoami`. Existing `send` / `listen` / `rooms` / |
| 125 | +`validate` keep their flags. |
| 126 | + |
| 127 | +## Realtime + replay (the one fiddly bit) |
| 128 | + |
| 129 | +`--replay N` and reconnect risk dropping or duplicating messages in the gap |
| 130 | +between "query backlog" and "subscription starts". Correct order: |
| 131 | + |
| 132 | +1. Subscribe to the room's realtime channel; buffer live inserts. |
| 133 | +2. Query the backlog (last N by `id` — ULIDs sort by time). |
| 134 | +3. Dedupe buffered live inserts against backlog by `id`. |
| 135 | +4. Drain backlog, then live buffer, then steady-state. |
| 136 | + |
| 137 | +ULID `id`s make ordering and dedupe trivial (no separate sequence needed). |
| 138 | + |
| 139 | +## Security (trust boundaries — not simplified away) |
| 140 | + |
| 141 | +- **Isolation** via RLS `owner = auth.uid()` on select and insert. A customer can |
| 142 | + never read or write another account's rooms. This is the core trust boundary |
| 143 | + and is mandatory even in the free beta. |
| 144 | +- Credentials file `~/.cc-bridge/credentials` written mode `0600`. |
| 145 | +- The anon/public Supabase key is shippable in the CLI (it is public by design); |
| 146 | + all access is gated by the user JWT + RLS, not by key secrecy. |
| 147 | +- Message size cap (existing 64 KB schema limit) carries over; enforced by zod |
| 148 | + before insert. |
| 149 | + |
| 150 | +## Open-core boundary |
| 151 | + |
| 152 | +| Mode | Storage | License | Cost to user | |
| 153 | +|---|---|---|---| |
| 154 | +| Local (today) | per-room JSONL files | MIT, free | free | |
| 155 | +| Cloud (this spec) | Supabase, authed | hosted service | free in beta; paid in #2 | |
| 156 | + |
| 157 | +Local mode is not modified. Cloud mode is additive. |
| 158 | + |
| 159 | +## Deliberate ceilings (`ponytail:`) |
| 160 | + |
| 161 | +- **One account across many machines** is the MVP. Teammates = a `team_members` |
| 162 | + table + an RLS tweak (`owner in (my teams' owners)`); that is a separate spec, |
| 163 | + built only after one-account works. |
| 164 | +- **No billing gate yet** → any authenticated user can use the relay (free private |
| 165 | + beta). The `owner`/RLS structure already leaves the seam for #2's `subscribed` |
| 166 | + check (a `profiles.subscribed` boolean folded into the RLS policy). |
| 167 | +- **Realtime latency** is sub-second, not instant. Accepted ceiling. |
| 168 | +- If Supabase Realtime ever caps out, the transport can be swapped behind the same |
| 169 | + two-function seam without touching the CLI surface. |
| 170 | + |
| 171 | +## Success criteria |
| 172 | + |
| 173 | +1. `cc-bridge login` on two different machines with the same email → both authed. |
| 174 | +2. `cc-bridge send "hi" --room demo` on machine A appears in |
| 175 | + `cc-bridge listen --room demo` on machine B within ~1s. |
| 176 | +3. `cc-bridge listen --room demo --replay 10` prints the last 10 messages then |
| 177 | + tails live, with no duplicates across the replay/live boundary. |
| 178 | +4. A second account cannot see account #1's `demo` room (RLS verified). |
| 179 | +5. `--local` / `CC_BRIDGE_LOCAL=1` still uses JSONL files, behavior identical to |
| 180 | + v0.1.0. |
| 181 | + |
| 182 | +## Deferred to later specs |
| 183 | + |
| 184 | +- **#2 Billing:** Stripe subscription, webhook → `profiles.subscribed`, RLS gate. |
| 185 | +- **#3 Distribution:** `/b` slash command (folder-derived room name at the command |
| 186 | + layer), `cc-bridge login` onboarding flow, pricing page. |
| 187 | +- Teams / shared rooms across multiple accounts. |
0 commit comments