From 55d7c8837b790064617ead0b368998706fe817cc Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 30 Sep 2026 22:46:45 +0800 Subject: [PATCH] fix(ai): sync provider configuration and keys across linked devices (#1506) --- .../migrations/mysql/0002_provider_sync.sql | 1 + .../postgres/0002_provider_sync.sql | 1 + .../migrations/sqlite/0002_provider_sync.sql | 2 + plugins/ai/src/backend/index.ts | 2 + plugins/ai/src/backend/sync.ts | 50 ++++++++ plugins/ai/src/backend/tables.ts | 1 + plugins/ai/tests/backend/sync.test.ts | 112 ++++++++++++++++++ .../tests/fixtures/upgrade/upgrade-mysql.sql | 3 + .../fixtures/upgrade/upgrade-postgres.sql | 3 + 9 files changed, 175 insertions(+) create mode 100644 plugins/ai/migrations/mysql/0002_provider_sync.sql create mode 100644 plugins/ai/migrations/postgres/0002_provider_sync.sql create mode 100644 plugins/ai/migrations/sqlite/0002_provider_sync.sql create mode 100644 plugins/ai/src/backend/sync.ts create mode 100644 plugins/ai/tests/backend/sync.test.ts diff --git a/plugins/ai/migrations/mysql/0002_provider_sync.sql b/plugins/ai/migrations/mysql/0002_provider_sync.sql new file mode 100644 index 000000000..3652ae1c6 --- /dev/null +++ b/plugins/ai/migrations/mysql/0002_provider_sync.sql @@ -0,0 +1 @@ +ALTER TABLE `p_ai_providers` ADD COLUMN `sync_id` varchar(255) UNIQUE; diff --git a/plugins/ai/migrations/postgres/0002_provider_sync.sql b/plugins/ai/migrations/postgres/0002_provider_sync.sql new file mode 100644 index 000000000..f5c46713b --- /dev/null +++ b/plugins/ai/migrations/postgres/0002_provider_sync.sql @@ -0,0 +1 @@ +ALTER TABLE "p_ai_providers" ADD COLUMN "sync_id" varchar(255) UNIQUE; diff --git a/plugins/ai/migrations/sqlite/0002_provider_sync.sql b/plugins/ai/migrations/sqlite/0002_provider_sync.sql new file mode 100644 index 000000000..2bd7bfcc2 --- /dev/null +++ b/plugins/ai/migrations/sqlite/0002_provider_sync.sql @@ -0,0 +1,2 @@ +ALTER TABLE "p_ai_providers" ADD COLUMN "sync_id" text; +CREATE UNIQUE INDEX "p_ai_providers_sync_id_unique" ON "p_ai_providers" ("sync_id"); diff --git a/plugins/ai/src/backend/index.ts b/plugins/ai/src/backend/index.ts index 29eb343f9..dd8b43661 100644 --- a/plugins/ai/src/backend/index.ts +++ b/plugins/ai/src/backend/index.ts @@ -2,10 +2,12 @@ import type { Router } from "express"; import type { PluginContext } from "@termix/plugin-sdk/backend"; import { hostImportNormalizer } from "./host-import.js"; import { createAiRepository } from "./repository.js"; +import { registerProviderSync } from "./sync.js"; import { registerAiRoutes } from "./routes.js"; export async function activate(ctx: PluginContext) { const repository = await createAiRepository(ctx); + await registerProviderSync(ctx, repository); registerAiRoutes(ctx.http.router(), repository, ctx); diff --git a/plugins/ai/src/backend/sync.ts b/plugins/ai/src/backend/sync.ts new file mode 100644 index 000000000..17af496fc --- /dev/null +++ b/plugins/ai/src/backend/sync.ts @@ -0,0 +1,50 @@ +import type { PluginContext, SyncRow } from "@termix/plugin-sdk/backend"; +import { + apiKeyPrefix, + providerSecretKey, + type AiRepository, +} from "./repository.js"; +import { providers } from "./tables.js"; + +function keyFromWire(row: SyncRow): string | null { + if (typeof row.apiKey === "string") return row.apiKey || null; + if (row.apiKey === null) return null; + throw new Error("Invalid synced provider key"); +} + +export async function registerProviderSync( + ctx: PluginContext, + repo: AiRepository, +) { + ctx.sync.registerEntity({ + type: "aiProviders", + table: await ctx.db.define(providers), + permissions: { + create: "ai.manage_providers", + update: "ai.manage_providers", + delete: "ai.manage_providers", + }, + serialize: async (row) => { + const apiKey = await ctx.asUser(String(row.userId), () => + ctx.secrets.get(providerSecretKey(Number(row.id))), + ); + return { ...row, apiKey, apiKeyPrefix: apiKeyPrefix(apiKey) }; + }, + deserialize: async (row) => { + const apiKey = keyFromWire(row); + const { apiKey: _key, ...fields } = row; + return { ...fields, apiKeyPrefix: apiKeyPrefix(apiKey) }; + }, + afterWrite: async ({ id, userId, wire }) => { + if (id === null) throw new Error("Synced provider has no local id"); + await ctx.asUser(userId, () => + ctx.secrets.set(providerSecretKey(id), keyFromWire(wire)), + ); + }, + remove: async (row, userId) => { + await ctx.asUser(userId, () => + repo.deleteProvider(Number(row.id), userId), + ); + }, + }); +} diff --git a/plugins/ai/src/backend/tables.ts b/plugins/ai/src/backend/tables.ts index 4a51e6fae..bef838e5e 100644 --- a/plugins/ai/src/backend/tables.ts +++ b/plugins/ai/src/backend/tables.ts @@ -30,6 +30,7 @@ export const providers = adoptLegacyTable( "providers", { id: id(), + syncId: varchar().unique(), userId: refUser(), // ollama | anthropic | openai | gemini | openai_compatible providerType: text().notNull(), diff --git a/plugins/ai/tests/backend/sync.test.ts b/plugins/ai/tests/backend/sync.test.ts new file mode 100644 index 000000000..7a8b26165 --- /dev/null +++ b/plugins/ai/tests/backend/sync.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createAiRepository } from "../../src/backend/repository.js"; +import { startServer, type TestServer } from "./helpers"; + +const servers: TestServer[] = []; +afterEach(async () => { + for (const server of servers.splice(0)) await server.close(); +}); +async function setup() { + const server = await startServer(); + servers.push(server); + const repo = await createAiRepository(server.mock.ctx); + const entity = server.mock.syncEntities.find( + (entry) => entry.type === "aiProviders", + )!; + expect(entity).toBeDefined(); + return { ...server, repo, entity }; +} +const noReference = async () => null; + +describe("AI provider sync", () => { + it("maps the key to the destination id, rotates and clears it without storing plaintext in the row", async () => { + const source = await setup(); + const target = await setup(); + const sourceRow = await source.mock.ctx.asUser("user-1", () => + source.repo.createProvider("user-1", { + providerType: "openai", + label: "Work", + apiKey: "test-key-first", + enabled: false, + }), + ); + // The other device already has a different provider at the source's id. + await target.mock.ctx.asUser("user-1", () => + target.repo.createProvider("user-1", { + providerType: "openai", + label: "Local", + apiKey: "test-local-key", + }), + ); + const targetRow = await target.mock.ctx.asUser("user-1", () => + target.repo.createProvider("user-1", { + providerType: "openai", + label: "Work", + }), + ); + expect(sourceRow.id).not.toBe(targetRow.id); + for (const apiKey of ["test-key-first", "test-key-rotated", null]) { + await source.mock.ctx.asUser("user-1", () => + source.repo.updateProvider(sourceRow.id, "user-1", { apiKey }), + ); + source.mock.setActor("user-2"); + const row = await source.repo.findProvider(sourceRow.id, "user-1"); + const wire = await source.entity.serialize!({ ...row! }, noReference); + expect(wire.apiKey).toBe(apiKey); + const fields = await target.entity.deserialize!(wire, noReference); + expect(fields).not.toHaveProperty("apiKey"); + expect(fields.enabled).toBe(false); + await target.entity.afterWrite!({ + id: targetRow.id, + userId: "user-1", + wire, + created: false, + }); + expect( + target.mock.secretStore.get(`user-1:provider:${targetRow.id}`) ?? null, + ).toBe(apiKey); + expect( + target.mock.secretStore.get(`user-1:provider:${sourceRow.id}`), + ).toBe("test-local-key"); + expect( + target.mock.secretStore.get(`user-2:provider:${targetRow.id}`), + ).toBeUndefined(); + } + expect( + target.db.sqlite.prepare("PRAGMA table_info(p_ai_providers)").all(), + ).not.toContainEqual(expect.objectContaining({ name: "api_key" })); + expect( + target.db.sqlite.prepare("PRAGMA table_info(p_ai_providers)").all(), + ).toContainEqual(expect.objectContaining({ name: "sync_id" })); + }); + + it("cleans up the destination secret on deletion and declares provider management permissions", async () => { + const server = await setup(); + const row = await server.mock.ctx.asUser("user-1", () => + server.repo.createProvider("user-1", { + providerType: "openai", + label: "Work", + apiKey: "test-delete-key", + }), + ); + expect(server.entity.permissions).toEqual({ + create: "ai.manage_providers", + update: "ai.manage_providers", + delete: "ai.manage_providers", + }); + await server.entity.remove!({ ...row }, "user-2"); + expect(await server.repo.findProvider(row.id, "user-1")).not.toBeNull(); + await server.entity.remove!({ ...row }, "user-1"); + expect(await server.repo.findProvider(row.id, "user-1")).toBeNull(); + expect( + server.mock.secretStore.get(`user-1:provider:${row.id}`), + ).toBeUndefined(); + }); + + it("rejects invalid secret payloads before the row is written", async () => { + const server = await setup(); + await expect( + server.entity.deserialize!({ apiKey: {} }, noReference), + ).rejects.toThrow("Invalid synced provider key"); + }); +}); diff --git a/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql b/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql index 82be47a59..0f55f2083 100644 --- a/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql +++ b/src/backend/tests/fixtures/upgrade/upgrade-mysql.sql @@ -881,6 +881,9 @@ CREATE TABLE IF NOT EXISTS `ai_proposals` ( ); RENAME TABLE `ai_proposals` TO `p_ai_proposals`; +-- ==== plugin ai 0002_provider_sync.sql ==== +ALTER TABLE `p_ai_providers` ADD COLUMN `sync_id` varchar(255) UNIQUE; + -- ==== plugin alerts 0001_create_alerts.sql ==== -- alerts 0001: create_alerts -- Generated by termix-plugin migrations. Review before committing. diff --git a/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql b/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql index 5161bd5b2..36c05e208 100644 --- a/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql +++ b/src/backend/tests/fixtures/upgrade/upgrade-postgres.sql @@ -929,6 +929,9 @@ CREATE INDEX IF NOT EXISTS "idx_ai_proposals_user" ON "ai_proposals" ("user_id", CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id"); ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals"; +-- ==== plugin ai 0002_provider_sync.sql ==== +ALTER TABLE "p_ai_providers" ADD COLUMN "sync_id" varchar(255) UNIQUE; + -- ==== plugin alerts 0001_create_alerts.sql ==== -- alerts 0001: create_alerts -- Generated by termix-plugin migrations. Review before committing.