thinkpool-pair 0.7.187 → 0.7.189
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.
- package/bridge.mjs +28 -5
- package/claude-session.mjs +1 -1
- package/cross-terminal.mjs +23 -0
- package/key-shape.mjs +49 -0
- package/package.json +3 -1
- package/supabase-key.mjs +176 -0
package/bridge.mjs
CHANGED
|
@@ -92,18 +92,33 @@ const flowRedispatch = new Map()
|
|
|
92
92
|
// wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
|
|
93
93
|
// broadcasts; without persistent state the cap can never bite.
|
|
94
94
|
const flowBudgets = new Map()
|
|
95
|
-
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow } from './cross-terminal.mjs'
|
|
95
|
+
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint } from './cross-terminal.mjs'
|
|
96
96
|
import { turnInFlight } from './update-gate.mjs'
|
|
97
97
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
98
98
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
99
99
|
import { planMeterLine } from './plan-meters.mjs'
|
|
100
100
|
import { makeThrottledTrack } from './presence.mjs'
|
|
101
|
+
import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
|
|
101
102
|
|
|
102
103
|
// Public client creds (the same anon values the web app ships — safe to embed).
|
|
103
104
|
// Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
|
|
104
|
-
const SUPABASE_URL = process.env.TP_SUPABASE_URL ||
|
|
105
|
+
const SUPABASE_URL = process.env.TP_SUPABASE_URL || DEFAULT_SUPABASE_URL
|
|
105
106
|
const WEB_BASE = process.env.TP_WEB_BASE || 'https://thinkpool.io'
|
|
106
|
-
|
|
107
|
+
|
|
108
|
+
// The anon key is RESOLVED, not baked. It used to be a string literal here, and
|
|
109
|
+
// that literal now sits in 224 published tarballs on 224 users' laptops: disable
|
|
110
|
+
// the key and every one of them dies at once, unfixable from our side. So we ask
|
|
111
|
+
// thinkpool.io/api/config for it (cached on disk, 6h), and keep the literal only
|
|
112
|
+
// as the offline fallback. A key rotation now repairs itself server-side, even
|
|
113
|
+
// for a bridge that never updates. Order: env → cache → network → stale → baked.
|
|
114
|
+
// See bridge/supabase-key.mjs and docs/specs/2026-07-10-supabase-key-migration.md §5.1.
|
|
115
|
+
const { key: SUPABASE_ANON, source: ANON_KEY_SOURCE } = await resolveAnonKey({
|
|
116
|
+
supabaseUrl: SUPABASE_URL,
|
|
117
|
+
webBase: WEB_BASE,
|
|
118
|
+
log: (m) => { if (process.env.TP_DEBUG) console.error(` ◇ ${m}`) },
|
|
119
|
+
})
|
|
120
|
+
// The SOURCE is diagnostic and safe to print; the KEY never is (C16).
|
|
121
|
+
if (process.env.TP_DEBUG) console.error(` ◇ supabase anon key source: ${ANON_KEY_SOURCE}`)
|
|
107
122
|
|
|
108
123
|
// Owner access token for AUTHED web writes (currently /api/code-mockup, whip L16).
|
|
109
124
|
// The room-bridge is anon by design; the account supervisor (account.mjs) owns the
|
|
@@ -1920,7 +1935,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1920
1935
|
// fork-bomb breaker, now one level deeper (2026-07-10 cascade-spawn-depth spec).
|
|
1921
1936
|
tool(
|
|
1922
1937
|
'spawn_terminal',
|
|
1923
|
-
'Open a NEW agent terminal (a fresh Claude lane) in this ThinkPool Code room — YOUR lane to use, not a sibling that is already working. Use it to fan work out: give it an initial `task` and it runs in parallel in its own lane; collect its result later with read_terminal,
|
|
1938
|
+
'Open a NEW agent terminal (a fresh Claude lane) in this ThinkPool Code room — YOUR lane to use, not a sibling that is already working. Use it to fan work out: give it an initial `task` and it runs in parallel in its own lane; collect its result later with read_terminal, then ALWAYS close_terminal it — a finished lane left open holds a slot against the room cap and blocks the next fan-out. By default the new lane INHERITS your current permission mode — so if you are in bypass, it runs autonomously with no per-action clicking; pass `mode` to override. ALWAYS prefer spawning a fresh lane over post_to_terminal into one that is already busy. Tier guidance: default worker lanes to opus; use sonnet for mechanical work (renames, boilerplate, doc sweeps); reserve your own model for planning/review. Bounded by the room terminal cap and a per-turn limit.',
|
|
1924
1939
|
{
|
|
1925
1940
|
name: z.string().max(80).optional().describe('a short label for the new lane so the room (and you) can find it, e.g. "Research: topologies"'),
|
|
1926
1941
|
task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
|
|
@@ -1940,7 +1955,15 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1940
1955
|
plan: ownerPlan, // Free 3 / Plus 6 dispatch ceiling
|
|
1941
1956
|
disabled: process.env.TP_SPAWN_OFF === '1',
|
|
1942
1957
|
})
|
|
1943
|
-
|
|
1958
|
+
// A refusal that says "close one" without saying WHICH one sends the caller
|
|
1959
|
+
// hunting. Adapt the live roster to the pure helper: only lanes THIS caller
|
|
1960
|
+
// spawned and that aren't mid-turn — the exact set close_terminal accepts.
|
|
1961
|
+
if (!gate.ok) return okText(gate.reason + formatClosableHint(id, [...sessions.entries()].map(([sid, s]) => ({
|
|
1962
|
+
ref: String(sid).slice(0, 8),
|
|
1963
|
+
name: termNames[sid] || undefined,
|
|
1964
|
+
spawnedBy: s.spawnedBy,
|
|
1965
|
+
busy: s.session?.turnActive ?? false,
|
|
1966
|
+
}))))
|
|
1944
1967
|
// Record this spawn, pruning timestamps that have already left the window
|
|
1945
1968
|
// (identical predicate to recentSpawnCount) so the array can't grow unbounded.
|
|
1946
1969
|
entry.spawnTimes = [...(entry.spawnTimes || []).filter((t) => Number.isFinite(t) && t > now - SPAWN.windowMs), now]
|
package/claude-session.mjs
CHANGED
|
@@ -700,7 +700,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
700
700
|
'RUN IT, DO NOT ASK: verify your own work before calling it done. Actually run or serve what you changed, observe that it behaves correctly, and show the evidence in the room — a screenshot of the running result, the passing test output, or the real response — rather than telling the user "this should work, go test it." If you could not verify something, say exactly what is unverified. This room follows verify-before-claiming: runtime evidence you produced, not assertion.',
|
|
701
701
|
'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
|
|
702
702
|
'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
|
|
703
|
-
'CROSS-TERMINAL SPAWN: when a person asks you to fan work out across multiple agents — research lanes, parallel sub-tasks, a swarm — open YOUR OWN fresh lanes with spawn_terminal(name?, task?, model?); do NOT dump the work into siblings that are already busy (a sibling is mid-task unless read_terminal shows it idle, and hijacking it derails their work). spawn_terminal opens a brand-new Claude lane and, if you pass `task`, hands it that task immediately; it appears as a new terminal in the room. A spawned lane INHERITS your current permission mode by default — so if you are in bypass, your lanes run autonomously with no per-action clicking; pass `mode` only to override. Raising a lane to bypass from a non-bypass lane asks the room to confirm once (everything else spawns without a prompt). Collect each lane\'s result later with read_terminal (a spawned lane cannot post back to you),
|
|
703
|
+
'CROSS-TERMINAL SPAWN: when a person asks you to fan work out across multiple agents — research lanes, parallel sub-tasks, a swarm — open YOUR OWN fresh lanes with spawn_terminal(name?, task?, model?); do NOT dump the work into siblings that are already busy (a sibling is mid-task unless read_terminal shows it idle, and hijacking it derails their work). spawn_terminal opens a brand-new Claude lane and, if you pass `task`, hands it that task immediately; it appears as a new terminal in the room. A spawned lane INHERITS your current permission mode by default — so if you are in bypass, your lanes run autonomously with no per-action clicking; pass `mode` only to override. Raising a lane to bypass from a non-bypass lane asks the room to confirm once (everything else spawns without a prompt). Collect each lane\'s result later with read_terminal (a spawned lane cannot post back to you). CLOSING IS PART OF THE JOB, not housekeeping you get to skip: the moment you have read a lane\'s result and acted on it, close_terminal it. A finished lane left open holds a slot against the room\'s machine cap and against your dispatch ceiling, so the NEXT fan-out — yours, or a person\'s — gets refused; rooms silt up with dead lanes exactly this way. Before each new wave of spawns, list your open lanes (read_terminal with no argument) and close the finished ones first. If a spawn is refused for capacity, the refusal names your own idle lanes so you do not have to hunt. You may only close lanes you spawned yourself — never close a sibling\'s, and never close one that is mid-turn. You are bounded: a sliding spawn-burst window, a per-plan dispatch ceiling on concurrent spawned lanes (Free 3 / Plus 6) that is separate from the human terminals so it never blocks them, an overall machine cap on live lanes, and a DEPTH bound — a lane opened by a person may spawn workers, and those workers may spawn one further level (a conductor dispatching its crew), but that second level can never spawn onward (so no runaway). This is how you "open the terminals yourself" instead of asking a person to.',
|
|
704
704
|
'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. list_sessions() lists your OTHER ThinkPool Code rooms — both your own rooms running on this machine AND your partner\'s rooms in the same pair, reachable over the per-pair bus (a room on the partner\'s machine shows its host). read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name to read that lane). Both are READ-ONLY — they never change another session, and they reach ONLY your own rooms and rooms you share with your partner, never a stranger\'s. Reach for them when work spans rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task elsewhere before you act here.',
|
|
705
705
|
'CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner\'s over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room\'s agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person\'s machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. All of this works only under the ThinkPool account bridge; a standalone room sees just its own terminals.',
|
|
706
706
|
'SUBAGENT POLICY: in this room, delegated work goes through the Ensemble. When you fan out research, parallel sub-tasks, reviews, or any substantive multi-step delegated work, open spawn_terminal lanes — they are visible in the room, the people can watch and steer them, and every other lane can peer at their work. Do NOT reach for the built-in Task/Agent subagents for that work: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble. The only exception is a trivial, seconds-scale read-only lookup where a room lane would be pure overhead.',
|
package/cross-terminal.mjs
CHANGED
|
@@ -284,6 +284,29 @@ export const spawnDecision = ({ hop = 0, spawnTimes = [], now = 0, spawnedLive =
|
|
|
284
284
|
return { ok: true }
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
// When a spawn is refused, "close one before opening another" is useless advice if the
|
|
288
|
+
// caller has to go hunting for WHICH one. Rooms fill up with finished lanes nobody
|
|
289
|
+
// closed (Max, 2026-07-10: "clean up the finished ensemble terminals there's a lot"),
|
|
290
|
+
// because the old preamble asked politely and nothing named the cost. So the refusal
|
|
291
|
+
// now names the caller's OWN idle spawned lanes — the exact ones close_terminal will
|
|
292
|
+
// accept — newest last, capped so a long list can't drown the reason above it.
|
|
293
|
+
//
|
|
294
|
+
// Pure: takes the roster, returns a string. `lanes` = [{ ref, name, spawnedBy, busy }].
|
|
295
|
+
// Only lanes this caller spawned are suggested: close_terminal refuses the others, and
|
|
296
|
+
// suggesting a sibling's lane is how you get an agent killing someone else's work.
|
|
297
|
+
export const CLOSABLE_HINT_MAX = 6
|
|
298
|
+
export const closableLanes = (callerId, lanes = []) =>
|
|
299
|
+
(Array.isArray(lanes) ? lanes : []).filter((l) => l && l.spawnedBy === callerId && !l.busy)
|
|
300
|
+
|
|
301
|
+
export const formatClosableHint = (callerId, lanes = [], max = CLOSABLE_HINT_MAX) => {
|
|
302
|
+
const idle = closableLanes(callerId, lanes)
|
|
303
|
+
if (!idle.length) return ''
|
|
304
|
+
const shown = idle.slice(0, max)
|
|
305
|
+
const list = shown.map((l) => `${l.ref}${l.name ? ` ("${l.name}")` : ''}`).join(', ')
|
|
306
|
+
const more = idle.length > shown.length ? ` (+${idle.length - shown.length} more)` : ''
|
|
307
|
+
return `\n\nYou have ${idle.length} finished lane${idle.length === 1 ? '' : 's'} of your own still open — collect ${idle.length === 1 ? 'it' : 'them'} with read_terminal, then close_terminal: ${list}${more}`
|
|
308
|
+
}
|
|
309
|
+
|
|
287
310
|
// ── Tier 1 cross-ROOM peek (read-only, same-machine) ─────────────────────────
|
|
288
311
|
// Thinkpool Ensemble expanded from cross-TERMINAL (sibling lanes in one room) to
|
|
289
312
|
// cross-SESSION (the account's OTHER rooms served by the same account supervisor).
|
package/key-shape.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* key-shape.mjs — classify a Supabase API key by its SHAPE, and decide whether
|
|
2
|
+
it is safe to hand to a client.
|
|
3
|
+
|
|
4
|
+
Deliberately dependency-free (not even node: builtins beyond Buffer) so it can
|
|
5
|
+
be imported from BOTH sides of the tarball boundary: the published
|
|
6
|
+
`thinkpool-pair` bridge and the Vercel serverless function api/config.js.
|
|
7
|
+
|
|
8
|
+
Supabase runs two key generations side by side (see
|
|
9
|
+
docs/specs/2026-07-10-supabase-key-migration.md §1a):
|
|
10
|
+
|
|
11
|
+
legacy a JWT — `eyJ…` — whose `role` payload claim is `anon` or `service_role`
|
|
12
|
+
new an opaque string — `sb_publishable_…` or `sb_secret_…`
|
|
13
|
+
|
|
14
|
+
Exactly two of those four are client-safe. The other two bypass RLS. Nothing
|
|
15
|
+
here VERIFIES a signature — that is Supabase's job, over the network, and we
|
|
16
|
+
have no key material to verify with (§4: nothing in this repo verifies a JWT).
|
|
17
|
+
We are reading a shape to answer one question: "could this possibly be a
|
|
18
|
+
secret?" A false "yes" costs a fallback. A false "no" costs the database. */
|
|
19
|
+
|
|
20
|
+
/** @returns {'publishable'|'secret'|'anon-jwt'|'service-jwt'|'invalid'} */
|
|
21
|
+
export function classifyKey(key) {
|
|
22
|
+
if (typeof key !== 'string') return 'invalid'
|
|
23
|
+
const k = key.trim()
|
|
24
|
+
if (!k) return 'invalid'
|
|
25
|
+
|
|
26
|
+
// New-generation keys are self-describing. Check `secret` first: a prefix
|
|
27
|
+
// test that fails open is exactly the bug this module exists to prevent.
|
|
28
|
+
if (k.startsWith('sb_secret_')) return 'secret'
|
|
29
|
+
if (k.startsWith('sb_publishable_')) return 'publishable'
|
|
30
|
+
|
|
31
|
+
// Legacy keys are unsigned-to-us JWTs; the role lives in the payload.
|
|
32
|
+
if (k.startsWith('eyJ')) {
|
|
33
|
+
const parts = k.split('.')
|
|
34
|
+
if (parts.length !== 3) return 'invalid'
|
|
35
|
+
try {
|
|
36
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))
|
|
37
|
+
if (payload.role === 'anon') return 'anon-jwt'
|
|
38
|
+
if (payload.role === 'service_role') return 'service-jwt'
|
|
39
|
+
return 'invalid' // an unknown role is not a role we will trust
|
|
40
|
+
} catch { return 'invalid' }
|
|
41
|
+
}
|
|
42
|
+
return 'invalid'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Only a publishable key or a legacy anon JWT may ever reach a client. */
|
|
46
|
+
export function isClientSafeKey(key) {
|
|
47
|
+
const c = classifyKey(key)
|
|
48
|
+
return c === 'publishable' || c === 'anon-jwt'
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.189",
|
|
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": {
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
"presence.mjs",
|
|
43
43
|
"account.mjs",
|
|
44
44
|
"auth-store.mjs",
|
|
45
|
+
"key-shape.mjs",
|
|
46
|
+
"supabase-key.mjs",
|
|
45
47
|
"provider.mjs",
|
|
46
48
|
"providers.mjs",
|
|
47
49
|
"README.md"
|
package/supabase-key.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/* supabase-key.mjs — resolve the bridge's client-safe Supabase key at RUNTIME.
|
|
2
|
+
|
|
3
|
+
THE PROBLEM THIS SOLVES (docs/specs/2026-07-10-supabase-key-migration.md §5.1)
|
|
4
|
+
|
|
5
|
+
`bridge.mjs` used to carry the legacy anon key as a string literal:
|
|
6
|
+
|
|
7
|
+
const SUPABASE_ANON = process.env.TP_SUPABASE_ANON || 'eyJhbGci…'
|
|
8
|
+
|
|
9
|
+
and 224 published `thinkpool-pair` versions shipped with it. The comment above
|
|
10
|
+
it said "safe to embed" — true about CONFIDENTIALITY (the web bundle ships the
|
|
11
|
+
same value), silent about LIFECYCLE. Disable that key in the Supabase dashboard
|
|
12
|
+
and every installed bridge, on every user's laptop, dies at the same instant —
|
|
13
|
+
and no server-side change repairs it, because Vercel env vars do not reach a
|
|
14
|
+
program running on someone else's machine. It is the one failure in the repo
|
|
15
|
+
our deploy pipeline cannot fix.
|
|
16
|
+
|
|
17
|
+
THE FIX. The key becomes a value the SERVER controls. The bridge asks
|
|
18
|
+
thinkpool.io for it at boot, caches it on disk, and keeps the literal only as
|
|
19
|
+
a last resort. A rotation then repairs itself for bridges that never update.
|
|
20
|
+
|
|
21
|
+
RESOLUTION ORDER — each step exists for a reason:
|
|
22
|
+
|
|
23
|
+
1 env TP_SUPABASE_ANON — the operator's explicit override. Always wins.
|
|
24
|
+
2 cache A fresh (< TTL) on-disk record. Keeps boot fast and works offline.
|
|
25
|
+
3 network GET {WEB_BASE}/api/config. The rotation-repair path.
|
|
26
|
+
4 stale An expired cache beats the literal: it is strictly newer.
|
|
27
|
+
5 baked The legacy literal. First-ever boot with no network. Never fails.
|
|
28
|
+
|
|
29
|
+
TWO INVARIANTS, both tested in supabase-key.test.mjs:
|
|
30
|
+
|
|
31
|
+
· A service-shaped key is NEVER adopted, from any source. The network is an
|
|
32
|
+
input we do not control; a compromised or misconfigured config surface
|
|
33
|
+
must not be able to hand a user's laptop an RLS-bypassing client.
|
|
34
|
+
· No key material is ever logged. We log the SOURCE and the SHAPE. (C16.) */
|
|
35
|
+
|
|
36
|
+
import os from 'node:os'
|
|
37
|
+
import fs from 'node:fs'
|
|
38
|
+
import path from 'node:path'
|
|
39
|
+
import { classifyKey, isClientSafeKey } from './key-shape.mjs'
|
|
40
|
+
|
|
41
|
+
export { classifyKey, isClientSafeKey }
|
|
42
|
+
|
|
43
|
+
/* The legacy anon key. Public by design (the web bundle ships it), but now a
|
|
44
|
+
FALLBACK rather than the source of truth. Do not add new readers. */
|
|
45
|
+
export const BAKED_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRheXR2dGFrbWxpeHBmYmJxempkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzgwNTMxNzEsImV4cCI6MjA5MzYyOTE3MX0.zYmz8bHUNEY4STUr2JuXChAsKwxNwkwFHe1Hd0Betqo'
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_SUPABASE_URL = 'https://daytvtakmlixpfbbqzjd.supabase.co'
|
|
48
|
+
export const DEFAULT_WEB_BASE = 'https://thinkpool.io'
|
|
49
|
+
|
|
50
|
+
const CACHE_FILE = path.join(os.homedir(), '.thinkpool-pair', 'supabase-key.json')
|
|
51
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // 6h — a rotation reaches a rebooting bridge same-day
|
|
52
|
+
const FETCH_TIMEOUT_MS = 2000 // boot latency budget; the literal is right there
|
|
53
|
+
|
|
54
|
+
// ── the on-disk cache ──────────────────────────────────────────────────────
|
|
55
|
+
// Sits beside auth.json / dirs.json / served.json in ~/.thinkpool-pair.
|
|
56
|
+
// Contains no secret (a publishable key), so no chmod dance — unlike auth.json.
|
|
57
|
+
export function readCacheFile() {
|
|
58
|
+
try {
|
|
59
|
+
const rec = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
|
|
60
|
+
if (!rec || typeof rec.key !== 'string' || typeof rec.url !== 'string') return null
|
|
61
|
+
return { key: rec.key, url: rec.url, at: Number(rec.at) || 0 }
|
|
62
|
+
} catch { return null }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function writeCacheFile(rec) {
|
|
66
|
+
// Atomic, for the same reason auth-store.mjs is: a kill mid-write must never
|
|
67
|
+
// leave a truncated file that reads as "no cache" — or worse, as half a key.
|
|
68
|
+
try {
|
|
69
|
+
fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true })
|
|
70
|
+
const tmp = `${CACHE_FILE}.${process.pid}.tmp`
|
|
71
|
+
fs.writeFileSync(tmp, JSON.stringify(rec))
|
|
72
|
+
fs.renameSync(tmp, CACHE_FILE)
|
|
73
|
+
} catch { /* a cache we cannot write is a cache we do without */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── the network step ───────────────────────────────────────────────────────
|
|
77
|
+
// Returns a validated client-safe key, or null. NEVER throws: a bridge must
|
|
78
|
+
// boot on a train. Never logs the value.
|
|
79
|
+
async function fetchConfigKey({ webBase, supabaseUrl, fetchImpl, timeoutMs, log }) {
|
|
80
|
+
const ac = new AbortController()
|
|
81
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs)
|
|
82
|
+
try {
|
|
83
|
+
const r = await fetchImpl(`${webBase}/api/config`, {
|
|
84
|
+
signal: ac.signal,
|
|
85
|
+
headers: { accept: 'application/json' },
|
|
86
|
+
})
|
|
87
|
+
if (!r || !r.ok) { log(`config: HTTP ${r?.status ?? '?'} — keeping current key`); return null }
|
|
88
|
+
|
|
89
|
+
// Until this endpoint is deployed, the SPA catch-all answers /api/config with
|
|
90
|
+
// 200 + index.html. That must read as "no config", not as a network outage —
|
|
91
|
+
// every bridge in the wild takes this path on the day before the deploy.
|
|
92
|
+
let body
|
|
93
|
+
try { body = await r.json() }
|
|
94
|
+
catch { log('config: response was not JSON (endpoint not deployed?) — keeping current key'); return null }
|
|
95
|
+
|
|
96
|
+
const key = body?.supabaseAnonKey
|
|
97
|
+
|
|
98
|
+
// The served key must be for OUR project. A config surface pointing at a
|
|
99
|
+
// different Supabase project would authenticate against the wrong database.
|
|
100
|
+
if (body?.supabaseUrl && body.supabaseUrl !== supabaseUrl) {
|
|
101
|
+
log('config: served a key for a different project — refused')
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
// The invariant. A service-shaped key from the network is never adopted.
|
|
105
|
+
if (!isClientSafeKey(key)) {
|
|
106
|
+
log(`config: served a ${classifyKey(key)} key — refused`)
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
return key
|
|
110
|
+
} catch (e) {
|
|
111
|
+
log(`config: unreachable (${e.name === 'AbortError' ? 'timeout' : 'network'}) — keeping current key`)
|
|
112
|
+
return null
|
|
113
|
+
} finally { clearTimeout(timer) }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── the resolver ───────────────────────────────────────────────────────────
|
|
117
|
+
/**
|
|
118
|
+
* @returns {Promise<{key: string, source: 'env'|'cache'|'network'|'stale-cache'|'baked'}>}
|
|
119
|
+
* @throws if TP_SUPABASE_ANON is set to something that is not client-safe.
|
|
120
|
+
*/
|
|
121
|
+
export async function resolveAnonKey(opts = {}) {
|
|
122
|
+
const {
|
|
123
|
+
env = process.env,
|
|
124
|
+
supabaseUrl = env.TP_SUPABASE_URL || DEFAULT_SUPABASE_URL,
|
|
125
|
+
webBase = env.TP_WEB_BASE || DEFAULT_WEB_BASE,
|
|
126
|
+
fetchImpl = globalThis.fetch,
|
|
127
|
+
readCache = readCacheFile,
|
|
128
|
+
writeCache = writeCacheFile,
|
|
129
|
+
now = Date.now,
|
|
130
|
+
ttlMs = CACHE_TTL_MS,
|
|
131
|
+
timeoutMs = FETCH_TIMEOUT_MS,
|
|
132
|
+
log = () => {},
|
|
133
|
+
} = opts
|
|
134
|
+
|
|
135
|
+
// 1 · env — the operator said so. But an operator who exports a SECRET key
|
|
136
|
+
// here would silently run an RLS-bypassing bridge, so this one throws
|
|
137
|
+
// rather than falling through: a wrong explicit answer is worse than none.
|
|
138
|
+
const fromEnv = String(env.TP_SUPABASE_ANON || '').trim()
|
|
139
|
+
if (fromEnv) {
|
|
140
|
+
if (!isClientSafeKey(fromEnv)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`TP_SUPABASE_ANON is not a client-safe key (looks like: ${classifyKey(fromEnv)}). ` +
|
|
143
|
+
'Use the project\'s publishable/anon key — never a secret or service_role key.',
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
log('key: source=env')
|
|
147
|
+
return { key: fromEnv, source: 'env' }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const cached = readCache()
|
|
151
|
+
const cacheUsable = !!cached && cached.url === supabaseUrl && isClientSafeKey(cached.key)
|
|
152
|
+
|
|
153
|
+
// 2 · a fresh cache — skip the network entirely
|
|
154
|
+
if (cacheUsable && (now() - cached.at) < ttlMs) {
|
|
155
|
+
log('key: source=cache')
|
|
156
|
+
return { key: cached.key, source: 'cache' }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 3 · the network — the whole point of this module
|
|
160
|
+
const fetched = await fetchConfigKey({ webBase, supabaseUrl, fetchImpl, timeoutMs, log })
|
|
161
|
+
if (fetched) {
|
|
162
|
+
writeCache({ key: fetched, url: supabaseUrl, at: now() })
|
|
163
|
+
log(`key: source=network shape=${classifyKey(fetched)}`)
|
|
164
|
+
return { key: fetched, source: 'network' }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 4 · a stale cache still beats the literal — it is strictly more recent
|
|
168
|
+
if (cacheUsable) {
|
|
169
|
+
log('key: source=stale-cache')
|
|
170
|
+
return { key: cached.key, source: 'stale-cache' }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 5 · the literal. Offline first boot. Always works, until the day it doesn't.
|
|
174
|
+
log('key: source=baked')
|
|
175
|
+
return { key: BAKED_ANON_KEY, source: 'baked' }
|
|
176
|
+
}
|