feat(oauth): federated_read read scope (#876)

Pre-fix, OAuth clients had a single source-scope axis (source_id, added
in v55). A client could either write+read one source OR be a super-reader
across all sources (via NULL source_id). There was no middle ground —
WeCare-style L3 dept clients that need to write to dept-x but read
dept-x + parent canon + shared canon had no expression.

#876 adds federated_read TEXT[] as an orthogonal read-scope axis. source_id
is the WRITE authority; federated_read is the READ authority. They default
to matching values (read scope == write scope, the pre-v0.34 default)
when a client is registered without an explicit federated read list.

Migrations v56-v60 (six new migrations on top of v55):

  - v56: ALTER TABLE ... ADD COLUMN federated_read TEXT[] NOT NULL DEFAULT '{}'.
  - v57 (F5): explicit CASE backfill so source_id IS NULL → '{}' (not an
    array containing NULL — codex caught this ambiguity during plan review).
  - v58: post-backfill validation. Fails loud if any row's source_id isn't
    in its federated_read array, pointing at a logic bug in v57 if fired.
  - v59: flip the source_id FK from ON DELETE SET NULL to ON DELETE
    RESTRICT now that federated_read provides the alternative scope-loss
    path. Pre-flip, deleting a source could silently widen any oauth_client
    to super-reader; post-flip, source delete is refused if any client
    references it (operator must revoke/re-scope first).
  - v60: GIN index on federated_read for array-containment queries.

Auth wiring:

  - GBrainOAuthProvider.verifyAccessToken JOINs c.federated_read and
    populates AuthInfo.allowedSources. Pre-v56 / pre-v55 brains degrade
    via the existing isUndefinedColumnError fallback chain.
  - registerClientManual gains a federatedRead?: string[] parameter
    (defaults to [sourceId]).
  - DCR registerClient sets source_id='default' + federated_read=['default']
    on the inserted row.
  - auth.ts CLI adds --federated-read SRC1,SRC2,... flag. The
    register-client output now prints "Federated reads:" so operators
    confirm the scope they set.

Engines consume the federated array through the SearchOpts.sourceIds /
PageFilters.sourceIds field that #861 added (no engine changes here — the
plumbing was D9). sourceScopeOpts in operations.ts already prefers the
auth.allowedSources array over scalar ctx.sourceId when set.

Test seam:
  - test/book-mirror.test.ts now spawns the CLI with GBRAIN_HOME pointed
    at a tempdir so the test isn't sensitive to the developer's local
    ~/.gbrain/config.json. Pre-fix the test could silently inherit a real
    Postgres connection and hang past the default 5s test timeout. Fresh
    GBRAIN_HOME → "No brain configured" → exit 1 in <1s.
  - test/e2e/source-isolation-pglite.test.ts gains one more regression
    case: AuthInfo.allowedSources = [] (explicit empty) MUST NOT widen
    scope to "all sources" — the silent-widen footgun precedence ladder.
  - test/openai-compat-multimodal.test.ts is part of the wave's commits
    via the migrate.ts changes that bump the schema chain. typecheck-only
    fix on a captured-auth type was already in #875's tree.

6045 unit tests pass / 0 fail. typecheck clean. PGLite initSchema runs
v55-v60 in ~786ms total (within the test-harness budget for tests using
the canonical beforeAll engine pattern).

Origin: PR #876 + plan-eng-review F5 (CASE backfill).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-14 10:12:39 -07:00
co-authored by Claude Opus 4.7
parent 21ac97b211
commit fed57f4db0
10 changed files with 324 additions and 68 deletions
+19 -7
View File
@@ -329,10 +329,14 @@ async function revokeClient(clientId: string) {
}
async function registerClient(name: string, args: string[]) {
if (!name) { console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S] [--source SOURCE]'); process.exit(1); }
if (!name) {
console.error('Usage: auth register-client <name> [--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}"`);
});
+1 -1
View File
@@ -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) {
+101
View File
@@ -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
+113 -28
View File
@@ -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 <id>`. 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<string, unknown>[];
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)
+9 -6
View File
@@ -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,
+7 -5
View File
@@ -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,
+7 -5
View File
@@ -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,
+36 -14
View File
@@ -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', () => {
+29
View File
@@ -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');
}
});
});
+2 -2
View File
@@ -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<string, string>).Authorization ?? null;
capturedAuth = (init.headers as Record<string, string>).Authorization ?? '';
capturedBody = JSON.parse(init.body as string);
return okResponse(1024, 1);
};