Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
+544
@@ -0,0 +1,544 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const pluginFindUniqueMock = vi.fn();
|
||||
const grantUpsertMock = vi.fn();
|
||||
const grantFindManyMock = vi.fn();
|
||||
const grantDeleteManyMock = vi.fn();
|
||||
const pluginFindFirstMock = vi.fn();
|
||||
const pluginUpdateMock = vi.fn();
|
||||
const transactionMock = vi.fn();
|
||||
const auditMock = vi.fn();
|
||||
const auditOutboxMock = vi.fn();
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
plugin: {
|
||||
findUnique: (...args: unknown[]) => pluginFindUniqueMock(...args),
|
||||
findFirst: (...args: unknown[]) => pluginFindFirstMock(...args),
|
||||
update: (...args: unknown[]) => pluginUpdateMock(...args),
|
||||
},
|
||||
$transaction: (...args: unknown[]) => transactionMock(...args),
|
||||
tenantPluginCapabilityGrant: {
|
||||
upsert: (...args: unknown[]) => grantUpsertMock(...args),
|
||||
findMany: (...args: unknown[]) => grantFindManyMock(...args),
|
||||
deleteMany: (...args: unknown[]) => grantDeleteManyMock(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@toolctrl/db/audit-events.server", () => ({
|
||||
createAuditEvent: (...args: unknown[]) => auditMock(...args),
|
||||
publishAuditEventViaOutbox: (...args: unknown[]) => auditOutboxMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/logger.server", () => ({
|
||||
createScopedLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const actor = { userId: "u-1", email: "admin@example.com", ip: null };
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
id: "com.acme.outbound",
|
||||
version: "1.0.0",
|
||||
name: "Outbound",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: "0".repeat(64),
|
||||
signature: "0".repeat(128),
|
||||
signedBy: "0".repeat(64),
|
||||
allowedHosts: [],
|
||||
hooks: [{ name: "tenant.created", export: "handleTenantCreated" }],
|
||||
capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }, { name: "log" }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
pluginFindUniqueMock.mockReset();
|
||||
grantUpsertMock.mockReset();
|
||||
grantFindManyMock.mockReset();
|
||||
grantDeleteManyMock.mockReset();
|
||||
pluginFindFirstMock.mockReset();
|
||||
pluginUpdateMock.mockReset();
|
||||
transactionMock.mockReset();
|
||||
auditMock.mockReset();
|
||||
auditOutboxMock.mockReset();
|
||||
transactionMock.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({
|
||||
plugin: {
|
||||
findFirst: (...args: unknown[]) => pluginFindFirstMock(...args),
|
||||
update: (...args: unknown[]) => pluginUpdateMock(...args),
|
||||
},
|
||||
tenantPluginCapabilityGrant: {
|
||||
findMany: (...args: unknown[]) => grantFindManyMock(...args),
|
||||
upsert: (...args: unknown[]) => grantUpsertMock(...args),
|
||||
deleteMany: (...args: unknown[]) => grantDeleteManyMock(...args),
|
||||
},
|
||||
})
|
||||
);
|
||||
auditOutboxMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
describe("plugin lifecycle capability grants", () => {
|
||||
test("accepts grants whose scope matches or narrows the manifest", async () => {
|
||||
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
|
||||
grantUpsertMock.mockImplementation(async ({ create }) => ({
|
||||
id: "grant-1",
|
||||
tenantId: create.tenantId,
|
||||
pluginId: create.pluginId,
|
||||
capabilityName: create.capabilityName,
|
||||
scopeJson: create.scopeJson,
|
||||
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
grantedByUserId: create.grantedByUserId,
|
||||
}));
|
||||
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(
|
||||
grantPluginCapabilities({
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
|
||||
actor,
|
||||
})
|
||||
).resolves.toHaveLength(1);
|
||||
expect(transactionMock).toHaveBeenCalledOnce();
|
||||
expect(auditOutboxMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "plugin.capabilities.granted",
|
||||
targetType: "plugin",
|
||||
targetId: "row-1",
|
||||
sourceModule: "plugin-lifecycle",
|
||||
outcome: "success",
|
||||
metadata: expect.objectContaining({
|
||||
pluginId: manifest.id,
|
||||
capabilityReviewDigest: null,
|
||||
grantedCapabilities: ["http.outbound"],
|
||||
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
|
||||
manifestCapabilities: [
|
||||
expect.objectContaining({
|
||||
capabilityName: "http.outbound",
|
||||
declaredScope: { allowedHosts: ["api.example.com"] },
|
||||
kind: "capability",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects grants that broaden manifest scope", async () => {
|
||||
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
|
||||
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(
|
||||
grantPluginCapabilities({
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com", "internal.example"] } }],
|
||||
actor,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "capability_scope_invalid" });
|
||||
expect(grantUpsertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects unavailable host capability grants in production", async () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
|
||||
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
|
||||
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(
|
||||
grantPluginCapabilities({
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
|
||||
actor,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "capability_unavailable" });
|
||||
expect(grantUpsertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects malformed or invented scope for unscoped capabilities", async () => {
|
||||
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest });
|
||||
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(
|
||||
grantPluginCapabilities({
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
grants: [{ capabilityName: "log", scopeJson: { tablePrefix: "tenant_" } }],
|
||||
actor,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "capability_scope_invalid" });
|
||||
|
||||
await expect(
|
||||
grantPluginCapabilities({
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
grants: [{ capabilityName: "log", scopeJson: "all" }],
|
||||
actor,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "capability_scope_invalid" });
|
||||
});
|
||||
|
||||
test("does not treat stale grants as sufficient after manifest scope changes", async () => {
|
||||
pluginFindFirstMock.mockResolvedValue({
|
||||
id: "row-1",
|
||||
pluginId: manifest.id,
|
||||
manifest: {
|
||||
...manifest,
|
||||
capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["api.example.com", "api2.example.com"] } }],
|
||||
},
|
||||
});
|
||||
grantFindManyMock.mockResolvedValue([
|
||||
{
|
||||
id: "grant-1",
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
capabilityName: "http.outbound",
|
||||
scopeJson: { allowedHosts: ["api.example.com", "internal.example"] },
|
||||
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
grantedByUserId: "u-1",
|
||||
},
|
||||
]);
|
||||
const { enablePlugin } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
|
||||
code: "capability_consent_missing",
|
||||
});
|
||||
expect(pluginUpdateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("requires manifest hook consent before enabling hook-only plugins", async () => {
|
||||
const hookOnlyManifest = {
|
||||
...manifest,
|
||||
capabilities: [],
|
||||
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
|
||||
};
|
||||
pluginFindFirstMock.mockResolvedValue({
|
||||
id: "row-1",
|
||||
pluginId: manifest.id,
|
||||
manifest: hookOnlyManifest,
|
||||
});
|
||||
grantFindManyMock.mockResolvedValue([]);
|
||||
const { enablePlugin } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
|
||||
code: "capability_consent_missing",
|
||||
message: expect.stringContaining("hook:workflow.executed"),
|
||||
});
|
||||
expect(pluginUpdateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("allows hook consent grants declared by the manifest", async () => {
|
||||
const hookOnlyManifest = {
|
||||
...manifest,
|
||||
capabilities: [],
|
||||
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
|
||||
};
|
||||
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest: hookOnlyManifest });
|
||||
grantUpsertMock.mockImplementation(async ({ create }) => ({
|
||||
id: "grant-1",
|
||||
tenantId: create.tenantId,
|
||||
pluginId: create.pluginId,
|
||||
capabilityName: create.capabilityName,
|
||||
scopeJson: create.scopeJson,
|
||||
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
grantedByUserId: create.grantedByUserId,
|
||||
}));
|
||||
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(
|
||||
grantPluginCapabilities({
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
grants: [{ capabilityName: "hook:workflow.executed", scopeJson: null }],
|
||||
actor,
|
||||
})
|
||||
).resolves.toHaveLength(1);
|
||||
expect(grantUpsertMock).toHaveBeenCalledWith(expect.objectContaining({ create: expect.objectContaining({ capabilityName: "hook:workflow.executed" }) }));
|
||||
});
|
||||
|
||||
test("rejects production hook consent grants for registered hooks outside the production allowlist", async () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
|
||||
const { registerHook } = await import("@toolctrl/plugin-runtime");
|
||||
const { z } = await import("zod");
|
||||
registerHook({
|
||||
name: "billing.refunded",
|
||||
outboxEventType: "plugin.hook.billing.refunded",
|
||||
payloadSchema: z.object({ invoiceId: z.string() }),
|
||||
});
|
||||
const hookManifest = {
|
||||
...manifest,
|
||||
capabilities: [],
|
||||
hooks: [{ name: "billing.refunded", export: "onBillingRefunded" }],
|
||||
};
|
||||
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest: hookManifest });
|
||||
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(
|
||||
grantPluginCapabilities({
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
grants: [{ capabilityName: "hook:billing.refunded", scopeJson: null }],
|
||||
actor,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "hook_unavailable" });
|
||||
expect(grantUpsertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects hook consent grants for hooks not registered by the host", async () => {
|
||||
const unregisteredHookManifest = {
|
||||
...manifest,
|
||||
capabilities: [],
|
||||
hooks: [{ name: "workflow.finished", export: "onWorkflowFinished" }],
|
||||
};
|
||||
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, manifest: unregisteredHookManifest });
|
||||
const { grantPluginCapabilities } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(
|
||||
grantPluginCapabilities({
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
grants: [{ capabilityName: "hook:workflow.finished", scopeJson: null }],
|
||||
actor,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "manifest_invalid",
|
||||
message: expect.stringContaining("workflow.finished"),
|
||||
});
|
||||
expect(grantUpsertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("enables hook-only plugins after hook consent is granted", async () => {
|
||||
const hookOnlyManifest = {
|
||||
...manifest,
|
||||
capabilities: [],
|
||||
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
|
||||
};
|
||||
pluginFindFirstMock.mockResolvedValue({
|
||||
id: "row-1",
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
version: "1.0.0",
|
||||
enabled: false,
|
||||
installedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
manifest: hookOnlyManifest,
|
||||
});
|
||||
grantFindManyMock.mockResolvedValue([
|
||||
{
|
||||
id: "grant-1",
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
capabilityName: "hook:workflow.executed",
|
||||
scopeJson: null,
|
||||
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
grantedByUserId: "u-1",
|
||||
},
|
||||
]);
|
||||
pluginUpdateMock.mockResolvedValue({
|
||||
id: "row-1",
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
installedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
manifest: hookOnlyManifest,
|
||||
});
|
||||
const { enablePlugin } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).resolves.toMatchObject({ enabled: true });
|
||||
expect(pluginUpdateMock).toHaveBeenCalledWith({ where: { id: "row-1" }, data: { enabled: true } });
|
||||
});
|
||||
|
||||
test("rejects enable for installed plugins with unregistered manifest hooks even when consent exists", async () => {
|
||||
const unregisteredHookManifest = {
|
||||
...manifest,
|
||||
capabilities: [],
|
||||
hooks: [{ name: "workflow.finished", export: "onWorkflowFinished" }],
|
||||
};
|
||||
pluginFindFirstMock.mockResolvedValue({
|
||||
id: "row-1",
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
version: "1.0.0",
|
||||
enabled: false,
|
||||
installedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
manifest: unregisteredHookManifest,
|
||||
});
|
||||
grantFindManyMock.mockResolvedValue([
|
||||
{
|
||||
id: "grant-1",
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
capabilityName: "hook:workflow.finished",
|
||||
scopeJson: null,
|
||||
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
grantedByUserId: "u-1",
|
||||
},
|
||||
]);
|
||||
const { enablePlugin } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
|
||||
code: "manifest_invalid",
|
||||
message: expect.stringContaining("workflow.finished"),
|
||||
});
|
||||
expect(pluginUpdateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects enable for registered hooks outside the production allowlist", async () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
|
||||
const { registerHook } = await import("@toolctrl/plugin-runtime");
|
||||
const { z } = await import("zod");
|
||||
registerHook({
|
||||
name: "billing.failed",
|
||||
outboxEventType: "plugin.hook.billing.failed",
|
||||
payloadSchema: z.object({ invoiceId: z.string() }),
|
||||
});
|
||||
const hookManifest = {
|
||||
...manifest,
|
||||
capabilities: [],
|
||||
hooks: [{ name: "billing.failed", export: "onBillingFailed" }],
|
||||
};
|
||||
pluginFindFirstMock.mockResolvedValue({
|
||||
id: "row-1",
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
version: "1.0.0",
|
||||
enabled: false,
|
||||
installedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
manifest: hookManifest,
|
||||
});
|
||||
grantFindManyMock.mockResolvedValue([
|
||||
{
|
||||
id: "grant-1",
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
capabilityName: "hook:billing.failed",
|
||||
scopeJson: null,
|
||||
grantedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
grantedByUserId: "u-1",
|
||||
},
|
||||
]);
|
||||
const { enablePlugin } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
|
||||
code: "hook_unavailable",
|
||||
});
|
||||
expect(grantFindManyMock).not.toHaveBeenCalled();
|
||||
expect(pluginUpdateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects unavailable host capabilities before enabling in production", async () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true";
|
||||
pluginFindFirstMock.mockResolvedValue({
|
||||
id: "row-1",
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
});
|
||||
const { enablePlugin } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "row-1", actor })).rejects.toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
});
|
||||
expect(grantFindManyMock).not.toHaveBeenCalled();
|
||||
expect(pluginUpdateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("revokes selected grants without disabling an already disabled plugin", async () => {
|
||||
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: false, manifest });
|
||||
grantDeleteManyMock.mockResolvedValue({ count: 1 });
|
||||
const { revokePluginCapabilities } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(
|
||||
revokePluginCapabilities({
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
capabilityNames: ["log"],
|
||||
actor,
|
||||
})
|
||||
).resolves.toEqual({ revokedCapabilities: ["log"], autoDisabled: false });
|
||||
|
||||
expect(pluginUpdateMock).not.toHaveBeenCalled();
|
||||
expect(grantDeleteManyMock).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
capabilityName: { in: ["log"] },
|
||||
},
|
||||
});
|
||||
expect(transactionMock).toHaveBeenCalledOnce();
|
||||
expect(auditOutboxMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "plugin.capabilities.revoked",
|
||||
metadata: { pluginId: manifest.id, revokedCapabilities: ["log"], autoDisabled: false },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("auto-disables enabled plugins before revoking grants", async () => {
|
||||
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: true, manifest });
|
||||
pluginUpdateMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: false, manifest });
|
||||
grantDeleteManyMock.mockResolvedValue({ count: 1 });
|
||||
const { revokePluginCapabilities } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(
|
||||
revokePluginCapabilities({
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
capabilityNames: ["http.outbound"],
|
||||
actor,
|
||||
})
|
||||
).resolves.toEqual({ revokedCapabilities: ["http.outbound"], autoDisabled: true });
|
||||
|
||||
expect(pluginUpdateMock).toHaveBeenCalledWith({
|
||||
where: { id: "row-1" },
|
||||
data: { enabled: false },
|
||||
});
|
||||
expect(grantDeleteManyMock).toHaveBeenCalledOnce();
|
||||
expect(transactionMock).toHaveBeenCalledOnce();
|
||||
expect(auditOutboxMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "plugin.capabilities.revoked",
|
||||
metadata: { pluginId: manifest.id, revokedCapabilities: ["http.outbound"], autoDisabled: true },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects revoke requests for capabilities not declared in the manifest", async () => {
|
||||
pluginFindUniqueMock.mockResolvedValue({ id: "row-1", pluginId: manifest.id, enabled: true, manifest });
|
||||
const { revokePluginCapabilities } = await import("./PluginLifecycleService");
|
||||
|
||||
await expect(
|
||||
revokePluginCapabilities({
|
||||
tenantId: "t-1",
|
||||
pluginId: manifest.id,
|
||||
capabilityNames: ["db.write"],
|
||||
actor,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "capability_not_in_manifest" });
|
||||
|
||||
expect(pluginUpdateMock).not.toHaveBeenCalled();
|
||||
expect(grantDeleteManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+1104
File diff suppressed because it is too large.
Load diff
+762
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* Phase 06 #22 — Plugin lifecycle service.
|
||||
*
|
||||
* Owns install / enable / disable / list operations against the
|
||||
* Plugin Prisma table created in #21. Each mutating call writes an
|
||||
* audit-event row through the existing `createAuditEvent` helper so
|
||||
* tenant admins have a tamper-evident trail of who installed or
|
||||
* toggled what.
|
||||
*
|
||||
* Foundation scope:
|
||||
* - install({ tenantId, pluginId, version, manifest, wasmSha256,
|
||||
* signaturePublicKey, actor }): create row + audit.
|
||||
* - enable / disable: flip Plugin.enabled + audit.
|
||||
* - list({ tenantId }): page through installed plugins.
|
||||
* - uninstall: delete row + audit (does NOT delete the .wasm binary
|
||||
* — operator-only step, deferred to #23 marketplace work).
|
||||
*
|
||||
* Deferred (see hooks-lifecycle.md):
|
||||
* - capability consent dialog flow (we trust the manifest's
|
||||
* declared capabilities for the foundation).
|
||||
* - per-tenant secret + settings tables.
|
||||
* - hot-reload + side-by-side version updates.
|
||||
*/
|
||||
|
||||
import { db } from "~/utils/db.server";
|
||||
import {
|
||||
isPluginCompatibleWithHost,
|
||||
isCapabilityGrantScopeSatisfied,
|
||||
parsePluginManifest,
|
||||
parseTrustedPublicKeys,
|
||||
verifyPluginSignature,
|
||||
assertPluginRuntimeProductionEnabled,
|
||||
getUnavailablePluginCapabilityNames,
|
||||
getUnavailablePluginHookNames,
|
||||
getPluginHook,
|
||||
type PluginManifest,
|
||||
type PluginVersionConstraints,
|
||||
} from "@toolctrl/plugin-runtime";
|
||||
import { createAuditEvent, publishAuditEventViaOutbox } from "@toolctrl/db/audit-events.server";
|
||||
import { createScopedLogger } from "~/utils/logger.server";
|
||||
import rootPackageJson from "../../../../../../../package.json" with { type: "json" };
|
||||
import { getPluginConsentRequirements, isPluginHookConsentName } from "../../utils/pluginConsentRequirements";
|
||||
|
||||
const logger = createScopedLogger("plugin-lifecycle");
|
||||
|
||||
export { isCapabilityGrantScopeSatisfied };
|
||||
|
||||
/**
|
||||
* Host version used by the install-time compat check (Phase 06 #23
|
||||
* Step 12). Reads from `PLUGIN_HOST_VERSION_OVERRIDE` first so operators
|
||||
* can pin a value during incident response without re-tagging the
|
||||
* monorepo, falls back to `npm_package_version` (set by pnpm at run
|
||||
* time), then to the literal version baked into `package.json` at
|
||||
* build time.
|
||||
*/
|
||||
function resolveHostVersion(): string {
|
||||
return process.env.PLUGIN_HOST_VERSION_OVERRIDE || process.env.npm_package_version || (rootPackageJson as { version: string }).version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull `minHostVersion` / `maxHostVersion` off the raw manifest input.
|
||||
* The Zod schema strips unknown keys via `.strict()`, so we read the
|
||||
* fields off the original object before the parse step would discard
|
||||
* them. Returns an empty object when the input is not a plain object
|
||||
* or carries neither field.
|
||||
*/
|
||||
function extractVersionConstraints(raw: unknown): PluginVersionConstraints {
|
||||
if (!raw || typeof raw !== "object") return {};
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const out: PluginVersionConstraints = {};
|
||||
if (typeof obj.minHostVersion === "string") out.minHostVersion = obj.minHostVersion;
|
||||
if (typeof obj.maxHostVersion === "string") out.maxHostVersion = obj.maxHostVersion;
|
||||
return out;
|
||||
}
|
||||
|
||||
export class PluginLifecycleError extends Error {
|
||||
public override readonly name = "PluginLifecycleError";
|
||||
public readonly code:
|
||||
| "manifest_invalid"
|
||||
| "already_installed"
|
||||
| "not_found"
|
||||
| "incompatible_host_version"
|
||||
| "signature_key_not_trusted"
|
||||
| "signature_key_mismatch"
|
||||
| "tenant_install_limit_exceeded"
|
||||
| "binary_not_found"
|
||||
| "binary_verification_failed"
|
||||
| "capability_consent_missing"
|
||||
| "production_guard_violation"
|
||||
| "capability_unavailable"
|
||||
| "hook_unavailable"
|
||||
| "capability_not_in_manifest"
|
||||
| "capability_scope_invalid";
|
||||
constructor(code: PluginLifecycleError["code"], message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F-005 Phase B — install-time binary signature verification.
|
||||
*
|
||||
* Phase A (already shipped) gates installs on the trust-store and on
|
||||
* `signedBy === signaturePublicKey`. Phase B closes the remaining gap:
|
||||
* we now also read the .wasm binary from disk and run the same
|
||||
* `verifyPluginSignature` the worker invokes per-call. If the binary
|
||||
* doesn't exist, has the wrong sha256, or fails Ed25519 verification,
|
||||
* we reject the install — the operator gets a synchronous, descriptive
|
||||
* error instead of learning about the mismatch via auto-disable noise.
|
||||
*
|
||||
* The binary base path mirrors the worker bootstrap convention; the
|
||||
* operator pre-positions the binary at
|
||||
* <PLUGIN_BINARY_BASE_PATH>/<pluginId>/<version>/plugin.wasm
|
||||
* before triggering the install API. Dev overrides via
|
||||
* `PLUGIN_BINARY_BASE_PATH`; default `/app/var/plugins`.
|
||||
*/
|
||||
const DEFAULT_PLUGIN_BINARY_BASE_PATH = "/app/var/plugins";
|
||||
|
||||
function isProductionRuntime() {
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
function assertPluginProductionEscapeHatchesDisabled() {
|
||||
if (!isProductionRuntime()) {
|
||||
return;
|
||||
}
|
||||
if (process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY === "true") {
|
||||
throw new PluginLifecycleError("production_guard_violation", "PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true is not allowed in production.");
|
||||
}
|
||||
if (process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT === "false") {
|
||||
throw new PluginLifecycleError("production_guard_violation", "PLUGIN_REQUIRE_CAPABILITY_CONSENT=false is not allowed in production.");
|
||||
}
|
||||
}
|
||||
|
||||
function assertPluginProductionRuntimeEnabled(operation: string) {
|
||||
try {
|
||||
assertPluginRuntimeProductionEnabled();
|
||||
} catch (error) {
|
||||
throw new PluginLifecycleError("production_guard_violation", `${operation} blocked: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertProductionCapabilitiesAvailable(operation: string, capabilities: ReadonlyArray<PluginManifest["capabilities"][number] | string>) {
|
||||
if (!isProductionRuntime()) {
|
||||
return;
|
||||
}
|
||||
const unavailable = getUnavailablePluginCapabilityNames(capabilities);
|
||||
if (unavailable.length === 0) {
|
||||
return;
|
||||
}
|
||||
throw new PluginLifecycleError(
|
||||
"capability_unavailable",
|
||||
`${operation} blocked: capabilities [${unavailable.join(", ")}] do not have reviewed production host bindings yet.`
|
||||
);
|
||||
}
|
||||
|
||||
function assertProductionHooksAvailable(operation: string, hooks: ReadonlyArray<PluginManifest["hooks"][number] | string>) {
|
||||
if (!isProductionRuntime()) {
|
||||
return;
|
||||
}
|
||||
const unavailable = getUnavailablePluginHookNames(hooks);
|
||||
if (unavailable.length === 0) {
|
||||
return;
|
||||
}
|
||||
throw new PluginLifecycleError(
|
||||
"hook_unavailable",
|
||||
`${operation} blocked: hooks [${unavailable.join(", ")}] are registered but not reviewed for production availability yet.`
|
||||
);
|
||||
}
|
||||
|
||||
function assertManifestHooksRegistered(manifest: PluginManifest) {
|
||||
const unknownHook = manifest.hooks.find((hook) => !getPluginHook(hook.name));
|
||||
if (unknownHook) {
|
||||
throw new PluginLifecycleError(
|
||||
"manifest_invalid",
|
||||
`Plugin ${manifest.id}@${manifest.version} declares unknown hook '${unknownHook.name}'. Register the hook in the host runtime before installing.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoredPluginManifest(manifestJson: unknown, context: { pluginId?: string; version?: string }): PluginManifest {
|
||||
const parsed = parsePluginManifest(manifestJson);
|
||||
if (parsed.ok) return parsed.manifest;
|
||||
const label = context.pluginId && context.version ? ` for ${context.pluginId}@${context.version}` : "";
|
||||
throw new PluginLifecycleError("manifest_invalid", `Stored plugin manifest${label} failed validation: ${JSON.stringify(parsed.issues)}`);
|
||||
}
|
||||
|
||||
async function readPluginBinary(args: { basePath: string; pluginId: string; version: string }): Promise<Uint8Array | null> {
|
||||
const { promises: fs } = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const filePath = path.join(args.basePath, args.pluginId, args.version, "plugin.wasm");
|
||||
try {
|
||||
const buf = await fs.readFile(filePath);
|
||||
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F-009 — per-tenant install cap. Without this, a tenant admin can
|
||||
* install thousands of plugin rows to bloat the Plugin table and the
|
||||
* hook fan-out path. The default is conservative (50); operators
|
||||
* override via `PLUGIN_MAX_INSTALLS_PER_TENANT`. A non-positive override
|
||||
* disables the cap so dev environments can opt out.
|
||||
*/
|
||||
const DEFAULT_MAX_INSTALLS_PER_TENANT = 50;
|
||||
|
||||
function resolveMaxInstallsPerTenant(): number {
|
||||
const raw = process.env.PLUGIN_MAX_INSTALLS_PER_TENANT;
|
||||
if (typeof raw !== "string" || raw.length === 0) return DEFAULT_MAX_INSTALLS_PER_TENANT;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_MAX_INSTALLS_PER_TENANT;
|
||||
if (parsed <= 0) return Number.POSITIVE_INFINITY; // 0 or negative disables the cap
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export type PluginLifecycleActor = {
|
||||
userId: string | null;
|
||||
email: string | null;
|
||||
ip: string | null;
|
||||
};
|
||||
|
||||
export type InstallPluginInput = {
|
||||
tenantId: string;
|
||||
manifestJson: unknown;
|
||||
wasmSha256: string;
|
||||
signaturePublicKey: string;
|
||||
actor: PluginLifecycleActor;
|
||||
};
|
||||
|
||||
export type PluginListItem = {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
manifest: PluginManifest;
|
||||
installedAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export async function installPlugin(input: InstallPluginInput): Promise<PluginListItem> {
|
||||
assertPluginProductionRuntimeEnabled("Plugin install");
|
||||
assertPluginProductionEscapeHatchesDisabled();
|
||||
const parsed = parsePluginManifest(input.manifestJson);
|
||||
if (!parsed.ok) {
|
||||
throw new PluginLifecycleError("manifest_invalid", `Manifest validation failed: ${JSON.stringify(parsed.issues)}`);
|
||||
}
|
||||
const manifest = parsed.manifest;
|
||||
assertManifestHooksRegistered(manifest);
|
||||
assertProductionHooksAvailable("Plugin install", manifest.hooks ?? []);
|
||||
assertProductionCapabilitiesAvailable("Plugin install", manifest.capabilities ?? []);
|
||||
|
||||
// Host-version compatibility check (Phase 06 #23 Step 12). The Zod
|
||||
// schema does not yet carry `minHostVersion` / `maxHostVersion` —
|
||||
// they ride on the raw manifest object when the plugin author opts
|
||||
// in, and we read them off the original input so the strict-parse
|
||||
// step does not strip them before the check runs.
|
||||
const rawConstraints = extractVersionConstraints(input.manifestJson);
|
||||
const hostVersion = resolveHostVersion();
|
||||
const compat = isPluginCompatibleWithHost({ ...manifest, ...rawConstraints }, hostVersion);
|
||||
if (!compat.compatible) {
|
||||
throw new PluginLifecycleError("incompatible_host_version", `Plugin ${manifest.id}@${manifest.version} rejected: ${compat.reason} — ${compat.details}`);
|
||||
}
|
||||
|
||||
// F-005 Phase A — install-time trust-store gate. The worker re-verifies
|
||||
// the binary signature at every invocation, but we should not let a
|
||||
// tenant admin pin a Plugin row to a publisher key the operator has
|
||||
// not trusted: doing so pollutes the table with rows that will always
|
||||
// fail to invoke and could mislead the audit trail. We also require
|
||||
// that the submitted `signaturePublicKey` matches the manifest's
|
||||
// `signedBy` field — anything else means the caller sent inconsistent
|
||||
// metadata and we reject up front.
|
||||
//
|
||||
// Phase B (full binary signature verify on install — i.e. running
|
||||
// `verifyPluginSignature` against the actual .wasm on disk before
|
||||
// creating the row) still depends on the operator-side binary upload
|
||||
// pipeline and is tracked in plugin-internal-findings.md F-005.
|
||||
const submittedKey = input.signaturePublicKey.toLowerCase();
|
||||
if (manifest.signedBy.toLowerCase() !== submittedKey) {
|
||||
throw new PluginLifecycleError("signature_key_mismatch", `signaturePublicKey does not match manifest.signedBy for plugin ${manifest.id}.`);
|
||||
}
|
||||
const trustedKeys = parseTrustedPublicKeys(process.env.PLUGIN_TRUSTED_PUBLIC_KEYS);
|
||||
if (!trustedKeys.includes(submittedKey)) {
|
||||
throw new PluginLifecycleError(
|
||||
"signature_key_not_trusted",
|
||||
`Publisher key ${submittedKey.slice(0, 16)}… is not in PLUGIN_TRUSTED_PUBLIC_KEYS — install rejected.`
|
||||
);
|
||||
}
|
||||
|
||||
// F-005 Phase B — verify the .wasm binary against the manifest BEFORE
|
||||
// creating the Plugin row. This catches sha256/signature mismatches at
|
||||
// install time instead of letting the worker discover them at invoke
|
||||
// (where they would surface as repeated `signature_invalid` failures
|
||||
// and trip the crash tracker).
|
||||
//
|
||||
// Skipped intentionally when PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true so
|
||||
// dev environments without binaries on disk can still run integration
|
||||
// tests. The flag must NOT be set in production — documented in the
|
||||
// operator runbook.
|
||||
if (process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY !== "true") {
|
||||
const basePath = process.env.PLUGIN_BINARY_BASE_PATH?.trim() || DEFAULT_PLUGIN_BINARY_BASE_PATH;
|
||||
const binary = await readPluginBinary({
|
||||
basePath,
|
||||
pluginId: manifest.id,
|
||||
version: manifest.version,
|
||||
});
|
||||
if (!binary) {
|
||||
throw new PluginLifecycleError(
|
||||
"binary_not_found",
|
||||
`Plugin binary for ${manifest.id}@${manifest.version} not found at ${basePath}. ` +
|
||||
"Operator must pre-position the .wasm file before triggering install."
|
||||
);
|
||||
}
|
||||
const verifyResult = verifyPluginSignature({
|
||||
wasmBuffer: binary,
|
||||
expectedWasmSha256: input.wasmSha256,
|
||||
signatureHex: manifest.signature,
|
||||
publicKeyHex: manifest.signedBy,
|
||||
trustedPublicKeysHex: trustedKeys,
|
||||
manifest,
|
||||
});
|
||||
if (!verifyResult.ok) {
|
||||
throw new PluginLifecycleError(
|
||||
"binary_verification_failed",
|
||||
`Plugin binary verification failed for ${manifest.id}@${manifest.version}: ${verifyResult.reason}.`
|
||||
);
|
||||
}
|
||||
// Cross-check submitted wasmSha256 matches manifest.wasmSha256.
|
||||
if (input.wasmSha256.toLowerCase() !== manifest.wasmSha256.toLowerCase()) {
|
||||
throw new PluginLifecycleError(
|
||||
"binary_verification_failed",
|
||||
`Submitted wasmSha256 does not match manifest.wasmSha256 for ${manifest.id}@${manifest.version}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await db.plugin.findUnique({
|
||||
where: { tenantId_pluginId: { tenantId: input.tenantId, pluginId: manifest.id } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new PluginLifecycleError("already_installed", `Plugin ${manifest.id} is already installed for tenant ${input.tenantId}.`);
|
||||
}
|
||||
|
||||
// F-009 — enforce per-tenant install cap. We count only AFTER all
|
||||
// cheaper rejections so an over-limit tenant still gets the most
|
||||
// useful error (e.g. signature_key_not_trusted) when they submit
|
||||
// garbage. The count + create are not transactional — racy double-
|
||||
// install can squeak by once at the boundary, but the unique
|
||||
// (tenantId, pluginId) index above rejects exact-duplicate ids, so
|
||||
// the worst case is a tenant landing at `cap + 1` distinct plugins.
|
||||
const maxInstalls = resolveMaxInstallsPerTenant();
|
||||
if (Number.isFinite(maxInstalls)) {
|
||||
const installed = await db.plugin.count({ where: { tenantId: input.tenantId } });
|
||||
if (installed >= maxInstalls) {
|
||||
throw new PluginLifecycleError(
|
||||
"tenant_install_limit_exceeded",
|
||||
`Tenant ${input.tenantId} has reached the install cap (${installed}/${maxInstalls}). ` +
|
||||
"Uninstall an existing plugin or raise PLUGIN_MAX_INSTALLS_PER_TENANT."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const row = await db.plugin.create({
|
||||
data: {
|
||||
tenantId: input.tenantId,
|
||||
pluginId: manifest.id,
|
||||
version: manifest.version,
|
||||
manifest: manifest as unknown as Parameters<typeof db.plugin.create>[0]["data"]["manifest"],
|
||||
wasmSha256: input.wasmSha256,
|
||||
signaturePublicKey: input.signaturePublicKey,
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
await createAuditEvent({
|
||||
tenantId: input.tenantId,
|
||||
actorId: input.actor.userId,
|
||||
actorType: input.actor.userId ? "user" : "system",
|
||||
actorEmail: input.actor.email,
|
||||
actorIp: input.actor.ip,
|
||||
action: "plugin.installed",
|
||||
targetType: "plugin",
|
||||
targetId: row.id,
|
||||
sourceModule: "plugin-lifecycle",
|
||||
outcome: "success",
|
||||
dataSensitivity: "internal",
|
||||
metadata: { pluginId: manifest.id, version: manifest.version },
|
||||
});
|
||||
logger.info({ tenantId: input.tenantId, pluginId: manifest.id, version: manifest.version }, "plugin.installed");
|
||||
|
||||
return toListItem(row);
|
||||
}
|
||||
|
||||
export async function enablePlugin(args: { tenantId: string; pluginRowId: string; actor: PluginLifecycleActor }): Promise<PluginListItem> {
|
||||
assertPluginProductionRuntimeEnabled("Plugin enable");
|
||||
return await setEnabled({ ...args, enabled: true });
|
||||
}
|
||||
|
||||
export async function disablePlugin(args: { tenantId: string; pluginRowId: string; actor: PluginLifecycleActor }): Promise<PluginListItem> {
|
||||
return await setEnabled({ ...args, enabled: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* F-001 — Capability grant types + service.
|
||||
*
|
||||
* `grantPluginCapabilities` upserts (tenantId, pluginId, capabilityName)
|
||||
* rows. When the operator approves all manifest-declared capabilities,
|
||||
* `enablePlugin` is then allowed to flip the Plugin row to enabled.
|
||||
* Missing grants raise `capability_consent_missing`.
|
||||
*
|
||||
* Disabling never requires grants — operators must always be able to
|
||||
* stop a plugin.
|
||||
*/
|
||||
export type CapabilityGrant = {
|
||||
capabilityName: string;
|
||||
scopeJson?: unknown;
|
||||
};
|
||||
|
||||
export type CapabilityRevokeResult = {
|
||||
revokedCapabilities: string[];
|
||||
autoDisabled: boolean;
|
||||
};
|
||||
|
||||
export type PluginCapabilityGrantRow = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
capabilityName: string;
|
||||
scopeJson: unknown;
|
||||
grantedAt: Date;
|
||||
grantedByUserId: string | null;
|
||||
};
|
||||
|
||||
export async function listPluginCapabilityGrants(args: { tenantId: string; pluginId: string }): Promise<PluginCapabilityGrantRow[]> {
|
||||
return await listPluginCapabilityGrantsWithClient(db, args);
|
||||
}
|
||||
|
||||
async function listPluginCapabilityGrantsWithClient(client: unknown, args: { tenantId: string; pluginId: string }): Promise<PluginCapabilityGrantRow[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tbl = (client as any).tenantPluginCapabilityGrant;
|
||||
if (!tbl) return [];
|
||||
const rows = await tbl.findMany({
|
||||
where: { tenantId: args.tenantId, pluginId: args.pluginId },
|
||||
orderBy: { grantedAt: "desc" },
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function listPluginCapabilityGrantsForPlugins(args: { tenantId: string; pluginIds: ReadonlyArray<string> }): Promise<PluginCapabilityGrantRow[]> {
|
||||
const pluginIds = [...new Set(args.pluginIds)].filter((pluginId) => pluginId.length > 0);
|
||||
if (pluginIds.length === 0) return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tbl = (db as any).tenantPluginCapabilityGrant;
|
||||
if (!tbl) return [];
|
||||
const rows = await tbl.findMany({
|
||||
where: { tenantId: args.tenantId, pluginId: { in: pluginIds } },
|
||||
orderBy: { grantedAt: "desc" },
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function grantPluginCapabilities(args: {
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
grants: ReadonlyArray<CapabilityGrant>;
|
||||
actor: PluginLifecycleActor;
|
||||
capabilityReviewDigest?: string;
|
||||
}): Promise<PluginCapabilityGrantRow[]> {
|
||||
assertPluginProductionRuntimeEnabled("Plugin capability grant");
|
||||
// Validate every requested grant is actually declared in the
|
||||
// installed plugin's manifest — operators cannot grant capabilities
|
||||
// the plugin never asked for.
|
||||
const installed = await db.plugin.findUnique({
|
||||
where: { tenantId_pluginId: { tenantId: args.tenantId, pluginId: args.pluginId } },
|
||||
});
|
||||
if (!installed) {
|
||||
throw new PluginLifecycleError("not_found", `Plugin ${args.pluginId} not installed for tenant ${args.tenantId}.`);
|
||||
}
|
||||
const manifest = parseStoredPluginManifest(installed.manifest, { pluginId: installed.pluginId, version: installed.version });
|
||||
assertManifestHooksRegistered(manifest);
|
||||
assertProductionHooksAvailable(
|
||||
"Plugin capability grant",
|
||||
args.grants
|
||||
.map((grant) => grant.capabilityName)
|
||||
.filter(isPluginHookConsentName)
|
||||
.map((name) => name.slice("hook:".length))
|
||||
);
|
||||
const declared = new Map(getPluginConsentRequirements(manifest).map((requirement) => [requirement.name, requirement]));
|
||||
assertProductionCapabilitiesAvailable(
|
||||
"Plugin capability grant",
|
||||
args.grants.map((grant) => grant.capabilityName).filter((name) => !isPluginHookConsentName(name))
|
||||
);
|
||||
for (const grant of args.grants) {
|
||||
const declaredRequirement = declared.get(grant.capabilityName);
|
||||
if (!declaredRequirement) {
|
||||
throw new PluginLifecycleError("capability_not_in_manifest", `Consent item '${grant.capabilityName}' is not declared in the plugin manifest.`);
|
||||
}
|
||||
if (
|
||||
!isCapabilityGrantScopeSatisfied({
|
||||
declaredScope: declaredRequirement.scope ?? null,
|
||||
grantedScopeJson: grant.scopeJson ?? null,
|
||||
})
|
||||
) {
|
||||
throw new PluginLifecycleError("capability_scope_invalid", `Consent item '${grant.capabilityName}' grant scope does not match the plugin manifest.`);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tbl = (db as any).tenantPluginCapabilityGrant;
|
||||
if (!tbl) {
|
||||
throw new PluginLifecycleError("not_found", "TenantPluginCapabilityGrant table not migrated; run prisma migrate deploy.");
|
||||
}
|
||||
const auditGrants = args.grants
|
||||
.map((grant) => ({
|
||||
capabilityName: grant.capabilityName,
|
||||
scopeJson: grant.scopeJson ?? null,
|
||||
}))
|
||||
.sort((left, right) => left.capabilityName.localeCompare(right.capabilityName));
|
||||
const manifestCapabilitySnapshot = auditGrants.map((grant) => {
|
||||
const declaredCapability = declared.get(grant.capabilityName);
|
||||
return {
|
||||
capabilityName: grant.capabilityName,
|
||||
declaredScope: declaredCapability?.scope ?? null,
|
||||
kind: declaredCapability?.kind ?? "capability",
|
||||
};
|
||||
});
|
||||
|
||||
const rows = await db.$transaction(async (tx) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const txTbl = (tx as any).tenantPluginCapabilityGrant;
|
||||
const grantedRows: PluginCapabilityGrantRow[] = [];
|
||||
for (const grant of args.grants) {
|
||||
const row = await txTbl.upsert({
|
||||
where: {
|
||||
tenantId_pluginId_capabilityName: {
|
||||
tenantId: args.tenantId,
|
||||
pluginId: args.pluginId,
|
||||
capabilityName: grant.capabilityName,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
tenantId: args.tenantId,
|
||||
pluginId: args.pluginId,
|
||||
capabilityName: grant.capabilityName,
|
||||
scopeJson: grant.scopeJson as never,
|
||||
grantedByUserId: args.actor.userId,
|
||||
},
|
||||
update: {
|
||||
scopeJson: grant.scopeJson as never,
|
||||
grantedByUserId: args.actor.userId,
|
||||
grantedAt: new Date(),
|
||||
},
|
||||
});
|
||||
grantedRows.push(row);
|
||||
}
|
||||
await publishAuditEventViaOutbox(tx, {
|
||||
tenantId: args.tenantId,
|
||||
actorId: args.actor.userId,
|
||||
actorType: args.actor.userId ? "user" : "system",
|
||||
actorEmail: args.actor.email,
|
||||
actorIp: args.actor.ip,
|
||||
action: "plugin.capabilities.granted",
|
||||
targetType: "plugin",
|
||||
targetId: installed.id,
|
||||
sourceModule: "plugin-lifecycle",
|
||||
outcome: "success",
|
||||
dataSensitivity: "internal",
|
||||
metadata: {
|
||||
pluginId: args.pluginId,
|
||||
version: installed.version,
|
||||
capabilityReviewDigest: args.capabilityReviewDigest ?? null,
|
||||
grantedCapabilities: auditGrants.map((grant) => grant.capabilityName),
|
||||
grants: auditGrants,
|
||||
manifestCapabilities: manifestCapabilitySnapshot,
|
||||
},
|
||||
});
|
||||
return grantedRows;
|
||||
});
|
||||
logger.info({ tenantId: args.tenantId, pluginId: args.pluginId, count: args.grants.length }, "plugin.capabilities.granted");
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function revokePluginCapabilities(args: {
|
||||
tenantId: string;
|
||||
pluginId: string;
|
||||
capabilityNames: ReadonlyArray<string>;
|
||||
actor: PluginLifecycleActor;
|
||||
}): Promise<CapabilityRevokeResult> {
|
||||
const capabilityNames = Array.from(new Set(args.capabilityNames.filter((name) => typeof name === "string" && name.length > 0)));
|
||||
if (capabilityNames.length === 0) {
|
||||
throw new PluginLifecycleError("capability_not_in_manifest", "At least one plugin capability must be selected for revocation.");
|
||||
}
|
||||
|
||||
const installed = await db.plugin.findUnique({
|
||||
where: { tenantId_pluginId: { tenantId: args.tenantId, pluginId: args.pluginId } },
|
||||
});
|
||||
if (!installed) {
|
||||
throw new PluginLifecycleError("not_found", `Plugin ${args.pluginId} not installed for tenant ${args.tenantId}.`);
|
||||
}
|
||||
const manifest = parseStoredPluginManifest(installed.manifest, { pluginId: installed.pluginId, version: installed.version });
|
||||
const declared = new Set(getPluginConsentRequirements(manifest).map((requirement) => requirement.name));
|
||||
const unknownCapability = capabilityNames.find((capabilityName) => !declared.has(capabilityName));
|
||||
if (unknownCapability) {
|
||||
throw new PluginLifecycleError("capability_not_in_manifest", `Consent item '${unknownCapability}' is not declared in the plugin manifest.`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tbl = (db as any).tenantPluginCapabilityGrant;
|
||||
if (!tbl) {
|
||||
throw new PluginLifecycleError("not_found", "TenantPluginCapabilityGrant table not migrated; run prisma migrate deploy.");
|
||||
}
|
||||
const autoDisabled = Boolean(installed.enabled);
|
||||
await db.$transaction(async (tx) => {
|
||||
if (autoDisabled) {
|
||||
await tx.plugin.update({
|
||||
where: { id: installed.id },
|
||||
data: { enabled: false },
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const txTbl = (tx as any).tenantPluginCapabilityGrant;
|
||||
await txTbl.deleteMany({
|
||||
where: {
|
||||
tenantId: args.tenantId,
|
||||
pluginId: args.pluginId,
|
||||
capabilityName: { in: capabilityNames },
|
||||
},
|
||||
});
|
||||
await publishAuditEventViaOutbox(tx, {
|
||||
tenantId: args.tenantId,
|
||||
actorId: args.actor.userId,
|
||||
actorType: args.actor.userId ? "user" : "system",
|
||||
actorEmail: args.actor.email,
|
||||
actorIp: args.actor.ip,
|
||||
action: "plugin.capabilities.revoked",
|
||||
targetType: "plugin",
|
||||
targetId: installed.id,
|
||||
sourceModule: "plugin-lifecycle",
|
||||
outcome: "success",
|
||||
dataSensitivity: "internal",
|
||||
metadata: {
|
||||
pluginId: args.pluginId,
|
||||
revokedCapabilities: capabilityNames,
|
||||
autoDisabled,
|
||||
},
|
||||
});
|
||||
});
|
||||
logger.info({ tenantId: args.tenantId, pluginId: args.pluginId, count: capabilityNames.length, autoDisabled }, "plugin.capabilities.revoked");
|
||||
return { revokedCapabilities: capabilityNames, autoDisabled };
|
||||
}
|
||||
|
||||
async function assertConsentSatisfied(args: { tenantId: string; pluginId: string; manifest: PluginManifest; client?: unknown }): Promise<void> {
|
||||
const declared = getPluginConsentRequirements(args.manifest);
|
||||
if (declared.length === 0) return; // nothing to consent to
|
||||
const grants = await listPluginCapabilityGrantsWithClient(args.client ?? db, {
|
||||
tenantId: args.tenantId,
|
||||
pluginId: args.pluginId,
|
||||
});
|
||||
const grantByCapability = new Map(grants.map((g) => [g.capabilityName, g]));
|
||||
const missing = declared
|
||||
.filter((capability) => {
|
||||
const grant = grantByCapability.get(capability.name);
|
||||
return (
|
||||
!grant ||
|
||||
!isCapabilityGrantScopeSatisfied({
|
||||
declaredScope: capability.scope ?? null,
|
||||
grantedScopeJson: grant.scopeJson,
|
||||
})
|
||||
);
|
||||
})
|
||||
.map((requirement) => requirement.name);
|
||||
if (missing.length > 0) {
|
||||
throw new PluginLifecycleError(
|
||||
"capability_consent_missing",
|
||||
`Plugin ${args.pluginId} cannot be enabled: missing operator consent for [${missing.join(", ")}].`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function setEnabled(args: { tenantId: string; pluginRowId: string; enabled: boolean; actor: PluginLifecycleActor }): Promise<PluginListItem> {
|
||||
assertPluginProductionEscapeHatchesDisabled();
|
||||
const row = await db.$transaction(
|
||||
async (tx) => {
|
||||
const existing = await tx.plugin.findFirst({
|
||||
where: { id: args.pluginRowId, tenantId: args.tenantId },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new PluginLifecycleError("not_found", `Plugin row ${args.pluginRowId} not found for tenant ${args.tenantId}.`);
|
||||
}
|
||||
const manifest = parseStoredPluginManifest(existing.manifest, { pluginId: existing.pluginId, version: existing.version });
|
||||
// F-001 — enable requires consent for every manifest-declared
|
||||
// capability. Disable is always allowed (kill-switch parity).
|
||||
if (args.enabled) {
|
||||
assertManifestHooksRegistered(manifest);
|
||||
assertProductionHooksAvailable("Plugin enable", manifest.hooks ?? []);
|
||||
if (process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT !== "false") {
|
||||
assertProductionCapabilitiesAvailable("Plugin enable", manifest.capabilities ?? []);
|
||||
await assertConsentSatisfied({
|
||||
tenantId: args.tenantId,
|
||||
pluginId: existing.pluginId,
|
||||
manifest,
|
||||
client: tx,
|
||||
});
|
||||
}
|
||||
}
|
||||
const updated = await tx.plugin.update({
|
||||
where: { id: existing.id },
|
||||
data: { enabled: args.enabled },
|
||||
});
|
||||
await publishAuditEventViaOutbox(tx, {
|
||||
tenantId: args.tenantId,
|
||||
actorId: args.actor.userId,
|
||||
actorType: args.actor.userId ? "user" : "system",
|
||||
actorEmail: args.actor.email,
|
||||
actorIp: args.actor.ip,
|
||||
action: args.enabled ? "plugin.enabled" : "plugin.disabled",
|
||||
targetType: "plugin",
|
||||
targetId: updated.id,
|
||||
sourceModule: "plugin-lifecycle",
|
||||
outcome: "success",
|
||||
dataSensitivity: "internal",
|
||||
metadata: { pluginId: updated.pluginId, version: updated.version },
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
{ isolationLevel: "Serializable" }
|
||||
);
|
||||
logger.info({ tenantId: args.tenantId, pluginId: row.pluginId, enabled: args.enabled }, args.enabled ? "plugin.enabled" : "plugin.disabled");
|
||||
return toListItem(row);
|
||||
}
|
||||
|
||||
export async function listPlugins(args: { tenantId: string }): Promise<PluginListItem[]> {
|
||||
const rows = await db.plugin.findMany({
|
||||
where: { tenantId: args.tenantId },
|
||||
orderBy: { installedAt: "desc" },
|
||||
});
|
||||
return rows.map(toListItem);
|
||||
}
|
||||
|
||||
export async function getPluginForTenant(args: { tenantId: string; pluginRowId: string }): Promise<PluginListItem | null> {
|
||||
const row = await db.plugin.findFirst({
|
||||
where: { id: args.pluginRowId, tenantId: args.tenantId },
|
||||
});
|
||||
return row ? toListItem(row) : null;
|
||||
}
|
||||
|
||||
type PluginRow = Awaited<ReturnType<typeof db.plugin.findFirstOrThrow>>;
|
||||
|
||||
function toListItem(row: PluginRow): PluginListItem {
|
||||
const manifest = parseStoredPluginManifest(row.manifest, { pluginId: row.pluginId, version: row.version });
|
||||
return {
|
||||
id: row.id,
|
||||
pluginId: row.pluginId,
|
||||
version: row.version,
|
||||
enabled: row.enabled,
|
||||
manifest,
|
||||
installedAt: row.installedAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
import { promises as nodeFs } from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { PluginManifest } from "@toolctrl/plugin-runtime";
|
||||
|
||||
const getPluginForTenantMock = vi.fn();
|
||||
const publishAuditEventViaOutboxMock = vi.fn();
|
||||
const dbMock = {
|
||||
$transaction: vi.fn(),
|
||||
pluginUserConfig: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("./PluginLifecycleService", () => ({
|
||||
getPluginForTenant: (...args: unknown[]) => getPluginForTenantMock(...args),
|
||||
}));
|
||||
vi.mock("@toolctrl/db/audit-events.server", () => ({
|
||||
publishAuditEventViaOutbox: (...args: unknown[]) => publishAuditEventViaOutboxMock(...args),
|
||||
}));
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: dbMock,
|
||||
}));
|
||||
|
||||
const baseManifest = {
|
||||
schemaVersion: 1,
|
||||
id: "com.acme.settings",
|
||||
version: "1.0.0",
|
||||
name: "Settings",
|
||||
entry: "plugin.wasm",
|
||||
wasmSha256: "0".repeat(64),
|
||||
signature: "0".repeat(128),
|
||||
signedBy: "0".repeat(64),
|
||||
allowedHosts: [],
|
||||
capabilities: [],
|
||||
hooks: [{ name: "entity.created", export: "handleEntityCreated" }],
|
||||
} satisfies PluginManifest;
|
||||
|
||||
const settingsHtml = "<main>Settings</main>";
|
||||
const settingsHtmlSha256 = createHash("sha256").update(settingsHtml).digest("hex");
|
||||
const settingsHtmlIntegrity = `sha256-${createHash("sha256").update(settingsHtml).digest("base64")}`;
|
||||
|
||||
function pluginFixture(manifest: PluginManifest = { ...baseManifest, ui: { settings: { entry: "ui/settings.html", sha256: settingsHtmlSha256 } } }) {
|
||||
return {
|
||||
id: "plugin-row-1",
|
||||
pluginId: "com.acme.settings",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
manifest,
|
||||
installedAt: new Date("2026-05-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getPluginForTenantMock.mockReset();
|
||||
publishAuditEventViaOutboxMock.mockReset();
|
||||
publishAuditEventViaOutboxMock.mockResolvedValue(undefined);
|
||||
dbMock.$transaction.mockReset();
|
||||
dbMock.$transaction.mockImplementation((callback: (tx: typeof dbMock) => unknown) => callback(dbMock));
|
||||
dbMock.pluginUserConfig.findUnique.mockReset();
|
||||
dbMock.pluginUserConfig.upsert.mockReset();
|
||||
delete process.env.PLUGIN_BINARY_BASE_PATH;
|
||||
});
|
||||
|
||||
describe("PluginSettingsService", () => {
|
||||
test("loads settings HTML through tenant-scoped plugin lookup", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
||||
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
||||
await mkdir(bundleRoot, { recursive: true });
|
||||
await writeFile(path.join(bundleRoot, "settings.html"), settingsHtml);
|
||||
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
||||
|
||||
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
||||
const result = await loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
||||
|
||||
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
||||
expect(result.entry).toBe("ui/settings.html");
|
||||
expect(result.trustedHtml).toBe(settingsHtml);
|
||||
});
|
||||
|
||||
test("loads settings HTML declared with SRI integrity metadata", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
||||
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
||||
await mkdir(bundleRoot, { recursive: true });
|
||||
await writeFile(path.join(bundleRoot, "settings.html"), settingsHtml);
|
||||
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
||||
getPluginForTenantMock.mockResolvedValue(
|
||||
pluginFixture({ ...baseManifest, ui: { settings: { entry: "ui/settings.html", integrity: settingsHtmlIntegrity } } })
|
||||
);
|
||||
|
||||
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
||||
const result = await loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
||||
|
||||
expect(result.trustedHtml).toBe(settingsHtml);
|
||||
});
|
||||
|
||||
test("rejects manifests without settings UI", async () => {
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture(baseManifest));
|
||||
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
||||
|
||||
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
||||
code: "settings_ui_unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects settings UI manifests without HTML integrity metadata before touching the filesystem", async () => {
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture({ ...baseManifest, ui: { settings: { entry: "ui/settings.html" } } as never }));
|
||||
const openSpy = vi.spyOn(nodeFs, "open");
|
||||
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
||||
|
||||
try {
|
||||
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
||||
code: "settings_ui_integrity_missing",
|
||||
});
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unsafe entries before touching the filesystem", async () => {
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture({ ...baseManifest, ui: { settings: { entry: "../settings.html" } } }));
|
||||
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
||||
|
||||
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
||||
code: "settings_ui_entry_unsafe",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects settings HTML whose bytes differ from the manifest sha256", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
||||
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
||||
await mkdir(bundleRoot, { recursive: true });
|
||||
await writeFile(path.join(bundleRoot, "settings.html"), "<main>Tampered</main>");
|
||||
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
||||
|
||||
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
||||
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
||||
code: "settings_ui_integrity_mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects symlink traversal out of the plugin bundle", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
||||
const outside = path.join(root, "outside.html");
|
||||
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
||||
await mkdir(bundleRoot, { recursive: true });
|
||||
await writeFile(outside, "<main>Outside</main>");
|
||||
await symlink(outside, path.join(bundleRoot, "settings.html"));
|
||||
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
||||
|
||||
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
||||
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
||||
code: "settings_ui_entry_unsafe",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects oversized settings HTML after opening the file handle and before reading it into the route payload", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
||||
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
||||
const settingsPath = path.join(bundleRoot, "settings.html");
|
||||
await mkdir(bundleRoot, { recursive: true });
|
||||
await writeFile(settingsPath, settingsHtml);
|
||||
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
||||
const originalOpen = nodeFs.open.bind(nodeFs);
|
||||
const openSpy = vi.spyOn(nodeFs, "open").mockImplementation(async (...args: Parameters<typeof nodeFs.open>) => {
|
||||
const fileHandle = (await originalOpen(...args)) as FileHandle;
|
||||
const originalStat = fileHandle.stat.bind(fileHandle);
|
||||
vi.spyOn(fileHandle, "stat").mockImplementation(async (...statArgs: Parameters<FileHandle["stat"]>) => {
|
||||
await writeFile(settingsPath, `<main>${"x".repeat(257 * 1024)}</main>`);
|
||||
return originalStat(...statArgs);
|
||||
});
|
||||
return fileHandle;
|
||||
});
|
||||
const { loadPluginSettingsHtml } = await import("./PluginSettingsService");
|
||||
|
||||
try {
|
||||
await expect(loadPluginSettingsHtml({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" })).rejects.toMatchObject({
|
||||
code: "settings_ui_file_too_large",
|
||||
});
|
||||
expect(openSpy).toHaveBeenCalledWith(expect.stringContaining("settings.html"), "r");
|
||||
} finally {
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads per-user settings through tenant-scoped plugin lookup", async () => {
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
||||
dbMock.pluginUserConfig.findUnique.mockResolvedValue({
|
||||
configJson: {
|
||||
theme: "dark",
|
||||
count: 2,
|
||||
apiKey: "must-not-reach-frame",
|
||||
password: "must-not-reach-frame",
|
||||
"../escape": "unsafe",
|
||||
__proto__: "polluted",
|
||||
constructor: "unsafe",
|
||||
prototype: "unsafe",
|
||||
oversized: "x".repeat(33 * 1024),
|
||||
tooManyItems: Array.from({ length: 513 }, (_, index) => index),
|
||||
tooManyKeys: Object.fromEntries(Array.from({ length: 257 }, (_, index) => [`k${index}`, index])),
|
||||
nestedReserved: JSON.parse('{"safe":true,"__proto__":{"polluted":true}}'),
|
||||
nestedSecret: { appearance: { apiKey: "must-not-reach-frame" } },
|
||||
ignored: () => "nope",
|
||||
},
|
||||
});
|
||||
const { loadPluginUserSettings } = await import("./PluginSettingsService");
|
||||
|
||||
const result = await loadPluginUserSettings({ tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" });
|
||||
|
||||
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
||||
expect(dbMock.pluginUserConfig.findUnique).toHaveBeenCalledWith({
|
||||
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
|
||||
select: { configJson: true },
|
||||
});
|
||||
expect(result).toEqual({ theme: "dark", count: 2 });
|
||||
expect(JSON.stringify(result)).not.toContain("must-not-reach-frame");
|
||||
expect(result).not.toHaveProperty("apiKey");
|
||||
expect(result).not.toHaveProperty("password");
|
||||
expect(result).not.toHaveProperty("../escape");
|
||||
expect(result).not.toHaveProperty("__proto__");
|
||||
expect(result).not.toHaveProperty("constructor");
|
||||
expect(result).not.toHaveProperty("prototype");
|
||||
expect(result).not.toHaveProperty("oversized");
|
||||
expect(result).not.toHaveProperty("tooManyItems");
|
||||
expect(result).not.toHaveProperty("tooManyKeys");
|
||||
expect(result).not.toHaveProperty("nestedReserved");
|
||||
expect(result).not.toHaveProperty("nestedSecret");
|
||||
});
|
||||
|
||||
test("loads settings page data with a single tenant plugin lookup", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "plugin-settings-"));
|
||||
const bundleRoot = path.join(root, "com.acme.settings", "1.0.0", "ui");
|
||||
await mkdir(bundleRoot, { recursive: true });
|
||||
await writeFile(path.join(bundleRoot, "settings.html"), settingsHtml);
|
||||
process.env.PLUGIN_BINARY_BASE_PATH = root;
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
||||
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: { theme: "dark" } });
|
||||
|
||||
const { loadPluginSettingsPageData } = await import("./PluginSettingsService");
|
||||
const result = await loadPluginSettingsPageData({ tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" });
|
||||
|
||||
expect(getPluginForTenantMock).toHaveBeenCalledOnce();
|
||||
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
||||
expect(dbMock.pluginUserConfig.findUnique).toHaveBeenCalledWith({
|
||||
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
|
||||
select: { configJson: true },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
entry: "ui/settings.html",
|
||||
trustedHtml: settingsHtml,
|
||||
settings: { theme: "dark" },
|
||||
plugin: { id: "plugin-row-1", pluginId: "com.acme.settings", version: "1.0.0" },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects per-user settings reads for disabled plugins before reading config", async () => {
|
||||
getPluginForTenantMock.mockResolvedValue({ ...pluginFixture(), enabled: false });
|
||||
const { loadPluginUserSettings } = await import("./PluginSettingsService");
|
||||
|
||||
await expect(loadPluginUserSettings({ tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" })).rejects.toMatchObject({
|
||||
code: "plugin_disabled",
|
||||
});
|
||||
|
||||
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects per-user settings reads when no safe settings UI is declared before reading config", async () => {
|
||||
const { loadPluginUserSettings } = await import("./PluginSettingsService");
|
||||
const args = { tenantId: "tenant-1", pluginRowId: "plugin-row-1", userId: "user-1" };
|
||||
|
||||
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture(baseManifest));
|
||||
await expect(loadPluginUserSettings(args)).rejects.toMatchObject({ code: "settings_ui_unavailable" });
|
||||
|
||||
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture({ ...baseManifest, ui: { settings: { entry: "../settings.html" } } }));
|
||||
await expect(loadPluginUserSettings(args)).rejects.toMatchObject({ code: "settings_ui_entry_unsafe" });
|
||||
|
||||
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("upserts a single per-user setting without storing secret-like keys", async () => {
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
||||
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: { theme: "dark" } });
|
||||
dbMock.pluginUserConfig.upsert.mockResolvedValue({});
|
||||
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
||||
|
||||
const result = await setPluginUserSetting({
|
||||
tenantId: "tenant-1",
|
||||
pluginRowId: "plugin-row-1",
|
||||
userId: "user-1",
|
||||
key: "layout.mode",
|
||||
value: "compact",
|
||||
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
|
||||
});
|
||||
|
||||
expect(dbMock.pluginUserConfig.upsert).toHaveBeenCalledWith({
|
||||
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
|
||||
create: { pluginId: "plugin-row-1", userId: "user-1", configJson: { theme: "dark", "layout.mode": "compact" } },
|
||||
update: { configJson: { theme: "dark", "layout.mode": "compact" } },
|
||||
});
|
||||
expect(dbMock.$transaction).toHaveBeenCalledOnce();
|
||||
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledWith(
|
||||
dbMock,
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant-1",
|
||||
actorId: "user-1",
|
||||
action: "plugin.configuration.updated",
|
||||
targetId: "plugin-row-1",
|
||||
metadata: { pluginId: "com.acme.settings", key: "layout.mode" },
|
||||
})
|
||||
);
|
||||
expect(JSON.stringify(publishAuditEventViaOutboxMock.mock.calls[0]?.[1]?.metadata)).not.toContain("compact");
|
||||
expect(result.settings).toEqual({ theme: "dark", "layout.mode": "compact" });
|
||||
});
|
||||
|
||||
test("merges per-user settings from the write transaction snapshot", async () => {
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
||||
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: { theme: "stale" } });
|
||||
const tx = {
|
||||
pluginUserConfig: {
|
||||
findUnique: vi.fn().mockResolvedValue({ configJson: { theme: "dark", density: "comfortable" } }),
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
dbMock.$transaction.mockImplementationOnce((callback: (tx: typeof tx) => unknown) => callback(tx));
|
||||
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
||||
|
||||
const result = await setPluginUserSetting({
|
||||
tenantId: "tenant-1",
|
||||
pluginRowId: "plugin-row-1",
|
||||
userId: "user-1",
|
||||
key: "layout.mode",
|
||||
value: "compact",
|
||||
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
|
||||
});
|
||||
|
||||
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
|
||||
expect(tx.pluginUserConfig.findUnique).toHaveBeenCalledWith({
|
||||
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
|
||||
select: { configJson: true },
|
||||
});
|
||||
expect(tx.pluginUserConfig.upsert).toHaveBeenCalledWith({
|
||||
where: { pluginId_userId: { pluginId: "plugin-row-1", userId: "user-1" } },
|
||||
create: { pluginId: "plugin-row-1", userId: "user-1", configJson: { theme: "dark", density: "comfortable", "layout.mode": "compact" } },
|
||||
update: { configJson: { theme: "dark", density: "comfortable", "layout.mode": "compact" } },
|
||||
});
|
||||
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledWith(tx, expect.objectContaining({ action: "plugin.configuration.updated" }));
|
||||
expect(result.settings).toEqual({ theme: "dark", density: "comfortable", "layout.mode": "compact" });
|
||||
});
|
||||
|
||||
test("rolls back per-user setting writes when audit enqueue fails", async () => {
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
||||
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: {} });
|
||||
dbMock.pluginUserConfig.upsert.mockResolvedValue({});
|
||||
publishAuditEventViaOutboxMock.mockRejectedValueOnce(new Error("outbox unavailable"));
|
||||
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
||||
|
||||
await expect(
|
||||
setPluginUserSetting({
|
||||
tenantId: "tenant-1",
|
||||
pluginRowId: "plugin-row-1",
|
||||
userId: "user-1",
|
||||
key: "theme",
|
||||
value: "dark",
|
||||
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
|
||||
})
|
||||
).rejects.toThrow("outbox unavailable");
|
||||
|
||||
expect(dbMock.$transaction).toHaveBeenCalledOnce();
|
||||
expect(dbMock.pluginUserConfig.upsert).toHaveBeenCalledOnce();
|
||||
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("reports settings iframe violations as redacted tenant-scoped audit events", async () => {
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
||||
const { reportPluginSettingsFrameViolation } = await import("./PluginSettingsService");
|
||||
|
||||
await reportPluginSettingsFrameViolation({
|
||||
tenantId: "tenant-1",
|
||||
pluginRowId: "plugin-row-1",
|
||||
reason: "wrong_origin",
|
||||
actor: { userId: "user-1", email: "user@example.com", ip: "203.0.113.7" },
|
||||
});
|
||||
|
||||
expect(getPluginForTenantMock).toHaveBeenCalledWith({ tenantId: "tenant-1", pluginRowId: "plugin-row-1" });
|
||||
expect(publishAuditEventViaOutboxMock).toHaveBeenCalledWith(dbMock, {
|
||||
tenantId: "tenant-1",
|
||||
actorId: "user-1",
|
||||
actorType: "user",
|
||||
actorEmail: "user@example.com",
|
||||
actorIp: "203.0.113.7",
|
||||
action: "plugin.settings_frame.violation",
|
||||
targetType: "plugin",
|
||||
targetId: "plugin-row-1",
|
||||
sourceModule: "plugin-settings",
|
||||
outcome: "blocked",
|
||||
dataSensitivity: "internal",
|
||||
metadata: { pluginId: "com.acme.settings", pluginRowId: "plugin-row-1", reason: "wrong_origin", reportedByClient: true },
|
||||
});
|
||||
expect(JSON.stringify(publishAuditEventViaOutboxMock.mock.calls[0]?.[1])).not.toContain("payload");
|
||||
});
|
||||
|
||||
test("rejects settings iframe violation reports for disabled plugins or missing settings UI", async () => {
|
||||
const { reportPluginSettingsFrameViolation } = await import("./PluginSettingsService");
|
||||
const base = {
|
||||
tenantId: "tenant-1",
|
||||
pluginRowId: "plugin-row-1",
|
||||
reason: "invalid_payload" as const,
|
||||
actor: { userId: "user-1", email: "user@example.com", ip: null },
|
||||
};
|
||||
|
||||
getPluginForTenantMock.mockResolvedValueOnce({ ...pluginFixture(), enabled: false });
|
||||
await expect(reportPluginSettingsFrameViolation(base)).rejects.toMatchObject({ code: "plugin_disabled" });
|
||||
|
||||
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture(baseManifest));
|
||||
await expect(reportPluginSettingsFrameViolation(base)).rejects.toMatchObject({ code: "settings_ui_unavailable" });
|
||||
|
||||
expect(publishAuditEventViaOutboxMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects direct per-user setting writes for disabled plugins", async () => {
|
||||
getPluginForTenantMock.mockResolvedValue({ ...pluginFixture(), enabled: false });
|
||||
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
||||
|
||||
await expect(
|
||||
setPluginUserSetting({
|
||||
tenantId: "tenant-1",
|
||||
pluginRowId: "plugin-row-1",
|
||||
userId: "user-1",
|
||||
key: "theme",
|
||||
value: "dark",
|
||||
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
|
||||
})
|
||||
).rejects.toMatchObject({ code: "plugin_disabled" });
|
||||
|
||||
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
|
||||
expect(dbMock.pluginUserConfig.upsert).not.toHaveBeenCalled();
|
||||
expect(publishAuditEventViaOutboxMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects direct per-user setting writes when no safe settings UI is declared", async () => {
|
||||
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
||||
const base = {
|
||||
tenantId: "tenant-1",
|
||||
pluginRowId: "plugin-row-1",
|
||||
userId: "user-1",
|
||||
key: "theme",
|
||||
value: "dark",
|
||||
actor: { userId: "user-1", email: "user@example.com", ip: "127.0.0.1" },
|
||||
};
|
||||
|
||||
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture(baseManifest));
|
||||
await expect(setPluginUserSetting(base)).rejects.toMatchObject({ code: "settings_ui_unavailable" });
|
||||
|
||||
getPluginForTenantMock.mockResolvedValueOnce(pluginFixture({ ...baseManifest, ui: { settings: { entry: "../settings.html" } } }));
|
||||
await expect(setPluginUserSetting(base)).rejects.toMatchObject({ code: "settings_ui_entry_unsafe" });
|
||||
|
||||
expect(dbMock.pluginUserConfig.findUnique).not.toHaveBeenCalled();
|
||||
expect(dbMock.pluginUserConfig.upsert).not.toHaveBeenCalled();
|
||||
expect(publishAuditEventViaOutboxMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects unsafe, secret-like, invalid, and oversized setting writes", async () => {
|
||||
getPluginForTenantMock.mockResolvedValue(pluginFixture());
|
||||
dbMock.pluginUserConfig.findUnique.mockResolvedValue({ configJson: {} });
|
||||
const { setPluginUserSetting } = await import("./PluginSettingsService");
|
||||
const base = {
|
||||
tenantId: "tenant-1",
|
||||
pluginRowId: "plugin-row-1",
|
||||
userId: "user-1",
|
||||
actor: { userId: "user-1", email: null, ip: null },
|
||||
};
|
||||
|
||||
await expect(setPluginUserSetting({ ...base, key: "../theme", value: "dark" })).rejects.toMatchObject({ code: "settings_key_invalid" });
|
||||
await expect(setPluginUserSetting({ ...base, key: "k".repeat(129), value: "dark" })).rejects.toMatchObject({ code: "settings_key_invalid" });
|
||||
await expect(setPluginUserSetting({ ...base, key: "__proto__", value: "polluted" })).rejects.toMatchObject({ code: "settings_key_invalid" });
|
||||
await expect(setPluginUserSetting({ ...base, key: "constructor", value: "unsafe" })).rejects.toMatchObject({ code: "settings_key_invalid" });
|
||||
await expect(setPluginUserSetting({ ...base, key: "prototype", value: "unsafe" })).rejects.toMatchObject({ code: "settings_key_invalid" });
|
||||
await expect(setPluginUserSetting({ ...base, key: "apiKey", value: "abc" })).rejects.toMatchObject({ code: "settings_key_secret_like" });
|
||||
await expect(setPluginUserSetting({ ...base, key: "theme", value: Number.NaN as never })).rejects.toMatchObject({ code: "settings_value_invalid" });
|
||||
await expect(
|
||||
setPluginUserSetting({ ...base, key: "deep", value: { a: { b: { c: { d: { e: { f: { g: { h: { i: true } } } } } } } } } })
|
||||
).rejects.toMatchObject({
|
||||
code: "settings_value_invalid",
|
||||
});
|
||||
await expect(setPluginUserSetting({ ...base, key: "array", value: Array.from({ length: 513 }, (_, index) => index) })).rejects.toMatchObject({
|
||||
code: "settings_value_invalid",
|
||||
});
|
||||
await expect(
|
||||
setPluginUserSetting({ ...base, key: "object", value: Object.fromEntries(Array.from({ length: 257 }, (_, index) => [`k${index}`, index])) })
|
||||
).rejects.toMatchObject({
|
||||
code: "settings_value_invalid",
|
||||
});
|
||||
await expect(
|
||||
setPluginUserSetting({ ...base, key: "nestedReserved", value: JSON.parse('{"theme":"dark","__proto__":{"polluted":true}}') })
|
||||
).rejects.toMatchObject({
|
||||
code: "settings_value_invalid",
|
||||
});
|
||||
await expect(setPluginUserSetting({ ...base, key: "appearance", value: { theme: { accessToken: "must-not-persist" } } })).rejects.toMatchObject({
|
||||
code: "settings_value_invalid",
|
||||
});
|
||||
await expect(setPluginUserSetting({ ...base, key: "blob", value: "x".repeat(70 * 1024) })).rejects.toMatchObject({
|
||||
code: "settings_value_too_large",
|
||||
});
|
||||
expect(dbMock.pluginUserConfig.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { PluginManifest } from "@toolctrl/plugin-runtime";
|
||||
import { publishAuditEventViaOutbox } from "@toolctrl/db/audit-events.server";
|
||||
import { db } from "~/utils/db.server";
|
||||
import {
|
||||
PLUGIN_SETTING_VALUE_MAX_BYTES,
|
||||
isPluginSettingKeyFormatSafe,
|
||||
isPluginSettingKeyReserved,
|
||||
isPluginSettingKeySecretLike,
|
||||
isPluginSettingValue,
|
||||
isPluginSettingValueShapeSafe,
|
||||
isPluginSettingValueWithinSize,
|
||||
isSafePluginSettingKey,
|
||||
type PluginSettingValue,
|
||||
} from "../../utils/pluginSettingsValidation";
|
||||
import { getPluginForTenant, type PluginLifecycleActor, type PluginListItem } from "./PluginLifecycleService";
|
||||
export type { PluginSettingValue } from "../../utils/pluginSettingsValidation";
|
||||
|
||||
const DEFAULT_PLUGIN_BINARY_BASE_PATH = "/app/var/plugins";
|
||||
const RELATIVE_HTML_ENTRY_PATTERN = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))(?![a-z][a-z0-9+.-]*:)[a-z0-9][a-z0-9._/-]*\.html$/iu;
|
||||
const MAX_PLUGIN_USER_CONFIG_BYTES = 64 * 1024;
|
||||
const MAX_PLUGIN_SETTINGS_HTML_BYTES = 256 * 1024;
|
||||
const MAX_PLUGIN_SETTING_VALUE_BYTES = PLUGIN_SETTING_VALUE_MAX_BYTES;
|
||||
|
||||
export type PluginSettingsServiceErrorCode =
|
||||
| "plugin_not_found"
|
||||
| "plugin_disabled"
|
||||
| "settings_ui_unavailable"
|
||||
| "settings_ui_entry_unsafe"
|
||||
| "settings_ui_file_not_found"
|
||||
| "settings_ui_file_too_large"
|
||||
| "settings_ui_integrity_missing"
|
||||
| "settings_ui_integrity_mismatch"
|
||||
| "settings_key_invalid"
|
||||
| "settings_key_secret_like"
|
||||
| "settings_value_invalid"
|
||||
| "settings_value_too_large";
|
||||
|
||||
export class PluginSettingsServiceError extends Error {
|
||||
public override readonly name = "PluginSettingsServiceError";
|
||||
|
||||
constructor(
|
||||
public readonly code: PluginSettingsServiceErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginSettingsHtml = {
|
||||
plugin: PluginListItem;
|
||||
entry: string;
|
||||
trustedHtml: string;
|
||||
};
|
||||
|
||||
export type PluginUserSettings = Readonly<Record<string, PluginSettingValue>>;
|
||||
export type PluginSettingsPageData = PluginSettingsHtml & {
|
||||
settings: PluginUserSettings;
|
||||
};
|
||||
export type PluginSettingsFrameViolationReason = "wrong_origin" | "wrong_source" | "invalid_payload" | "unexpected_navigation";
|
||||
|
||||
export async function loadPluginSettingsHtml(args: { tenantId: string; pluginRowId: string }): Promise<PluginSettingsHtml> {
|
||||
const plugin = await getPluginForTenant({ tenantId: args.tenantId, pluginRowId: args.pluginRowId });
|
||||
if (!plugin) {
|
||||
throw new PluginSettingsServiceError("plugin_not_found", `Plugin row ${args.pluginRowId} was not found for this tenant.`);
|
||||
}
|
||||
return loadPluginSettingsHtmlForPlugin(plugin);
|
||||
}
|
||||
|
||||
export async function loadPluginSettingsPageData(args: { tenantId: string; pluginRowId: string; userId: string }): Promise<PluginSettingsPageData> {
|
||||
const plugin = await resolveTenantPlugin(args);
|
||||
const [settingsHtml, settings] = await Promise.all([
|
||||
loadPluginSettingsHtmlForPlugin(plugin),
|
||||
loadPluginUserSettingsForPlugin({ plugin, userId: args.userId }),
|
||||
]);
|
||||
return { ...settingsHtml, settings };
|
||||
}
|
||||
|
||||
async function loadPluginSettingsHtmlForPlugin(plugin: PluginListItem): Promise<PluginSettingsHtml> {
|
||||
if (!plugin.enabled) {
|
||||
throw new PluginSettingsServiceError("settings_ui_unavailable", `Plugin ${plugin.pluginId} is disabled.`);
|
||||
}
|
||||
const entry = assertPluginDeclaresSafeSettingsUi(plugin);
|
||||
|
||||
const bundleRoot = path.join(resolvePluginBundleBasePath(), plugin.pluginId, plugin.version);
|
||||
let rootReal: string;
|
||||
let fileReal: string;
|
||||
try {
|
||||
rootReal = await fs.realpath(bundleRoot);
|
||||
fileReal = await fs.realpath(path.resolve(rootReal, entry));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw new PluginSettingsServiceError("settings_ui_file_not_found", `Plugin settings UI file ${entry} was not found.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const rootPrefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`;
|
||||
if (!fileReal.startsWith(rootPrefix)) {
|
||||
throw new PluginSettingsServiceError("settings_ui_entry_unsafe", `Plugin settings UI entry ${entry} escapes the plugin bundle.`);
|
||||
}
|
||||
const fileHandle = await fs.open(fileReal, "r");
|
||||
try {
|
||||
const fileStats = await fileHandle.stat();
|
||||
if (fileStats.size > MAX_PLUGIN_SETTINGS_HTML_BYTES) {
|
||||
throw new PluginSettingsServiceError("settings_ui_file_too_large", `Plugin settings UI file ${entry} exceeds ${MAX_PLUGIN_SETTINGS_HTML_BYTES} bytes.`);
|
||||
}
|
||||
const htmlBytes = await fileHandle.readFile();
|
||||
if (htmlBytes.byteLength > MAX_PLUGIN_SETTINGS_HTML_BYTES) {
|
||||
throw new PluginSettingsServiceError("settings_ui_file_too_large", `Plugin settings UI file ${entry} exceeds ${MAX_PLUGIN_SETTINGS_HTML_BYTES} bytes.`);
|
||||
}
|
||||
assertSettingsHtmlIntegrity({ plugin, entry, htmlBytes });
|
||||
|
||||
return {
|
||||
plugin,
|
||||
entry,
|
||||
trustedHtml: htmlBytes.toString("utf8"),
|
||||
};
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPluginUserSettings(args: { tenantId: string; pluginRowId: string; userId: string }): Promise<PluginUserSettings> {
|
||||
const plugin = await resolveTenantPlugin(args);
|
||||
return loadPluginUserSettingsForPlugin({ plugin, userId: args.userId });
|
||||
}
|
||||
|
||||
async function loadPluginUserSettingsForPlugin({ plugin, userId }: { plugin: PluginListItem; userId: string }): Promise<PluginUserSettings> {
|
||||
if (!plugin.enabled) {
|
||||
throw new PluginSettingsServiceError("plugin_disabled", `Plugin ${plugin.pluginId} is disabled.`);
|
||||
}
|
||||
assertPluginDeclaresSafeSettingsUi(plugin);
|
||||
|
||||
const config = await db.pluginUserConfig.findUnique({
|
||||
where: { pluginId_userId: { pluginId: plugin.id, userId } },
|
||||
select: { configJson: true },
|
||||
});
|
||||
return normalizeSettingsConfig(config?.configJson);
|
||||
}
|
||||
|
||||
export async function setPluginUserSetting(args: {
|
||||
tenantId: string;
|
||||
pluginRowId: string;
|
||||
userId: string;
|
||||
key: string;
|
||||
value: PluginSettingValue;
|
||||
actor: PluginLifecycleActor;
|
||||
}): Promise<{ plugin: PluginListItem; settings: PluginUserSettings; key: string; value: PluginSettingValue }> {
|
||||
const plugin = await resolveTenantPlugin(args);
|
||||
if (!plugin.enabled) {
|
||||
throw new PluginSettingsServiceError("plugin_disabled", `Plugin ${plugin.pluginId} is disabled.`);
|
||||
}
|
||||
assertPluginDeclaresSafeSettingsUi(plugin);
|
||||
assertSafePluginSettingKeyForWrite(args.key);
|
||||
assertPluginSettingValue(args.value);
|
||||
|
||||
const settings = await db.$transaction(async (tx) => {
|
||||
const current = await tx.pluginUserConfig.findUnique({
|
||||
where: { pluginId_userId: { pluginId: plugin.id, userId: args.userId } },
|
||||
select: { configJson: true },
|
||||
});
|
||||
const nextSettings = {
|
||||
...normalizeSettingsConfig(current?.configJson),
|
||||
[args.key]: args.value,
|
||||
} satisfies PluginUserSettings;
|
||||
assertPluginSettingsConfigSize(nextSettings);
|
||||
|
||||
await tx.pluginUserConfig.upsert({
|
||||
where: { pluginId_userId: { pluginId: plugin.id, userId: args.userId } },
|
||||
create: {
|
||||
pluginId: plugin.id,
|
||||
userId: args.userId,
|
||||
configJson: nextSettings as never,
|
||||
},
|
||||
update: {
|
||||
configJson: nextSettings as never,
|
||||
},
|
||||
});
|
||||
|
||||
await publishAuditEventViaOutbox(tx, {
|
||||
tenantId: args.tenantId,
|
||||
actorId: args.actor.userId,
|
||||
actorType: args.actor.userId ? "user" : "system",
|
||||
actorEmail: args.actor.email,
|
||||
actorIp: args.actor.ip,
|
||||
action: "plugin.configuration.updated",
|
||||
targetType: "plugin",
|
||||
targetId: plugin.id,
|
||||
sourceModule: "plugin-settings",
|
||||
outcome: "success",
|
||||
dataSensitivity: "internal",
|
||||
metadata: { pluginId: plugin.pluginId, key: args.key },
|
||||
});
|
||||
|
||||
return nextSettings;
|
||||
});
|
||||
|
||||
return { plugin, settings, key: args.key, value: args.value };
|
||||
}
|
||||
|
||||
export async function reportPluginSettingsFrameViolation(args: {
|
||||
tenantId: string;
|
||||
pluginRowId: string;
|
||||
reason: PluginSettingsFrameViolationReason;
|
||||
actor: PluginLifecycleActor;
|
||||
}): Promise<void> {
|
||||
const plugin = await resolveTenantPlugin(args);
|
||||
if (!plugin.enabled) {
|
||||
throw new PluginSettingsServiceError("plugin_disabled", `Plugin ${plugin.pluginId} is disabled.`);
|
||||
}
|
||||
assertPluginDeclaresSafeSettingsUi(plugin);
|
||||
await publishAuditEventViaOutbox(db, {
|
||||
tenantId: args.tenantId,
|
||||
actorId: args.actor.userId,
|
||||
actorType: args.actor.userId ? "user" : "system",
|
||||
actorEmail: args.actor.email,
|
||||
actorIp: args.actor.ip,
|
||||
action: "plugin.settings_frame.violation",
|
||||
targetType: "plugin",
|
||||
targetId: plugin.id,
|
||||
sourceModule: "plugin-settings",
|
||||
outcome: "blocked",
|
||||
dataSensitivity: "internal",
|
||||
metadata: { pluginId: plugin.pluginId, pluginRowId: plugin.id, reason: args.reason, reportedByClient: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function getPluginSettingsEntry(manifest: PluginManifest): string | null {
|
||||
const entry = manifest.ui?.settings?.entry;
|
||||
return typeof entry === "string" && entry.length > 0 ? entry : null;
|
||||
}
|
||||
|
||||
export function assertSafePluginSettingsEntry(entry: string): void {
|
||||
if (!RELATIVE_HTML_ENTRY_PATTERN.test(entry)) {
|
||||
throw new PluginSettingsServiceError("settings_ui_entry_unsafe", `Plugin settings UI entry ${entry} is not a safe relative HTML path.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPluginDeclaresSafeSettingsUi(plugin: PluginListItem): string {
|
||||
const entry = getPluginSettingsEntry(plugin.manifest);
|
||||
if (!entry) {
|
||||
throw new PluginSettingsServiceError("settings_ui_unavailable", `Plugin ${plugin.pluginId} does not declare a settings UI.`);
|
||||
}
|
||||
assertSafePluginSettingsEntry(entry);
|
||||
if (!getPluginSettingsExpectedSha256(plugin.manifest)) {
|
||||
throw new PluginSettingsServiceError(
|
||||
"settings_ui_integrity_missing",
|
||||
`Plugin ${plugin.pluginId} settings UI ${entry} does not declare sha256 or integrity.`
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function getPluginSettingsExpectedSha256(manifest: PluginManifest): string | null {
|
||||
const settings = manifest.ui?.settings;
|
||||
if (!settings) return null;
|
||||
if (typeof settings.sha256 === "string" && settings.sha256.length > 0) {
|
||||
return settings.sha256;
|
||||
}
|
||||
if (typeof settings.integrity === "string" && settings.integrity.startsWith("sha256-")) {
|
||||
return Buffer.from(settings.integrity.slice("sha256-".length), "base64").toString("hex");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertSettingsHtmlIntegrity({ plugin, entry, htmlBytes }: { plugin: PluginListItem; entry: string; htmlBytes: Buffer }): void {
|
||||
const expectedSha256 = getPluginSettingsExpectedSha256(plugin.manifest);
|
||||
if (!expectedSha256) {
|
||||
throw new PluginSettingsServiceError(
|
||||
"settings_ui_integrity_missing",
|
||||
`Plugin ${plugin.pluginId} settings UI ${entry} does not declare sha256 or integrity.`
|
||||
);
|
||||
}
|
||||
const actualSha256 = createHash("sha256").update(htmlBytes).digest("hex");
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new PluginSettingsServiceError("settings_ui_integrity_mismatch", `Plugin settings UI file ${entry} does not match the manifest sha256.`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePluginBundleBasePath(): string {
|
||||
return process.env.PLUGIN_BINARY_BASE_PATH?.trim() || DEFAULT_PLUGIN_BINARY_BASE_PATH;
|
||||
}
|
||||
|
||||
async function resolveTenantPlugin(args: { tenantId: string; pluginRowId: string }): Promise<PluginListItem> {
|
||||
const plugin = await getPluginForTenant({ tenantId: args.tenantId, pluginRowId: args.pluginRowId });
|
||||
if (!plugin) {
|
||||
throw new PluginSettingsServiceError("plugin_not_found", `Plugin row ${args.pluginRowId} was not found for this tenant.`);
|
||||
}
|
||||
return plugin;
|
||||
}
|
||||
|
||||
function normalizeSettingsConfig(config: unknown): PluginUserSettings {
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const settings: Record<string, PluginSettingValue> = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (isSafePluginSettingKey(key) && isPluginSettingValue(value) && isPluginSettingValueWithinSize(value)) {
|
||||
settings[key] = value;
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
function assertPluginSettingKeyNotSecretLike(key: string): void {
|
||||
if (isPluginSettingKeySecretLike(key)) {
|
||||
throw new PluginSettingsServiceError("settings_key_secret_like", `Plugin setting key ${key} looks like a secret.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafePluginSettingKeyForWrite(key: string): void {
|
||||
if (!isPluginSettingKeyFormatSafe(key)) {
|
||||
throw new PluginSettingsServiceError("settings_key_invalid", `Plugin setting key ${key} is not a safe key.`);
|
||||
}
|
||||
if (isPluginSettingKeyReserved(key)) {
|
||||
throw new PluginSettingsServiceError("settings_key_invalid", `Plugin setting key ${key} is reserved.`);
|
||||
}
|
||||
assertPluginSettingKeyNotSecretLike(key);
|
||||
}
|
||||
|
||||
function assertPluginSettingValue(value: unknown): asserts value is PluginSettingValue {
|
||||
if (!isPluginSettingValueShapeSafe(value)) {
|
||||
throw new PluginSettingsServiceError("settings_value_invalid", "Plugin setting value must be JSON-serializable.");
|
||||
}
|
||||
assertPluginSettingValueSize(value);
|
||||
}
|
||||
|
||||
function assertPluginSettingValueSize(value: PluginSettingValue): void {
|
||||
if (!isPluginSettingValueWithinSize(value)) {
|
||||
throw new PluginSettingsServiceError("settings_value_too_large", `Plugin setting value exceeds ${MAX_PLUGIN_SETTING_VALUE_BYTES} bytes.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPluginSettingsConfigSize(settings: PluginUserSettings): void {
|
||||
const bytes = Buffer.byteLength(JSON.stringify(settings), "utf8");
|
||||
if (bytes > MAX_PLUGIN_USER_CONFIG_BYTES) {
|
||||
throw new PluginSettingsServiceError("settings_value_too_large", `Plugin settings exceed ${MAX_PLUGIN_USER_CONFIG_BYTES} bytes.`);
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type PluginHookPublisherMocks = {
|
||||
transaction: ReturnType<typeof vi.fn>;
|
||||
publishOutboxEvent: ReturnType<typeof vi.fn>;
|
||||
emitPluginHook: ReturnType<typeof vi.fn>;
|
||||
loggerWarn: ReturnType<typeof vi.fn>;
|
||||
loggerError: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const { TestPluginHookValidationError } = vi.hoisted(() => {
|
||||
class TestPluginHookValidationError extends Error {
|
||||
public override readonly name = "PluginHookValidationError";
|
||||
public readonly hookName: string;
|
||||
public readonly issues: unknown[];
|
||||
|
||||
constructor(hookName: string, issues: unknown[]) {
|
||||
super(`Invalid payload for plugin hook '${hookName}'`);
|
||||
this.hookName = hookName;
|
||||
this.issues = issues;
|
||||
}
|
||||
}
|
||||
|
||||
return { TestPluginHookValidationError };
|
||||
});
|
||||
|
||||
function getMocks(): PluginHookPublisherMocks {
|
||||
const store = globalThis as typeof globalThis & { __pluginHookPublisherMocks__?: PluginHookPublisherMocks };
|
||||
store.__pluginHookPublisherMocks__ ??= {
|
||||
transaction: vi.fn(async (callback: (tx: unknown) => Promise<unknown>) => callback({ tx: true })),
|
||||
publishOutboxEvent: vi.fn(async () => ({ id: "outbox_1" })),
|
||||
emitPluginHook: vi.fn(async (tx, def, payload, publish, options) =>
|
||||
publish(tx, {
|
||||
tenantId: options?.tenantId ?? null,
|
||||
type: def.outboxEventType,
|
||||
payload,
|
||||
dispatchKey: options?.dispatchKey ?? null,
|
||||
})
|
||||
),
|
||||
loggerWarn: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
};
|
||||
return store.__pluginHookPublisherMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
$transaction: getMocks().transaction,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@toolctrl/db/outbox", () => ({
|
||||
publishOutboxEvent: getMocks().publishOutboxEvent,
|
||||
}));
|
||||
|
||||
vi.mock("@toolctrl/plugin-runtime", () => ({
|
||||
emitPluginHook: getMocks().emitPluginHook,
|
||||
PluginHookValidationError: TestPluginHookValidationError,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/logger.server", () => ({
|
||||
createScopedLogger: () => ({
|
||||
warn: getMocks().loggerWarn,
|
||||
error: getMocks().loggerError,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { emitPluginHookFireAndForget } from "./pluginHookPublisher";
|
||||
|
||||
const mocks = getMocks();
|
||||
|
||||
const hookDefinition = {
|
||||
name: "workflow.failed",
|
||||
outboxEventType: "plugin.hook.workflow.failed",
|
||||
};
|
||||
|
||||
describe("emitPluginHookFireAndForget", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.transaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => callback({ tx: true }));
|
||||
mocks.publishOutboxEvent.mockResolvedValue({ id: "outbox_1" });
|
||||
mocks.emitPluginHook.mockImplementation(async (tx, def, payload, publish, options) =>
|
||||
publish(tx, {
|
||||
tenantId: options?.tenantId ?? null,
|
||||
type: def.outboxEventType,
|
||||
payload,
|
||||
dispatchKey: options?.dispatchKey ?? null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("records hook publish failures in logs without failing the caller", async () => {
|
||||
mocks.publishOutboxEvent.mockRejectedValueOnce(new Error("outbox unavailable"));
|
||||
|
||||
await expect(
|
||||
emitPluginHookFireAndForget(
|
||||
hookDefinition,
|
||||
{ tenantId: "tenant_1", workflowId: "workflow_1" },
|
||||
{ tenantId: "tenant_1", dispatchKey: "workflow-execution:execution_1:failed" }
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.publishOutboxEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.loggerError).toHaveBeenCalledWith(
|
||||
{
|
||||
hookName: "workflow.failed",
|
||||
tenantId: "tenant_1",
|
||||
error: "outbox unavailable",
|
||||
},
|
||||
"plugin.hook.emit_failed"
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes a plugin hook outbox event inside its one-shot transaction", async () => {
|
||||
await emitPluginHookFireAndForget(
|
||||
{
|
||||
name: "tenant.created",
|
||||
outboxEventType: "plugin.hook.tenant.created",
|
||||
},
|
||||
{
|
||||
tenantId: "tenant_1",
|
||||
slug: "acme",
|
||||
ownerEmail: "owner@example.com",
|
||||
plan: "free",
|
||||
createdAt: "2026-05-17T10:00:00.000Z",
|
||||
},
|
||||
{ tenantId: "tenant_1", dispatchKey: "tenant:tenant_1" }
|
||||
);
|
||||
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
|
||||
{ tx: true },
|
||||
{
|
||||
tenantId: "tenant_1",
|
||||
type: "plugin.hook.tenant.created",
|
||||
payload: {
|
||||
tenantId: "tenant_1",
|
||||
slug: "acme",
|
||||
ownerEmail: "owner@example.com",
|
||||
plan: "free",
|
||||
createdAt: "2026-05-17T10:00:00.000Z",
|
||||
},
|
||||
dispatchKey: "tenant:tenant_1",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("records hook validation failures in logs without failing the caller", async () => {
|
||||
const issues = [{ path: ["workflowId"], message: "Required" }];
|
||||
mocks.emitPluginHook.mockRejectedValueOnce(new TestPluginHookValidationError("workflow.failed", issues));
|
||||
|
||||
await expect(
|
||||
emitPluginHookFireAndForget(hookDefinition, { tenantId: "tenant_1" }, { tenantId: "tenant_1", dispatchKey: "workflow:bad" })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
||||
{
|
||||
hookName: "workflow.failed",
|
||||
tenantId: "tenant_1",
|
||||
issues,
|
||||
},
|
||||
"plugin.hook.payload_invalid"
|
||||
);
|
||||
});
|
||||
|
||||
it("records transaction failures in logs without failing the caller", async () => {
|
||||
mocks.transaction.mockRejectedValueOnce(new Error("db down"));
|
||||
|
||||
await expect(
|
||||
emitPluginHookFireAndForget(
|
||||
{
|
||||
name: "user.created",
|
||||
outboxEventType: "plugin.hook.user.created",
|
||||
},
|
||||
{
|
||||
userId: "user_1",
|
||||
tenantId: "tenant_1",
|
||||
email: "u@example.com",
|
||||
locale: "en",
|
||||
createdAt: "2026-05-17T10:00:00.000Z",
|
||||
},
|
||||
{ tenantId: "tenant_1", dispatchKey: "user:user_1" }
|
||||
)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerError).toHaveBeenCalledWith(
|
||||
{
|
||||
hookName: "user.created",
|
||||
tenantId: "tenant_1",
|
||||
error: "db down",
|
||||
},
|
||||
"plugin.hook.emit_failed"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Phase 06 #22 — Host-side adapter for emitting plugin hooks.
|
||||
*
|
||||
* Wraps `publishOutboxEvent` + `emitPluginHook` so call-sites in the
|
||||
* existing host code (tenant signup, user register, entity CRUD) stay
|
||||
* a single line and never touch the outbox-row construction directly.
|
||||
*
|
||||
* Each emit runs inside a one-shot Prisma transaction so the outbox
|
||||
* row is committed atomically with respect to itself. Hook emission is
|
||||
* intentionally fire-and-forget against the surrounding business
|
||||
* operation — failures are logged but never thrown, because hook
|
||||
* delivery must never break tenant signup / user create / row CRUD.
|
||||
*
|
||||
* Foundation scope: the worker side that fans the single
|
||||
* `plugin.hook.<name>` outbox event out to one invocation per
|
||||
* installed plugin is part of the same task (commit 8 wires it into
|
||||
* apps/worker/src/jobs/plugin-invoke.ts).
|
||||
*/
|
||||
|
||||
import { db } from "~/utils/db.server";
|
||||
import {
|
||||
publishOutboxEvent,
|
||||
type OutboxPublisherTransaction,
|
||||
} from "@toolctrl/db/outbox";
|
||||
import {
|
||||
emitPluginHook,
|
||||
PluginHookValidationError,
|
||||
type PluginHookDefinition,
|
||||
} from "@toolctrl/plugin-runtime";
|
||||
import { createScopedLogger } from "~/utils/logger.server";
|
||||
|
||||
const pluginHookLogger = createScopedLogger("plugin-hook-publisher");
|
||||
|
||||
/**
|
||||
* Emit a plugin hook outside an existing transaction. Use this for the
|
||||
* "fire after the business write succeeded" pattern — it owns its own
|
||||
* one-statement tx so the outbox row is atomic in itself, and the
|
||||
* caller is decoupled from outbox failure modes.
|
||||
*/
|
||||
export async function emitPluginHookFireAndForget<TPayload>(
|
||||
def: PluginHookDefinition<TPayload>,
|
||||
payload: TPayload,
|
||||
options: {
|
||||
tenantId?: string | null;
|
||||
dispatchKey?: string | null;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db.$transaction(async (tx) => {
|
||||
await emitPluginHook(
|
||||
tx,
|
||||
def,
|
||||
payload,
|
||||
async (innerTx, args) => {
|
||||
await publishOutboxEvent(innerTx as OutboxPublisherTransaction, {
|
||||
tenantId: args.tenantId,
|
||||
type: args.type,
|
||||
payload: args.payload as unknown as Parameters<typeof publishOutboxEvent>[1]["payload"],
|
||||
dispatchKey: args.dispatchKey,
|
||||
});
|
||||
},
|
||||
options
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof PluginHookValidationError) {
|
||||
pluginHookLogger.warn(
|
||||
{
|
||||
hookName: def.name,
|
||||
tenantId: options.tenantId ?? null,
|
||||
issues: error.issues,
|
||||
},
|
||||
"plugin.hook.payload_invalid"
|
||||
);
|
||||
return;
|
||||
}
|
||||
pluginHookLogger.error(
|
||||
{
|
||||
hookName: def.name,
|
||||
tenantId: options.tenantId ?? null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"plugin.hook.emit_failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user