Files
gbrain/test/local-federated-search-scope.test.ts
16782aee7f fix(sources): recover corrupted config shapes (#3420)
* fix(sources): recover corrupted config shapes (#3401)

Use one canonical normalizer for nested string and array-shaped source configs across federation reads, config writes, archive/restore, and doctor remediation.\n\nFixes #3401\nFixes #3402\nFixes #3403

Signed-off-by: arisgysel-design <arisgysel-design@users.noreply.github.com>

* fix(sources): bind restoreSource federated patch via ::text::jsonb (#2339 class)

restoreSource bound a JS JSON string to a bare $1::jsonb placeholder;
postgres.js double-encodes that into a jsonb string scalar, so on the
Postgres engine the coerced object || string-scalar concat evaluates as
array-concat and restore RE-CORRUPTS the exact config shape this PR
repairs. PGLite masks the bug (its driver parses the bind natively).
Fix: bind through $1::text::jsonb per the repo JSONB rule.

Adds the DATABASE_URL-gated Postgres regression
(test/e2e/restore-source-config-jsonb-postgres.test.ts): seeds a
corrupted string-scalar config, runs archive -> restore, asserts
jsonb_typeof(config) = 'object' with the federated flag applied and
pre-existing keys preserved. Verified red on the bare ::jsonb bind
(config became a jsonb array) and green on the fix against a real
pgvector Postgres; skips cleanly without DATABASE_URL.

Co-authored-by: arisgysel-design <arisgysel-design@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Signed-off-by: arisgysel-design <arisgysel-design@users.noreply.github.com>
Co-authored-by: arisgysel-design <arisgysel-design@users.noreply.github.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 14:16:05 -07:00

234 lines
11 KiB
TypeScript

/**
* #2561 — sources.config.federated participates in UNQUALIFIED local CLI
* search/query.
*
* Pre-fix: the local CLI always emitted a scalar `{sourceId}` scope (required
* field, auto-filled 'default'), so a source registered with
* `gbrain sources add --federated` was invisible to an unqualified
* `gbrain search "X"` — contradicting docs/guides/multi-source-brains.md
* ("Source participates in unqualified `gbrain search` results").
*
* Fix: the CLI context builder computes `ctx.localFederatedSourceIds`
* (resolved source + every other federated source) whenever the source
* resolved via a NON-explicit tier; `federatedSearchScope` widens the scalar
* scope to that set — never when a per-call `source_id`, a grant array, or an
* explicit --source/env/dotfile was given.
*
* #3242 extends the same visibility set to `get_page` / `list_pages` /
* `resolve_slugs` (pages ingested into a `federated: true` source were
* invisible to normal reads while the unscoped resolve_slugs leaked them),
* and to transports whose caller carries NO explicit source scope (stdio
* without GBRAIN_SOURCE; legacy HTTP tokens without a `permissions.source_id`
* grant) — those transports now populate `localFederatedSourceIds` themselves,
* so the widening gate is field-presence (transport-decided, never
* param-controlled), not `ctx.remote`.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { localFederatedSourceIds } from '../src/core/source-resolver.ts';
import {
federatedSearchScope,
operations,
type OperationContext,
} from '../src/core/operations.ts';
let engine: PGLiteEngine;
const search = operations.find((o) => o.name === 'search')!;
function ctxOf(overrides: Partial<OperationContext> = {}): OperationContext {
return {
engine: engine as any,
config: {} as any,
logger: console as any,
dryRun: false,
remote: false,
sourceId: 'default',
...overrides,
};
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Seeded 'default' source is federated=true. Add:
// wiki — federated (must join unqualified search)
// private — NOT federated (must stay invisible unless explicitly named)
// oldnews — federated but archived (must stay excluded)
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config) VALUES ('wiki', 'wiki', '/tmp/wiki', '{"federated": true}'::jsonb)`,
);
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config) VALUES ('private', 'private', '/tmp/private', '{}'::jsonb)`,
);
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, archived) VALUES ('oldnews', 'oldnews', '/tmp/oldnews', '{"federated": true}'::jsonb, true)`,
);
const pages: Array<[slug: string, sourceId: string, where: string]> = [
['notes/home', 'default', 'default'],
['wiki/topic', 'wiki', 'wiki'],
['private/topic', 'private', 'private'],
['old/topic', 'oldnews', 'oldnews'],
];
for (const [slug, sourceId, where] of pages) {
await engine.putPage(slug, {
type: 'note', title: `Topic in ${where}`, compiled_truth: `the zebra telescope in ${where}`, frontmatter: {},
}, { sourceId });
await engine.upsertChunks(slug, [
{ chunk_index: 0, chunk_text: `the zebra telescope in ${where}`, chunk_source: 'compiled_truth' },
], { sourceId });
}
// Keyword-only search path: no embedding provider needed in tests.
await engine.setConfig('search.mcp_keyword_only', 'true');
}, 60_000);
afterAll(async () => {
if (engine) await engine.disconnect();
}, 60_000);
describe('localFederatedSourceIds — CLI-side scope computation', () => {
test('non-explicit tier: resolved source first, then other federated, archived excluded', async () => {
expect(await localFederatedSourceIds(engine, 'default', 'seed_default')).toEqual(['default', 'wiki']);
});
test('non-federated resolved source still joins its own scope', async () => {
expect(await localFederatedSourceIds(engine, 'private', 'brain_default')).toEqual(['private', 'default', 'wiki']);
});
test('explicit tiers (--source / env / dotfile) never expand', async () => {
expect(await localFederatedSourceIds(engine, 'default', 'flag')).toBeUndefined();
expect(await localFederatedSourceIds(engine, 'default', 'env')).toBeUndefined();
expect(await localFederatedSourceIds(engine, 'default', 'dotfile')).toBeUndefined();
});
test('single federated source (the resolved one) keeps the scalar fast path', async () => {
const solo = { executeRaw: async () => [{ id: 'default', config: { federated: true } }] } as any;
expect(await localFederatedSourceIds(solo, 'default', 'seed_default')).toBeUndefined();
});
test('historical string and array config shapes remain federated', async () => {
const historical = {
executeRaw: async () => [
{ id: 'default', config: { federated: true }, archived: false },
{ id: 'nested', config: JSON.stringify(JSON.stringify({ federated: true })), archived: false },
{ id: 'array', config: ['{"remote_url":"x"}', { federated: true }], archived: false },
{ id: 'archived', config: ['{}', { federated: true }], archived: true },
],
} as any;
expect(await localFederatedSourceIds(historical, 'default', 'seed_default')).toEqual([
'default',
'nested',
'array',
]);
});
});
describe('federatedSearchScope — trust + explicitness matrix', () => {
test('trusted local + unqualified widens to the federated set', () => {
const ctx = ctxOf({ localFederatedSourceIds: ['default', 'wiki'] });
expect(federatedSearchScope(ctx)).toEqual({ sourceIds: ['default', 'wiki'] });
});
test('remote caller WITHOUT the field never widens (fail-closed)', () => {
const ctx = ctxOf({ remote: true });
expect(federatedSearchScope(ctx)).toEqual({ sourceId: 'default' });
});
test('#3242: remote caller widens when its transport populated the field (no-grant floor)', () => {
// The field is set only by server-side transports (stdio without
// GBRAIN_SOURCE / legacy HTTP token without a source grant) — never from
// caller params — so presence of the field IS the trust decision.
const ctx = ctxOf({ remote: true, localFederatedSourceIds: ['default', 'wiki'] });
expect(federatedSearchScope(ctx)).toEqual({ sourceIds: ['default', 'wiki'] });
});
test('per-call source_id wins over the federated set', () => {
const ctx = ctxOf({ localFederatedSourceIds: ['default', 'wiki'] });
expect(federatedSearchScope(ctx, 'wiki')).toEqual({ sourceId: 'wiki' });
});
test('per-call __all__ keeps the whole-brain semantics for trusted local', () => {
const ctx = ctxOf({ localFederatedSourceIds: ['default', 'wiki'] });
expect(federatedSearchScope(ctx, '__all__')).toEqual({});
});
test('a federated OAuth grant wins over the local set', () => {
const ctx = ctxOf({
localFederatedSourceIds: ['default', 'wiki'],
auth: { allowedSources: ['a', 'b'] } as OperationContext['auth'],
});
expect(federatedSearchScope(ctx)).toEqual({ sourceIds: ['a', 'b'] });
});
test('no local federated set → unchanged scalar scope', () => {
expect(federatedSearchScope(ctxOf())).toEqual({ sourceId: 'default' });
});
});
describe('search op — unqualified local search spans federated sources', () => {
test('federated source results appear; non-federated + archived stay invisible', async () => {
const ctx = ctxOf({
localFederatedSourceIds: await localFederatedSourceIds(engine, 'default', 'seed_default'),
});
const results = (await search.handler(ctx, { query: 'zebra telescope' })) as Array<{ slug: string }>;
const slugs = results.map((r) => r.slug);
expect(slugs).toContain('notes/home');
expect(slugs).toContain('wiki/topic'); // pre-#2561 this was missing
expect(slugs).not.toContain('private/topic');
expect(slugs).not.toContain('old/topic');
});
test('explicit source resolution (no federated set on ctx) stays single-source', async () => {
const results = (await search.handler(ctxOf(), { query: 'zebra telescope' })) as Array<{ slug: string }>;
const slugs = results.map((r) => r.slug);
expect(slugs).toEqual(['notes/home']);
});
});
// #3242 — pages in a federated source must be visible to the normal read ops,
// not just search/query; and resolve_slugs must be SCOPED (pre-fix it was the
// one read that leaked every source's slugs).
describe('#3242 — get_page / list_pages / resolve_slugs share the federated visibility set', () => {
const getPage = operations.find((o) => o.name === 'get_page')!;
const listPages = operations.find((o) => o.name === 'list_pages')!;
const resolveSlugsOp = operations.find((o) => o.name === 'resolve_slugs')!;
function federatedCtx(overrides: Partial<OperationContext> = {}): OperationContext {
return ctxOf({ localFederatedSourceIds: ['default', 'wiki'], ...overrides });
}
test('get_page: federated-source page readable on an unqualified ctx (pre-fix: page_not_found)', async () => {
const page = (await getPage.handler(federatedCtx(), { slug: 'wiki/topic' })) as { slug: string };
expect(page.slug).toBe('wiki/topic');
});
test('get_page: non-federated source stays invisible', async () => {
await expect(getPage.handler(federatedCtx(), { slug: 'private/topic' })).rejects.toThrow(/not found/i);
});
test('get_page: scalar ctx (explicit source, no field) keeps single-source behavior', async () => {
await expect(getPage.handler(ctxOf(), { slug: 'wiki/topic' })).rejects.toThrow(/not found/i);
});
test('list_pages: federated-source pages listed on an unqualified ctx (pre-fix: missing)', async () => {
const rows = (await listPages.handler(federatedCtx(), {})) as Array<{ slug: string }>;
const slugs = rows.map((r) => r.slug);
expect(slugs).toContain('notes/home');
expect(slugs).toContain('wiki/topic');
expect(slugs).not.toContain('private/topic');
expect(slugs).not.toContain('old/topic');
});
test('resolve_slugs: scoped to the visibility set (pre-fix: leaked every source)', async () => {
const federated = (await resolveSlugsOp.handler(federatedCtx(), { partial: 'topic' })) as string[];
expect(federated).toContain('wiki/topic');
expect(federated).not.toContain('private/topic');
// A remote scalar caller (no field, no grant) must no longer see foreign slugs.
const scalar = (await resolveSlugsOp.handler(ctxOf({ remote: true }), { partial: 'topic' })) as string[];
expect(scalar).not.toContain('wiki/topic');
expect(scalar).not.toContain('private/topic');
expect(scalar).not.toContain('old/topic');
});
});