thinkpool-pair 0.7.186 → 0.7.188
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 +34 -4
- package/key-shape.mjs +49 -0
- package/package.json +3 -1
- package/session-store.mjs +74 -19
- package/supabase-key.mjs +176 -0
package/bridge.mjs
CHANGED
|
@@ -94,16 +94,31 @@ const flowRedispatch = new Map()
|
|
|
94
94
|
const flowBudgets = new Map()
|
|
95
95
|
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow } from './cross-terminal.mjs'
|
|
96
96
|
import { turnInFlight } from './update-gate.mjs'
|
|
97
|
-
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
|
|
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
|
|
@@ -2761,6 +2776,13 @@ channel
|
|
|
2761
2776
|
.on('broadcast', { event: 'history-page-request' }, ({ payload }) => {
|
|
2762
2777
|
const id = payload?.term
|
|
2763
2778
|
if (!id) return
|
|
2779
|
+
// Multi-bridge rooms (Max's two Macs; the account-supervisor path): only the bridge
|
|
2780
|
+
// that HOLDS this terminal may answer. A foreign bridge used to reply with an empty
|
|
2781
|
+
// page — the client reads the first empty page as "no older history", clears the gap
|
|
2782
|
+
// and kills the load-earlier pill, so recoverable history reads as gone. Targeting
|
|
2783
|
+
// (payload.host) is honoured too, matching term-open / file-put / code-open / flow-*,
|
|
2784
|
+
// but no client stamps it yet — ownership is what actually closes the race.
|
|
2785
|
+
if (!servesHistoryPage({ payloadHost: payload?.host, bridgeName: name, hasSession: sessions.has(id), hasArchive: hasDurableArchive(room, id) })) return
|
|
2764
2786
|
const { events, hasMore } = readDurablePage(room, id, Number(payload?.beforeSeq) || null, 200)
|
|
2765
2787
|
const to = payload?.to ?? null
|
|
2766
2788
|
if (!events.length) { bcast('history-page', { to, term: id, events: [], hasMore: false }); return }
|
|
@@ -2856,7 +2878,15 @@ channel
|
|
|
2856
2878
|
if (/^\/clear\s*$/.test(text)) {
|
|
2857
2879
|
s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
|
|
2858
2880
|
s.session.sendTurn(text)
|
|
2859
|
-
|
|
2881
|
+
// The seq reset is load-bearing on BOTH ends: the client's clear handler sets
|
|
2882
|
+
// seqHi = 0 (room.jsx), so the counter must restart with it. The durable archive
|
|
2883
|
+
// is append-only and keeps the pre-clear era under colliding seqs — session-store's
|
|
2884
|
+
// epochStart() confines every read to the newest era, so the archive never splices
|
|
2885
|
+
// two conversations. Drop the cached floor with it: the next pushLog re-seeds
|
|
2886
|
+
// archiveOldestSeq from the FIRST post-clear event (the new epoch's floor), instead
|
|
2887
|
+
// of leaving a stale pre-clear floor to mis-aim trimmedBeforeSeq's phantom-pill guard.
|
|
2888
|
+
s.log = []; s.seq = makeSeqCounter(0); s.archiveOldestSeq = null
|
|
2889
|
+
bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
|
|
2860
2890
|
flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
|
|
2861
2891
|
ctlLine('context cleared. You can continue with these answers in mind.')
|
|
2862
2892
|
return
|
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.188",
|
|
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/session-store.mjs
CHANGED
|
@@ -115,17 +115,75 @@ export function seedDurableEvents(room, id, events) {
|
|
|
115
115
|
} catch { /* noop */ }
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
// Does this bridge hold a durable archive for `id`? The capability half of the
|
|
119
|
+
// history-page-request guard (a foreign bridge in a multi-bridge room has no file).
|
|
120
|
+
export function hasDurableArchive(room, id) {
|
|
121
|
+
try { return !!room && !!id && fs.existsSync(eventsLog(room, id)) } catch { return false }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Should THIS bridge answer a history-page-request? (pure — tested in test-history-ab.mjs)
|
|
125
|
+
//
|
|
126
|
+
// Two independent gates, both required:
|
|
127
|
+
// · `payloadHost` — the same targeting guard every other broadcast handler carries
|
|
128
|
+
// (term-open, file-put, code-open, flow-*): a targeted request is for ONE machine.
|
|
129
|
+
// · ownership — no client build to date STAMPS a host on this event, so targeting
|
|
130
|
+
// alone leaves an untargeted request answerable by every bridge in the room. The
|
|
131
|
+
// bridge that doesn't serve the terminal has no archive, replies
|
|
132
|
+
// `{ events: [], hasMore: false }`, and the client reads that first empty page as
|
|
133
|
+
// "no older history" — the load-earlier pill dies and recoverable history looks
|
|
134
|
+
// gone. Only the bridge that actually HOLDS the terminal may speak: a live session,
|
|
135
|
+
// or (a closed lane whose transcript is still on screen) its durable archive.
|
|
136
|
+
export function servesHistoryPage({ payloadHost, bridgeName, hasSession, hasArchive }) {
|
|
137
|
+
if (payloadHost && payloadHost !== bridgeName) return false
|
|
138
|
+
return !!(hasSession || hasArchive)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Epochs: /clear resets the per-session seq counter (bridge.mjs) but the durable
|
|
142
|
+
// archive is append-only and keeps every era. So one file can hold seq 1..500 (pre-clear)
|
|
143
|
+
// followed by seq 1..30 (post-clear) — colliding keys. `findIndex(e.seq >= beforeSeq)`
|
|
144
|
+
// then anchors in the WRONG era and a page splices two conversations together.
|
|
145
|
+
//
|
|
146
|
+
// A seq that does not strictly increase over its predecessor is, by construction, a
|
|
147
|
+
// counter reset: within one bridge run the counter only climbs, and a restart reseeds it
|
|
148
|
+
// from maxSeq(restored log). So the last regression marks the start of the CURRENT epoch,
|
|
149
|
+
// and only that epoch is pageable. Everything older is a conversation the user explicitly
|
|
150
|
+
// cleared — surfacing it under colliding seqs would be wrong twice over.
|
|
151
|
+
//
|
|
152
|
+
// This is read-side only: it needs no migration and fixes archives already on disk
|
|
153
|
+
// (every user's, right now) as well as ones written from here on.
|
|
154
|
+
function epochStart(all) {
|
|
155
|
+
let start = 0, last = -Infinity
|
|
156
|
+
for (let i = 0; i < all.length; i++) {
|
|
157
|
+
const s = all[i]?.seq
|
|
158
|
+
if (typeof s !== 'number') continue
|
|
159
|
+
if (s <= last) { start = i; last = s; continue } // counter went backwards → new era
|
|
160
|
+
last = s
|
|
161
|
+
}
|
|
162
|
+
return start
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readArchive(room, id) {
|
|
166
|
+
const f = eventsLog(room, id)
|
|
167
|
+
if (!fs.existsSync(f)) return null
|
|
168
|
+
const all = []
|
|
169
|
+
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
|
|
170
|
+
if (!line) continue
|
|
171
|
+
try { all.push(JSON.parse(line)) } catch { /* skip a torn line */ }
|
|
172
|
+
}
|
|
173
|
+
return all
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The archive FLOOR: the lowest transcript seq durably stored for this terminal *in the
|
|
177
|
+
// current epoch*. The client compares its oldest loaded seq against this — only OFFER
|
|
178
|
+
// "load earlier" when there genuinely are older events in the archive (vs a fresh lane
|
|
179
|
+
// whose first turn happens to land at seq > 1, or an evicted-but-unarchived gap).
|
|
180
|
+
// null = no archive.
|
|
122
181
|
export function readDurableOldestSeq(room, id) {
|
|
123
182
|
try {
|
|
124
|
-
const
|
|
125
|
-
if (!
|
|
126
|
-
for (
|
|
127
|
-
if (
|
|
128
|
-
try { const e = JSON.parse(line); if (e && e.seq != null) return e.seq } catch { /* skip torn line */ }
|
|
183
|
+
const all = readArchive(room, id)
|
|
184
|
+
if (!all) return null
|
|
185
|
+
for (let i = epochStart(all); i < all.length; i++) {
|
|
186
|
+
if (all[i] && all[i].seq != null) return all[i].seq
|
|
129
187
|
}
|
|
130
188
|
return null
|
|
131
189
|
} catch { return null }
|
|
@@ -134,22 +192,19 @@ export function readDurableOldestSeq(room, id) {
|
|
|
134
192
|
// Read a backward page: events with transcript seq < beforeSeq (newest `limit` of
|
|
135
193
|
// them, no-seq chrome interleaved by file order preserved). Returns { events, hasMore }.
|
|
136
194
|
// beforeSeq null → the newest `limit` events (the tail). hasMore = there is older still.
|
|
195
|
+
// Never reads below the current epoch — see epochStart.
|
|
137
196
|
export function readDurablePage(room, id, beforeSeq, limit = 200) {
|
|
138
197
|
try {
|
|
139
|
-
const
|
|
140
|
-
if (!
|
|
141
|
-
const
|
|
142
|
-
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
|
|
143
|
-
if (!line) continue
|
|
144
|
-
try { all.push(JSON.parse(line)) } catch { /* skip a torn line */ }
|
|
145
|
-
}
|
|
198
|
+
const all = readArchive(room, id)
|
|
199
|
+
if (!all) return { events: [], hasMore: false }
|
|
200
|
+
const from = epochStart(all)
|
|
146
201
|
let bound = all.length
|
|
147
202
|
if (beforeSeq != null) {
|
|
148
|
-
const i = all.findIndex((e) => e && e.seq != null && e.seq >= beforeSeq)
|
|
203
|
+
const i = all.findIndex((e, idx) => idx >= from && e && e.seq != null && e.seq >= beforeSeq)
|
|
149
204
|
if (i >= 0) bound = i
|
|
150
205
|
}
|
|
151
|
-
const start = Math.max(
|
|
152
|
-
return { events: all.slice(start, bound), hasMore: start >
|
|
206
|
+
const start = Math.max(from, bound - limit)
|
|
207
|
+
return { events: all.slice(start, Math.max(start, bound)), hasMore: start > from }
|
|
153
208
|
} catch { return { events: [], hasMore: false } }
|
|
154
209
|
}
|
|
155
210
|
|
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
|
+
}
|