diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index ec58bf4e..94d0e1fa 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -627,8 +627,16 @@ export function createHostClientHandlers( return { // Config - "config.get": gated("config.get", async (params) => { - return services.config.get(params); + "config.get": gated("config.get", async (params, context) => { + const scopedCompanyId = readNonEmptyString(context?.invocationScope?.companyId); + const explicitCompanyId = Object.prototype.hasOwnProperty.call(params ?? {}, "companyId") + ? params.companyId ?? null + : undefined; + return services.config.get( + explicitCompanyId === undefined + ? (scopedCompanyId ? { companyId: scopedCompanyId } : {}) + : { companyId: explicitCompanyId }, + ); }), "localFolders.declarations": gated("localFolders.declarations", async (params) => { diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts index ef1eead9..e4f0b2ce 100644 --- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts +++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts @@ -514,4 +514,5 @@ describe("startWorkerRpcHost runtime company context", () => { stdout.destroy(); } }); + }); diff --git a/server/src/__tests__/plugin-config-bridge.test.ts b/server/src/__tests__/plugin-config-bridge.test.ts new file mode 100644 index 00000000..57d0a1e1 --- /dev/null +++ b/server/src/__tests__/plugin-config-bridge.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js"; + +describe("plugin config bridge scoping", () => { + it("falls back to the invocation company scope for config.get when the worker omits companyId", async () => { + const getConfig = vi.fn(async (params: { companyId?: string | null }) => ({ + scope: params.companyId ?? null, + })); + + const handlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: [], + services: { + config: { get: getConfig }, + } as never, + }); + + await expect( + handlers["config.get"]({}, { invocationScope: { companyId: "company-a" } }), + ).resolves.toEqual({ scope: "company-a" }); + expect(getConfig).toHaveBeenCalledWith({ companyId: "company-a" }); + }); + + it("preserves an explicit global config.get request even inside a company-scoped invocation", async () => { + const getConfig = vi.fn(async (params: { companyId?: string | null }) => ({ + scope: params.companyId ?? null, + })); + + const handlers = createHostClientHandlers({ + pluginId: "test.plugin", + capabilities: [], + services: { + config: { get: getConfig }, + } as never, + }); + + await expect( + handlers["config.get"]({ companyId: null }, { invocationScope: { companyId: "company-a" } }), + ).resolves.toEqual({ scope: null }); + expect(getConfig).toHaveBeenCalledWith({ companyId: null }); + }); +}); diff --git a/server/src/__tests__/plugin-host-services-config-scope.test.ts b/server/src/__tests__/plugin-host-services-config-scope.test.ts new file mode 100644 index 00000000..28518b4f --- /dev/null +++ b/server/src/__tests__/plugin-host-services-config-scope.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getConfig: vi.fn(), + getConfigExactScope: vi.fn(), +})); + +vi.mock("../services/plugin-registry.js", () => ({ + pluginRegistryService: () => ({ + getConfig: mocks.getConfig, + getConfigExactScope: mocks.getConfigExactScope, + }), +})); + +import { buildHostServices } from "../services/plugin-host-services.js"; + +function createEventBusStub() { + return { + forPlugin() { + return { + emit: vi.fn(), + subscribe: vi.fn(), + clear: vi.fn(), + }; + }, + } as any; +} + +describe("plugin host services config scoping", () => { + beforeEach(() => { + mocks.getConfig.mockReset(); + mocks.getConfigExactScope.mockReset(); + }); + + it("does not fall back to legacy global config for company-scoped reads", async () => { + mocks.getConfigExactScope.mockResolvedValue(null); + mocks.getConfig.mockResolvedValue({ + configJson: { tokenRef: "global-token-ref" }, + }); + + const services = buildHostServices( + {} as never, + "plugin-record-id", + "paperclip.example", + createEventBusStub(), + ); + + await expect( + services.config.get({ companyId: "company-a" }), + ).resolves.toEqual({}); + expect(mocks.getConfigExactScope).toHaveBeenCalledWith("plugin-record-id", "company-a"); + expect(mocks.getConfig).not.toHaveBeenCalled(); + + services.dispose(); + }); + + it("still reads the exact global row for explicit global requests", async () => { + mocks.getConfigExactScope.mockResolvedValue({ + configJson: { tokenRef: "global-token-ref" }, + }); + + const services = buildHostServices( + {} as never, + "plugin-record-id", + "paperclip.example", + createEventBusStub(), + ); + + await expect( + services.config.get({ companyId: null }), + ).resolves.toEqual({ tokenRef: "global-token-ref" }); + expect(mocks.getConfigExactScope).toHaveBeenCalledWith("plugin-record-id", null); + + services.dispose(); + }); +}); diff --git a/server/src/__tests__/plugin-secrets-handler-runtime.test.ts b/server/src/__tests__/plugin-secrets-handler-runtime.test.ts index da152656..504ad9e0 100644 --- a/server/src/__tests__/plugin-secrets-handler-runtime.test.ts +++ b/server/src/__tests__/plugin-secrets-handler-runtime.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - getConfig: vi.fn(), + getConfigExactScope: vi.fn(), resolveSecretValue: vi.fn(), })); vi.mock("../services/plugin-registry.js", () => ({ pluginRegistryService: () => ({ - getConfig: mocks.getConfig, + getConfigExactScope: mocks.getConfigExactScope, }), })); @@ -40,7 +40,7 @@ const manifest = { describe("createPluginSecretsHandler runtime company scoping", () => { beforeEach(() => { - mocks.getConfig.mockReset(); + mocks.getConfigExactScope.mockReset(); mocks.resolveSecretValue.mockReset(); }); @@ -54,12 +54,12 @@ describe("createPluginSecretsHandler runtime company scoping", () => { await expect(handler.resolve({ secretRef })).rejects.toThrow( PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE, ); - expect(mocks.getConfig).not.toHaveBeenCalled(); + expect(mocks.getConfigExactScope).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({ + mocks.getConfigExactScope.mockResolvedValue({ configJson: { apiKeyRef: "88888888-8888-4888-8888-888888888888", }, @@ -74,12 +74,12 @@ describe("createPluginSecretsHandler runtime company scoping", () => { 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.getConfigExactScope).toHaveBeenCalledWith(pluginId, companyId); expect(mocks.resolveSecretValue).not.toHaveBeenCalled(); }); it("resolves only through the company plugin binding context from saved config", async () => { - mocks.getConfig.mockResolvedValue({ + mocks.getConfigExactScope.mockResolvedValue({ configJson: { apiKeyRef: secretRef, }, @@ -102,4 +102,20 @@ describe("createPluginSecretsHandler runtime company scoping", () => { pluginId, }); }); + + it("does not authorize a company-scoped secret resolution from legacy global config fallback", async () => { + mocks.getConfigExactScope.mockResolvedValue(null); + + 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.getConfigExactScope).toHaveBeenCalledWith(pluginId, companyId); + expect(mocks.resolveSecretValue).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 4ea90454..e76151cf 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -1054,7 +1054,10 @@ export function buildHostServices( return { config: { async get(params) { - const configRow = await registry.getConfig(pluginId, params?.companyId ?? null); + const companyId = Object.prototype.hasOwnProperty.call(params ?? {}, "companyId") + ? params.companyId ?? null + : null; + const configRow = await registry.getConfigExactScope(pluginId, companyId); return configRow?.configJson ?? {}; }, }, diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index 355717e3..c80c0cf4 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -287,6 +287,14 @@ export function pluginRegistryService(db: Db) { // ----- Config --------------------------------------------------------- + /** Retrieve a plugin's config at the exact requested scope. */ + getConfigExactScope: (pluginId: string, companyId?: string | null) => + db + .select() + .from(pluginConfig) + .where(pluginConfigExactScopeCondition(pluginId, companyId)) + .then((rows) => rows[0] ?? null), + /** Retrieve a plugin's company-scoped config, or the legacy global fallback. */ getConfig: async (pluginId: string, companyId?: string | null) => { if (companyId) { diff --git a/server/src/services/plugin-secrets-handler.ts b/server/src/services/plugin-secrets-handler.ts index cadb33c7..84d370d0 100644 --- a/server/src/services/plugin-secrets-handler.ts +++ b/server/src/services/plugin-secrets-handler.ts @@ -242,7 +242,10 @@ export function createPluginSecretsHandler( throw new Error(PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE); } - const configRow = await registry.getConfig(pluginId, companyId); + // Authorize secret refs only from the exact company-scoped config row. + // Falling back to legacy/global config would let a live worker keep using + // stale global refs after a company-scoped invocation takes over. + const configRow = await registry.getConfigExactScope(pluginId, companyId); const refsBySecret = extractSecretRefPathsFromConfig( configRow?.configJson ?? {}, manifest?.instanceConfigSchema,