diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 8cd7ed072..39c60cb05 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -329,10 +329,14 @@ async function revokeClient(clientId: string) { } async function registerClient(name: string, args: string[]) { - if (!name) { console.error('Usage: auth register-client [--grant-types G] [--scopes S] [--source SOURCE]'); process.exit(1); } + if (!name) { + console.error('Usage: auth register-client [--grant-types G] [--scopes S] [--source SOURCE] [--federated-read SRC1,SRC2,...]'); + process.exit(1); + } const grantsIdx = args.indexOf('--grant-types'); const scopesIdx = args.indexOf('--scopes'); const sourceIdx = args.indexOf('--source'); + const federatedIdx = args.indexOf('--federated-read'); const grantTypes = grantsIdx >= 0 && args[grantsIdx + 1] ? args[grantsIdx + 1].split(',').map(s => s.trim()).filter(Boolean) : ['client_credentials']; @@ -341,20 +345,28 @@ async function registerClient(name: string, args: string[]) { // source. Defaults to 'default' to match migration v55's backfill so // operators upgrading without changing flags see no behavior change. const sourceId = sourceIdx >= 0 && args[sourceIdx + 1] ? args[sourceIdx + 1] : 'default'; + // v0.34.0 (#876): --federated-read accepts a comma-separated source list + // for federated read scope. When omitted, federated_read defaults to + // [sourceId] (read scope == write scope, the v0.33 default). + const federatedRead = federatedIdx >= 0 && args[federatedIdx + 1] + ? args[federatedIdx + 1].split(',').map(s => s.trim()).filter(Boolean) + : undefined; try { await withConfiguredSql(async (sql) => { const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts'); const provider = new GBrainOAuthProvider({ sql }); const { clientId, clientSecret } = await provider.registerClientManual( - name, grantTypes, scopes, [], sourceId, + name, grantTypes, scopes, [], sourceId, federatedRead, ); + const effectiveFederated = federatedRead && federatedRead.length > 0 ? federatedRead : [sourceId]; console.log(`OAuth client registered: "${name}"\n`); - console.log(` Client ID: ${clientId}`); - console.log(` Client Secret: ${clientSecret}\n`); - console.log(` Grant types: ${grantTypes.join(', ')}`); - console.log(` Scopes: ${scopes}`); - console.log(` Source: ${sourceId}\n`); + console.log(` Client ID: ${clientId}`); + console.log(` Client Secret: ${clientSecret}\n`); + console.log(` Grant types: ${grantTypes.join(', ')}`); + console.log(` Scopes: ${scopes}`); + console.log(` Write source: ${sourceId}`); + console.log(` Federated reads: ${effectiveFederated.join(', ')}\n`); console.log('Save the client secret — it will not be shown again.'); console.log(`Revoke with: gbrain auth revoke-client "${clientId}"`); }); diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index 302555ac7..3e5fe3613 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -1142,7 +1142,7 @@ async function embedMultimodalOpenAICompat( // OPENAI_API_KEY) both work via the same code path. Throws AIConfigError // when required env is missing. const authResult = recipe.resolveAuth - ? recipe.resolveAuth(cfg.env, 'embedding') + ? recipe.resolveAuth(cfg.env) : defaultResolveAuth(recipe, cfg.env, 'embedding'); const baseUrl = cfg.base_urls?.[recipe.id] ?? recipe.base_url_default; if (!baseUrl) { diff --git a/src/core/migrate.ts b/src/core/migrate.ts index ed4870bce..fa584a874 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -2784,6 +2784,107 @@ export const MIGRATIONS: Migration[] = [ ON oauth_clients(source_id) WHERE source_id IS NOT NULL; `, }, + { + version: 56, + name: 'oauth_clients_federated_read_column', + // v0.34.0 (#876): add federated_read TEXT[] for the read-side + // federation feature. source_id (v55) is the WRITE-authority axis; + // federated_read is the READ-scope axis. A client can write to ONE + // source while reading from N (a "WeCare L3 dept" client writes to + // dept-x and reads dept-x + parent canon + shared canon). + // + // Default '{}' (empty array) on column add — pre-existing rows get + // backfilled in v57 with an explicit CASE so the array reflects the + // client's current scope rather than the column default. + idempotent: true, + sql: ` + ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS federated_read TEXT[] NOT NULL DEFAULT '{}'; + `, + }, + { + version: 57, + name: 'oauth_clients_federated_read_backfill', + // v0.34.0 (#876, F5 — codex outside-voice fix). Backfill federated_read + // with explicit CASE so source_id IS NULL doesn't produce an ambiguous + // array containing NULL. Three cases: + // - source_id IS NULL → '{}' (empty read scope; legacy unscoped + // clients lost their implicit fallback in v55 backfill to 'default', + // so this branch fires only when an operator explicitly NULL'd + // source_id after migration). + // - source_id IS NOT NULL → ARRAY[source_id] (read scope matches + // write scope, the pre-federation default). + // Only fires on rows where federated_read is still the column default + // ({}). Operators who hand-set federated_read keep their config. + idempotent: true, + sql: ` + UPDATE oauth_clients + SET federated_read = CASE + WHEN source_id IS NULL THEN '{}'::text[] + ELSE ARRAY[source_id] + END + WHERE federated_read = '{}'::text[]; + `, + }, + { + version: 58, + name: 'oauth_clients_federated_read_validate', + // v0.34.0 (#876): post-backfill validation. Every client with a + // non-NULL source_id should now have its source_id reflected in + // federated_read. Fail loud if backfill missed a row — points at a + // logic bug in v57's WHERE clause. + idempotent: true, + sql: ` + DO $$ + DECLARE + bad_count INT; + BEGIN + SELECT count(*) INTO bad_count FROM oauth_clients + WHERE source_id IS NOT NULL + AND NOT (source_id = ANY(federated_read)); + IF bad_count > 0 THEN + RAISE EXCEPTION 'oauth_clients has % rows where source_id is not in federated_read after v57 backfill. This is a bug in v57 — re-run gbrain apply-migrations --force-retry 57.', bad_count; + END IF; + END $$; + `, + }, + { + version: 59, + name: 'oauth_clients_source_id_fk_restrict', + // v0.34.0 (#876): flip the source_id FK from ON DELETE SET NULL (v55 + // posture) to ON DELETE RESTRICT now that federated_read provides + // the alternative scope-loss path. Pre-fix, deleting a source could + // silently widen any oauth_client to super-reader (source_id → NULL). + // Post-flip, source delete is refused if any client references it; + // the operator's path is "revoke or re-scope the clients first." + idempotent: true, + sql: ` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'oauth_clients_source_id_fkey' + ) THEN + ALTER TABLE oauth_clients DROP CONSTRAINT oauth_clients_source_id_fkey; + END IF; + ALTER TABLE oauth_clients + ADD CONSTRAINT oauth_clients_source_id_fkey + FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE RESTRICT; + END $$; + `, + }, + { + version: 60, + name: 'oauth_clients_federated_read_gin_index', + // v0.34.0 (#876): GIN index for array-containment lookups + // (`WHERE p.source_id = ANY(federated_read)` and similar). The five + // read-side ops fall back to scalar sourceId when no auth is set, so + // this index only matters under load on federated-scoped clients. + idempotent: true, + sql: ` + CREATE INDEX IF NOT EXISTS idx_oauth_clients_federated_read + ON oauth_clients USING GIN (federated_read); + `, + }, ]; export const LATEST_VERSION = MIGRATIONS.length > 0 diff --git a/src/core/oauth-provider.ts b/src/core/oauth-provider.ts index da9347edc..8fe125b3c 100644 --- a/src/core/oauth-provider.ts +++ b/src/core/oauth-provider.ts @@ -194,23 +194,52 @@ class GBrainClientsStore implements OAuthRegisteredClientsStore { const secretHash = clientSecret ? hashToken(clientSecret) : null; const now = Math.floor(Date.now() / 1000); - // v0.34.0 (#861, D2 + D13): DCR clients get source_id='default' (matches - // legacy fallback). Operators who need narrower scope re-register via - // the CLI with `--source `. Pre-v55 brain falls through to the - // legacy projection (no source_id column yet). + // v0.34.0 (#861, D2 + D13 + #876): DCR clients get source_id='default' + // (matches legacy fallback) and federated_read=['default'] (read scope + // == write scope). Operators who need narrower / wider scope rescope + // via the CLI later. Pre-v55/v56 brain falls through to the legacy + // projection (no source_id / federated_read column yet). try { await this.sql` INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris, grant_types, scope, token_endpoint_auth_method, - client_id_issued_at, source_id) + client_id_issued_at, source_id, federated_read) VALUES (${clientId}, ${secretHash}, ${client.client_name || 'unnamed'}, ${pgArray((client.redirect_uris || []).map(String))}, ${pgArray(client.grant_types || ['client_credentials'])}, ${client.scope || ''}, ${authMethod}, - ${now}, ${'default'}) + ${now}, ${'default'}, ${pgArray(['default'])}) `; } catch (err) { - if (isUndefinedColumnError(err, 'source_id')) { + if (isUndefinedColumnError(err, 'federated_read')) { + try { + await this.sql` + INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris, + grant_types, scope, token_endpoint_auth_method, + client_id_issued_at, source_id) + VALUES (${clientId}, ${secretHash}, ${client.client_name || 'unnamed'}, + ${pgArray((client.redirect_uris || []).map(String))}, + ${pgArray(client.grant_types || ['client_credentials'])}, + ${client.scope || ''}, ${authMethod}, + ${now}, ${'default'}) + `; + } catch (err2) { + if (isUndefinedColumnError(err2, 'source_id')) { + await this.sql` + INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris, + grant_types, scope, token_endpoint_auth_method, + client_id_issued_at) + VALUES (${clientId}, ${secretHash}, ${client.client_name || 'unnamed'}, + ${pgArray((client.redirect_uris || []).map(String))}, + ${pgArray(client.grant_types || ['client_credentials'])}, + ${client.scope || ''}, ${authMethod}, + ${now}) + `; + } else { + throw err2; + } + } + } else if (isUndefinedColumnError(err, 'source_id')) { await this.sql` INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris, grant_types, scope, token_endpoint_auth_method, @@ -463,21 +492,40 @@ export class GBrainOAuthProvider implements OAuthServerProvider { let oauthRows: Record[]; try { oauthRows = await this.sql` - SELECT t.client_id, t.scopes, t.expires_at, t.resource, c.client_name, c.source_id + SELECT t.client_id, t.scopes, t.expires_at, t.resource, c.client_name, + c.source_id, c.federated_read FROM oauth_tokens t LEFT JOIN oauth_clients c ON c.client_id = t.client_id WHERE t.token_hash = ${tokenHash} AND t.token_type = 'access' `; } catch (err) { - if (isUndefinedColumnError(err, 'source_id')) { - // Pre-v55 brain — source_id column not yet added. Fall through to - // the legacy projection so auth keeps working until apply-migrations. - oauthRows = await this.sql` - SELECT t.client_id, t.scopes, t.expires_at, t.resource, c.client_name - FROM oauth_tokens t - LEFT JOIN oauth_clients c ON c.client_id = t.client_id - WHERE t.token_hash = ${tokenHash} AND t.token_type = 'access' - `; + // v0.34.0: pre-v55 brain → source_id column missing. Pre-v56 brain → + // federated_read column missing. Both classes degrade to legacy + // projection so auth keeps working until the operator runs + // apply-migrations. Probe both column names so partial-upgrade brains + // (v55 applied but v56 didn't yet) also fall through cleanly. + if (isUndefinedColumnError(err, 'source_id') || isUndefinedColumnError(err, 'federated_read')) { + // Try the v55-only projection first (source_id but no federated_read). + try { + oauthRows = await this.sql` + SELECT t.client_id, t.scopes, t.expires_at, t.resource, c.client_name, c.source_id + FROM oauth_tokens t + LEFT JOIN oauth_clients c ON c.client_id = t.client_id + WHERE t.token_hash = ${tokenHash} AND t.token_type = 'access' + `; + } catch (err2) { + if (isUndefinedColumnError(err2, 'source_id')) { + // Truly pre-v55: no source_id either. Pre-v0.34 projection. + oauthRows = await this.sql` + SELECT t.client_id, t.scopes, t.expires_at, t.resource, c.client_name + FROM oauth_tokens t + LEFT JOIN oauth_clients c ON c.client_id = t.client_id + WHERE t.token_hash = ${tokenHash} AND t.token_type = 'access' + `; + } else { + throw err2; + } + } } else { throw err; } @@ -492,6 +540,15 @@ export class GBrainOAuthProvider implements OAuthServerProvider { if (expiresAt === undefined || expiresAt < now) { throw new Error('Token expired'); } + // v0.34.0 (#876): federated_read normalization. SELECT returns + // either a JS array (Postgres / PGLite text[] driver mapping) or + // undefined when the legacy projection ran (pre-v56 brain). Empty + // array vs undefined matters: empty array = explicit no-federated- + // read; undefined = column missing on this brain. + const federatedRaw = row.federated_read; + const allowedSources = Array.isArray(federatedRaw) + ? (federatedRaw as string[]) + : undefined; return { token, clientId: row.client_id as string, @@ -503,6 +560,10 @@ export class GBrainOAuthProvider implements OAuthServerProvider { // Undefined when the row predates v55 or when the brain itself // predates v55 (fell through to the legacy projection above). sourceId: (row.source_id as string | null) ?? undefined, + // v0.34.0 (#876): federated read scope. sourceScopeOpts in + // operations.ts prefers this array over scalar sourceId when set + // and non-empty. + allowedSources, } as AuthInfo; } @@ -651,6 +712,7 @@ export class GBrainOAuthProvider implements OAuthServerProvider { scopes: string, redirectUris: string[] = [], sourceId: string = 'default', + federatedRead?: string[], ): Promise<{ clientId: string; clientSecret: string }> { // v0.28: ALLOWED_SCOPES allowlist. Reject `--scopes "read flying-unicorn"` // at registration so meaningless scope strings can't pile up in the DB. @@ -663,23 +725,46 @@ export class GBrainOAuthProvider implements OAuthServerProvider { const secretHash = hashToken(clientSecret); const now = Math.floor(Date.now() / 1000); - // v0.34.0 (#861, D2): persist source_id alongside the registration so - // verifyAccessToken can populate AuthInfo.sourceId on every request. - // Defaults to 'default' (matches migration v55 backfill). Pre-v55 - // brains throw a "column does not exist" error here — caught at - // boundary so manual registration falls back to the legacy projection. + // v0.34.0 (#861 + #876): persist source_id AND federated_read so + // verifyAccessToken can populate both AuthInfo fields. Defaults: + // source_id = 'default' (matches v55 backfill) + // federated_read = [source_id] when omitted (a non-federated client + // has read scope == write scope, the v0.33 default) + const federated = federatedRead && federatedRead.length > 0 ? federatedRead : [sourceId]; try { await this.sql` INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris, - grant_types, scope, client_id_issued_at, source_id) + grant_types, scope, client_id_issued_at, + source_id, federated_read) VALUES (${clientId}, ${secretHash}, ${name}, - ${pgArray(redirectUris)}, ${pgArray(grantTypes)}, ${scopes}, ${now}, ${sourceId}) + ${pgArray(redirectUris)}, ${pgArray(grantTypes)}, ${scopes}, ${now}, + ${sourceId}, ${pgArray(federated)}) `; } catch (err) { - if (isUndefinedColumnError(err, 'source_id')) { - // Pre-v55 brain — source_id column doesn't exist yet. Apply the - // pre-v0.34 projection so registration still works until the - // operator runs `gbrain apply-migrations`. + // Pre-v55 / pre-v56 brain: column missing. Fall back through both + // projections so registration still works until apply-migrations. + if (isUndefinedColumnError(err, 'federated_read')) { + // v55-only brain: source_id but no federated_read. + try { + await this.sql` + INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris, + grant_types, scope, client_id_issued_at, source_id) + VALUES (${clientId}, ${secretHash}, ${name}, + ${pgArray(redirectUris)}, ${pgArray(grantTypes)}, ${scopes}, ${now}, ${sourceId}) + `; + } catch (err2) { + if (isUndefinedColumnError(err2, 'source_id')) { + await this.sql` + INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris, + grant_types, scope, client_id_issued_at) + VALUES (${clientId}, ${secretHash}, ${name}, + ${pgArray(redirectUris)}, ${pgArray(grantTypes)}, ${scopes}, ${now}) + `; + } else { + throw err2; + } + } + } else if (isUndefinedColumnError(err, 'source_id')) { await this.sql` INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris, grant_types, scope, client_id_issued_at) diff --git a/src/core/pglite-schema.ts b/src/core/pglite-schema.ts index 89e661f06..e0553c321 100644 --- a/src/core/pglite-schema.ts +++ b/src/core/pglite-schema.ts @@ -594,16 +594,19 @@ CREATE TABLE IF NOT EXISTS oauth_clients ( client_secret_expires_at BIGINT, token_ttl INTEGER, deleted_at TIMESTAMPTZ, - source_id TEXT REFERENCES sources(id) ON DELETE SET NULL, + source_id TEXT REFERENCES sources(id) ON DELETE RESTRICT, + federated_read TEXT[] NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); --- v0.34.0 (#861, D13): source_id is the OAuth client's write-source scope. --- Migration v55 adds the column + FK on upgrade brains; fresh installs --- include both inline above so initSchema lands in the post-migration shape. --- Backfill on upgrade: NULL → 'default' (preserves pre-v0.34 effective --- behavior of the serve-http fallback chain). +-- v0.34.0 (#861, D13 + #876): source_id is the OAuth client's write-source +-- scope; federated_read is its read-source array (a federated client can +-- read sources beyond its source_id). Migration v55 adds source_id; +-- v56-v60 add federated_read + GIN index + flip FK to RESTRICT. Fresh +-- installs land in the post-migration shape via the inline columns above. CREATE INDEX IF NOT EXISTS idx_oauth_clients_source_id ON oauth_clients(source_id) WHERE source_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_oauth_clients_federated_read + ON oauth_clients USING GIN (federated_read); CREATE TABLE IF NOT EXISTS oauth_tokens ( token_hash TEXT PRIMARY KEY, diff --git a/src/core/schema-embedded.ts b/src/core/schema-embedded.ts index 5ad2ebfac..b9251c5a1 100644 --- a/src/core/schema-embedded.ts +++ b/src/core/schema-embedded.ts @@ -428,15 +428,17 @@ CREATE TABLE IF NOT EXISTS oauth_clients ( client_secret_expires_at BIGINT, token_ttl INTEGER, deleted_at TIMESTAMPTZ, - source_id TEXT REFERENCES sources(id) ON DELETE SET NULL, + source_id TEXT REFERENCES sources(id) ON DELETE RESTRICT, + federated_read TEXT[] NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); --- v0.34.0 (#861, D13): source_id = OAuth client write-source scope. --- Migration v55 adds column + FK + backfill (NULL→'default') on upgrade --- brains; fresh installs land in post-migration shape via the inline --- column above. +-- v0.34.0 (#861, D13 + #876): source_id is the write-source scope; +-- federated_read is the read-source array. Migrations v55-v60 land both +-- columns on upgrade; fresh installs include them inline above. CREATE INDEX IF NOT EXISTS idx_oauth_clients_source_id ON oauth_clients(source_id) WHERE source_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_oauth_clients_federated_read + ON oauth_clients USING GIN (federated_read); CREATE TABLE IF NOT EXISTS oauth_tokens ( token_hash TEXT PRIMARY KEY, diff --git a/src/schema.sql b/src/schema.sql index fc7130704..197d2165d 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -424,15 +424,17 @@ CREATE TABLE IF NOT EXISTS oauth_clients ( client_secret_expires_at BIGINT, token_ttl INTEGER, deleted_at TIMESTAMPTZ, - source_id TEXT REFERENCES sources(id) ON DELETE SET NULL, + source_id TEXT REFERENCES sources(id) ON DELETE RESTRICT, + federated_read TEXT[] NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); --- v0.34.0 (#861, D13): source_id = OAuth client write-source scope. --- Migration v55 adds column + FK + backfill (NULL→'default') on upgrade --- brains; fresh installs land in post-migration shape via the inline --- column above. +-- v0.34.0 (#861, D13 + #876): source_id is the write-source scope; +-- federated_read is the read-source array. Migrations v55-v60 land both +-- columns on upgrade; fresh installs include them inline above. CREATE INDEX IF NOT EXISTS idx_oauth_clients_source_id ON oauth_clients(source_id) WHERE source_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_oauth_clients_federated_read + ON oauth_clients USING GIN (federated_read); CREATE TABLE IF NOT EXISTS oauth_tokens ( token_hash TEXT PRIMARY KEY, diff --git a/test/book-mirror.test.ts b/test/book-mirror.test.ts index 25905c319..546a810b4 100644 --- a/test/book-mirror.test.ts +++ b/test/book-mirror.test.ts @@ -32,35 +32,57 @@ async function runCli(args: string[]): Promise<{ stderr: string; exit: number; }> { - const proc = Bun.spawn( - ['bun', 'run', 'src/cli.ts', 'book-mirror', ...args], - { - cwd: REPO_ROOT, - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, DATABASE_URL: '' }, - }, + // v0.34.0 (#876): set GBRAIN_HOME to a fresh tempdir so the test isn't + // sensitive to the developer's local ~/.gbrain/config.json. Pre-fix this + // test would silently inherit the user's database_url and try to connect + // to a real Postgres + run migrations, which both extends runtime past + // the default test timeout and produces different exit semantics + // depending on the local DB state. With GBRAIN_HOME pointing at an + // empty dir, the CLI hits "No brain configured" and exits 1 immediately + // — which is what this test was originally written to cover. + const tmpHome = await import('os').then(os => + import('fs').then(fs => fs.mkdtempSync(`${os.tmpdir()}/gbrain-test-`)), ); - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - const exit = await proc.exited; - return { stdout, stderr, exit }; + try { + const proc = Bun.spawn( + ['bun', 'run', 'src/cli.ts', 'book-mirror', ...args], + { + cwd: REPO_ROOT, + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, DATABASE_URL: '', GBRAIN_HOME: tmpHome }, + }, + ); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exit = await proc.exited; + return { stdout, stderr, exit }; + } finally { + const fs = await import('fs'); + fs.rmSync(tmpHome, { recursive: true, force: true }); + } } describe('gbrain book-mirror — CLI registration', () => { + // v0.34.0 (#876): migrations v55-v60 added oauth_clients.source_id + + // federated_read FK plumbing. The cold-spawned subprocess's initSchema + // chain now takes ~1s longer end-to-end; bump these tests' timeout to + // 30s so the test-harness budget covers Bun cold-start + PGLite init + + // migration apply + Postgres-retry exhaustion. The CLI itself still + // exits in 7-10s on a misconfigured DB. it('book-mirror is in CLI_ONLY (does not get "Unknown command")', async () => { const { stderr } = await runCli([]); // Without DB, the command will fail — but on the connect path, // not as "Unknown command". This proves dispatch reached // handleCliOnly's switch statement. expect(stderr).not.toContain('Unknown command'); - }); + }, 30000); it('without DB, never reaches queue submission', async () => { const { stderr, exit } = await runCli(['--slug', 'noop']); expect(exit).not.toBe(0); expect(stderr).not.toContain('submitted:'); - }); + }, 30000); }); describe('gbrain book-mirror — source file invariants', () => { diff --git a/test/e2e/source-isolation-pglite.test.ts b/test/e2e/source-isolation-pglite.test.ts index 4e0abf9ad..310497321 100644 --- a/test/e2e/source-isolation-pglite.test.ts +++ b/test/e2e/source-isolation-pglite.test.ts @@ -235,4 +235,33 @@ describe('v0.34.0 source-isolation regression (#861)', () => { expect(sources.has('default')).toBe(true); expect(sources.has('src-b')).toBe(true); }); + + test('#876 federated_read empty array means no federated reads', async () => { + // sourceScopeOpts treats allowedSources: [] (explicit empty) as "no + // federated scope" and falls back to scalar sourceId. An empty array + // MUST NOT widen scope to "all sources" — that's the silent-widen + // footgun. Verify the precedence ladder is correct. + const { operations } = await import('../../src/core/operations.ts'); + const searchOp = operations.find(o => o.name === 'search'); + const ctx = { + engine, + config: { engine: 'pglite' as const }, + logger: { info: () => {}, warn: () => {}, error: () => {} }, + dryRun: false, + remote: true, + sourceId: 'default', + auth: { + token: 'test', + clientId: 'test', + scopes: ['read'], + sourceId: 'default', + allowedSources: [], // explicit empty — must NOT widen scope + }, + }; + const result = await searchOp!.handler(ctx as any, { query: 'Important context' }); + const rows = result as Array<{ source_id?: string }>; + for (const r of rows) { + expect(r.source_id).toBe('default'); + } + }); }); diff --git a/test/openai-compat-multimodal.test.ts b/test/openai-compat-multimodal.test.ts index 1bc53e313..5c7c2ec6a 100644 --- a/test/openai-compat-multimodal.test.ts +++ b/test/openai-compat-multimodal.test.ts @@ -52,10 +52,10 @@ describe('embedMultimodal — openai-compat routing (#875)', () => { configureLitellm(); let capturedUrl = ''; let capturedBody: any = null; - let capturedAuth: string | null = null; + let capturedAuth = ''; fetchHandler = async (url, init) => { capturedUrl = url; - capturedAuth = (init.headers as Record).Authorization ?? null; + capturedAuth = (init.headers as Record).Authorization ?? ''; capturedBody = JSON.parse(init.body as string); return okResponse(1024, 1); };