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 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 || 'https://daytvtakmlixpfbbqzjd.supabase.co'
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
- const SUPABASE_ANON = process.env.TP_SUPABASE_ANON || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRheXR2dGFrbWxpeHBmYmJxempkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzgwNTMxNzEsImV4cCI6MjA5MzYyOTE3MX0.zYmz8bHUNEY4STUr2JuXChAsKwxNwkwFHe1Hd0Betqo'
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
- s.log = []; s.seq = makeSeqCounter(0); bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
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.186",
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
- // The archive FLOOR: the lowest transcript seq durably stored for this terminal.
119
- // The client compares its oldest loaded seq against this — only OFFER "load earlier"
120
- // when there genuinely are older events in the archive (vs a fresh lane whose first
121
- // turn happens to land at seq > 1, or an evicted-but-unarchived gap). null = no archive.
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 f = eventsLog(room, id)
125
- if (!fs.existsSync(f)) return null
126
- for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
127
- if (!line) continue
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 f = eventsLog(room, id)
140
- if (!fs.existsSync(f)) return { events: [], hasMore: false }
141
- const all = []
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(0, bound - limit)
152
- return { events: all.slice(start, bound), hasMore: start > 0 }
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
 
@@ -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
+ }