diff --git a/.changeset/global-provider-connections.md b/.changeset/global-provider-connections.md new file mode 100644 index 0000000000..d707eac6c1 --- /dev/null +++ b/.changeset/global-provider-connections.md @@ -0,0 +1,5 @@ +--- +'manifest': minor +--- + +Add global (user-level) provider connections: connect each provider or subscription once and every one of your agents can use it. Existing agent-scoped connections are migrated up to the global pool (relabeled per source agent, never duplicated). New Subscriptions / BYOK / Local pages in the sidebar manage connections. diff --git a/packages/backend/src/analytics/controllers/overview.controller.spec.ts b/packages/backend/src/analytics/controllers/overview.controller.spec.ts index 0c5e5e24ec..f44f2c7b9e 100644 --- a/packages/backend/src/analytics/controllers/overview.controller.spec.ts +++ b/packages/backend/src/analytics/controllers/overview.controller.spec.ts @@ -150,7 +150,9 @@ describe('OverviewController', () => { expect(result.has_providers).toBe(true); expect(mockResolveAgent).toHaveBeenCalledWith('u1', 'bot-1'); - expect(mockGetProviders).toHaveBeenCalledWith('agent-uuid-1'); + // Provider reads are user-scoped — passing the agentId here would query + // user_providers by an agentId and silently return no providers. + expect(mockGetProviders).toHaveBeenCalledWith('u1'); }); it('returns has_providers false when no providers exist', async () => { diff --git a/packages/backend/src/analytics/controllers/overview.controller.ts b/packages/backend/src/analytics/controllers/overview.controller.ts index 010aabe5d2..d8de8648f4 100644 --- a/packages/backend/src/analytics/controllers/overview.controller.ts +++ b/packages/backend/src/analytics/controllers/overview.controller.ts @@ -63,8 +63,10 @@ export class OverviewController { private async hasActiveProviders(userId: string, agentName?: string): Promise { if (!agentName) return false; try { - const agent = await this.resolveAgent.resolve(userId, agentName); - const providers = await this.providerService.getProviders(agent.id); + // Resolve still runs so an unknown/foreign agentName surfaces as "no + // providers" (throws → caught below), but provider reads are user-scoped. + await this.resolveAgent.resolve(userId, agentName); + const providers = await this.providerService.getProviders(userId); return providers.some((p) => p.is_active); } catch { return false; diff --git a/packages/backend/src/database/database.module.ts b/packages/backend/src/database/database.module.ts index 5b2e472644..808789dede 100644 --- a/packages/backend/src/database/database.module.ts +++ b/packages/backend/src/database/database.module.ts @@ -112,6 +112,7 @@ import { AddReasoningContentCache1790100000000 } from './migrations/179010000000 import { AddDedupCompositeIndex1790200000000 } from './migrations/1790200000000-AddDedupCompositeIndex'; import { AddErrorsPartialIndex1790300000000 } from './migrations/1790300000000-AddErrorsPartialIndex'; import { DropRedundantAgentApiKeyPrefixIndex1790400000000 } from './migrations/1790400000000-DropRedundantAgentApiKeyPrefixIndex'; +import { LiftAgentProvidersToGlobal1791000000000 } from './migrations/1791000000000-LiftAgentProvidersToGlobal'; const entities = [ AgentMessage, @@ -225,6 +226,7 @@ const migrations = [ AddDedupCompositeIndex1790200000000, AddErrorsPartialIndex1790300000000, DropRedundantAgentApiKeyPrefixIndex1790400000000, + LiftAgentProvidersToGlobal1791000000000, ]; @Module({ diff --git a/packages/backend/src/database/migrations/1791000000000-LiftAgentProvidersToGlobal.spec.ts b/packages/backend/src/database/migrations/1791000000000-LiftAgentProvidersToGlobal.spec.ts new file mode 100644 index 0000000000..ef40e4ac9c --- /dev/null +++ b/packages/backend/src/database/migrations/1791000000000-LiftAgentProvidersToGlobal.spec.ts @@ -0,0 +1,225 @@ +import { Client } from 'pg'; +import { DataSource, QueryRunner } from 'typeorm'; +import { LiftAgentProvidersToGlobal1791000000000 } from './1791000000000-LiftAgentProvidersToGlobal'; + +/** + * This spec executes the real migration against a live PostgreSQL database so + * that both up() and down() are exercised end-to-end. It creates a throwaway + * database, builds the minimal schema the migration touches (user_providers + + * agents), seeds collision scenarios, and asserts on the resulting rows and + * indexes via pg_indexes. + */ + +const ADMIN_URL = + process.env['MIGRATION_DATABASE_URL'] ?? + process.env['DATABASE_URL'] ?? + 'postgresql://myuser:mypassword@localhost:5432/postgres'; + +function baseUrlFor(dbName: string): string { + const url = new URL(ADMIN_URL); + url.pathname = `/${dbName}`; + return url.toString(); +} + +const OLD_INDEX = 'IDX_user_providers_agent_provider_auth_label'; +const NEW_INDEX = 'IDX_user_providers_user_provider_auth_label'; + +describe('LiftAgentProvidersToGlobal1791000000000 (live DB)', () => { + const migration = new LiftAgentProvidersToGlobal1791000000000(); + const dbName = `manifest_mig_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; + let dataSource: DataSource; + let runner: QueryRunner; + + async function indexExists(name: string): Promise { + const rows: Array<{ indexname: string }> = await runner.query( + `SELECT indexname FROM pg_indexes WHERE tablename = 'user_providers' AND indexname = $1`, + [name], + ); + return rows.length === 1; + } + + async function seedSchema(): Promise { + await runner.query(` + CREATE TABLE "agents" ( + "id" varchar PRIMARY KEY, + "name" varchar, + "display_name" varchar + ) + `); + await runner.query(` + CREATE TABLE "user_providers" ( + "id" varchar PRIMARY KEY, + "user_id" varchar NOT NULL, + "agent_id" varchar NOT NULL, + "provider" varchar NOT NULL, + "auth_type" varchar NOT NULL DEFAULT 'api_key', + "label" varchar NOT NULL DEFAULT 'Default', + "priority" integer NOT NULL DEFAULT 0, + "connected_at" timestamp NOT NULL DEFAULT now() + ) + `); + // The old agent-scoped unique index that up() must drop. + await runner.query(` + CREATE UNIQUE INDEX "${OLD_INDEX}" + ON "user_providers" ("agent_id", "provider", "auth_type", LOWER("label")) + `); + } + + beforeAll(async () => { + const admin = new Client({ connectionString: ADMIN_URL }); + await admin.connect(); + await admin.query(`CREATE DATABASE "${dbName}"`); + await admin.end(); + + dataSource = new DataSource({ type: 'postgres', url: baseUrlFor(dbName) }); + await dataSource.initialize(); + runner = dataSource.createQueryRunner(); + await seedSchema(); + }); + + afterAll(async () => { + if (runner) await runner.release(); + if (dataSource?.isInitialized) await dataSource.destroy(); + const admin = new Client({ connectionString: ADMIN_URL }); + await admin.connect(); + // Terminate stragglers then drop the throwaway DB. + await admin.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, + [dbName], + ); + await admin.query(`DROP DATABASE IF EXISTS "${dbName}"`); + await admin.end(); + }); + + beforeEach(async () => { + await runner.query(`DELETE FROM "user_providers"`); + await runner.query(`DELETE FROM "agents"`); + // Reset the schema to the pre-up() baseline (old agent-scoped index present, + // new user-scoped index absent) so each test starts from the same state. + await runner.query(`DROP INDEX IF EXISTS "${NEW_INDEX}"`); + await runner.query(`DROP INDEX IF EXISTS "${OLD_INDEX}"`); + await runner.query(` + CREATE UNIQUE INDEX "${OLD_INDEX}" + ON "user_providers" ("agent_id", "provider", "auth_type", LOWER("label")) + `); + }); + + it('relabels colliding Default rows to agent display names, keeps every row, and swaps the index', async () => { + await runner.query( + `INSERT INTO "agents" ("id", "name", "display_name") VALUES ($1, $2, $3), ($4, $5, $6)`, + ['agent-1', 'agent-one', 'Sales Bot', 'agent-2', 'agent-two', 'Support Bot'], + ); + await runner.query( + `INSERT INTO "user_providers" ("id", "user_id", "agent_id", "provider", "auth_type", "label") + VALUES ($1, $2, $3, $4, $5, $6), ($7, $8, $9, $10, $11, $12)`, + [ + 'up-1', + 'user-1', + 'agent-1', + 'openai', + 'api_key', + 'Default', + 'up-2', + 'user-1', + 'agent-2', + 'openai', + 'api_key', + 'Default', + ], + ); + + const before: Array<{ c: string }> = await runner.query( + `SELECT COUNT(*)::text AS c FROM "user_providers"`, + ); + + await migration.up(runner); + + const after: Array<{ c: string }> = await runner.query( + `SELECT COUNT(*)::text AS c FROM "user_providers"`, + ); + // No rows deleted. + expect(after[0].c).toBe(before[0].c); + expect(after[0].c).toBe('2'); + + const rows: Array<{ id: string; label: string; agent_id: string | null }> = await runner.query( + `SELECT "id", "label", "agent_id" FROM "user_providers" ORDER BY "id"`, + ); + const labels = rows.map((r) => r.label).sort(); + expect(labels).toEqual(['Sales Bot', 'Support Bot']); + // agent_id is never nulled by this migration. + expect(rows.every((r) => r.agent_id !== null)).toBe(true); + + expect(await indexExists(NEW_INDEX)).toBe(true); + expect(await indexExists(OLD_INDEX)).toBe(false); + }); + + it('relabels a colliding custom label as "