import { Effect, Schema } from "effect"; import { definePlugin, type ExecutorPlugin, type PluginContext, type SecretProvider, type ScopedKv, SecretId, } from "@executor/sdk"; import { OnePasswordConfig, Vault, ConnectionStatus } from "./types"; import type { OnePasswordAuth } from "./types"; import { OnePasswordError } from "./errors"; import { makeOnePasswordService, type ResolvedAuth, type OnePasswordService, } from "./service"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PLUGIN_KEY = "onepassword"; const CREDENTIAL_FIELD = "credential"; const DEFAULT_TIMEOUT_MS = 15_000; const CONFIG_KEY = "config"; // --------------------------------------------------------------------------- // Plugin extension — public API on executor.onepassword // --------------------------------------------------------------------------- export interface OnePasswordExtension { /** Configure the 1Password connection */ readonly configure: ( config: OnePasswordConfig, ) => Effect.Effect; /** Get current configuration (if any) */ readonly getConfig: () => Effect.Effect; /** Remove the 1Password configuration */ readonly removeConfig: () => Effect.Effect; /** Check connection status */ readonly status: () => Effect.Effect; /** List accessible vaults (requires auth) */ readonly listVaults: ( auth: OnePasswordAuth, ) => Effect.Effect, OnePasswordError>; /** Resolve a secret directly by op:// URI */ readonly resolve: (uri: string) => Effect.Effect; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const resolveAuth = ( auth: OnePasswordAuth, ctx: PluginContext, ): Effect.Effect => { if (auth.kind === "desktop-app") { return Effect.succeed({ kind: "desktop-app" as const, accountName: auth.accountName, }); } return ctx.secrets.resolve(SecretId.make(auth.tokenSecretId), ctx.scope.id).pipe( Effect.map( (token): ResolvedAuth => ({ kind: "service-account", token }), ), Effect.mapError( (e) => new OnePasswordError({ operation: "auth resolution", message: `Failed to resolve service account token secret "${auth.tokenSecretId}": ${e._tag}`, }), ), ); }; const getServiceFromConfig = ( config: OnePasswordConfig, ctx: PluginContext, timeoutMs: number, ): Effect.Effect => resolveAuth(config.auth, ctx).pipe( Effect.flatMap((resolved) => makeOnePasswordService(resolved, { timeoutMs })), ); // --------------------------------------------------------------------------- // SecretProvider — read-only, resolves op:// URIs or vaultId-based lookups // --------------------------------------------------------------------------- const makeProvider = ( getConfig: () => Effect.Effect, ctx: PluginContext, timeoutMs: number, ): SecretProvider => ({ key: "onepassword", writable: false, get: (secretId) => getConfig().pipe( Effect.flatMap((config) => { if (!config) return Effect.succeed(null); const uri = secretId.startsWith("op://") ? secretId : `op://${config.vaultId}/${secretId}/${CREDENTIAL_FIELD}`; return getServiceFromConfig(config, ctx, timeoutMs).pipe( Effect.flatMap((svc) => svc.resolveSecret(uri)), Effect.map((v): string | null => v), Effect.orElseSucceed(() => null), ); }), Effect.orElseSucceed(() => null), ), list: () => getConfig().pipe( Effect.flatMap((config) => { if (!config) return Effect.succeed([] as { id: string; name: string }[]); return getServiceFromConfig(config, ctx, timeoutMs).pipe( Effect.flatMap((svc) => svc.listItems(config.vaultId)), Effect.map((items) => items.map((item) => ({ id: item.id, name: item.title })), ), ); }), Effect.orElseSucceed(() => [] as { id: string; name: string }[]), ), }); // --------------------------------------------------------------------------- // Config persistence via ScopedKv // --------------------------------------------------------------------------- const decodeConfig = Schema.decodeUnknownSync(OnePasswordConfig); const loadConfig = (kv: ScopedKv): Effect.Effect => kv.get(CONFIG_KEY).pipe( Effect.flatMap((v) => { if (v === null) return Effect.succeed(null); return Effect.try(() => decodeConfig(JSON.parse(v))).pipe( Effect.mapError( (cause) => new OnePasswordError({ operation: "config decode", message: cause instanceof Error ? cause.message : String(cause), }), ), ); }), ); const saveConfig = ( kv: ScopedKv, config: OnePasswordConfig, ): Effect.Effect => kv.set(CONFIG_KEY, JSON.stringify({ auth: config.auth, vaultId: config.vaultId, name: config.name, })); const deleteConfig = (kv: ScopedKv): Effect.Effect => kv.delete(CONFIG_KEY); // --------------------------------------------------------------------------- // Plugin factory // --------------------------------------------------------------------------- export interface OnePasswordPluginOptions { /** Scoped KV for persisting config (provided by server) */ readonly kv: ScopedKv; /** Request timeout in ms (default: 15000) */ readonly timeoutMs?: number; /** Force use of the native SDK instead of the CLI (default: false) */ readonly preferSdk?: boolean; } export const onepasswordPlugin = ( options: OnePasswordPluginOptions, ): ExecutorPlugin => { const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const kv = options.kv; return definePlugin({ key: PLUGIN_KEY, init: (ctx) => Effect.gen(function* () { const getConfig = () => loadConfig(kv); yield* ctx.secrets.addProvider( makeProvider(getConfig, ctx, timeoutMs), ); const extension: OnePasswordExtension = { configure: (config) => saveConfig(kv, config), getConfig: () => getConfig(), removeConfig: () => deleteConfig(kv), status: () => Effect.gen(function* () { const config = yield* getConfig(); if (!config) { return new ConnectionStatus({ connected: false, error: "Not configured", }); } const svc = yield* getServiceFromConfig(config, ctx, timeoutMs); const vaults = yield* svc.listVaults(); const vault = vaults.find((v) => v.id === config.vaultId); return new ConnectionStatus({ connected: true, vaultName: vault?.title, }); }), listVaults: (auth) => Effect.gen(function* () { const resolved = yield* resolveAuth(auth, ctx); const svc = yield* makeOnePasswordService(resolved, { timeoutMs, preferSdk: options.preferSdk, }); const vaults = yield* svc.listVaults(); return vaults .map((v) => new Vault({ id: v.id, name: v.title })) .sort((a, b) => a.name.localeCompare(b.name)); }), resolve: (uri) => Effect.gen(function* () { const config = yield* getConfig(); if (!config) { return yield* new OnePasswordError({ operation: "resolve", message: "1Password is not configured", }); } const svc = yield* getServiceFromConfig(config, ctx, timeoutMs); return yield* svc.resolveSecret(uri); }), }; return { extension }; }), }); };