thinkpool-pair 0.7.163 → 0.7.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bridge.mjs +77 -6
  2. package/package.json +1 -1
package/bridge.mjs CHANGED
@@ -42,7 +42,7 @@ import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
42
42
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
43
43
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
44
44
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
45
- import { resolveProviderEnv, providerNameMap } from './providers.mjs'
45
+ import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
46
46
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
47
47
  import { z } from 'zod'
48
48
  import { startClaudeSession } from './claude-session.mjs'
@@ -635,11 +635,15 @@ let yielding = false // set once when standing down, so reclaim + revoke
635
635
  const { authedClient } = await import('./account.mjs')
636
636
  const refreshed = await authedClient(SUPABASE_URL, SUPABASE_ANON)
637
637
  if (refreshed?.session?.access_token) codeAuthToken = refreshed.session.access_token
638
- // If the refresh FAILS (dead refresh token / offline), keep the stale token: an
639
- // offline refresh must not strand a legit owner. A dead refresh token then yields a
640
- // 401 at fetchServeRow → refuse (correct: the login is genuinely gone); a transient
641
- // offline refresh yields a fetch reject → fail-open (correct: availability).
642
- } catch { /* account module / refresh failed — proceed with the stored token */ }
638
+ // If refresh fails, DO NOT clear the token yet. Let the serve-consent gate below
639
+ // (lines 648-676) validate the token via getUser and handle auth-shaped errors
640
+ // vs network errors correctly. This fixes GAP 2 where a transient network error
641
+ // refused serving when the existing token might still be valid.
642
+ } catch {
643
+ // Refresh failed — but keep the existing token. Let serve-consent gate validate it.
644
+ // If it's an auth error, the gate will refuse (exit 0). If it's a network error,
645
+ // the gate will fail open and we can serve with the existing token.
646
+ }
643
647
  }
644
648
  try {
645
649
  if (codeAuthToken) {
@@ -920,6 +924,13 @@ const announce = () => {
920
924
  // host (e.g. openrouter.ai). Older web clients ignore unknown fields.
921
925
  provider: PROVIDER_INFO.provider,
922
926
  providerBaseUrl: PROVIDER_INFO.baseUrl || undefined,
927
+ // providerPubKey: the bridge's RSA-OAEP public key (SPKI base64), used by the
928
+ // dashboard to seal a provider's API key so it reaches the bridge without ever
929
+ // crossing the wire in the clear. Computed once (the keypair is stable).
930
+ // providers: the name-only registry (id+name+model, NEVER the key or baseUrl)
931
+ // so the dashboard can list what's available. Both additive; older clients ignore.
932
+ providerPubKey: publicKeyB64(),
933
+ providers: announceProviders(),
923
934
  // pendingUpdate: a newer thinkpool-pair is out — drives the room's "update ready"
924
935
  // chip; re-sent per announce so reloads/late-joiners see it (Slice 3).
925
936
  pendingUpdate,
@@ -2628,6 +2639,66 @@ channel
2628
2639
  // Peer bridge announces (owner reclaim) — a grantee yields when the owner's bridge
2629
2640
  // reappears. broadcast self:false, so this only fires for OTHER bridges on the topic.
2630
2641
  .on('broadcast', { event: 'bridge' }, ({ payload }) => onPeerBridgeAnnounce(payload))
2642
+ // ── Provider registry — the multi-BYOK wire contract (room mode). ─────────────
2643
+ // AUTH: room mode serves a room the user may not own (post-#208 grant). We verify
2644
+ // the sender is a participant of THIS room before ANY operation. List is open to
2645
+ // any participant; add/remove mutate the HOST's registry, so gate to the BRIDGE
2646
+ // HOST's own uid (the authed login). Anon is refused for all three.
2647
+ .on('broadcast', { event: 'provider-add' }, async ({ payload }) => {
2648
+ const nonce = payload?.nonce
2649
+ // Auth: must be the bridge host (the authed login running this bridge)
2650
+ // Use the same pattern as providers-list-req: getUser + uid comparison, not byte comparison
2651
+ try {
2652
+ const { data } = await supabase.auth.getUser(payload?.jwt)
2653
+ if (!data?.user || data.user.id !== myServeUid) {
2654
+ return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'unauthorized' } })
2655
+ }
2656
+ } catch {
2657
+ return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'unauthorized' } })
2658
+ }
2659
+ let fields
2660
+ try { fields = unseal(payload?.sealed) } catch { return channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: false, error: 'could not decrypt the sealed payload' } }) }
2661
+ const r = addProvider(fields)
2662
+ if (r.ok) { announce(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
2663
+ channel.send({ type: 'broadcast', event: 'provider-add-res', payload: { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined } })
2664
+ })
2665
+ // provider-remove {id, nonce, jwt} → remove, re-announce.
2666
+ .on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
2667
+ const nonce = payload?.nonce
2668
+ // Auth: must be the bridge host (the authed login running this bridge)
2669
+ // Use the same pattern as providers-list-req: getUser + uid comparison, not byte comparison
2670
+ try {
2671
+ const { data } = await supabase.auth.getUser(payload?.jwt)
2672
+ if (!data?.user || data.user.id !== myServeUid) {
2673
+ return channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: false, error: 'unauthorized' } })
2674
+ }
2675
+ } catch {
2676
+ return channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: false, error: 'unauthorized' } })
2677
+ }
2678
+ const r = removeProvider(payload?.id)
2679
+ if (r.ok) { announce(); process.stderr.write('\n ◆ provider removed — re-announced.\n') }
2680
+ channel.send({ type: 'broadcast', event: 'provider-remove-res', payload: { nonce, ok: r.ok, error: r.ok ? undefined : r.error } })
2681
+ })
2682
+ // providers-list-req {nonce, jwt} → the masked list (keyHint = last 4 chars only).
2683
+ .on('broadcast', { event: 'providers-list-req' }, async ({ payload }) => {
2684
+ const nonce = payload?.nonce
2685
+ const isParticipant = async (jwt) => {
2686
+ if (!jwt || typeof jwt !== 'string') return false
2687
+ try {
2688
+ const { data } = await supabase.auth.getUser(jwt)
2689
+ if (!data?.user) return false
2690
+ const uid = data.user.id
2691
+ // Verify the user is a participant of THIS room (owner OR granted OR joined).
2692
+ // Room mode serves the room, so RLS returns participants; we just need to confirm
2693
+ // the jwt's user is one of them. A simple existence check on code_sessions
2694
+ // with the room code + the user's id in the participants array is enough.
2695
+ const r = await fetch(`${SUPABASE_URL}/rest/v1/code_sessions?code=eq.${encodeURIComponent(room)}&participants=cs.${uid}`, { headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${codeAuthToken}` } })
2696
+ return r.ok
2697
+ } catch { return false }
2698
+ }
2699
+ if (!(await isParticipant(payload?.jwt))) return channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: false, error: 'unauthorized', providers: [] } })
2700
+ channel.send({ type: 'broadcast', event: 'providers-list-res', payload: { nonce, ok: true, providers: listProviders() } })
2701
+ })
2631
2702
  .subscribe(status => {
2632
2703
  if (status === 'SUBSCRIBED') {
2633
2704
  realtimeHealthy = true; brokenSince = 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.163",
3
+ "version": "0.7.164",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {