mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
Merge 0a56b88fa7 into 384903bdf4
This commit is contained in:
commit
e3d2851bf5
9 changed files with 262 additions and 12 deletions
|
|
@ -627,8 +627,16 @@ export function createHostClientHandlers(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Config
|
// Config
|
||||||
"config.get": gated("config.get", async (params) => {
|
"config.get": gated("config.get", async (params, context) => {
|
||||||
return services.config.get(params);
|
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) => {
|
"localFolders.declarations": gated("localFolders.declarations", async (params) => {
|
||||||
|
|
|
||||||
|
|
@ -1502,7 +1502,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
throw new Error(`No handler registered for job "${params.job.jobKey}"`);
|
throw new Error(`No handler registered for job "${params.job.jobKey}"`);
|
||||||
}
|
}
|
||||||
await handler(params.job);
|
await runtimeCompanyContext.run(
|
||||||
|
{ companyId: params.job.companyId ?? null },
|
||||||
|
() => handler(params.job),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWebhook(params: PluginWebhookInput): Promise<void> {
|
async function handleWebhook(params: PluginWebhookInput): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -514,4 +514,95 @@ describe("startWorkerRpcHost runtime company context", () => {
|
||||||
stdout.destroy();
|
stdout.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes runJob company context into config host calls", async () => {
|
||||||
|
const stdin = new PassThrough();
|
||||||
|
const stdout = new PassThrough();
|
||||||
|
const nextMessage = collectJsonLines(stdout);
|
||||||
|
|
||||||
|
const plugin = definePlugin({
|
||||||
|
async setup(ctx) {
|
||||||
|
ctx.jobs.register("check-job-context", async () => {
|
||||||
|
const config = await ctx.config.get();
|
||||||
|
await ctx.state.set(
|
||||||
|
{
|
||||||
|
scopeKind: "instance",
|
||||||
|
namespace: "job-context",
|
||||||
|
stateKey: "mode",
|
||||||
|
},
|
||||||
|
config.mode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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: "runJob",
|
||||||
|
params: {
|
||||||
|
job: {
|
||||||
|
id: "job-1",
|
||||||
|
jobKey: "check-job-context",
|
||||||
|
companyId: "company-1",
|
||||||
|
payload: {},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 stateRequest = await nextMessage();
|
||||||
|
expect(stateRequest).toMatchObject({
|
||||||
|
method: "state.set",
|
||||||
|
params: {
|
||||||
|
scopeKind: "instance",
|
||||||
|
namespace: "job-context",
|
||||||
|
stateKey: "mode",
|
||||||
|
value: "company-config",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeMessage(stdin, {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: stateRequest.id,
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(nextMessage()).resolves.toMatchObject({
|
||||||
|
id: 2,
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
host.stop();
|
||||||
|
stdin.destroy();
|
||||||
|
stdout.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
42
server/src/__tests__/plugin-config-bridge.test.ts
Normal file
42
server/src/__tests__/plugin-config-bridge.test.ts
Normal file
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
getConfig: vi.fn(),
|
getConfigExactScope: vi.fn(),
|
||||||
resolveSecretValue: vi.fn(),
|
resolveSecretValue: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../services/plugin-registry.js", () => ({
|
vi.mock("../services/plugin-registry.js", () => ({
|
||||||
pluginRegistryService: () => ({
|
pluginRegistryService: () => ({
|
||||||
getConfig: mocks.getConfig,
|
getConfigExactScope: mocks.getConfigExactScope,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ const manifest = {
|
||||||
|
|
||||||
describe("createPluginSecretsHandler runtime company scoping", () => {
|
describe("createPluginSecretsHandler runtime company scoping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.getConfig.mockReset();
|
mocks.getConfigExactScope.mockReset();
|
||||||
mocks.resolveSecretValue.mockReset();
|
mocks.resolveSecretValue.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -54,12 +54,12 @@ describe("createPluginSecretsHandler runtime company scoping", () => {
|
||||||
await expect(handler.resolve({ secretRef })).rejects.toThrow(
|
await expect(handler.resolve({ secretRef })).rejects.toThrow(
|
||||||
PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE,
|
PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE,
|
||||||
);
|
);
|
||||||
expect(mocks.getConfig).not.toHaveBeenCalled();
|
expect(mocks.getConfigExactScope).not.toHaveBeenCalled();
|
||||||
expect(mocks.resolveSecretValue).not.toHaveBeenCalled();
|
expect(mocks.resolveSecretValue).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a secret ref that is not referenced by that company's plugin config", async () => {
|
it("rejects a secret ref that is not referenced by that company's plugin config", async () => {
|
||||||
mocks.getConfig.mockResolvedValue({
|
mocks.getConfigExactScope.mockResolvedValue({
|
||||||
configJson: {
|
configJson: {
|
||||||
apiKeyRef: "88888888-8888-4888-8888-888888888888",
|
apiKeyRef: "88888888-8888-4888-8888-888888888888",
|
||||||
},
|
},
|
||||||
|
|
@ -74,12 +74,12 @@ describe("createPluginSecretsHandler runtime company scoping", () => {
|
||||||
await expect(handler.resolve({ secretRef, companyId })).rejects.toThrow(
|
await expect(handler.resolve({ secretRef, companyId })).rejects.toThrow(
|
||||||
/not referenced by this company's plugin config/i,
|
/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();
|
expect(mocks.resolveSecretValue).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves only through the company plugin binding context from saved config", async () => {
|
it("resolves only through the company plugin binding context from saved config", async () => {
|
||||||
mocks.getConfig.mockResolvedValue({
|
mocks.getConfigExactScope.mockResolvedValue({
|
||||||
configJson: {
|
configJson: {
|
||||||
apiKeyRef: secretRef,
|
apiKeyRef: secretRef,
|
||||||
},
|
},
|
||||||
|
|
@ -102,4 +102,20 @@ describe("createPluginSecretsHandler runtime company scoping", () => {
|
||||||
pluginId,
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1054,7 +1054,10 @@ export function buildHostServices(
|
||||||
return {
|
return {
|
||||||
config: {
|
config: {
|
||||||
async get(params) {
|
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 ?? {};
|
return configRow?.configJson ?? {};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,14 @@ export function pluginRegistryService(db: Db) {
|
||||||
|
|
||||||
// ----- Config ---------------------------------------------------------
|
// ----- 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. */
|
/** Retrieve a plugin's company-scoped config, or the legacy global fallback. */
|
||||||
getConfig: async (pluginId: string, companyId?: string | null) => {
|
getConfig: async (pluginId: string, companyId?: string | null) => {
|
||||||
if (companyId) {
|
if (companyId) {
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,10 @@ export function createPluginSecretsHandler(
|
||||||
throw new Error(PLUGIN_SECRET_REFS_REQUIRE_COMPANY_MESSAGE);
|
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(
|
const refsBySecret = extractSecretRefPathsFromConfig(
|
||||||
configRow?.configJson ?? {},
|
configRow?.configJson ?? {},
|
||||||
manifest?.instanceConfigSchema,
|
manifest?.instanceConfigSchema,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue