mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix(ai): sync provider configuration and keys across linked devices (#1506)
This commit is contained in:
1 parent
4ed9b69aab
commit
55d7c8837b
9 files changed
+175
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `p_ai_providers` ADD COLUMN `sync_id` varchar(255) UNIQUE;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "p_ai_providers" ADD COLUMN "sync_id" varchar(255) UNIQUE;
|
||||||
@@ -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");
|
||||||
@@ -2,10 +2,12 @@ import type { Router } from "express";
|
|||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import { hostImportNormalizer } from "./host-import.js";
|
import { hostImportNormalizer } from "./host-import.js";
|
||||||
import { createAiRepository } from "./repository.js";
|
import { createAiRepository } from "./repository.js";
|
||||||
|
import { registerProviderSync } from "./sync.js";
|
||||||
import { registerAiRoutes } from "./routes.js";
|
import { registerAiRoutes } from "./routes.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
const repository = await createAiRepository(ctx);
|
const repository = await createAiRepository(ctx);
|
||||||
|
await registerProviderSync(ctx, repository);
|
||||||
|
|
||||||
registerAiRoutes(ctx.http.router<Router>(), repository, ctx);
|
registerAiRoutes(ctx.http.router<Router>(), repository, ctx);
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export const providers = adoptLegacyTable(
|
|||||||
"providers",
|
"providers",
|
||||||
{
|
{
|
||||||
id: id(),
|
id: id(),
|
||||||
|
syncId: varchar().unique(),
|
||||||
userId: refUser(),
|
userId: refUser(),
|
||||||
// ollama | anthropic | openai | gemini | openai_compatible
|
// ollama | anthropic | openai | gemini | openai_compatible
|
||||||
providerType: text().notNull(),
|
providerType: text().notNull(),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -881,6 +881,9 @@ CREATE TABLE IF NOT EXISTS `ai_proposals` (
|
|||||||
);
|
);
|
||||||
RENAME TABLE `ai_proposals` TO `p_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 ====
|
-- ==== plugin alerts 0001_create_alerts.sql ====
|
||||||
-- alerts 0001: create_alerts
|
-- alerts 0001: create_alerts
|
||||||
-- Generated by termix-plugin migrations. Review before committing.
|
-- Generated by termix-plugin migrations. Review before committing.
|
||||||
|
|||||||
@@ -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");
|
CREATE INDEX IF NOT EXISTS "idx_ai_proposals_conversation" ON "ai_proposals" ("conversation_id");
|
||||||
ALTER TABLE "ai_proposals" RENAME TO "p_ai_proposals";
|
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 ====
|
-- ==== plugin alerts 0001_create_alerts.sql ====
|
||||||
-- alerts 0001: create_alerts
|
-- alerts 0001: create_alerts
|
||||||
-- Generated by termix-plugin migrations. Review before committing.
|
-- Generated by termix-plugin migrations. Review before committing.
|
||||||
|
|||||||
Reference in new issue
Block a user