mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-20 04:20:38 +09:00
Compare commits
No commits in common. "384903bdf4c4a3ecce409ba14d392d3ff74048d3" and "62863126a3f9f8af2275cb5753edf8e3d1332238" have entirely different histories.
384903bdf4
...
62863126a3
24 changed files with 137 additions and 20418 deletions
|
|
@ -1,11 +0,0 @@
|
||||||
ALTER TABLE "plugin_config" ADD COLUMN IF NOT EXISTS "company_id" uuid;--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plugin_config_company_id_companies_id_fk') THEN
|
|
||||||
ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
END IF;
|
|
||||||
END $$;--> statement-breakpoint
|
|
||||||
DROP INDEX IF EXISTS "plugin_config_plugin_id_idx";--> statement-breakpoint
|
|
||||||
CREATE INDEX IF NOT EXISTS "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX IF NOT EXISTS "plugin_config_company_id_idx" ON "plugin_config" USING btree ("company_id");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_config_legacy_plugin_id_uq" ON "plugin_config" USING btree ("plugin_id") WHERE "plugin_config"."company_id" is null;--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_config_company_plugin_uq" ON "plugin_config" USING btree ("plugin_id","company_id") WHERE "plugin_config"."company_id" is not null;
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -659,13 +659,6 @@
|
||||||
"when": 1780040470886,
|
"when": 1780040470886,
|
||||||
"tag": "0093_giant_green_goblin",
|
"tag": "0093_giant_green_goblin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 94,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1780444800000,
|
|
||||||
"tag": "0094_plugin_config_company_scope",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import { sql } from "drizzle-orm";
|
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
|
|
||||||
import { companies } from "./companies.js";
|
|
||||||
import { plugins } from "./plugins.js";
|
import { plugins } from "./plugins.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `plugin_config` table — stores operator-provided plugin configuration.
|
* `plugin_config` table — stores operator-provided instance configuration
|
||||||
*
|
* for each plugin (one row per plugin, enforced by a unique index on
|
||||||
* New configuration is company-scoped. Legacy rows may still have a null
|
* `plugin_id`).
|
||||||
* `company_id` so existing installs keep working until re-saved.
|
|
||||||
*
|
*
|
||||||
* The `config_json` column holds the values that the operator enters in the
|
* The `config_json` column holds the values that the operator enters in the
|
||||||
* plugin settings UI. These values are validated at runtime against the
|
* plugin settings UI. These values are validated at runtime against the
|
||||||
|
|
@ -22,21 +19,12 @@ export const pluginConfig = pgTable(
|
||||||
pluginId: uuid("plugin_id")
|
pluginId: uuid("plugin_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
companyId: uuid("company_id")
|
|
||||||
.references(() => companies.id, { onDelete: "cascade" }),
|
|
||||||
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
|
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
lastError: text("last_error"),
|
lastError: text("last_error"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
pluginIdIdx: index("plugin_config_plugin_id_idx").on(table.pluginId),
|
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
|
||||||
companyIdIdx: index("plugin_config_company_id_idx").on(table.companyId),
|
|
||||||
legacyPluginIdUq: uniqueIndex("plugin_config_legacy_plugin_id_uq")
|
|
||||||
.on(table.pluginId)
|
|
||||||
.where(sql`${table.companyId} is null`),
|
|
||||||
companyPluginUq: uniqueIndex("plugin_config_company_plugin_uq")
|
|
||||||
.on(table.pluginId, table.companyId)
|
|
||||||
.where(sql`${table.companyId} is not null`),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export class InvocationScopeDeniedError extends Error {
|
||||||
export interface HostServices {
|
export interface HostServices {
|
||||||
/** Provides `config.get`. */
|
/** Provides `config.get`. */
|
||||||
config: {
|
config: {
|
||||||
get(params: WorkerToHostMethods["config.get"][0]): Promise<Record<string, unknown>>;
|
get(): Promise<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Provides trusted company-scoped local folder helpers. */
|
/** Provides trusted company-scoped local folder helpers. */
|
||||||
|
|
@ -627,8 +627,8 @@ export function createHostClientHandlers(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Config
|
// Config
|
||||||
"config.get": gated("config.get", async (params) => {
|
"config.get": gated("config.get", async () => {
|
||||||
return services.config.get(params);
|
return services.config.get();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
"localFolders.declarations": gated("localFolders.declarations", async (params) => {
|
"localFolders.declarations": gated("localFolders.declarations", async (params) => {
|
||||||
|
|
|
||||||
|
|
@ -672,7 +672,7 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
|
||||||
*/
|
*/
|
||||||
export interface WorkerToHostMethods {
|
export interface WorkerToHostMethods {
|
||||||
// Config
|
// Config
|
||||||
"config.get": [params: { companyId?: string | null }, result: Record<string, unknown>];
|
"config.get": [params: Record<string, never>, result: Record<string, unknown>];
|
||||||
|
|
||||||
// Trusted local folders
|
// Trusted local folders
|
||||||
"localFolders.declarations": [
|
"localFolders.declarations": [
|
||||||
|
|
@ -809,7 +809,7 @@ export interface WorkerToHostMethods {
|
||||||
|
|
||||||
// Secrets
|
// Secrets
|
||||||
"secrets.resolve": [
|
"secrets.resolve": [
|
||||||
params: { secretRef: string; companyId?: string | null },
|
params: { secretRef: string },
|
||||||
result: string,
|
result: string,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -425,7 +425,7 @@ export interface PluginConfigClient {
|
||||||
* Values are validated against the plugin's `instanceConfigSchema` by the
|
* Values are validated against the plugin's `instanceConfigSchema` by the
|
||||||
* host before being passed to the worker.
|
* host before being passed to the worker.
|
||||||
*/
|
*/
|
||||||
get(params?: { companyId?: string | null }): Promise<Record<string, unknown>>;
|
get(): Promise<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginLocalFolderProblem {
|
export interface PluginLocalFolderProblem {
|
||||||
|
|
@ -656,7 +656,7 @@ export interface PluginSecretsClient {
|
||||||
* @param secretRef - The secret reference string from plugin config
|
* @param secretRef - The secret reference string from plugin config
|
||||||
* @returns The resolved secret value
|
* @returns The resolved secret value
|
||||||
*/
|
*/
|
||||||
resolve(secretRef: string, companyId?: string | null): Promise<string>;
|
resolve(secretRef: string): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -164,10 +164,6 @@ export interface WorkerRpcHost {
|
||||||
stop(): void;
|
stop(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimeCompanyContext {
|
|
||||||
companyId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Internal: event registration
|
// Internal: event registration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -289,7 +285,6 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
let currentConfig: Record<string, unknown> = {};
|
let currentConfig: Record<string, unknown> = {};
|
||||||
let databaseNamespace: string | null = null;
|
let databaseNamespace: string | null = null;
|
||||||
const invocationContextStorage = new AsyncLocalStorage<PluginInvocationContext>();
|
const invocationContextStorage = new AsyncLocalStorage<PluginInvocationContext>();
|
||||||
const runtimeCompanyContext = new AsyncLocalStorage<RuntimeCompanyContext>();
|
|
||||||
|
|
||||||
// Plugin handler registrations (populated during setup())
|
// Plugin handler registrations (populated during setup())
|
||||||
const eventHandlers: EventRegistration[] = [];
|
const eventHandlers: EventRegistration[] = [];
|
||||||
|
|
@ -418,12 +413,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
},
|
},
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
async get(params) {
|
async get() {
|
||||||
if (params && "companyId" in params) {
|
return callHost("config.get", {} as Record<string, never>);
|
||||||
return callHost("config.get", { companyId: params.companyId ?? null });
|
|
||||||
}
|
|
||||||
const companyId = runtimeCompanyContext.getStore()?.companyId ?? null;
|
|
||||||
return callHost("config.get", companyId ? { companyId } : {});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -573,11 +564,8 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
},
|
},
|
||||||
|
|
||||||
secrets: {
|
secrets: {
|
||||||
async resolve(secretRef: string, companyId?: string | null): Promise<string> {
|
async resolve(secretRef: string): Promise<string> {
|
||||||
const scopedCompanyId = arguments.length >= 2
|
return callHost("secrets.resolve", { secretRef });
|
||||||
? companyId ?? null
|
|
||||||
: runtimeCompanyContext.getStore()?.companyId ?? null;
|
|
||||||
return callHost("secrets.resolve", { secretRef, companyId: scopedCompanyId });
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1479,10 +1467,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
if (registration.filter && !allowsEvent(registration.filter, event)) continue;
|
if (registration.filter && !allowsEvent(registration.filter, event)) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runtimeCompanyContext.run(
|
await registration.fn(event);
|
||||||
{ companyId: event.companyId },
|
|
||||||
() => registration.fn(event),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Log error but continue processing other handlers so one failing
|
// Log error but continue processing other handlers so one failing
|
||||||
// handler doesn't prevent the rest from running.
|
// handler doesn't prevent the rest from running.
|
||||||
|
|
@ -1522,10 +1507,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
{ code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED },
|
{ code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return runtimeCompanyContext.run(
|
return plugin.definition.onApiRequest(params);
|
||||||
{ companyId: params.companyId },
|
|
||||||
() => plugin.definition.onApiRequest!(params),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGetData(params: GetDataParams): Promise<unknown> {
|
async function handleGetData(params: GetDataParams): Promise<unknown> {
|
||||||
|
|
@ -1533,14 +1515,11 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
throw new Error(`No data handler registered for key "${params.key}"`);
|
throw new Error(`No data handler registered for key "${params.key}"`);
|
||||||
}
|
}
|
||||||
const handlerParams =
|
return handler({
|
||||||
params.renderEnvironment === undefined
|
...params.params,
|
||||||
? params.params
|
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
|
||||||
: { ...params.params, renderEnvironment: params.renderEnvironment };
|
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
|
||||||
return runtimeCompanyContext.run(
|
});
|
||||||
{ companyId: params.companyId ?? null },
|
|
||||||
() => handler(handlerParams),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringOrNull(value: unknown): string | null {
|
function stringOrNull(value: unknown): string | null {
|
||||||
|
|
@ -1573,14 +1552,13 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
throw new Error(`No action handler registered for key "${params.key}"`);
|
throw new Error(`No action handler registered for key "${params.key}"`);
|
||||||
}
|
}
|
||||||
const handlerParams =
|
return handler(
|
||||||
params.renderEnvironment === undefined
|
{
|
||||||
? params.params
|
...params.params,
|
||||||
: { ...params.params, renderEnvironment: params.renderEnvironment };
|
...(params.companyId === undefined ? {} : { companyId: params.companyId }),
|
||||||
const actionContext = actionContextFromParams(params);
|
...(params.renderEnvironment === undefined ? {} : { renderEnvironment: params.renderEnvironment }),
|
||||||
return runtimeCompanyContext.run(
|
},
|
||||||
{ companyId: params.companyId ?? actionContext.companyId },
|
actionContextFromParams(params),
|
||||||
() => handler(handlerParams ?? {}, actionContext),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1589,10 +1567,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw new Error(`No tool handler registered for "${params.toolName}"`);
|
throw new Error(`No tool handler registered for "${params.toolName}"`);
|
||||||
}
|
}
|
||||||
return runtimeCompanyContext.run(
|
return entry.fn(params.parameters, params.runContext);
|
||||||
{ companyId: params.runContext.companyId },
|
|
||||||
() => entry.fn(params.parameters, params.runContext),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function methodNotImplemented(method: string): Error & { code: number } {
|
function methodNotImplemented(method: string): Error & { code: number } {
|
||||||
|
|
@ -1615,70 +1590,49 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
if (!plugin.definition.onEnvironmentProbe) {
|
if (!plugin.definition.onEnvironmentProbe) {
|
||||||
throw methodNotImplemented("environmentProbe");
|
throw methodNotImplemented("environmentProbe");
|
||||||
}
|
}
|
||||||
return runtimeCompanyContext.run(
|
return plugin.definition.onEnvironmentProbe(params);
|
||||||
{ companyId: params.companyId },
|
|
||||||
() => plugin.definition.onEnvironmentProbe!(params),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEnvironmentAcquireLease(params: PluginEnvironmentAcquireLeaseParams) {
|
async function handleEnvironmentAcquireLease(params: PluginEnvironmentAcquireLeaseParams) {
|
||||||
if (!plugin.definition.onEnvironmentAcquireLease) {
|
if (!plugin.definition.onEnvironmentAcquireLease) {
|
||||||
throw methodNotImplemented("environmentAcquireLease");
|
throw methodNotImplemented("environmentAcquireLease");
|
||||||
}
|
}
|
||||||
return runtimeCompanyContext.run(
|
return plugin.definition.onEnvironmentAcquireLease(params);
|
||||||
{ companyId: params.companyId },
|
|
||||||
() => plugin.definition.onEnvironmentAcquireLease!(params),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEnvironmentResumeLease(params: PluginEnvironmentResumeLeaseParams) {
|
async function handleEnvironmentResumeLease(params: PluginEnvironmentResumeLeaseParams) {
|
||||||
if (!plugin.definition.onEnvironmentResumeLease) {
|
if (!plugin.definition.onEnvironmentResumeLease) {
|
||||||
throw methodNotImplemented("environmentResumeLease");
|
throw methodNotImplemented("environmentResumeLease");
|
||||||
}
|
}
|
||||||
return runtimeCompanyContext.run(
|
return plugin.definition.onEnvironmentResumeLease(params);
|
||||||
{ companyId: params.companyId },
|
|
||||||
() => plugin.definition.onEnvironmentResumeLease!(params),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEnvironmentReleaseLease(params: PluginEnvironmentReleaseLeaseParams) {
|
async function handleEnvironmentReleaseLease(params: PluginEnvironmentReleaseLeaseParams) {
|
||||||
if (!plugin.definition.onEnvironmentReleaseLease) {
|
if (!plugin.definition.onEnvironmentReleaseLease) {
|
||||||
throw methodNotImplemented("environmentReleaseLease");
|
throw methodNotImplemented("environmentReleaseLease");
|
||||||
}
|
}
|
||||||
return runtimeCompanyContext.run(
|
return plugin.definition.onEnvironmentReleaseLease(params);
|
||||||
{ companyId: params.companyId },
|
|
||||||
() => plugin.definition.onEnvironmentReleaseLease!(params),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEnvironmentDestroyLease(params: PluginEnvironmentDestroyLeaseParams) {
|
async function handleEnvironmentDestroyLease(params: PluginEnvironmentDestroyLeaseParams) {
|
||||||
if (!plugin.definition.onEnvironmentDestroyLease) {
|
if (!plugin.definition.onEnvironmentDestroyLease) {
|
||||||
throw methodNotImplemented("environmentDestroyLease");
|
throw methodNotImplemented("environmentDestroyLease");
|
||||||
}
|
}
|
||||||
return runtimeCompanyContext.run(
|
return plugin.definition.onEnvironmentDestroyLease(params);
|
||||||
{ companyId: params.companyId },
|
|
||||||
() => plugin.definition.onEnvironmentDestroyLease!(params),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEnvironmentRealizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams) {
|
async function handleEnvironmentRealizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams) {
|
||||||
if (!plugin.definition.onEnvironmentRealizeWorkspace) {
|
if (!plugin.definition.onEnvironmentRealizeWorkspace) {
|
||||||
throw methodNotImplemented("environmentRealizeWorkspace");
|
throw methodNotImplemented("environmentRealizeWorkspace");
|
||||||
}
|
}
|
||||||
return runtimeCompanyContext.run(
|
return plugin.definition.onEnvironmentRealizeWorkspace(params);
|
||||||
{ companyId: params.companyId },
|
|
||||||
() => plugin.definition.onEnvironmentRealizeWorkspace!(params),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEnvironmentExecute(params: PluginEnvironmentExecuteParams) {
|
async function handleEnvironmentExecute(params: PluginEnvironmentExecuteParams) {
|
||||||
if (!plugin.definition.onEnvironmentExecute) {
|
if (!plugin.definition.onEnvironmentExecute) {
|
||||||
throw methodNotImplemented("environmentExecute");
|
throw methodNotImplemented("environmentExecute");
|
||||||
}
|
}
|
||||||
return runtimeCompanyContext.run(
|
return plugin.definition.onEnvironmentExecute(params);
|
||||||
{ companyId: params.companyId },
|
|
||||||
() => plugin.definition.onEnvironmentExecute!(params),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -296,222 +296,3 @@ describe("worker invocation scope propagation", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("startWorkerRpcHost runtime company context", () => {
|
|
||||||
function collectJsonLines(stream: PassThrough) {
|
|
||||||
const queue: unknown[] = [];
|
|
||||||
const waiters: Array<(value: unknown) => void> = [];
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
stream.on("data", (chunk) => {
|
|
||||||
buffer += chunk.toString("utf8");
|
|
||||||
let newlineIndex = buffer.indexOf("\n");
|
|
||||||
while (newlineIndex !== -1) {
|
|
||||||
const line = buffer.slice(0, newlineIndex).trim();
|
|
||||||
buffer = buffer.slice(newlineIndex + 1);
|
|
||||||
if (line) {
|
|
||||||
const message = JSON.parse(line);
|
|
||||||
const waiter = waiters.shift();
|
|
||||||
if (waiter) waiter(message);
|
|
||||||
else queue.push(message);
|
|
||||||
}
|
|
||||||
newlineIndex = buffer.indexOf("\n");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return async function nextMessage(): Promise<any> {
|
|
||||||
const queued = queue.shift();
|
|
||||||
if (queued) return queued;
|
|
||||||
return new Promise((resolve) => waiters.push(resolve));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeMessage(stream: PassThrough, message: unknown): void {
|
|
||||||
stream.write(`${JSON.stringify(message)}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("passes executeTool company context into config and secret host calls", async () => {
|
|
||||||
const stdin = new PassThrough();
|
|
||||||
const stdout = new PassThrough();
|
|
||||||
const nextMessage = collectJsonLines(stdout);
|
|
||||||
|
|
||||||
const plugin = definePlugin({
|
|
||||||
async setup(ctx) {
|
|
||||||
ctx.tools.register(
|
|
||||||
"check-context",
|
|
||||||
{
|
|
||||||
displayName: "Check Context",
|
|
||||||
description: "Checks runtime context propagation",
|
|
||||||
parametersSchema: { type: "object", properties: {} },
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const config = await ctx.config.get();
|
|
||||||
const token = await ctx.secrets.resolve("77777777-7777-4777-8777-777777777777");
|
|
||||||
return { content: `${config.mode}:${token}` };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const host = startWorkerRpcHost({ plugin, stdin, stdout });
|
|
||||||
|
|
||||||
try {
|
|
||||||
writeMessage(stdin, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "initialize",
|
|
||||||
params: {
|
|
||||||
manifest: { id: "test-plugin", name: "test-plugin", version: "1.0.0" },
|
|
||||||
config: {},
|
|
||||||
instanceInfo: { instanceId: "inst-1", hostVersion: "0.0.0-test" },
|
|
||||||
apiVersion: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await expect(nextMessage()).resolves.toMatchObject({ id: 1, result: { ok: true } });
|
|
||||||
|
|
||||||
writeMessage(stdin, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 2,
|
|
||||||
method: "executeTool",
|
|
||||||
params: {
|
|
||||||
toolName: "check-context",
|
|
||||||
parameters: {},
|
|
||||||
runContext: {
|
|
||||||
agentId: "agent-1",
|
|
||||||
runId: "run-1",
|
|
||||||
companyId: "company-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const configRequest = await nextMessage();
|
|
||||||
expect(configRequest).toMatchObject({
|
|
||||||
method: "config.get",
|
|
||||||
params: { companyId: "company-1" },
|
|
||||||
});
|
|
||||||
writeMessage(stdin, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: configRequest.id,
|
|
||||||
result: { mode: "company-config" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const secretRequest = await nextMessage();
|
|
||||||
expect(secretRequest).toMatchObject({
|
|
||||||
method: "secrets.resolve",
|
|
||||||
params: {
|
|
||||||
secretRef: "77777777-7777-4777-8777-777777777777",
|
|
||||||
companyId: "company-1",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
writeMessage(stdin, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: secretRequest.id,
|
|
||||||
result: "company-secret",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(nextMessage()).resolves.toMatchObject({
|
|
||||||
id: 2,
|
|
||||||
result: { content: "company-config:company-secret" },
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
host.stop();
|
|
||||||
stdin.destroy();
|
|
||||||
stdout.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves explicit null company context in config and secret host calls", async () => {
|
|
||||||
const stdin = new PassThrough();
|
|
||||||
const stdout = new PassThrough();
|
|
||||||
const nextMessage = collectJsonLines(stdout);
|
|
||||||
|
|
||||||
const plugin = definePlugin({
|
|
||||||
async setup(ctx) {
|
|
||||||
ctx.tools.register(
|
|
||||||
"check-explicit-null",
|
|
||||||
{
|
|
||||||
displayName: "Check Explicit Null",
|
|
||||||
description: "Checks explicit null runtime context propagation",
|
|
||||||
parametersSchema: { type: "object", properties: {} },
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const config = await ctx.config.get({ companyId: null });
|
|
||||||
const token = await ctx.secrets.resolve(
|
|
||||||
"77777777-7777-4777-8777-777777777777",
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
return { content: `${config.mode}:${token}` };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const host = startWorkerRpcHost({ plugin, stdin, stdout });
|
|
||||||
|
|
||||||
try {
|
|
||||||
writeMessage(stdin, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "initialize",
|
|
||||||
params: {
|
|
||||||
manifest: { id: "test-plugin", name: "test-plugin", version: "1.0.0" },
|
|
||||||
config: {},
|
|
||||||
instanceInfo: { instanceId: "inst-1", hostVersion: "0.0.0-test" },
|
|
||||||
apiVersion: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await expect(nextMessage()).resolves.toMatchObject({ id: 1, result: { ok: true } });
|
|
||||||
|
|
||||||
writeMessage(stdin, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 2,
|
|
||||||
method: "executeTool",
|
|
||||||
params: {
|
|
||||||
toolName: "check-explicit-null",
|
|
||||||
parameters: {},
|
|
||||||
runContext: {
|
|
||||||
agentId: "agent-1",
|
|
||||||
runId: "run-1",
|
|
||||||
companyId: "company-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const configRequest = await nextMessage();
|
|
||||||
expect(configRequest).toMatchObject({
|
|
||||||
method: "config.get",
|
|
||||||
params: { companyId: null },
|
|
||||||
});
|
|
||||||
writeMessage(stdin, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: configRequest.id,
|
|
||||||
result: { mode: "global-config" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const secretRequest = await nextMessage();
|
|
||||||
expect(secretRequest).toMatchObject({
|
|
||||||
method: "secrets.resolve",
|
|
||||||
params: {
|
|
||||||
secretRef: "77777777-7777-4777-8777-777777777777",
|
|
||||||
companyId: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
writeMessage(stdin, {
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: secretRequest.id,
|
|
||||||
result: "global-secret",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(nextMessage()).resolves.toMatchObject({
|
|
||||||
id: 2,
|
|
||||||
result: { content: "global-config:global-secret" },
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
host.stop();
|
|
||||||
stdin.destroy();
|
|
||||||
stdout.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -678,17 +678,15 @@ export interface PluginStateRecord {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Domain type for a plugin's configuration as persisted in the
|
* Domain type for a plugin's instance configuration as persisted in the
|
||||||
* `plugin_config` table.
|
* `plugin_config` table.
|
||||||
* See PLUGIN_SPEC.md §21.3 for the schema definition.
|
* See PLUGIN_SPEC.md §21.3 for the schema definition.
|
||||||
*/
|
*/
|
||||||
export interface PluginConfig {
|
export interface PluginConfig {
|
||||||
/** UUID primary key. */
|
/** UUID primary key. */
|
||||||
id: string;
|
id: string;
|
||||||
/** FK to `plugins.id`. */
|
/** FK to `plugins.id`. Unique — each plugin has at most one config row. */
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
/** Company scope for this config row. Null only for legacy/global fallback rows. */
|
|
||||||
companyId: string | null;
|
|
||||||
/** Operator-provided configuration values (validated against `instanceConfigSchema`). */
|
/** Operator-provided configuration values (validated against `instanceConfigSchema`). */
|
||||||
configJson: Record<string, unknown>;
|
configJson: Record<string, unknown>;
|
||||||
/** Most recent config validation error, if any. */
|
/** Most recent config validation error, if any. */
|
||||||
|
|
|
||||||
|
|
@ -126,35 +126,4 @@ describe("pluginLifecycleManager.restartWorker", () => {
|
||||||
expect(started).toHaveBeenCalledTimes(1);
|
expect(started).toHaveBeenCalledTimes(1);
|
||||||
expect(started).toHaveBeenCalledWith({ pluginId: "plugin-1", pluginKey: "example.plugin" });
|
expect(started).toHaveBeenCalledWith({ pluginId: "plugin-1", pluginKey: "example.plugin" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a late-bound runtime loader after bootstrap wiring completes", async () => {
|
|
||||||
mockRegistry.getById.mockResolvedValue(pluginRecord);
|
|
||||||
mockRegistry.updateStatus.mockResolvedValue(pluginRecord);
|
|
||||||
|
|
||||||
const { handle, workerManager } = makeWorkerManagerStub();
|
|
||||||
let runtimeLoader: Partial<PluginLoader> | undefined;
|
|
||||||
const lifecycle = pluginLifecycleManager(
|
|
||||||
{} as never,
|
|
||||||
{
|
|
||||||
resolveLoader: () => runtimeLoader as PluginLoader | undefined,
|
|
||||||
workerManager,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
runtimeLoader = {
|
|
||||||
hasRuntimeServices: vi.fn().mockReturnValue(true) as PluginLoader["hasRuntimeServices"],
|
|
||||||
loadSingle: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
plugin: pluginRecord,
|
|
||||||
registered: { worker: true, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 },
|
|
||||||
}) as PluginLoader["loadSingle"],
|
|
||||||
unloadSingle: vi.fn().mockResolvedValue(undefined) as PluginLoader["unloadSingle"],
|
|
||||||
};
|
|
||||||
|
|
||||||
await lifecycle.restartWorker("plugin-1");
|
|
||||||
|
|
||||||
expect(runtimeLoader.unloadSingle).toHaveBeenCalledWith("plugin-1", "example.plugin");
|
|
||||||
expect(runtimeLoader.loadSingle).toHaveBeenCalledWith("plugin-1");
|
|
||||||
expect(handle.restart).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,6 @@ const mockRegistry = vi.hoisted(() => ({
|
||||||
upsertConfig: vi.fn(),
|
upsertConfig: vi.fn(),
|
||||||
getCompanySettings: vi.fn(),
|
getCompanySettings: vi.fn(),
|
||||||
upsertCompanySettings: vi.fn(),
|
upsertCompanySettings: vi.fn(),
|
||||||
getConfig: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockSecrets = vi.hoisted(() => ({
|
|
||||||
syncSecretRefsForTarget: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockLifecycle = vi.hoisted(() => ({
|
const mockLifecycle = vi.hoisted(() => ({
|
||||||
|
|
@ -27,10 +22,6 @@ vi.mock("../services/plugin-registry.js", () => ({
|
||||||
pluginRegistryService: () => mockRegistry,
|
pluginRegistryService: () => mockRegistry,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../services/secrets.js", () => ({
|
|
||||||
secretService: () => mockSecrets,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../services/plugin-lifecycle.js", () => ({
|
vi.mock("../services/plugin-lifecycle.js", () => ({
|
||||||
pluginLifecycleManager: () => mockLifecycle,
|
pluginLifecycleManager: () => mockLifecycle,
|
||||||
}));
|
}));
|
||||||
|
|
@ -336,105 +327,8 @@ describe.sequential("plugin install and upgrade authz", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(422);
|
expect(res.status).toBe(422);
|
||||||
expect(res.body.error).toMatch(/secret references require companyId/i);
|
expect(res.body.error).toMatch(/secret references are disabled/i);
|
||||||
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
|
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
|
||||||
expect(mockSecrets.syncSecretRefsForTarget).not.toHaveBeenCalled();
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
it("saves company-scoped plugin config secret refs as plugin bindings", async () => {
|
|
||||||
readyPlugin();
|
|
||||||
mockRegistry.upsertConfig.mockResolvedValue({
|
|
||||||
id: "99999999-9999-4999-8999-999999999999",
|
|
||||||
pluginId,
|
|
||||||
companyId: companyA,
|
|
||||||
configJson: {
|
|
||||||
apiKeyRef: "77777777-7777-4777-8777-777777777777",
|
|
||||||
},
|
|
||||||
lastError: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { app } = await createApp(boardActor({
|
|
||||||
isInstanceAdmin: true,
|
|
||||||
companyIds: [companyA],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/api/plugins/${pluginId}/config`)
|
|
||||||
.send({
|
|
||||||
companyId: companyA,
|
|
||||||
configJson: {
|
|
||||||
apiKeyRef: "77777777-7777-4777-8777-777777777777",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(mockSecrets.syncSecretRefsForTarget).toHaveBeenCalledWith(
|
|
||||||
companyA,
|
|
||||||
{ targetType: "plugin", targetId: pluginId },
|
|
||||||
[{
|
|
||||||
secretId: "77777777-7777-4777-8777-777777777777",
|
|
||||||
configPath: "$",
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
expect(mockRegistry.upsertConfig).toHaveBeenCalledWith(
|
|
||||||
pluginId,
|
|
||||||
{
|
|
||||||
configJson: {
|
|
||||||
apiKeyRef: "77777777-7777-4777-8777-777777777777",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
companyA,
|
|
||||||
);
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
it("rejects company-scoped plugin config secret refs that do not belong to the selected company", async () => {
|
|
||||||
readyPlugin();
|
|
||||||
mockSecrets.syncSecretRefsForTarget.mockRejectedValueOnce(new Error("Secret not found"));
|
|
||||||
|
|
||||||
const { app } = await createApp(boardActor({
|
|
||||||
isInstanceAdmin: true,
|
|
||||||
companyIds: [companyA],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/api/plugins/${pluginId}/config`)
|
|
||||||
.send({
|
|
||||||
companyId: companyA,
|
|
||||||
configJson: {
|
|
||||||
apiKeyRef: "88888888-8888-4888-8888-888888888888",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toMatch(/secret not found/i);
|
|
||||||
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
it("reads plugin config from the requested company scope", async () => {
|
|
||||||
readyPlugin();
|
|
||||||
mockRegistry.getConfig.mockResolvedValue({
|
|
||||||
id: "99999999-9999-4999-8999-999999999999",
|
|
||||||
pluginId,
|
|
||||||
companyId: companyA,
|
|
||||||
configJson: { botName: "company-a" },
|
|
||||||
lastError: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { app } = await createApp(boardActor({
|
|
||||||
isInstanceAdmin: true,
|
|
||||||
companyIds: [companyA, companyB],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get(`/api/plugins/${pluginId}/config?companyId=${companyA}`);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(mockRegistry.getConfig).toHaveBeenCalledWith(pluginId, companyA);
|
|
||||||
expect(res.body.configJson).toEqual({ botName: "company-a" });
|
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("allows instance admins to upgrade plugins", async () => {
|
it("allows instance admins to upgrade plugins", async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
getConfig: vi.fn(),
|
|
||||||
resolveSecretValue: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../services/plugin-registry.js", () => ({
|
|
||||||
pluginRegistryService: () => ({
|
|
||||||
getConfig: mocks.getConfig,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../services/secrets.js", () => ({
|
|
||||||
secretService: () => ({
|
|
||||||
resolveSecretValue: mocks.resolveSecretValue,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import {
|
|
||||||
createPluginSecretsHandler,
|
|
||||||
PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE,
|
|
||||||
} from "../services/plugin-secrets-handler.js";
|
|
||||||
|
|
||||||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
|
||||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
|
||||||
const secretRef = "77777777-7777-4777-8777-777777777777";
|
|
||||||
|
|
||||||
const manifest = {
|
|
||||||
instanceConfigSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
apiKeyRef: {
|
|
||||||
type: "string",
|
|
||||||
format: "secret-ref",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("createPluginSecretsHandler runtime company scoping", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks.getConfig.mockReset();
|
|
||||||
mocks.resolveSecretValue.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fails closed when the runtime call has no company context", async () => {
|
|
||||||
const handler = createPluginSecretsHandler({
|
|
||||||
db: {} as never,
|
|
||||||
pluginId,
|
|
||||||
manifest: manifest as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(handler.resolve({ secretRef })).rejects.toThrow(
|
|
||||||
PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE,
|
|
||||||
);
|
|
||||||
expect(mocks.getConfig).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.resolveSecretValue).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a secret ref that is not referenced by that company's plugin config", async () => {
|
|
||||||
mocks.getConfig.mockResolvedValue({
|
|
||||||
configJson: {
|
|
||||||
apiKeyRef: "88888888-8888-4888-8888-888888888888",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handler = createPluginSecretsHandler({
|
|
||||||
db: {} as never,
|
|
||||||
pluginId,
|
|
||||||
manifest: manifest as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(handler.resolve({ secretRef, companyId })).rejects.toThrow(
|
|
||||||
/not referenced by this company's plugin config/i,
|
|
||||||
);
|
|
||||||
expect(mocks.getConfig).toHaveBeenCalledWith(pluginId, companyId);
|
|
||||||
expect(mocks.resolveSecretValue).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves only through the company plugin binding context from saved config", async () => {
|
|
||||||
mocks.getConfig.mockResolvedValue({
|
|
||||||
configJson: {
|
|
||||||
apiKeyRef: secretRef,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mocks.resolveSecretValue.mockResolvedValue("plaintext-token");
|
|
||||||
|
|
||||||
const handler = createPluginSecretsHandler({
|
|
||||||
db: {} as never,
|
|
||||||
pluginId,
|
|
||||||
manifest: manifest as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(handler.resolve({ secretRef, companyId })).resolves.toBe("plaintext-token");
|
|
||||||
expect(mocks.resolveSecretValue).toHaveBeenCalledWith(companyId, secretRef, "latest", {
|
|
||||||
consumerType: "plugin",
|
|
||||||
consumerId: pluginId,
|
|
||||||
configPath: "apiKeyRef",
|
|
||||||
actorType: "plugin",
|
|
||||||
actorId: pluginId,
|
|
||||||
pluginId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
createPluginSecretsHandler,
|
createPluginSecretsHandler,
|
||||||
PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE,
|
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
|
||||||
} from "../services/plugin-secrets-handler.js";
|
} from "../services/plugin-secrets-handler.js";
|
||||||
|
|
||||||
describe("createPluginSecretsHandler", () => {
|
describe("createPluginSecretsHandler", () => {
|
||||||
it("fails closed for plugin secret resolution without company scope", async () => {
|
it("fails closed for plugin secret resolution until company scoping lands", async () => {
|
||||||
const handler = createPluginSecretsHandler({
|
const handler = createPluginSecretsHandler({
|
||||||
db: {} as never,
|
db: {} as never,
|
||||||
pluginId: "11111111-1111-4111-8111-111111111111",
|
pluginId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
|
@ -13,7 +13,7 @@ describe("createPluginSecretsHandler", () => {
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
handler.resolve({ secretRef: "77777777-7777-4777-8777-777777777777" }),
|
handler.resolve({ secretRef: "77777777-7777-4777-8777-777777777777" }),
|
||||||
).rejects.toThrow(PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE);
|
).rejects.toThrow(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still rejects malformed secret refs before the feature-disable guard", async () => {
|
it("still rejects malformed secret refs before the feature-disable guard", async () => {
|
||||||
|
|
|
||||||
|
|
@ -242,11 +242,7 @@ export async function createApp(
|
||||||
const eventBus = createPluginEventBus();
|
const eventBus = createPluginEventBus();
|
||||||
setPluginEventBus(eventBus);
|
setPluginEventBus(eventBus);
|
||||||
const jobStore = pluginJobStore(db);
|
const jobStore = pluginJobStore(db);
|
||||||
let loader: ReturnType<typeof pluginLoader>;
|
const lifecycle = pluginLifecycleManager(db, { workerManager });
|
||||||
const lifecycle = pluginLifecycleManager(db, {
|
|
||||||
workerManager,
|
|
||||||
resolveLoader: () => loader,
|
|
||||||
});
|
|
||||||
const scheduler = createPluginJobScheduler({
|
const scheduler = createPluginJobScheduler({
|
||||||
db,
|
db,
|
||||||
jobStore,
|
jobStore,
|
||||||
|
|
@ -265,7 +261,7 @@ export async function createApp(
|
||||||
});
|
});
|
||||||
const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers);
|
const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers);
|
||||||
let viteHtmlRenderer: ReturnType<typeof createCachedViteHtmlRenderer> | null = null;
|
let viteHtmlRenderer: ReturnType<typeof createCachedViteHtmlRenderer> | null = null;
|
||||||
loader = pluginLoader(
|
const loader = pluginLoader(
|
||||||
db,
|
db,
|
||||||
{
|
{
|
||||||
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,8 @@ import {
|
||||||
} from "../services/plugin-local-folders.js";
|
} from "../services/plugin-local-folders.js";
|
||||||
import {
|
import {
|
||||||
extractSecretRefPathsFromConfig,
|
extractSecretRefPathsFromConfig,
|
||||||
|
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
|
||||||
} from "../services/plugin-secrets-handler.js";
|
} from "../services/plugin-secrets-handler.js";
|
||||||
import { secretService } from "../services/secrets.js";
|
|
||||||
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||||
|
|
||||||
/** UI slot declaration extracted from plugin manifest */
|
/** UI slot declaration extracted from plugin manifest */
|
||||||
|
|
@ -481,7 +481,6 @@ export function pluginRoutes(
|
||||||
) {
|
) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const registry = pluginRegistryService(db);
|
const registry = pluginRegistryService(db);
|
||||||
const secrets = secretService(db);
|
|
||||||
const lifecycle = pluginLifecycleManager(db, {
|
const lifecycle = pluginLifecycleManager(db, {
|
||||||
loader,
|
loader,
|
||||||
workerManager: bridgeDeps?.workerManager ?? webhookDeps?.workerManager,
|
workerManager: bridgeDeps?.workerManager ?? webhookDeps?.workerManager,
|
||||||
|
|
@ -670,27 +669,6 @@ export function pluginRoutes(
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePluginConfigCompanyId(req: Request): string | null {
|
|
||||||
const body = req.body as { companyId?: unknown } | undefined;
|
|
||||||
const rawCompanyId = typeof req.query.companyId === "string"
|
|
||||||
? req.query.companyId
|
|
||||||
: typeof body?.companyId === "string"
|
|
||||||
? body.companyId
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (rawCompanyId) {
|
|
||||||
if (
|
|
||||||
req.actor.type !== "board" ||
|
|
||||||
(req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin)
|
|
||||||
) {
|
|
||||||
assertCompanyAccess(req, rawCompanyId);
|
|
||||||
}
|
|
||||||
return rawCompanyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPluginBridgeScope(req: Request, companyId: unknown): string | undefined {
|
function assertPluginBridgeScope(req: Request, companyId: unknown): string | undefined {
|
||||||
if (companyId === undefined || companyId === null) {
|
if (companyId === undefined || companyId === null) {
|
||||||
assertInstanceAdmin(req);
|
assertInstanceAdmin(req);
|
||||||
|
|
@ -2133,8 +2111,7 @@ export function pluginRoutes(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyId = resolvePluginConfigCompanyId(req);
|
const config = await registry.getConfig(plugin.id);
|
||||||
const config = await registry.getConfig(plugin.id, companyId);
|
|
||||||
res.json(config);
|
res.json(config);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2164,29 +2141,27 @@ export function pluginRoutes(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = req.body as { configJson?: Record<string, unknown>; companyId?: string } | undefined;
|
const body = req.body as { configJson?: Record<string, unknown> } | undefined;
|
||||||
if (!body?.configJson || typeof body.configJson !== "object") {
|
if (!body?.configJson || typeof body.configJson !== "object") {
|
||||||
res.status(400).json({ error: '"configJson" is required and must be an object' });
|
res.status(400).json({ error: '"configJson" is required and must be an object' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const configJson = body.configJson;
|
|
||||||
const companyId = resolvePluginConfigCompanyId(req);
|
|
||||||
|
|
||||||
// Strip devUiUrl unless the caller is an instance admin. devUiUrl activates
|
// Strip devUiUrl unless the caller is an instance admin. devUiUrl activates
|
||||||
// a dev-proxy in the static file route that could be abused for SSRF if any
|
// a dev-proxy in the static file route that could be abused for SSRF if any
|
||||||
// board-level user were allowed to set it.
|
// board-level user were allowed to set it.
|
||||||
if (
|
if (
|
||||||
"devUiUrl" in configJson &&
|
"devUiUrl" in body.configJson &&
|
||||||
!(req.actor.type === "board" && req.actor.isInstanceAdmin)
|
!(req.actor.type === "board" && req.actor.isInstanceAdmin)
|
||||||
) {
|
) {
|
||||||
delete configJson.devUiUrl;
|
delete body.configJson.devUiUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate configJson against the plugin's instanceConfigSchema (if declared).
|
// Validate configJson against the plugin's instanceConfigSchema (if declared).
|
||||||
// This ensures CLI/API callers get the same validation the UI performs client-side.
|
// This ensures CLI/API callers get the same validation the UI performs client-side.
|
||||||
const schema = plugin.manifestJson?.instanceConfigSchema;
|
const schema = plugin.manifestJson?.instanceConfigSchema;
|
||||||
if (schema && Object.keys(schema).length > 0) {
|
if (schema && Object.keys(schema).length > 0) {
|
||||||
const validation = validateInstanceConfig(configJson, schema);
|
const validation = validateInstanceConfig(body.configJson, schema);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: "Configuration does not match the plugin's instanceConfigSchema",
|
error: "Configuration does not match the plugin's instanceConfigSchema",
|
||||||
|
|
@ -2197,55 +2172,31 @@ export function pluginRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const secretRefsByPath = extractSecretRefPathsFromConfig(configJson, schema);
|
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
|
||||||
if (secretRefsByPath.size > 0 && !companyId) {
|
if (secretRefsByPath.size > 0) {
|
||||||
res.status(422).json({ error: "Plugin secret references require companyId" });
|
res.status(422).json({ error: PLUGIN_SECRET_REFS_DISABLED_MESSAGE });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const refs = [...secretRefsByPath.entries()].flatMap(([secretId, paths]) =>
|
|
||||||
[...paths].map((configPath) => ({ secretId, configPath })),
|
const result = await registry.upsertConfig(plugin.id, {
|
||||||
);
|
configJson: body.configJson,
|
||||||
const persistConfig = async (
|
});
|
||||||
scopedSecrets: typeof secrets,
|
|
||||||
scopedRegistry: typeof registry,
|
|
||||||
secretDb?: Db,
|
|
||||||
) => {
|
|
||||||
if (companyId) {
|
|
||||||
const target = { targetType: "plugin" as const, targetId: plugin.id };
|
|
||||||
if (secretDb) {
|
|
||||||
await scopedSecrets.syncSecretRefsForTarget(companyId, target, refs, { db: secretDb });
|
|
||||||
} else {
|
|
||||||
await scopedSecrets.syncSecretRefsForTarget(companyId, target, refs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return scopedRegistry.upsertConfig(plugin.id, {
|
|
||||||
configJson,
|
|
||||||
}, companyId);
|
|
||||||
};
|
|
||||||
const result = typeof db.transaction === "function"
|
|
||||||
? await db.transaction((tx) =>
|
|
||||||
persistConfig(
|
|
||||||
secretService(tx as unknown as Db),
|
|
||||||
pluginRegistryService(tx as unknown as Db),
|
|
||||||
tx as unknown as Db,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: await persistConfig(secrets, registry);
|
|
||||||
await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, {
|
await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, {
|
||||||
pluginId: plugin.id,
|
pluginId: plugin.id,
|
||||||
pluginKey: plugin.pluginKey,
|
pluginKey: plugin.pluginKey,
|
||||||
companyId,
|
configKeyCount: Object.keys(body.configJson).length,
|
||||||
configKeyCount: Object.keys(configJson).length,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only legacy/global config is still pushed into the process-global worker state.
|
// Notify the running worker about the config change (PLUGIN_SPEC §25.4.4).
|
||||||
// Company-scoped config is read at call time through ctx.config.get().
|
// If the worker implements onConfigChanged, send the new config via RPC.
|
||||||
if (companyId === null && bridgeDeps?.workerManager.isRunning(plugin.id)) {
|
// If it doesn't (METHOD_NOT_IMPLEMENTED), restart the worker so it picks
|
||||||
|
// up the new config on re-initialize. If no worker is running, skip.
|
||||||
|
if (bridgeDeps?.workerManager.isRunning(plugin.id)) {
|
||||||
try {
|
try {
|
||||||
await bridgeDeps.workerManager.call(
|
await bridgeDeps.workerManager.call(
|
||||||
plugin.id,
|
plugin.id,
|
||||||
"configChanged",
|
"configChanged",
|
||||||
{ config: configJson },
|
{ config: body.configJson },
|
||||||
);
|
);
|
||||||
} catch (rpcErr) {
|
} catch (rpcErr) {
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -489,7 +489,7 @@ export function buildHostServices(
|
||||||
const registry = pluginRegistryService(db);
|
const registry = pluginRegistryService(db);
|
||||||
const stateStore = pluginStateStore(db);
|
const stateStore = pluginStateStore(db);
|
||||||
const pluginDb = pluginDatabaseService(db);
|
const pluginDb = pluginDatabaseService(db);
|
||||||
const secretsHandler = createPluginSecretsHandler({ db, pluginId, manifest: options.manifest });
|
const secretsHandler = createPluginSecretsHandler({ db, pluginId });
|
||||||
const companies = companyService(db);
|
const companies = companyService(db);
|
||||||
const agents = agentService(db);
|
const agents = agentService(db);
|
||||||
const managedAgents = pluginManagedAgentService(db, {
|
const managedAgents = pluginManagedAgentService(db, {
|
||||||
|
|
@ -1053,8 +1053,8 @@ export function buildHostServices(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: {
|
config: {
|
||||||
async get(params) {
|
async get() {
|
||||||
const configRow = await registry.getConfig(pluginId, params?.companyId ?? null);
|
const configRow = await registry.getConfig(pluginId);
|
||||||
return configRow?.configJson ?? {};
|
return configRow?.configJson ?? {};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -267,8 +267,6 @@ export interface PluginLifecycleManager {
|
||||||
export interface PluginLifecycleManagerOptions {
|
export interface PluginLifecycleManagerOptions {
|
||||||
/** Plugin loader instance. Falls back to the default if omitted. */
|
/** Plugin loader instance. Falls back to the default if omitted. */
|
||||||
loader?: PluginLoader;
|
loader?: PluginLoader;
|
||||||
/** Resolve the loader lazily for bootstrap paths with circular wiring. */
|
|
||||||
resolveLoader?: () => PluginLoader | undefined;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Worker process manager. When provided, lifecycle transitions that bring
|
* Worker process manager. When provided, lifecycle transitions that bring
|
||||||
|
|
@ -310,7 +308,6 @@ export function pluginLifecycleManager(
|
||||||
// Support the legacy signature: pluginLifecycleManager(db, loader)
|
// Support the legacy signature: pluginLifecycleManager(db, loader)
|
||||||
// as well as the new options object form.
|
// as well as the new options object form.
|
||||||
let loaderArg: PluginLoader | undefined;
|
let loaderArg: PluginLoader | undefined;
|
||||||
let resolveLoader: (() => PluginLoader | undefined) | undefined;
|
|
||||||
let workerManager: PluginWorkerManager | undefined;
|
let workerManager: PluginWorkerManager | undefined;
|
||||||
|
|
||||||
if (options && typeof options === "object" && "discoverAll" in options) {
|
if (options && typeof options === "object" && "discoverAll" in options) {
|
||||||
|
|
@ -319,12 +316,11 @@ export function pluginLifecycleManager(
|
||||||
} else if (options && typeof options === "object") {
|
} else if (options && typeof options === "object") {
|
||||||
const opts = options as PluginLifecycleManagerOptions;
|
const opts = options as PluginLifecycleManagerOptions;
|
||||||
loaderArg = opts.loader;
|
loaderArg = opts.loader;
|
||||||
resolveLoader = opts.resolveLoader;
|
|
||||||
workerManager = opts.workerManager;
|
workerManager = opts.workerManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = pluginRegistryService(db);
|
const registry = pluginRegistryService(db);
|
||||||
const fallbackLoader = loaderArg ?? pluginLoader(db);
|
const pluginLoaderInstance = loaderArg ?? pluginLoader(db);
|
||||||
const emitter = new EventEmitter();
|
const emitter = new EventEmitter();
|
||||||
emitter.setMaxListeners(100); // plugins may have many listeners; 100 is a safe upper bound
|
emitter.setMaxListeners(100); // plugins may have many listeners; 100 is a safe upper bound
|
||||||
|
|
||||||
|
|
@ -390,10 +386,6 @@ export function pluginLifecycleManager(
|
||||||
emitter.emit(event, payload);
|
emitter.emit(event, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentLoader(): PluginLoader {
|
|
||||||
return resolveLoader?.() ?? loaderArg ?? fallbackLoader;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Worker management helpers
|
// Worker management helpers
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -423,15 +415,14 @@ export function pluginLifecycleManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activateReadyPlugin(pluginId: string): Promise<void> {
|
async function activateReadyPlugin(pluginId: string): Promise<void> {
|
||||||
const loader = currentLoader();
|
|
||||||
const supportsRuntimeActivation =
|
const supportsRuntimeActivation =
|
||||||
typeof loader.hasRuntimeServices === "function"
|
typeof pluginLoaderInstance.hasRuntimeServices === "function"
|
||||||
&& typeof loader.loadSingle === "function";
|
&& typeof pluginLoaderInstance.loadSingle === "function";
|
||||||
if (!supportsRuntimeActivation || !loader.hasRuntimeServices()) {
|
if (!supportsRuntimeActivation || !pluginLoaderInstance.hasRuntimeServices()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadResult = await loader.loadSingle(pluginId);
|
const loadResult = await pluginLoaderInstance.loadSingle(pluginId);
|
||||||
if (!loadResult.success) {
|
if (!loadResult.success) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
loadResult.error
|
loadResult.error
|
||||||
|
|
@ -444,13 +435,12 @@ export function pluginLifecycleManager(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
pluginKey: string,
|
pluginKey: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const loader = currentLoader();
|
|
||||||
const supportsRuntimeDeactivation =
|
const supportsRuntimeDeactivation =
|
||||||
typeof loader.hasRuntimeServices === "function"
|
typeof pluginLoaderInstance.hasRuntimeServices === "function"
|
||||||
&& typeof loader.unloadSingle === "function";
|
&& typeof pluginLoaderInstance.unloadSingle === "function";
|
||||||
|
|
||||||
if (supportsRuntimeDeactivation && loader.hasRuntimeServices()) {
|
if (supportsRuntimeDeactivation && pluginLoaderInstance.hasRuntimeServices()) {
|
||||||
await loader.unloadSingle(pluginId, pluginKey);
|
await pluginLoaderInstance.unloadSingle(pluginId, pluginKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -551,7 +541,7 @@ export function pluginLifecycleManager(
|
||||||
// If already uninstalled and removeData, hard-delete
|
// If already uninstalled and removeData, hard-delete
|
||||||
if (plugin.status === "uninstalled") {
|
if (plugin.status === "uninstalled") {
|
||||||
if (removeData) {
|
if (removeData) {
|
||||||
await currentLoader().cleanupInstallArtifacts(plugin);
|
await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
|
||||||
const deleted = await registry.uninstall(pluginId, true);
|
const deleted = await registry.uninstall(pluginId, true);
|
||||||
log.info(
|
log.info(
|
||||||
{ pluginId, pluginKey: plugin.pluginKey },
|
{ pluginId, pluginKey: plugin.pluginKey },
|
||||||
|
|
@ -571,7 +561,7 @@ export function pluginLifecycleManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||||
await currentLoader().cleanupInstallArtifacts(plugin);
|
await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
|
||||||
|
|
||||||
// Perform the uninstall via registry (handles soft/hard delete)
|
// Perform the uninstall via registry (handles soft/hard delete)
|
||||||
const result = await registry.uninstall(pluginId, removeData);
|
const result = await registry.uninstall(pluginId, removeData);
|
||||||
|
|
@ -666,7 +656,7 @@ export function pluginLifecycleManager(
|
||||||
|
|
||||||
// 1. Download and validate new package via loader
|
// 1. Download and validate new package via loader
|
||||||
const { oldManifest, newManifest, discovered } =
|
const { oldManifest, newManifest, discovered } =
|
||||||
await currentLoader().upgradePlugin(pluginId, { version });
|
await pluginLoaderInstance.upgradePlugin(pluginId, { version });
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
{
|
{
|
||||||
|
|
@ -787,10 +777,10 @@ export function pluginLifecycleManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportsRuntimeActivation =
|
const supportsRuntimeActivation =
|
||||||
typeof currentLoader().hasRuntimeServices === "function"
|
typeof pluginLoaderInstance.hasRuntimeServices === "function"
|
||||||
&& typeof currentLoader().loadSingle === "function"
|
&& typeof pluginLoaderInstance.loadSingle === "function"
|
||||||
&& typeof currentLoader().unloadSingle === "function"
|
&& typeof pluginLoaderInstance.unloadSingle === "function"
|
||||||
&& currentLoader().hasRuntimeServices();
|
&& pluginLoaderInstance.hasRuntimeServices();
|
||||||
|
|
||||||
if (supportsRuntimeActivation) {
|
if (supportsRuntimeActivation) {
|
||||||
log.info(
|
log.info(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { asc, eq, ne, sql, and, isNull } from "drizzle-orm";
|
import { asc, eq, ne, sql, and } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
plugins,
|
plugins,
|
||||||
|
|
@ -44,13 +44,6 @@ function isPluginKeyConflict(error: unknown): boolean {
|
||||||
return err.code === "23505" && constraint === "plugins_plugin_key_idx";
|
return err.code === "23505" && constraint === "plugins_plugin_key_idx";
|
||||||
}
|
}
|
||||||
|
|
||||||
function pluginConfigExactScopeCondition(pluginId: string, companyId?: string | null) {
|
|
||||||
return and(
|
|
||||||
eq(pluginConfig.pluginId, pluginId),
|
|
||||||
companyId ? eq(pluginConfig.companyId, companyId) : isNull(pluginConfig.companyId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Service
|
// Service
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -287,37 +280,27 @@ export function pluginRegistryService(db: Db) {
|
||||||
|
|
||||||
// ----- Config ---------------------------------------------------------
|
// ----- Config ---------------------------------------------------------
|
||||||
|
|
||||||
/** Retrieve a plugin's company-scoped config, or the legacy global fallback. */
|
/** Retrieve a plugin's instance configuration. */
|
||||||
getConfig: async (pluginId: string, companyId?: string | null) => {
|
getConfig: (pluginId: string) =>
|
||||||
if (companyId) {
|
db
|
||||||
const scoped = await db
|
|
||||||
.select()
|
.select()
|
||||||
.from(pluginConfig)
|
.from(pluginConfig)
|
||||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
.where(eq(pluginConfig.pluginId, pluginId))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null),
|
||||||
if (scoped) return scoped;
|
|
||||||
}
|
|
||||||
|
|
||||||
return db
|
|
||||||
.select()
|
|
||||||
.from(pluginConfig)
|
|
||||||
.where(pluginConfigExactScopeCondition(pluginId, null))
|
|
||||||
.then((rows) => rows[0] ?? null);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create or fully replace a plugin's instance configuration.
|
* Create or fully replace a plugin's instance configuration.
|
||||||
* If a config row already exists for the plugin it is replaced;
|
* If a config row already exists for the plugin it is replaced;
|
||||||
* otherwise a new row is inserted.
|
* otherwise a new row is inserted.
|
||||||
*/
|
*/
|
||||||
upsertConfig: async (pluginId: string, input: UpsertPluginConfig, companyId?: string | null) => {
|
upsertConfig: async (pluginId: string, input: UpsertPluginConfig) => {
|
||||||
const plugin = await getById(pluginId);
|
const plugin = await getById(pluginId);
|
||||||
if (!plugin) throw notFound("Plugin not found");
|
if (!plugin) throw notFound("Plugin not found");
|
||||||
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(pluginConfig)
|
.from(pluginConfig)
|
||||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
.where(eq(pluginConfig.pluginId, pluginId))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -328,7 +311,7 @@ export function pluginRegistryService(db: Db) {
|
||||||
lastError: null,
|
lastError: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
.where(eq(pluginConfig.pluginId, pluginId))
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0]);
|
.then((rows) => rows[0]);
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +320,6 @@ export function pluginRegistryService(db: Db) {
|
||||||
.insert(pluginConfig)
|
.insert(pluginConfig)
|
||||||
.values({
|
.values({
|
||||||
pluginId,
|
pluginId,
|
||||||
companyId: companyId ?? null,
|
|
||||||
configJson: input.configJson,
|
configJson: input.configJson,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
@ -348,14 +330,14 @@ export function pluginRegistryService(db: Db) {
|
||||||
* Partially update a plugin's instance configuration via shallow merge.
|
* Partially update a plugin's instance configuration via shallow merge.
|
||||||
* If no config row exists yet one is created with the supplied values.
|
* If no config row exists yet one is created with the supplied values.
|
||||||
*/
|
*/
|
||||||
patchConfig: async (pluginId: string, input: PatchPluginConfig, companyId?: string | null) => {
|
patchConfig: async (pluginId: string, input: PatchPluginConfig) => {
|
||||||
const plugin = await getById(pluginId);
|
const plugin = await getById(pluginId);
|
||||||
if (!plugin) throw notFound("Plugin not found");
|
if (!plugin) throw notFound("Plugin not found");
|
||||||
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(pluginConfig)
|
.from(pluginConfig)
|
||||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
.where(eq(pluginConfig.pluginId, pluginId))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -367,7 +349,7 @@ export function pluginRegistryService(db: Db) {
|
||||||
lastError: null,
|
lastError: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
.where(eq(pluginConfig.pluginId, pluginId))
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0]);
|
.then((rows) => rows[0]);
|
||||||
}
|
}
|
||||||
|
|
@ -376,7 +358,6 @@ export function pluginRegistryService(db: Db) {
|
||||||
.insert(pluginConfig)
|
.insert(pluginConfig)
|
||||||
.values({
|
.values({
|
||||||
pluginId,
|
pluginId,
|
||||||
companyId: companyId ?? null,
|
|
||||||
configJson: input.configJson,
|
configJson: input.configJson,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
@ -387,11 +368,11 @@ export function pluginRegistryService(db: Db) {
|
||||||
* Record an error against a plugin's config (e.g. validation failure
|
* Record an error against a plugin's config (e.g. validation failure
|
||||||
* against the plugin's instanceConfigSchema).
|
* against the plugin's instanceConfigSchema).
|
||||||
*/
|
*/
|
||||||
setConfigError: async (pluginId: string, lastError: string | null, companyId?: string | null) => {
|
setConfigError: async (pluginId: string, lastError: string | null) => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.update(pluginConfig)
|
.update(pluginConfig)
|
||||||
.set({ lastError, updatedAt: new Date() })
|
.set({ lastError, updatedAt: new Date() })
|
||||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
.where(eq(pluginConfig.pluginId, pluginId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (rows.length === 0) throw notFound("Plugin config not found");
|
if (rows.length === 0) throw notFound("Plugin config not found");
|
||||||
|
|
@ -399,10 +380,10 @@ export function pluginRegistryService(db: Db) {
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Delete a plugin's config row. */
|
/** Delete a plugin's config row. */
|
||||||
deleteConfig: async (pluginId: string, companyId?: string | null) => {
|
deleteConfig: async (pluginId: string) => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.delete(pluginConfig)
|
.delete(pluginConfig)
|
||||||
.where(pluginConfigExactScopeCondition(pluginId, companyId))
|
.where(eq(pluginConfig.pluginId, pluginId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
||||||
import {
|
import {
|
||||||
collectSecretRefPaths,
|
collectSecretRefPaths,
|
||||||
isUuidSecretRef,
|
isUuidSecretRef,
|
||||||
readConfigValueAtPath,
|
readConfigValueAtPath,
|
||||||
} from "./json-schema-secret-refs.js";
|
} from "./json-schema-secret-refs.js";
|
||||||
import { pluginRegistryService } from "./plugin-registry.js";
|
|
||||||
import { secretService } from "./secrets.js";
|
|
||||||
|
|
||||||
export const PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE =
|
export const PLUGIN_SECRET_REFS_DISABLED_MESSAGE =
|
||||||
"Plugin secret references require an active company-scoped runtime context";
|
"Plugin secret references are disabled until company-scoped plugin config lands";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Error helpers
|
// Error helpers
|
||||||
|
|
@ -128,8 +125,6 @@ export function extractSecretRefPathsFromConfig(
|
||||||
export interface PluginSecretsResolveParams {
|
export interface PluginSecretsResolveParams {
|
||||||
/** The secret reference string (a secret UUID). */
|
/** The secret reference string (a secret UUID). */
|
||||||
secretRef: string;
|
secretRef: string;
|
||||||
/** The company whose scoped plugin config is active for this invocation. */
|
|
||||||
companyId?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -144,8 +139,6 @@ export interface PluginSecretsHandlerOptions {
|
||||||
* that reach the plugin worker.
|
* that reach the plugin worker.
|
||||||
*/
|
*/
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
/** Plugin manifest, used to extract schema-declared secret-ref paths. */
|
|
||||||
manifest?: PaperclipPluginManifestV1 | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -206,17 +199,24 @@ function createRateLimiter(maxAttempts: number, windowMs: number) {
|
||||||
export function createPluginSecretsHandler(
|
export function createPluginSecretsHandler(
|
||||||
options: PluginSecretsHandlerOptions,
|
options: PluginSecretsHandlerOptions,
|
||||||
): PluginSecretsService {
|
): PluginSecretsService {
|
||||||
const { pluginId, manifest } = options;
|
const { pluginId } = options;
|
||||||
const registry = pluginRegistryService(options.db);
|
|
||||||
const secrets = secretService(options.db);
|
|
||||||
|
|
||||||
// Rate limit: max 30 resolution attempts per plugin+company per minute.
|
// Rate limit: max 30 resolution attempts per plugin per minute
|
||||||
const rateLimiter = createRateLimiter(30, 60_000);
|
const rateLimiter = createRateLimiter(30, 60_000);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async resolve(params: PluginSecretsResolveParams): Promise<string> {
|
async resolve(params: PluginSecretsResolveParams): Promise<string> {
|
||||||
const { secretRef } = params;
|
const { secretRef } = params;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 0. Rate limiting — prevent brute-force UUID enumeration
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
if (!rateLimiter.check(pluginId)) {
|
||||||
|
const err = new Error("Rate limit exceeded for secret resolution");
|
||||||
|
err.name = "RateLimitExceededError";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// 1. Validate the ref format
|
// 1. Validate the ref format
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|
@ -230,41 +230,9 @@ export function createPluginSecretsHandler(
|
||||||
throw invalidSecretRef(trimmedRef);
|
throw invalidSecretRef(trimmedRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyId = typeof params.companyId === "string" ? params.companyId.trim() : "";
|
// Fail closed until plugin config and worker runtime both carry an
|
||||||
const rateLimitKey = `${pluginId}:${companyId || "__no_company__"}`;
|
// explicit company scope for secret bindings and resolution.
|
||||||
if (!rateLimiter.check(rateLimitKey)) {
|
throw new Error(PLUGIN_SECRET_REFS_DISABLED_MESSAGE);
|
||||||
const err = new Error("Rate limit exceeded for secret resolution");
|
|
||||||
err.name = "RateLimitExceededError";
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!companyId) {
|
|
||||||
throw new Error(PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configRow = await registry.getConfig(pluginId, companyId);
|
|
||||||
const refsBySecret = extractSecretRefPathsFromConfig(
|
|
||||||
configRow?.configJson ?? {},
|
|
||||||
manifest?.instanceConfigSchema,
|
|
||||||
);
|
|
||||||
const paths = [...(refsBySecret.get(trimmedRef) ?? [])];
|
|
||||||
if (paths.length === 0) {
|
|
||||||
throw new Error("Secret is not referenced by this company's plugin config");
|
|
||||||
}
|
|
||||||
if (paths.length > 1) {
|
|
||||||
throw new Error(
|
|
||||||
`Secret reference is ambiguous in this company's plugin config at: ${paths.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return secrets.resolveSecretValue(companyId, trimmedRef, "latest", {
|
|
||||||
consumerType: "plugin",
|
|
||||||
consumerId: pluginId,
|
|
||||||
configPath: paths[0],
|
|
||||||
actorType: "plugin",
|
|
||||||
actorId: pluginId,
|
|
||||||
pluginId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2034,7 +2034,6 @@ export function secretService(db: Db) {
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
}>,
|
}>,
|
||||||
options?: { db?: SecretBindingDb },
|
|
||||||
) => {
|
) => {
|
||||||
const normalizedRefs: Array<{
|
const normalizedRefs: Array<{
|
||||||
secretId: string;
|
secretId: string;
|
||||||
|
|
@ -2043,9 +2042,8 @@ export function secretService(db: Db) {
|
||||||
required: boolean;
|
required: boolean;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
}> = [];
|
}> = [];
|
||||||
const bindingDb = options?.db ?? db;
|
|
||||||
for (const ref of refs) {
|
for (const ref of refs) {
|
||||||
await assertSecretInCompany(companyId, ref.secretId, bindingDb);
|
await assertSecretInCompany(companyId, ref.secretId);
|
||||||
normalizedRefs.push({
|
normalizedRefs.push({
|
||||||
secretId: ref.secretId,
|
secretId: ref.secretId,
|
||||||
configPath: ref.configPath,
|
configPath: ref.configPath,
|
||||||
|
|
@ -2057,10 +2055,10 @@ export function secretService(db: Db) {
|
||||||
|
|
||||||
const pathPrefixes = [...new Set(normalizedRefs.map((ref) => ref.configPath.split(".")[0]))];
|
const pathPrefixes = [...new Set(normalizedRefs.map((ref) => ref.configPath.split(".")[0]))];
|
||||||
|
|
||||||
const writeBindings = async (targetDb: SecretBindingDb) => {
|
await db.transaction(async (tx) => {
|
||||||
if (pathPrefixes.length > 0) {
|
if (pathPrefixes.length > 0) {
|
||||||
for (const pathPrefix of pathPrefixes) {
|
for (const pathPrefix of pathPrefixes) {
|
||||||
await targetDb
|
await tx
|
||||||
.delete(companySecretBindings)
|
.delete(companySecretBindings)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
|
@ -2072,7 +2070,7 @@ export function secretService(db: Db) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await targetDb
|
await tx
|
||||||
.delete(companySecretBindings)
|
.delete(companySecretBindings)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
|
@ -2083,7 +2081,7 @@ export function secretService(db: Db) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (normalizedRefs.length === 0) return;
|
if (normalizedRefs.length === 0) return;
|
||||||
await targetDb.insert(companySecretBindings).values(
|
await tx.insert(companySecretBindings).values(
|
||||||
normalizedRefs.map((ref) => ({
|
normalizedRefs.map((ref) => ({
|
||||||
companyId,
|
companyId,
|
||||||
secretId: ref.secretId,
|
secretId: ref.secretId,
|
||||||
|
|
@ -2095,13 +2093,7 @@ export function secretService(db: Db) {
|
||||||
label: ref.label,
|
label: ref.label,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
if (options?.db) {
|
|
||||||
await writeBindings(options.db);
|
|
||||||
} else {
|
|
||||||
await db.transaction(async (tx) => writeBindings(tx));
|
|
||||||
}
|
|
||||||
return normalizedRefs;
|
return normalizedRefs;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -357,10 +357,8 @@ export const pluginsApi = {
|
||||||
*
|
*
|
||||||
* @param pluginId - UUID of the plugin.
|
* @param pluginId - UUID of the plugin.
|
||||||
*/
|
*/
|
||||||
getConfig: (pluginId: string, companyId?: string | null) => {
|
getConfig: (pluginId: string) =>
|
||||||
const qs = companyId ? `?companyId=${encodeURIComponent(companyId)}` : "";
|
api.get<PluginConfig | null>(`/plugins/${pluginId}/config`),
|
||||||
return api.get<PluginConfig | null>(`/plugins/${pluginId}/config${qs}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save (create or update) the configuration for a plugin.
|
* Save (create or update) the configuration for a plugin.
|
||||||
|
|
@ -371,8 +369,8 @@ export const pluginsApi = {
|
||||||
* @param pluginId - UUID of the plugin.
|
* @param pluginId - UUID of the plugin.
|
||||||
* @param configJson - Configuration values matching the plugin's `instanceConfigSchema`.
|
* @param configJson - Configuration values matching the plugin's `instanceConfigSchema`.
|
||||||
*/
|
*/
|
||||||
saveConfig: (pluginId: string, configJson: Record<string, unknown>, companyId?: string | null) =>
|
saveConfig: (pluginId: string, configJson: Record<string, unknown>) =>
|
||||||
api.post<PluginConfig>(`/plugins/${pluginId}/config`, { configJson, companyId }),
|
api.post<PluginConfig>(`/plugins/${pluginId}/config`, { configJson }),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call the plugin's `validateConfig` RPC method to test the configuration
|
* Call the plugin's `validateConfig` RPC method to test the configuration
|
||||||
|
|
|
||||||
|
|
@ -199,8 +199,7 @@ export const queryKeys = {
|
||||||
detail: (pluginId: string) => ["plugins", pluginId] as const,
|
detail: (pluginId: string) => ["plugins", pluginId] as const,
|
||||||
health: (pluginId: string) => ["plugins", pluginId, "health"] as const,
|
health: (pluginId: string) => ["plugins", pluginId, "health"] as const,
|
||||||
uiContributions: ["plugins", "ui-contributions"] as const,
|
uiContributions: ["plugins", "ui-contributions"] as const,
|
||||||
config: (pluginId: string, companyId?: string | null) =>
|
config: (pluginId: string) => ["plugins", pluginId, "config"] as const,
|
||||||
["plugins", pluginId, "config", companyId ?? null] as const,
|
|
||||||
localFolders: (pluginId: string, companyId: string) =>
|
localFolders: (pluginId: string, companyId: string) =>
|
||||||
["plugins", pluginId, "companies", companyId, "local-folders"] as const,
|
["plugins", pluginId, "companies", companyId, "local-folders"] as const,
|
||||||
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
|
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ import {
|
||||||
* - `GET /api/plugins/:pluginId/health` — health diagnostics (polling).
|
* - `GET /api/plugins/:pluginId/health` — health diagnostics (polling).
|
||||||
* Only fetched when `plugin.status === "ready"`.
|
* Only fetched when `plugin.status === "ready"`.
|
||||||
* - `GET /api/plugins/:pluginId/dashboard` — aggregated runtime dashboard data (polling).
|
* - `GET /api/plugins/:pluginId/dashboard` — aggregated runtime dashboard data (polling).
|
||||||
* - `GET /api/plugins/:pluginId/config?companyId=...` — current company config values.
|
* - `GET /api/plugins/:pluginId/config` — current config values.
|
||||||
* - `POST /api/plugins/:pluginId/config` — save company config values.
|
* - `POST /api/plugins/:pluginId/config` — save config values.
|
||||||
* - `POST /api/plugins/:pluginId/config/test` — test configuration.
|
* - `POST /api/plugins/:pluginId/config/test` — test configuration.
|
||||||
*
|
*
|
||||||
* URL params:
|
* URL params:
|
||||||
|
|
@ -97,9 +97,9 @@ export function PluginSettings() {
|
||||||
const hasConfigSchema = configSchema && configSchema.properties && Object.keys(configSchema.properties).length > 0;
|
const hasConfigSchema = configSchema && configSchema.properties && Object.keys(configSchema.properties).length > 0;
|
||||||
|
|
||||||
const { data: configData, isLoading: configLoading } = useQuery({
|
const { data: configData, isLoading: configLoading } = useQuery({
|
||||||
queryKey: queryKeys.plugins.config(pluginId!, selectedCompanyId),
|
queryKey: queryKeys.plugins.config(pluginId!),
|
||||||
queryFn: () => pluginsApi.getConfig(pluginId!, selectedCompanyId),
|
queryFn: () => pluginsApi.getConfig(pluginId!),
|
||||||
enabled: !!pluginId && !!hasConfigSchema && !!selectedCompanyId,
|
enabled: !!pluginId && !!hasConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { slots } = usePluginSlots({
|
const { slots } = usePluginSlots({
|
||||||
|
|
@ -245,7 +245,6 @@ export function PluginSettings() {
|
||||||
) : hasConfigSchema ? (
|
) : hasConfigSchema ? (
|
||||||
<PluginConfigForm
|
<PluginConfigForm
|
||||||
pluginId={pluginId!}
|
pluginId={pluginId!}
|
||||||
companyId={selectedCompanyId}
|
|
||||||
schema={configSchema!}
|
schema={configSchema!}
|
||||||
initialValues={configData?.configJson}
|
initialValues={configData?.configJson}
|
||||||
isLoading={configLoading}
|
isLoading={configLoading}
|
||||||
|
|
@ -920,7 +919,6 @@ function isLikelyAbsolutePath(pathValue: string) {
|
||||||
|
|
||||||
interface PluginConfigFormProps {
|
interface PluginConfigFormProps {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
companyId?: string | null;
|
|
||||||
schema: JsonSchemaNode;
|
schema: JsonSchemaNode;
|
||||||
initialValues?: Record<string, unknown>;
|
initialValues?: Record<string, unknown>;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
|
@ -937,7 +935,7 @@ interface PluginConfigFormProps {
|
||||||
* Separated from PluginSettings to isolate re-render scope — only the form
|
* Separated from PluginSettings to isolate re-render scope — only the form
|
||||||
* re-renders on field changes, not the entire page.
|
* re-renders on field changes, not the entire page.
|
||||||
*/
|
*/
|
||||||
function PluginConfigForm({ pluginId, companyId, schema, initialValues, isLoading, pluginStatus, supportsConfigTest }: PluginConfigFormProps) {
|
function PluginConfigForm({ pluginId, schema, initialValues, isLoading, pluginStatus, supportsConfigTest }: PluginConfigFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Form values: start with saved values, fall back to schema defaults
|
// Form values: start with saved values, fall back to schema defaults
|
||||||
|
|
@ -973,11 +971,11 @@ function PluginConfigForm({ pluginId, companyId, schema, initialValues, isLoadin
|
||||||
// Save mutation
|
// Save mutation
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (configJson: Record<string, unknown>) =>
|
mutationFn: (configJson: Record<string, unknown>) =>
|
||||||
pluginsApi.saveConfig(pluginId, configJson, companyId),
|
pluginsApi.saveConfig(pluginId, configJson),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSaveMessage({ type: "success", text: "Configuration saved." });
|
setSaveMessage({ type: "success", text: "Configuration saved." });
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.config(pluginId, companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.config(pluginId) });
|
||||||
// Clear success message after 3s
|
// Clear success message after 3s
|
||||||
setTimeout(() => setSaveMessage(null), 3000);
|
setTimeout(() => setSaveMessage(null), 3000);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue