mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 17:45:24 +00:00
feat: add the operations layer for declared plugin actions
This commit is contained in:
1 parent
b87fe3c6a5
commit
cc725c7889
54 files changed
+22319
-8
No files matched your search
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* ctx.operations: declaring what a plugin can do, and asking for another
|
||||
* plugin's action to run.
|
||||
*
|
||||
* invoke acts for the current user, so on its own it would let any plugin spend
|
||||
* that user's full authority. It needs the capability, the action named in the
|
||||
* manifest's invokes list, and the origin always names the calling plugin, set
|
||||
* here from the closure rather than from anything the plugin passes.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginOperations,
|
||||
PluginOperationInvokeOptions,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import {
|
||||
invokeAllowed,
|
||||
type OperationAction,
|
||||
} from "@termix-ssh/plugin-sdk/operations";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
import { assertCapability, capabilityRefused } from "./permissions.js";
|
||||
import { getActor, getActorTrail, type ActorOrigin } from "./actor.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import {
|
||||
registerAction,
|
||||
revokeAction,
|
||||
type OperationHandler,
|
||||
} from "../operations/registry.js";
|
||||
import { provideApprovalPolicy } from "../operations/approvals.js";
|
||||
import {
|
||||
emitOperationEvent,
|
||||
subscribeOperationEvents,
|
||||
} from "../operations/events.js";
|
||||
import {
|
||||
drainPlugin,
|
||||
OperationError,
|
||||
runOperation,
|
||||
} from "../operations/gate.js";
|
||||
import { buildCatalog } from "../operations/catalog.js";
|
||||
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
bag: DisposableBag;
|
||||
}
|
||||
|
||||
export function createPluginOperations({
|
||||
manifest,
|
||||
bag,
|
||||
}: Deps): PluginOperations {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
const contribution = manifest.contributes?.operations;
|
||||
let drainRegistered = false;
|
||||
|
||||
const declaredAction = (id: string): OperationAction => {
|
||||
const action = contribution?.actions?.find((entry) => entry.id === id);
|
||||
if (!action) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot register action "${id}": it is not declared in contributes.operations`,
|
||||
);
|
||||
}
|
||||
return action;
|
||||
};
|
||||
|
||||
const actingUser = (): string => {
|
||||
const userId = getActor();
|
||||
if (!userId) {
|
||||
throw new Error(
|
||||
"ctx.operations needs an acting user: call it inside a request or ctx.asUser",
|
||||
);
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
|
||||
return {
|
||||
register: (id, handler) => {
|
||||
if (!declared.includes("operations:provide")) {
|
||||
throw capabilityRefused(pluginId, "operations:provide");
|
||||
}
|
||||
const registration = registerAction(
|
||||
pluginId,
|
||||
declaredAction(id),
|
||||
handler as OperationHandler,
|
||||
);
|
||||
bag.add(() => void revokeAction(registration), `action "${id}"`);
|
||||
// Added once and after the actions, so disposal, which runs newest
|
||||
// first, drains before anything the handlers use is torn down.
|
||||
if (!drainRegistered) {
|
||||
drainRegistered = true;
|
||||
bag.add(() => drainPlugin(pluginId), "operations drain");
|
||||
}
|
||||
},
|
||||
|
||||
// A plugin's own route running its own action, so a button press lands in
|
||||
// the same history. The caller stays the origin, not this plugin.
|
||||
run: async (actionId, options = {}) => {
|
||||
if (!declared.includes("operations:provide")) {
|
||||
throw capabilityRefused(pluginId, "operations:provide");
|
||||
}
|
||||
declaredAction(actionId);
|
||||
const trail = getActorTrail();
|
||||
return runOperation({
|
||||
actionId,
|
||||
hostId: options.hostId,
|
||||
resourceKey: options.resourceKey,
|
||||
input: options.input,
|
||||
dryRun: options.dryRun,
|
||||
confirmed: options.confirmed,
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
batchId: options.batchId,
|
||||
userId: actingUser(),
|
||||
origin: trail.origin ?? { kind: "plugin", pluginId },
|
||||
apiKeyScopes: trail.apiKeyScopes,
|
||||
depth: trail.depth,
|
||||
});
|
||||
},
|
||||
|
||||
invoke: async (actionId, options = {}) => {
|
||||
await assertCapability(pluginId, "operations:invoke", declared);
|
||||
if (!invokeAllowed(contribution?.invokes, actionId)) {
|
||||
throw new OperationError(
|
||||
"FORBIDDEN",
|
||||
`${pluginId} did not declare that it runs ${actionId}`,
|
||||
);
|
||||
}
|
||||
const trail = getActorTrail();
|
||||
const origin: ActorOrigin = {
|
||||
kind: "plugin",
|
||||
pluginId,
|
||||
via: (options as PluginOperationInvokeOptions).via,
|
||||
};
|
||||
return runOperation({
|
||||
actionId,
|
||||
hostId: options.hostId,
|
||||
resourceKey: options.resourceKey,
|
||||
input: options.input,
|
||||
dryRun: options.dryRun,
|
||||
confirmed: options.confirmed,
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
batchId: options.batchId,
|
||||
userId: actingUser(),
|
||||
origin,
|
||||
apiKeyScopes: trail.apiKeyScopes,
|
||||
depth: trail.depth,
|
||||
});
|
||||
},
|
||||
|
||||
list: async (options) => {
|
||||
await assertCapability(pluginId, "operations:invoke", declared);
|
||||
return buildCatalog({
|
||||
userId: actingUser(),
|
||||
lang: options?.lang,
|
||||
surface: options?.surface,
|
||||
});
|
||||
},
|
||||
|
||||
emit: async (eventId, payload, options) => {
|
||||
if (!declared.includes("operations:provide")) {
|
||||
throw capabilityRefused(pluginId, "operations:provide");
|
||||
}
|
||||
const event = contribution?.events?.find((entry) => entry.id === eventId);
|
||||
if (!event) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot emit "${eventId}": it is not declared in contributes.operations`,
|
||||
);
|
||||
}
|
||||
await emitOperationEvent({
|
||||
eventId,
|
||||
pluginId,
|
||||
hostId: options?.hostId,
|
||||
severity: event.severity,
|
||||
payload,
|
||||
});
|
||||
},
|
||||
|
||||
onEvent: (subscription) => {
|
||||
if (!declared.includes("operations:invoke")) {
|
||||
throw capabilityRefused(pluginId, "operations:invoke");
|
||||
}
|
||||
const remove = subscribeOperationEvents(pluginId, subscription);
|
||||
bag.add(remove, `events "${subscription.pattern}"`);
|
||||
return remove;
|
||||
},
|
||||
|
||||
approvals: {
|
||||
provide: (provider) => {
|
||||
if (!declared.includes("operations:approve")) {
|
||||
throw capabilityRefused(pluginId, "operations:approve");
|
||||
}
|
||||
const remove = provideApprovalPolicy(pluginId, manifest, provider);
|
||||
bag.add(remove, "approval rules");
|
||||
return remove;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user