diff --git a/.gitleaks.toml b/.gitleaks.toml index 555ff26..2bff356 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,20 +1,22 @@ # Gitleaks configuration — extends the default ruleset. # # The only allowlisted secrets are PUBLIC key material committed in -# src/config/environments.ts: -# - the two Supabase anon keys — anon-role JWTs, designed to be embedded in -# client code and gated by RLS (see the doc comment in that file). -# - the two KEK public keys (prod + dev) for client-side binary envelope -# encryption — base64 of the raw 32-byte X25519 PUBLIC half. They can only -# *encrypt*; the private halves live solely on the API, never in this repo. -# Both are intentionally not secret; gitleaks flags them on entropy alone +# src/config/environments.ts: the two KEK public keys (prod + dev) for +# client-side binary envelope encryption — base64 of the raw 32-byte X25519 +# PUBLIC half. They can only *encrypt*; the private halves live solely on the +# API, never in this repo. gitleaks flags them on entropy alone # (generic-api-key), not because it recognizes them as credentials. # +# The two legacy Supabase anon keys (prod + dev) are also listed, but only +# because they remain in git history and CI scans the full history. The source +# no longer contains them: the CLI fetches its publishable key from the API's +# GET /cli-config at runtime (dcd#1112). Do not add a Supabase key back. +# # They are allowlisted by EXACT VALUE, deliberately not by file path or by the -# whole `jwt` / `generic-api-key` rule: a Supabase service_role key is also a -# JWT, and a KEK *private* key is the same shape as its public half, so a -# path/rule allowlist would let a genuinely sensitive key pasted into the same -# file slip through. Matching exact values keeps that detection intact. +# whole `generic-api-key` rule: a KEK *private* key is the same shape as its +# public half, so a path/rule allowlist would let a genuinely sensitive key +# pasted into the same file slip through. Matching exact values keeps that +# detection intact. # # When a KEK is rotated, replace the corresponding value below — do not simply # append, or the retired key stops being distinguishable from a live secret. @@ -22,8 +24,9 @@ useDefault = true [allowlist] -description = "Public Supabase anon keys and KEK public keys (safe to commit)" +description = "KEK public keys, and retired Supabase anon keys left in history (safe)" regexes = [ + # retired legacy anon keys (prod, dev), history only '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0\.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8''', '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30\.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ''', # prod kekPublicKey v1 diff --git a/.husky/pre-commit b/.husky/pre-commit index aa604fd..0386007 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,7 @@ #!/usr/bin/env sh # Scan staged changes for secrets before they ever enter git history. # Mirrors the gitleaks CI gate (.github/workflows/cli-ci.yml) and shares the -# same allowlist (.gitleaks.toml), so the public Supabase anon keys won't trip. +# same allowlist (.gitleaks.toml), so the public KEK keys won't trip. # # gitleaks is NOT a dependency of this package — contributors install the binary # themselves. If it's missing we skip (exit 0) rather than block: CI is the diff --git a/src/commands/login.ts b/src/commands/login.ts index fe15f7a..b438ec2 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -36,7 +36,13 @@ import { defineCommand } from 'citty'; import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; -import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.js'; +import { + type DcdEnvName, + ENVIRONMENTS, + inferEnvFromApiUrl, + resolveFrontendUrl, +} from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; import { jsonFlag } from '../config/flags/output.flags.js'; import { canPrompt } from '../utils/ci.js'; import { CliError, logger } from '../utils/cli.js'; @@ -200,11 +206,7 @@ export const loginCommand = defineCommand({ // rejects it, the token itself is bad; if Supabase accepts, the // api's JwtService is misconfigured. const iss = decodeJwtIssuer(payload.access_token); - const supabaseUser = await probeSupabaseUser( - iss, - ENVIRONMENTS[env].supabase.anonKey, - payload.access_token, - ); + const supabaseUser = await probeSupabaseUser(iss, env, payload.access_token); const parts = [ iss ? `token iss: ${iss}` : null, `supabase /auth/v1/user: ${supabaseUser}`, @@ -335,7 +337,7 @@ function base64url(buf: Buffer): string { async function probeSupabaseUser( iss: string | null, - anonKey: string, + env: DcdEnvName, token: string, ): Promise { if (!iss) return 'skipped (no iss)'; @@ -343,7 +345,7 @@ async function probeSupabaseUser( const res = await fetch(`${iss}/user`, { headers: { authorization: `Bearer ${token}`, - apikey: anonKey, + apikey: await getSupabaseKey(env), }, }); const bodyText = await res.text().catch(() => ''); diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 8072b31..be2a163 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -4,7 +4,6 @@ */ import { defineCommand } from 'citty'; -import { ENVIRONMENTS } from '../config/environments.js'; import { jsonFlag } from '../config/flags/output.flags.js'; import { CliAuthGateway } from '../gateways/cli-auth-gateway.js'; import { logger } from '../utils/cli.js'; @@ -33,8 +32,7 @@ export const logoutCommand = defineCommand({ return; } - const { anonKey } = ENVIRONMENTS[config.env].supabase; - await CliAuthGateway.signOut(config.supabase_url, anonKey, config.session); + await CliAuthGateway.signOut(config.supabase_url, config.env, config.session); clearConfig(); if (json) { logger.json({ status: 'SUCCESS', loggedOut: true, user: config.session.user_email }); diff --git a/src/config/environments.ts b/src/config/environments.ts index 22a16c8..477ce53 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -1,12 +1,10 @@ /** * Per-env devicecloud.dev coordinates — single source of truth for the URLs - * and Supabase credentials the CLI uses. + * the CLI uses. * - * **These `anonKey` values are public.** Supabase anon keys are JWTs designed - * to be embedded in client code (web apps, mobile apps, CLIs). They only - * grant whatever the project's RLS policies expose. Don't worry about seeing - * them here — every Supabase-based product ships its anon key the same way. - * https://supabase.com/docs/guides/api/api-keys + * There is deliberately no Supabase key here. A compiled-in key can never be + * rotated without breaking every pinned CLI version, so it is fetched from the + * API at runtime instead: see `getSupabaseKey` in ./supabase-key.ts (dcd#1112). */ export type DcdEnvName = 'dev' | 'prod'; @@ -17,13 +15,12 @@ export interface DcdEnvironment { supabase: { url: string; projectRef: string; - anonKey: string; }; /** * Pinned KEK public key for client-side binary envelope encryption (dcd#1138). * `key` is base64 of the raw 32-byte X25519 public key; `version` selects which - * KEK the platform API unwraps with. **Public — safe to embed** (like the anon - * key above): it can only *encrypt*; the private half lives solely on the API. + * KEK the platform API unwraps with. **Public — safe to embed**: it can only + * *encrypt*; the private half lives solely on the API. * * Left `null` until the KEK is generated and provisioned. Generate a keypair * with, e.g.: @@ -42,8 +39,6 @@ export const ENVIRONMENTS: Record = { supabase: { url: 'https://cloud.devicecloud.dev', projectRef: 'pgydnphbimetinsgfkbo', - anonKey: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8', }, kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' }, }, @@ -53,8 +48,6 @@ export const ENVIRONMENTS: Record = { supabase: { url: 'https://lbmsowehtjwnqlurpemb.supabase.co', projectRef: 'lbmsowehtjwnqlurpemb', - anonKey: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ', }, kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' }, }, diff --git a/src/config/supabase-key.ts b/src/config/supabase-key.ts new file mode 100644 index 0000000..5f3ca42 --- /dev/null +++ b/src/config/supabase-key.ts @@ -0,0 +1,60 @@ +/** + * The Supabase publishable key, fetched from the dcd API at runtime (dcd#1112). + * + * Releases up to this one compiled the anon key in, so the key could never be + * rotated without breaking every CLI version pinned in customer CI. The API now + * serves it from the public `GET /cli-config`, and rotating it is an env change + * on the API. + * + * Fetched once per process and kept in memory only. It is deliberately not + * cached on disk: a stale key makes GoTrue answer a refresh with a 4xx, which + * `resolveAuth` treats as a dead session and deletes the user's login. + * + * Only the key comes from the API. The Supabase URLs stay pinned in + * `ENVIRONMENTS`: prod goes through the `cloud.devicecloud.dev` custom domain, + * which customer firewalls may allow-list, not the API's raw `supabase.co` URL. + */ +import { ENVIRONMENTS, type DcdEnvName } from './environments.js'; + +const FETCH_TIMEOUT_MS = 10_000; + +const cache = new Map>(); + +async function fetchSupabaseKey(env: DcdEnvName): Promise { + const url = `${ENVIRONMENTS[env].apiUrl}/cli-config`; + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) { + throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`); + } + const body = (await res.json()) as { supabase_publishable_key?: unknown }; + const key = body.supabase_publishable_key; + if (typeof key !== 'string' || key.length === 0) { + throw new Error(`CLI config from ${url} has no supabase_publishable_key`); + } + return key; +} + +/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */ +export function getSupabaseKey(env: DcdEnvName): Promise { + let pending = cache.get(env); + if (!pending) { + pending = fetchSupabaseKey(env); + cache.set(env, pending); + pending.catch(() => cache.delete(env)); + } + return pending; +} + +/** + * Headers that authenticate a raw Storage/Auth request with `key` alone. A + * legacy anon key is a JWT and also goes in `Authorization`; an + * `sb_publishable_` key is not a JWT and must only be sent as `apikey`. + */ +export function supabaseKeyHeaders(key: string): Record { + return key.startsWith('sb_') ? { apikey: key } : { apikey: key, authorization: `Bearer ${key}` }; +} + +/** Test seam: forget fetched keys. */ +export function resetSupabaseKeyCache(): void { + cache.clear(); +} diff --git a/src/gateways/cli-auth-gateway.ts b/src/gateways/cli-auth-gateway.ts index 35b10cf..18caf9a 100644 --- a/src/gateways/cli-auth-gateway.ts +++ b/src/gateways/cli-auth-gateway.ts @@ -7,6 +7,8 @@ */ import { createClient, isAuthApiError } from '@supabase/supabase-js'; +import type { DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; import type { StoredSession } from '../utils/config-store.js'; /** @@ -34,8 +36,8 @@ export interface RefreshedSession { user_id: string; } -function client(supabaseUrl: string, supabaseAnonKey: string) { - return createClient(supabaseUrl, supabaseAnonKey, { +function client(supabaseUrl: string, supabaseKey: string) { + return createClient(supabaseUrl, supabaseKey, { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, }); } @@ -43,10 +45,21 @@ function client(supabaseUrl: string, supabaseAnonKey: string) { export const CliAuthGateway = { async refresh( supabaseUrl: string, - supabaseAnonKey: string, + env: DcdEnvName, session: StoredSession, ): Promise { - const sb = client(supabaseUrl, supabaseAnonKey); + let supabaseKey: string; + try { + supabaseKey = await getSupabaseKey(env); + } catch (error) { + // Never definitive: the refresh token was not even tried, so the stored + // session must survive for the next attempt. + throw new SessionRefreshError( + `Failed to refresh session: ${(error as Error).message}`, + false, + ); + } + const sb = client(supabaseUrl, supabaseKey); const { data, error } = await sb.auth.refreshSession({ refresh_token: session.refresh_token, }); @@ -78,7 +91,7 @@ export const CliAuthGateway = { async signOut( supabaseUrl: string, - supabaseAnonKey: string, + env: DcdEnvName, session: StoredSession, ): Promise { // `scope: 'local'` clears this client's SDK state only — no server call, @@ -86,8 +99,8 @@ export const CliAuthGateway = { // EVERY session for the user (browser, mobile, other CLIs), so // `dcd logout` would kick the user out of the web app. We don't want // that — logging out locally should be local. - const sb = client(supabaseUrl, supabaseAnonKey); try { + const sb = client(supabaseUrl, await getSupabaseKey(env)); await sb.auth.setSession({ access_token: session.access_token, refresh_token: session.refresh_token, diff --git a/src/gateways/realtime-gateway.ts b/src/gateways/realtime-gateway.ts index 49035c5..2ede962 100644 --- a/src/gateways/realtime-gateway.ts +++ b/src/gateways/realtime-gateway.ts @@ -16,10 +16,12 @@ import { createClient, REALTIME_SUBSCRIBE_STATES, + type RealtimeChannel, type SupabaseClient, } from '@supabase/supabase-js'; import { ENVIRONMENTS, type DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; export interface RealtimeResultsSubscription { /** @@ -83,87 +85,99 @@ export class RealtimeResultsGateway { }; let client: SupabaseClient | undefined; - try { - const { url, anonKey } = ENVIRONMENTS[env].supabase; - client = createClient(url, anonKey, { - // Match SupabaseClientGateway / the frontend; no session persistence — - // we set the token explicitly below. - auth: { autoRefreshToken: false, persistSession: false }, - realtime: { params: { eventsPerSecond: 10 } }, - }); - - // Attach the user's JWT to the socket so RLS is enforced on the channel. - client.realtime.setAuth(accessToken); + let channel: RealtimeChannel | undefined; + let closed = false; + // The latest JWT, so a refresh that lands while the key is still being + // fetched is the one the socket joins with. + let token = accessToken; - const channel = client - .channel(`results-cli-${uploadId}`) - .on( - // The typings don't cover the postgres_changes overload cleanly. - 'postgres_changes' as never, - { - event: '*', - schema: 'public', - table: 'results', - filter: `org_id=eq.${orgId}`, - } as never, - (payload: ResultChangePayload) => { - if (payload.new?.test_upload_id === uploadId) { - dbg(`change for upload ${uploadId}`); - onChange(); - } - }, - ) - .subscribe((status) => { - if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { - dbg('subscribed'); - setConnected(true); - } else if ( - status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR || - status === REALTIME_SUBSCRIBE_STATES.TIMED_OUT || - status === REALTIME_SUBSCRIBE_STATES.CLOSED - ) { - // Don't try to recover — the backstop poll covers us. Surface in - // debug so a network-blocked websocket is diagnosable. - dbg(`channel ${status}; relying on backstop poll`); - setConnected(false); - } + // The publishable key comes from the API (dcd#1112), so the socket opens + // asynchronously. `subscribe` stays synchronous for the caller: until it + // connects, `isConnected` is false and the backstop poll covers the gap. + const ready = getSupabaseKey(env) + .then((supabaseKey) => { + if (closed) return; + const sb = createClient(ENVIRONMENTS[env].supabase.url, supabaseKey, { + // Match SupabaseClientGateway / the frontend; no session persistence — + // we set the token explicitly below. + auth: { autoRefreshToken: false, persistSession: false }, + realtime: { params: { eventsPerSecond: 10 } }, }); + client = sb; - const activeClient = client; - return { - isConnected: () => connected, - async unsubscribe() { - setConnected(false); - try { - await activeClient.removeChannel(channel); - await activeClient.realtime.disconnect(); - } catch { - /* best effort — process is exiting anyway */ - } - }, - updateAccessToken(nextToken: string) { - // setAuth updates the join payload and pushes the token to joined - // channels. A failure only costs realtime; the backstop poll stays. - activeClient.realtime.setAuth(nextToken).catch((error: unknown) => { - dbg( - `failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`, - ); + // Attach the user's JWT to the socket so RLS is enforced on the channel. + sb.realtime.setAuth(token); + + channel = sb + .channel(`results-cli-${uploadId}`) + .on( + // The typings don't cover the postgres_changes overload cleanly. + 'postgres_changes' as never, + { + event: '*', + schema: 'public', + table: 'results', + filter: `org_id=eq.${orgId}`, + } as never, + (payload: ResultChangePayload) => { + if (payload.new?.test_upload_id === uploadId) { + dbg(`change for upload ${uploadId}`); + onChange(); + } + }, + ) + .subscribe((status) => { + if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { + dbg('subscribed'); + setConnected(true); + } else if ( + status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR || + status === REALTIME_SUBSCRIBE_STATES.TIMED_OUT || + status === REALTIME_SUBSCRIBE_STATES.CLOSED + ) { + // Don't try to recover — the backstop poll covers us. Surface in + // debug so a network-blocked websocket is diagnosable. + dbg(`channel ${status}; relying on backstop poll`); + setConnected(false); + } }); - }, - }; - } catch (error) { - dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`); - // Best-effort cleanup of a half-built client, then degrade to polling. - try { - client?.realtime.disconnect(); - } catch { - /* ignore */ - } - return { - isConnected: () => false, - async unsubscribe() {}, - updateAccessToken() {}, - }; - } + }) + .catch((error: unknown) => { + dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`); + // Best-effort cleanup of a half-built client, then degrade to polling. + try { + client?.realtime.disconnect(); + } catch { + /* ignore */ + } + client = undefined; + channel = undefined; + }); + + return { + isConnected: () => connected, + async unsubscribe() { + closed = true; + setConnected(false); + await ready; + if (!client) return; + try { + if (channel) await client.removeChannel(channel); + await client.realtime.disconnect(); + } catch { + /* best effort — process is exiting anyway */ + } + }, + updateAccessToken(nextToken: string) { + token = nextToken; + // setAuth updates the join payload and pushes the token to joined + // channels. A failure only costs realtime; the backstop poll stays. + client?.realtime.setAuth(nextToken).catch((error: unknown) => { + dbg( + `failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }, + }; } } diff --git a/src/gateways/supabase-gateway.ts b/src/gateways/supabase-gateway.ts index 67d0478..c37efa7 100644 --- a/src/gateways/supabase-gateway.ts +++ b/src/gateways/supabase-gateway.ts @@ -4,6 +4,7 @@ import { createClient } from '@supabase/supabase-js'; import * as tus from 'tus-js-client'; import { ENVIRONMENTS, type DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey, supabaseKeyHeaders } from '../config/supabase-key.js'; /** Disk-backed upload descriptor — see UploadSource in src/methods.ts. */ export interface ResumableUploadSource { @@ -16,7 +17,7 @@ export interface ResumableUploadSource { export class SupabaseGateway { /** * Upload to Supabase using resumable uploads (TUS protocol) - * Uploads to staging location (uploads/{id}/) using anon key + * Uploads to staging location (uploads/{id}/) using the publishable key * File is later moved to final location by API after finalization * @param env - Environment (dev or prod) * @param path - Staging storage path (uploads/{id}/file.ext) @@ -32,8 +33,8 @@ export class SupabaseGateway { debug = false, onProgress?: (bytesUploaded: number, bytesTotal: number) => void, ): Promise { - const { anonKey: SUPABASE_PUBLIC_KEY, projectRef } = - ENVIRONMENTS[env].supabase; + const { projectRef } = ENVIRONMENTS[env].supabase; + const supabaseKey = await getSupabaseKey(env); const storageUrl = `https://${projectRef}.storage.supabase.co`; if (debug) { @@ -58,9 +59,9 @@ export class SupabaseGateway { retryDelays: [0, 3000, 5000, 10_000, 20_000], // Authentication and headers - // Use anon key for staging uploads to uploads/* folder + // Publishable key for staging uploads to uploads/* folder headers: { - authorization: `Bearer ${SUPABASE_PUBLIC_KEY}`, + ...supabaseKeyHeaders(supabaseKey), 'x-upsert': 'true', // Allow overwriting existing files }, @@ -137,7 +138,7 @@ export class SupabaseGateway { file: File, debug = false, ) { - const { url: SUPABASE_URL, anonKey: SUPABASE_PUBLIC_KEY } = ENVIRONMENTS[env].supabase; + const { url: SUPABASE_URL } = ENVIRONMENTS[env].supabase; if (debug) { console.log(`[DEBUG] Supabase upload starting...`); @@ -148,7 +149,7 @@ export class SupabaseGateway { } try { - const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLIC_KEY); + const supabase = createClient(SUPABASE_URL, await getSupabaseKey(env)); const uploadToUrl = await supabase.storage .from('organizations') .uploadToSignedUrl(path, token, file); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 2b4c146..5e338b7 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -10,7 +10,6 @@ */ import { closeSync, openSync, rmSync, statSync } from 'node:fs'; -import { ENVIRONMENTS } from '../config/environments.js'; import { CliAuthGateway, SessionRefreshError, @@ -196,12 +195,11 @@ async function refreshSessionWithLock( return { config: current, session }; } - const { anonKey } = ENVIRONMENTS[current.env].supabase; let refreshed; try { refreshed = await CliAuthGateway.refresh( current.supabase_url, - anonKey, + current.env, session, ); } catch (error) { diff --git a/test/unit/supabase-key.test.ts b/test/unit/supabase-key.test.ts new file mode 100644 index 0000000..20bd390 --- /dev/null +++ b/test/unit/supabase-key.test.ts @@ -0,0 +1,96 @@ +import { expect } from 'chai'; + +import { + getSupabaseKey, + resetSupabaseKeyCache, + supabaseKeyHeaders, +} from '../../src/config/supabase-key.js'; +import { CliAuthGateway, SessionRefreshError } from '../../src/gateways/cli-auth-gateway.js'; + +/** + * The Supabase key is fetched from the API's `GET /cli-config` (dcd#1112) + * rather than compiled in, so it can be rotated without a CLI release. + */ +describe('Supabase key from /cli-config', () => { + const realFetch = globalThis.fetch; + let requested: string[]; + + function stubFetch(respond: () => Response | Promise): void { + requested = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requested.push(String(input)); + return respond(); + }) as typeof fetch; + } + + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + beforeEach(() => resetSupabaseKeyCache()); + afterEach(() => { + globalThis.fetch = realFetch; + resetSupabaseKeyCache(); + }); + + it('fetches the key from the env API once per process', async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_abc' })); + + expect(await getSupabaseKey('prod')).to.equal('sb_publishable_abc'); + expect(await getSupabaseKey('prod')).to.equal('sb_publishable_abc'); + expect(requested).to.deep.equal(['https://api.devicecloud.dev/cli-config']); + }); + + it('does not cache a failure, so the next call retries', async () => { + stubFetch(() => json({}, 503)); + let failed = false; + try { + await getSupabaseKey('dev'); + } catch { + failed = true; + } + expect(failed).to.equal(true); + + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_dev' })); + expect(await getSupabaseKey('dev')).to.equal('sb_publishable_dev'); + }); + + it('rejects a response with no key', async () => { + stubFetch(() => json({ supabase_url: 'https://x.supabase.co' })); + let message = ''; + try { + await getSupabaseKey('prod'); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.contain('no supabase_publishable_key'); + }); + + it('sends a publishable key as apikey only, and a legacy JWT key as a Bearer too', () => { + expect(supabaseKeyHeaders('sb_publishable_abc')).to.deep.equal({ apikey: 'sb_publishable_abc' }); + expect(supabaseKeyHeaders('eyJhbGciOi.x.y')).to.deep.equal({ + apikey: 'eyJhbGciOi.x.y', + authorization: 'Bearer eyJhbGciOi.x.y', + }); + }); + + it('keeps the stored session when the key cannot be fetched during a refresh', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + let caught: unknown; + try { + await CliAuthGateway.refresh('https://cloud.devicecloud.dev', 'prod', { + access_token: 'a', + refresh_token: 'r', + expires_at: 0, + user_email: 'u@example.com', + user_id: 'u1', + }); + } catch (error) { + caught = error; + } + expect(caught).to.be.instanceOf(SessionRefreshError); + // Not definitive, so resolveAuth will not delete the user's login. + expect((caught as SessionRefreshError).definitive).to.equal(false); + }); +});