mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
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:
co-authored by
Claude Opus 4.7
parent
21ac97b211
commit
fed57f4db0
+19
-7
@@ -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}"`);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user