mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
fix: scope residual federated reads (#3550)
Co-Authored-By: dialthewolff <jordan@jcwolff.com>
This commit is contained in:
committed by
Sina Matian
co-authored by
dialthewolff
parent
7522b90db9
commit
273bd0e2be
+3
-3
@@ -995,7 +995,7 @@ export interface BrainEngine {
|
||||
* same-slug source (importCodeFile uses this for incremental embedding
|
||||
* reuse, which would then attach the wrong source's embeddings).
|
||||
*/
|
||||
getChunks(slug: string, opts?: { sourceId?: string }): Promise<Chunk[]>;
|
||||
getChunks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Chunk[]>;
|
||||
/**
|
||||
* Count chunks across the brain where embedding IS NULL.
|
||||
* Pre-flight short-circuit for `embed --stale` so a 100%-embedded brain
|
||||
@@ -1499,7 +1499,7 @@ export interface BrainEngine {
|
||||
* it, multi-source brains return raw_data rows from every same-slug page
|
||||
* (preserved via two-branch query for back-compat).
|
||||
*/
|
||||
getRawData(slug: string, source?: string, opts?: { sourceId?: string }): Promise<RawData[]>;
|
||||
getRawData(slug: string, source?: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<RawData[]>;
|
||||
|
||||
// Files (v0.27.1: binary asset metadata + storage_path. Image bytes never
|
||||
// enter the DB; storage_path references a path inside the brain repo or an
|
||||
@@ -1925,7 +1925,7 @@ export interface BrainEngine {
|
||||
* When omitted, returns versions for every same-slug page across sources
|
||||
* (pre-v0.31.8 behavior; preserved via two-branch query).
|
||||
*/
|
||||
getVersions(slug: string, opts?: { sourceId?: string }): Promise<PageVersion[]>;
|
||||
getVersions(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<PageVersion[]>;
|
||||
/**
|
||||
* v0.31.8 (D12): `opts.sourceId` source-scopes both the version lookup
|
||||
* and the page revert. Without it, multi-source brains can revert the
|
||||
|
||||
@@ -2708,9 +2708,7 @@ const get_versions: Operation = {
|
||||
slug: { type: 'string', required: true },
|
||||
},
|
||||
handler: async (ctx, p) => {
|
||||
// v0.31.8 (D20): thread ctx.sourceId.
|
||||
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
|
||||
const versions = await ctx.engine.getVersions(p.slug as string, sourceOpts);
|
||||
const versions = await ctx.engine.getVersions(p.slug as string, sourceScopeOpts(ctx));
|
||||
// Same takes-allow-list privacy boundary as get_page. Snapshots persist
|
||||
// historical compiled_truth verbatim, including the takes fence, so
|
||||
// a remote token bypassing get_page via /history would re-introduce
|
||||
@@ -2807,9 +2805,7 @@ const get_raw_data: Operation = {
|
||||
source: { type: 'string', description: 'Filter by source' },
|
||||
},
|
||||
handler: async (ctx, p) => {
|
||||
// v0.31.8 (D20 + D21): thread ctx.sourceId.
|
||||
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
|
||||
return ctx.engine.getRawData(p.slug as string, p.source as string | undefined, sourceOpts);
|
||||
return ctx.engine.getRawData(p.slug as string, p.source as string | undefined, sourceScopeOpts(ctx));
|
||||
},
|
||||
scope: 'read',
|
||||
};
|
||||
@@ -2839,9 +2835,7 @@ const get_chunks: Operation = {
|
||||
slug: { type: 'string', required: true },
|
||||
},
|
||||
handler: async (ctx, p) => {
|
||||
// v0.31.8 (D20): thread ctx.sourceId.
|
||||
const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {};
|
||||
return ctx.engine.getChunks(p.slug as string, sourceOpts);
|
||||
return ctx.engine.getChunks(p.slug as string, sourceScopeOpts(ctx));
|
||||
},
|
||||
scope: 'read',
|
||||
};
|
||||
|
||||
@@ -2472,14 +2472,15 @@ export class PGLiteEngine implements BrainEngine {
|
||||
);
|
||||
}
|
||||
|
||||
async getChunks(slug: string, opts?: { sourceId?: string }): Promise<Chunk[]> {
|
||||
const sourceId = opts?.sourceId ?? 'default';
|
||||
async getChunks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Chunk[]> {
|
||||
const sourceIds = opts?.sourceIds && opts.sourceIds.length > 0 ? opts.sourceIds : undefined;
|
||||
const source = sourceIds ?? opts?.sourceId ?? 'default';
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT cc.* FROM content_chunks cc
|
||||
JOIN pages p ON p.id = cc.page_id
|
||||
WHERE p.slug = $1 AND p.source_id = $2
|
||||
WHERE p.slug = $1 AND ${sourceIds ? 'p.source_id = ANY($2::text[])' : 'p.source_id = $2'}
|
||||
ORDER BY cc.chunk_index`,
|
||||
[slug, sourceId]
|
||||
[slug, source]
|
||||
);
|
||||
return (rows as Record<string, unknown>[]).map(r => rowToChunk(r));
|
||||
}
|
||||
@@ -4047,7 +4048,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
async getRawData(
|
||||
slug: string,
|
||||
source?: string,
|
||||
opts?: { sourceId?: string },
|
||||
opts?: { sourceId?: string; sourceIds?: string[] },
|
||||
): Promise<RawData[]> {
|
||||
// v0.31.8 (D21): build WHERE clause dynamically. Without opts.sourceId,
|
||||
// no source filter (preserves pre-v0.31.8 cross-source read).
|
||||
@@ -4057,7 +4058,10 @@ export class PGLiteEngine implements BrainEngine {
|
||||
params.push(source);
|
||||
where.push(`rd.source = $${params.length}`);
|
||||
}
|
||||
if (opts?.sourceId) {
|
||||
if (opts?.sourceIds && opts.sourceIds.length > 0) {
|
||||
params.push(opts.sourceIds);
|
||||
where.push(`p.source_id = ANY($${params.length}::text[])`);
|
||||
} else if (opts?.sourceId) {
|
||||
params.push(opts.sourceId);
|
||||
where.push(`p.source_id = $${params.length}`);
|
||||
}
|
||||
@@ -5288,9 +5292,17 @@ export class PGLiteEngine implements BrainEngine {
|
||||
return rows[0] as unknown as PageVersion;
|
||||
}
|
||||
|
||||
async getVersions(slug: string, opts?: { sourceId?: string }): Promise<PageVersion[]> {
|
||||
// v0.31.8 (D16): two-branch. Without opts.sourceId, joins return versions
|
||||
// for every same-slug page (preserves pre-v0.31.8 cross-source view).
|
||||
async getVersions(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<PageVersion[]> {
|
||||
if (opts?.sourceIds && opts.sourceIds.length > 0) {
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT pv.* FROM page_versions pv
|
||||
JOIN pages p ON p.id = pv.page_id
|
||||
WHERE p.slug = $1 AND p.source_id = ANY($2::text[])
|
||||
ORDER BY pv.snapshot_at DESC`,
|
||||
[slug, opts.sourceIds]
|
||||
);
|
||||
return rows as unknown as PageVersion[];
|
||||
}
|
||||
if (opts?.sourceId) {
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT pv.* FROM page_versions pv
|
||||
|
||||
+30
-12
@@ -2605,14 +2605,18 @@ export class PostgresEngine implements BrainEngine {
|
||||
);
|
||||
}
|
||||
|
||||
async getChunks(slug: string, opts?: { sourceId?: string }): Promise<Chunk[]> {
|
||||
const sourceId = opts?.sourceId ?? 'default';
|
||||
async getChunks(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<Chunk[]> {
|
||||
const sourceIds = opts?.sourceIds && opts.sourceIds.length > 0 ? opts.sourceIds : undefined;
|
||||
const scalarSourceId = opts?.sourceId ?? 'default';
|
||||
// RLS scope binding (opt-in via GBRAIN_RLS_SCOPE_BINDING).
|
||||
return await this.withScopedReadTransaction(undefined, sourceId, async (tx) => {
|
||||
return await this.withScopedReadTransaction(sourceIds, sourceIds ? undefined : scalarSourceId, async (tx) => {
|
||||
const scope = sourceIds
|
||||
? tx`p.source_id = ANY(${sourceIds}::text[])`
|
||||
: tx`p.source_id = ${scalarSourceId}`;
|
||||
const rows = await tx`
|
||||
SELECT cc.* FROM content_chunks cc
|
||||
JOIN pages p ON p.id = cc.page_id
|
||||
WHERE p.slug = ${slug} AND p.source_id = ${sourceId}
|
||||
WHERE p.slug = ${slug} AND ${scope}
|
||||
ORDER BY cc.chunk_index
|
||||
`;
|
||||
return rows.map((r: Record<string, unknown>) => rowToChunk(r));
|
||||
@@ -4188,15 +4192,21 @@ export class PostgresEngine implements BrainEngine {
|
||||
async getRawData(
|
||||
slug: string,
|
||||
source?: string,
|
||||
opts?: { sourceId?: string },
|
||||
opts?: { sourceId?: string; sourceIds?: string[] },
|
||||
): Promise<RawData[]> {
|
||||
const sql = this.sql;
|
||||
// v0.31.8 (D21): four-branch shape on (source provided, sourceId provided).
|
||||
// Postgres.js template-literal style doesn't compose fragments cleanly so
|
||||
// we enumerate.
|
||||
const sourceId = opts?.sourceId;
|
||||
const sourceIds = opts?.sourceIds && opts.sourceIds.length > 0 ? opts.sourceIds : undefined;
|
||||
const sourceId = sourceIds ? undefined : opts?.sourceId;
|
||||
let rows;
|
||||
if (source && sourceId) {
|
||||
if (source && sourceIds) {
|
||||
rows = await sql`SELECT rd.source, rd.data, rd.fetched_at FROM raw_data rd
|
||||
JOIN pages p ON p.id = rd.page_id
|
||||
WHERE p.slug = ${slug} AND rd.source = ${source} AND p.source_id = ANY(${sourceIds}::text[])`;
|
||||
} else if (sourceIds) {
|
||||
rows = await sql`SELECT rd.source, rd.data, rd.fetched_at FROM raw_data rd
|
||||
JOIN pages p ON p.id = rd.page_id
|
||||
WHERE p.slug = ${slug} AND p.source_id = ANY(${sourceIds}::text[])`;
|
||||
} else if (source && sourceId) {
|
||||
rows = await sql`SELECT rd.source, rd.data, rd.fetched_at FROM raw_data rd
|
||||
JOIN pages p ON p.id = rd.page_id
|
||||
WHERE p.slug = ${slug} AND rd.source = ${source} AND p.source_id = ${sourceId}`;
|
||||
@@ -5384,9 +5394,17 @@ export class PostgresEngine implements BrainEngine {
|
||||
return rows[0] as unknown as PageVersion;
|
||||
}
|
||||
|
||||
async getVersions(slug: string, opts?: { sourceId?: string }): Promise<PageVersion[]> {
|
||||
async getVersions(slug: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<PageVersion[]> {
|
||||
const sql = this.sql;
|
||||
// v0.31.8 (D16): two-branch.
|
||||
if (opts?.sourceIds && opts.sourceIds.length > 0) {
|
||||
const rows = await sql`
|
||||
SELECT pv.* FROM page_versions pv
|
||||
JOIN pages p ON p.id = pv.page_id
|
||||
WHERE p.slug = ${slug} AND p.source_id = ANY(${opts.sourceIds}::text[])
|
||||
ORDER BY pv.snapshot_at DESC
|
||||
`;
|
||||
return rows as unknown as PageVersion[];
|
||||
}
|
||||
if (opts?.sourceId) {
|
||||
const rows = await sql`
|
||||
SELECT pv.* FROM page_versions pv
|
||||
|
||||
@@ -32,6 +32,9 @@ const get_tags = operations.find(o => o.name === 'get_tags')!;
|
||||
const get_links = operations.find(o => o.name === 'get_links')!;
|
||||
const get_backlinks = operations.find(o => o.name === 'get_backlinks')!;
|
||||
const get_timeline = operations.find(o => o.name === 'get_timeline')!;
|
||||
const get_chunks = operations.find(o => o.name === 'get_chunks')!;
|
||||
const get_raw_data = operations.find(o => o.name === 'get_raw_data')!;
|
||||
const get_versions = operations.find(o => o.name === 'get_versions')!;
|
||||
|
||||
function ctxOf(overrides: Partial<OperationContext> = {}): OperationContext {
|
||||
return {
|
||||
@@ -77,6 +80,16 @@ beforeEach(async () => {
|
||||
type: 'note', title: 'Default decoy', compiled_truth: 'default content', frontmatter: {},
|
||||
}, { sourceId: 'default' });
|
||||
await engine.addTag('secret/beta-doc', 'default-secret-tag', { sourceId: 'default' });
|
||||
await engine.upsertChunks('secret/beta-doc', [{
|
||||
chunk_index: 0, chunk_text: 'beta chunk', chunk_source: 'compiled_truth', token_count: 2,
|
||||
}], { sourceId: 'beta' });
|
||||
await engine.upsertChunks('secret/beta-doc', [{
|
||||
chunk_index: 0, chunk_text: 'default chunk', chunk_source: 'compiled_truth', token_count: 2,
|
||||
}], { sourceId: 'default' });
|
||||
await engine.putRawData('secret/beta-doc', 'crm', { owner: 'beta' }, { sourceId: 'beta' });
|
||||
await engine.putRawData('secret/beta-doc', 'crm', { owner: 'default' }, { sourceId: 'default' });
|
||||
await engine.createVersion('secret/beta-doc', { sourceId: 'beta' });
|
||||
await engine.createVersion('secret/beta-doc', { sourceId: 'default' });
|
||||
// Link endpoints. NOTE (Codex #7): addLink defaults BOTH endpoints to 'default'
|
||||
// unless given {fromSourceId,toSourceId} — pass them or the beta edges won't seed.
|
||||
await engine.putPage('secret/beta-target', {
|
||||
@@ -277,6 +290,26 @@ describe('#2200 get_timeline honors the federated grant', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2200 residual by-slug reads honor the federated grant', () => {
|
||||
test('get_chunks returns only in-grant chunks', async () => {
|
||||
const hit = await get_chunks.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' }) as any[];
|
||||
expect(hit.map(c => c.chunk_text)).toEqual(['beta chunk']);
|
||||
expect(await get_chunks.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' })).toEqual([]);
|
||||
});
|
||||
|
||||
test('get_raw_data returns only in-grant rows', async () => {
|
||||
const hit = await get_raw_data.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc', source: 'crm' }) as any[];
|
||||
expect(hit.map(r => r.data.owner)).toEqual(['beta']);
|
||||
expect(await get_raw_data.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc', source: 'crm' })).toEqual([]);
|
||||
});
|
||||
|
||||
test('get_versions returns only in-grant snapshots', async () => {
|
||||
const hit = await get_versions.handler(remoteCtx(['alpha', 'beta']), { slug: 'secret/beta-doc' }) as any[];
|
||||
expect(hit.map(v => v.compiled_truth)).toEqual(['beta-only content']);
|
||||
expect(await get_versions.handler(remoteCtx(['alpha']), { slug: 'secret/beta-doc' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2200 engine secondary-fetch methods honor sourceIds[]', () => {
|
||||
test('getTags: sourceIds[] matching → returns; excluding → empty; union on collision', async () => {
|
||||
expect((await engine.getTags('secret/beta-doc', { sourceIds: ['alpha', 'beta'] })).sort())
|
||||
|
||||
Reference in New Issue
Block a user