379 lines · 13.2 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// @executor-js/plugin-toolkits/server//// The server half of the toolkits plugin. A toolkit is a named slice of the// caller's connections, persisted as owner-scoped plugin storage://   - workspace toolkit  -> owner "org"  (visible to every org member)//   - personal toolkit   -> owner "user" (visible only to its creator)// The pluginStorage facade is already request-bound to (tenant, subject), so// the org/user owner literal is the entire workspace-vs-personal visibility// story. Slice 1 ships data + CRUD only — no MCP narrowing yet.//// React and other browser-only deps live in `./client` — never here.// --------------------------------------------------------------------------- import { Schema } from "effect";import { Context, definePlugin, Effect, HttpApiBuilder } from "@executor-js/sdk/core";import {  definePluginStorageCollection,  Owner,  type PluginCtx,  type RequestScope,  type StorageFailure,} from "@executor-js/sdk";import { addGroup, capture } from "@executor-js/api"; import {  ToolkitForbidden,  ToolkitNotFound,  ToolkitsApi,  type CreateToolkitPayload,  type ToolkitAccess,  type ToolkitPolicyAction,  type ToolkitScope,  type ToolkitView,  type UpdateToolkitPayload,} from "./shared";import {  EMPTY_TOOLKIT_SCOPE,  toolkitScopeToRequestScope,  type ResolvedToolkitScope,} from "./toolkit-scope"; const ORG = Owner.make("org");const USER = Owner.make("user"); // ---------------------------------------------------------------------------// Storage rows (internal — not the HTTP contract). Keyed by uuid, never by a// user-supplied value (long keyed values hit a fumadb keyed-lookup bug).// --------------------------------------------------------------------------- const ToolkitRow = Schema.Struct({  slug: Schema.String,  name: Schema.String,  inheritOrgPolicies: Schema.Boolean,  briefing: Schema.NullOr(Schema.String),}); const ConnectionRow = Schema.Struct({  toolkitId: Schema.String,  integration: Schema.String,  connection: Schema.String,  access: Schema.String,  note: Schema.NullOr(Schema.String),}); const PolicyRow = Schema.Struct({  toolkitId: Schema.String,  pattern: Schema.String,  action: Schema.String,}); const TOOLKITS = definePluginStorageCollection("toolkits", ToolkitRow, {  indexes: ["slug"],});const CONNECTIONS = definePluginStorageCollection("connections", ConnectionRow, {  indexes: ["toolkitId"],});const POLICIES = definePluginStorageCollection("policies", PolicyRow, {  indexes: ["toolkitId"],}); const newId = Effect.sync(() => crypto.randomUUID());const scopeOfOwner = (owner: Owner): ToolkitScope => (owner === ORG ? "workspace" : "personal"); // ---------------------------------------------------------------------------// Extension — the canonical implementation. Handlers (and later the MCP// contribution) all hit this same code path.// --------------------------------------------------------------------------- type ToolkitRowEntry = {  readonly key: string;  readonly owner: Owner;  readonly data: typeof ToolkitRow.Type;}; const makeToolkitsExtension = (ctx: PluginCtx) => {  const toolkits = ctx.pluginStorage.collection(TOOLKITS);  const connections = ctx.pluginStorage.collection(CONNECTIONS);  const policies = ctx.pluginStorage.collection(POLICIES);   const viewFromRow = (row: ToolkitRowEntry) =>    Effect.gen(function* () {      const conns = yield* connections.query({ where: { toolkitId: row.key } });      const pols = yield* policies.query({ where: { toolkitId: row.key } });      return {        id: row.key,        slug: row.data.slug,        name: row.data.name,        scope: scopeOfOwner(row.owner),        inheritOrgPolicies: row.data.inheritOrgPolicies,        briefing: row.data.briefing,        connections: conns.map((c) => ({          integration: c.data.integration as ToolkitConnectionIntegration,          connection: c.data.connection,          access: c.data.access as ToolkitAccess,          ...(c.data.note == null ? {} : { note: c.data.note }),        })),        policies: pols.map((p) => ({          pattern: p.data.pattern,          action: p.data.action as ToolkitPolicyAction,        })),      } satisfies ToolkitView;    });   // candidate connections honoring scope: a workspace toolkit may use only  // org connections; a personal toolkit may use org + the caller's own.  const candidateConnections = (scope: ToolkitScope) =>    scope === "workspace" ? ctx.connections.list({ owner: ORG }) : ctx.connections.list();   const validate = (scope: ToolkitScope, entries: ReadonlyArray<ToolkitConnectionEntryInput>) =>    Effect.gen(function* () {      const allowed = yield* candidateConnections(scope);      const pairs = new Set(allowed.map((c) => `${c.integration}/${c.name}`));      const integrations = new Set(allowed.map((c) => c.integration));      for (const e of entries) {        const ok =          e.connection === "*"            ? integrations.has(e.integration)            : pairs.has(`${e.integration}/${e.connection}`);        if (!ok) {          return yield* new ToolkitForbidden({            reason: `connection ${e.integration}/${e.connection} is not available to a ${scope} toolkit`,          });        }      }    });   const putConnections = (    toolkitId: string,    owner: Owner,    entries: ReadonlyArray<ToolkitConnectionEntryInput>,  ) =>    Effect.forEach(entries, (e) =>      newId.pipe(        Effect.flatMap((cid) =>          connections.put({            key: cid,            owner,            data: {              toolkitId,              integration: e.integration,              connection: e.connection,              access: e.access,              note: e.note ?? null,            },          }),        ),      ),    );   const putPolicies = (    toolkitId: string,    owner: Owner,    rules: ReadonlyArray<{ readonly pattern: string; readonly action: string }>,  ) =>    Effect.forEach(rules, (r) =>      newId.pipe(        Effect.flatMap((pid) =>          policies.put({            key: pid,            owner,            data: { toolkitId, pattern: r.pattern, action: r.action },          }),        ),      ),    );   const create = (input: CreateToolkitPayload) =>    Effect.gen(function* () {      if (input.scope === "personal" && ctx.owner.subject == null) {        return yield* new ToolkitForbidden({          reason: "personal toolkits require a signed-in user",        });      }      const owner = input.scope === "workspace" ? ORG : USER;      const entries = input.connections ?? [];      yield* validate(input.scope, entries);      const id = yield* newId;      const data = {        slug: input.slug,        name: input.name,        inheritOrgPolicies: input.inheritOrgPolicies ?? true,        briefing: input.briefing ?? null,      };      yield* toolkits.put({ key: id, owner, data });      yield* putConnections(id, owner, entries);      yield* putPolicies(id, owner, input.policies ?? []);      return yield* viewFromRow({ key: id, owner, data });    });   const get = (id: string) =>    Effect.gen(function* () {      const row = yield* toolkits.get({ key: id });      if (row == null) return yield* new ToolkitNotFound({ id });      return yield* viewFromRow(row);    });   const list = () =>    toolkits.list().pipe(Effect.flatMap((rows) => Effect.forEach(rows, viewFromRow)));   const update = (id: string, patch: UpdateToolkitPayload) =>    Effect.gen(function* () {      const row = yield* toolkits.get({ key: id });      if (row == null) return yield* new ToolkitNotFound({ id });      const data = {        slug: row.data.slug,        name: patch.name ?? row.data.name,        inheritOrgPolicies: patch.inheritOrgPolicies ?? row.data.inheritOrgPolicies,        briefing: patch.briefing === undefined ? row.data.briefing : patch.briefing,      };      yield* toolkits.put({ key: id, owner: row.owner, data });      if (patch.connections !== undefined) {        yield* validate(scopeOfOwner(row.owner), patch.connections);        const existing = yield* connections.query({ where: { toolkitId: id } });        yield* Effect.forEach(existing, (c) =>          connections.remove({ key: c.key, owner: row.owner }),        );        yield* putConnections(id, row.owner, patch.connections);      }      if (patch.policies !== undefined) {        const existing = yield* policies.query({ where: { toolkitId: id } });        yield* Effect.forEach(existing, (p) => policies.remove({ key: p.key, owner: row.owner }));        yield* putPolicies(id, row.owner, patch.policies);      }      return yield* viewFromRow({ key: id, owner: row.owner, data });    });   const remove = (id: string) =>    Effect.gen(function* () {      const row = yield* toolkits.get({ key: id });      if (row == null) return yield* new ToolkitNotFound({ id });      const conns = yield* connections.query({ where: { toolkitId: id } });      yield* Effect.forEach(conns, (c) => connections.remove({ key: c.key, owner: row.owner }));      const pols = yield* policies.query({ where: { toolkitId: id } });      yield* Effect.forEach(pols, (p) => policies.remove({ key: p.key, owner: row.owner }));      yield* toolkits.remove({ key: id, owner: row.owner });      return { removed: true };    });   // Resolve a selector (slug, then id) to the scope the MCP narrowing seam  // applies. Request-scoped, so it only finds toolkits visible to the caller —  // a cross-tenant/personal-of-another-user selector returns null (the seam  // then fails closed to an empty slice).  const resolveScope = (    selector: string,  ): Effect.Effect<ResolvedToolkitScope | null, StorageFailure> =>    Effect.gen(function* () {      const bySlug = yield* toolkits.query({        where: { slug: selector },        limit: 1,      });      const row = bySlug[0] ?? (yield* toolkits.get({ key: selector }));      if (row == null) return null;      const conns = yield* connections.query({ where: { toolkitId: row.key } });      const pols = yield* policies.query({ where: { toolkitId: row.key } });      return {        entries: conns.map((c) => ({          integration: c.data.integration,          connection: c.data.connection,          access: c.data.access as ToolkitAccess,        })),        policies: pols.map((p) => ({          pattern: p.data.pattern,          action: p.data.action as ToolkitPolicyAction,        })),        inheritOrgPolicies: row.data.inheritOrgPolicies,      };    });   return { create, get, list, update, remove, resolveScope };}; // Local aliases so the storage/extension layer stays decoupled from the wire// schema's branded types without re-importing them everywhere.type ToolkitConnectionIntegration = ToolkitView["connections"][number]["integration"];type ToolkitConnectionEntryInput = {  readonly integration: ToolkitConnectionIntegration;  readonly connection: string;  readonly access: ToolkitAccess;  readonly note?: string | undefined;}; type ToolkitsExtension = ReturnType<typeof makeToolkitsExtension>; export class ToolkitsExtensionService extends Context.Service<  ToolkitsExtensionService,  ToolkitsExtension>()("ToolkitsExtensionService") {} // Build handlers against the core executor API with the toolkits group added,// so the handler layer satisfies `ApiGroup<"executor", "toolkits">` (the marker// the host's composed API requires) — not a standalone single-group bundle.const ExecutorApiWithToolkits = addGroup(ToolkitsApi); const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits", (h) =>  h    .handle("list", () =>      capture(        Effect.gen(function* () {          const ext = yield* ToolkitsExtensionService;          return yield* ext.list();        }),      ),    )    .handle("create", ({ payload }) =>      capture(        Effect.gen(function* () {          const ext = yield* ToolkitsExtensionService;          return yield* ext.create(payload);        }),      ),    )    .handle("get", ({ params }) =>      capture(        Effect.gen(function* () {          const ext = yield* ToolkitsExtensionService;          return yield* ext.get(params.id);        }),      ),    )    .handle("update", ({ params, payload }) =>      capture(        Effect.gen(function* () {          const ext = yield* ToolkitsExtensionService;          return yield* ext.update(params.id, payload);        }),      ),    )    .handle("remove", ({ params }) =>      capture(        Effect.gen(function* () {          const ext = yield* ToolkitsExtensionService;          return yield* ext.remove(params.id);        }),      ),    ),); const resolveToolkitRequestScope = (  ctx: PluginCtx,  selector: string,): Effect.Effect<RequestScope, StorageFailure> =>  makeToolkitsExtension(ctx)    .resolveScope(selector)    .pipe(Effect.map((scope) => toolkitScopeToRequestScope(scope ?? EMPTY_TOOLKIT_SCOPE))); export const toolkitsPlugin = definePlugin(() => ({  id: "toolkits" as const,  packageName: "@executor-js/plugin-toolkits",  storage: () => ({}),  pluginStorage: { toolkits: TOOLKITS, connections: CONNECTIONS },  extension: makeToolkitsExtension,  resolveRequestScope: resolveToolkitRequestScope,  routes: () => ToolkitsApi,  handlers: () => ToolkitsHandlers,  extensionService: ToolkitsExtensionService,})); export default toolkitsPlugin;