import { Effect, Option, Predicate, Schema } from "effect"; import { type CredentialProvider, Owner, type OwnerBinding, type PluginStorageEntry, ProviderItemId, ProviderKey, StorageError, type StorageDeps, type StorageFailure, } from "@executor-js/sdk/core"; import { type WorkOSVaultClient, type WorkOSVaultClientError, type WorkOSVaultObject, } from "./client"; export const WORKOS_VAULT_PROVIDER_KEY = ProviderKey.make("workos-vault"); const DEFAULT_OBJECT_PREFIX = "executor"; const MAX_WRITE_ATTEMPTS = 3; // WorkOS creates a per-context KEK just-in-time on first write; a create // call immediately after that provisioning step can race with the KEK // becoming usable and return a transient error whose message ends in // "KEK was created but is not yet ready. This request can be retried." // We back off and retry the whole attempt (read + create) a few times. const MAX_KEK_NOT_READY_ATTEMPTS = 20; const KEK_NOT_READY_BACKOFF_MS = 1000; // The vault `context` is the KEK-matching dimension — WorkOS provisions one KEK // per distinct context, so it doubles as a cryptographic partition. Object // names alone already isolate partitions (see `secretObjectName`); the context // makes that isolation cryptographic so a partition's objects can only be // decrypted under its own KEK. Each value stays colon-free by construction // (tenant/subject ids contain no `:`), sidestepping the "KEK was created but is // not yet ready" hang we previously hit when a context value contained `:`. // --------------------------------------------------------------------------- // Metadata storage — values live in WorkOS Vault; regular plugin storage // tracks what we know about and lets us enumerate. Keyed by the opaque // `ProviderItemId`; writes carry the executor's `owner` binding. // --------------------------------------------------------------------------- const METADATA_COLLECTION = "metadata"; const WorkosVaultMetadataData = Schema.Struct({ name: Schema.String, purpose: Schema.NullOr(Schema.String), createdAt: Schema.DateFromString, }); type WorkosVaultMetadataDataEncoded = typeof WorkosVaultMetadataData.Encoded; type MetadataRow = { readonly id: string; readonly name: string; readonly purpose: string | null; readonly created_at: Date; }; const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); const decodeMetadataData = Schema.decodeUnknownOption(WorkosVaultMetadataData); const coerceJson = (value: unknown): unknown => { if (typeof value !== "string") return value; return Option.getOrElse(decodeJson(value), () => value); }; const metadataData = (row: MetadataRow): WorkosVaultMetadataDataEncoded => ({ name: row.name, purpose: row.purpose, createdAt: row.created_at.toISOString(), }); const entryToMetadataRow = (entry: PluginStorageEntry): MetadataRow | null => Option.match(decodeMetadataData(coerceJson(entry.data)), { onNone: () => null, onSome: (data: WorkosVaultMetadataData): MetadataRow => ({ id: entry.key, name: data.name, purpose: data.purpose, created_at: data.createdAt, }), }); type WorkosVaultMetadataData = typeof WorkosVaultMetadataData.Type; /** Map the executor's (tenant, subject?) binding onto the storage `Owner` * literal: a bound subject writes the user's own partition, otherwise the * org-shared one. Fallback only — prefer `ownerForItemId`. */ const ownerOf = (binding: OwnerBinding): Owner => binding.subject == null ? Owner.make("org") : Owner.make("user"); // Item ids whose SECOND colon-segment is the owning partition: // connection:::: // oauth:::[:refresh] // oauth-client:::secret const OWNER_SCOPED_PREFIXES: ReadonlySet = new Set(["connection", "oauth", "oauth-client"]); /** The owner a logical item id embeds, or null for ids that carry none * (legacy random `secret_*` ids). Reads the second colon-segment of the * owner-scoped prefixes. */ const embeddedOwner = (id: string): Owner | null => { const [prefix, owner] = id.split(":"); if (OWNER_SCOPED_PREFIXES.has(prefix ?? "") && (owner === "org" || owner === "user")) { return Owner.make(owner); } return null; }; /** The partition a credential's metadata belongs to: the CREDENTIAL's owner * (embedded in the item id), not the acting caller's binding — so an org * member's workspace connection files org-shared metadata that every member * can resolve. Ids without an embedded owner fall back to the caller binding. */ const ownerForItemId = (id: string, binding: OwnerBinding): Owner => embeddedOwner(id) ?? ownerOf(binding); // --------------------------------------------------------------------------- // WorkosVaultStore — typed metadata-store the plugin uses internally. // // v2: keyed solely by the opaque `ProviderItemId`. Writes carry the executor's // `owner` (so plugin storage knows which partition to file under); reads/list // are not owner-filtered — the connection row that references the id owns the // partition. // --------------------------------------------------------------------------- export interface WorkosVaultStore { readonly get: (id: string) => Effect.Effect; readonly upsert: (row: MetadataRow) => Effect.Effect; readonly remove: (id: string) => Effect.Effect; readonly list: () => Effect.Effect; } export const makeWorkosVaultStore = (deps: StorageDeps): WorkosVaultStore => { const { pluginStorage } = deps; const find = (id: string): Effect.Effect => pluginStorage .get({ collection: METADATA_COLLECTION, key: id }) .pipe( Effect.map((entry: PluginStorageEntry | null): MetadataRow | null => entry ? entryToMetadataRow(entry) : null, ), ); return { get: (id: string) => find(id), upsert: (row: MetadataRow) => pluginStorage .put({ owner: ownerForItemId(row.id, deps.owner), collection: METADATA_COLLECTION, key: row.id, data: metadataData(row), }) .pipe(Effect.asVoid), remove: (id: string) => Effect.gen(function* () { const existing = yield* find(id); if (!existing) return false; yield* pluginStorage.remove({ owner: ownerForItemId(id, deps.owner), collection: METADATA_COLLECTION, key: id, }); return true; }), list: () => pluginStorage.list({ collection: METADATA_COLLECTION }).pipe( Effect.map((rows: readonly PluginStorageEntry[]): readonly MetadataRow[] => rows .map(entryToMetadataRow) .filter(Predicate.isNotNull) .sort( (l: MetadataRow, r: MetadataRow) => l.created_at.getTime() - r.created_at.getTime(), ), ), ), }; }; // --------------------------------------------------------------------------- // Vault helpers — partition-scoped object naming + 409-retry upsert. // // The object name encodes the credential's partition, because a logical item id // (`connection:`/`oauth:`/`oauth-client:`) carries no tenant and no subject — two // tenants (or two users) that pick the same integration + name derive the same // id, so the name must add the partition to keep their objects distinct. We // scope by tenant always, and by subject for user-owned credentials (org // credentials are shared across a tenant's members, so they stay subject-less — // mirroring `ownerForItemId`). Legacy random `secret_*` ids are globally unique // already, so they keep their flat, unscoped name and existing objects keep // resolving unchanged. Segments are URL-encoded because ids can carry `/`, `:`. // --------------------------------------------------------------------------- const isStatusError = (error: WorkOSVaultClientError, status: number): boolean => error.status === status; const isKekNotReadyError = (error: WorkOSVaultClientError): boolean => error.retryKind === "kek_not_ready"; const encodeObjectNameSegment = (segment: string): string => encodeURIComponent(segment); // WorkOS Vault accepts createObject names of any length but every read of an // object whose name exceeds 200 characters fails with 400 — by name AND by id // (verified empirically against the live API, 2026-06-10: 200 reads fine, 201 // is permanently unreadable). Names that would exceed the limit swap the // encoded-id tail for a sha256 digest; the partition segments stay literal so // the name remains attributable. `h~` cannot collide with an encoded literal // id: every owner-scoped id starts with its `connection`/`oauth`/`oauth-client` // prefix, never `h~`. const MAX_OBJECT_NAME_LENGTH = 200; const sha256Base64Url = (input: string): Effect.Effect => Effect.promise(async () => { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); let binary = ""; for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte); return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); }); /** Partition path segments for a logical item id, or null for legacy ids that * carry no embedded owner (those keep the flat, unscoped name). */ const objectScopeSegments = (id: string, binding: OwnerBinding): readonly string[] | null => { const owner = embeddedOwner(id); if (owner === null) return null; const tenant = encodeObjectNameSegment(String(binding.tenant)); return owner === "user" ? ["user", tenant, encodeObjectNameSegment(String(binding.subject ?? ""))] : ["org", tenant]; }; const secretObjectName = ( prefix: string, id: string, binding: OwnerBinding, ): Effect.Effect => Effect.gen(function* () { const scope = objectScopeSegments(id, binding); const head = scope === null ? prefix : `${prefix}/${scope.join("/")}`; const name = `${head}/secrets/${encodeObjectNameSegment(id)}`; if (name.length <= MAX_OBJECT_NAME_LENGTH) return name; return `${head}/secrets/h~${yield* sha256Base64Url(id)}`; }); /** KEK-matching context for a credential. Logical ids get a per-tenant (and * per-user) context so WorkOS provisions an isolated KEK per partition; legacy * ids keep the original shared context so existing objects stay decryptable. */ const vaultContextFor = (id: string, binding: OwnerBinding): Record => { const owner = embeddedOwner(id); if (owner === null) return { app: "executor" }; const context: Record = { app: "executor", organization_id: String(binding.tenant), }; if (owner === "user") context.user_id = String(binding.subject ?? ""); return context; }; const loadSecretObject = ( client: WorkOSVaultClient, name: string, ): Effect.Effect => client.readObjectByName(name).pipe( Effect.catch((error: WorkOSVaultClientError) => { // 400 (invalid name) and 404 (absent) both mean "no value here". if (isStatusError(error, 400) || isStatusError(error, 404)) return Effect.succeed(null); return Effect.fail(error); }), ); const upsertSecretValue = ( client: WorkOSVaultClient, name: string, value: string, context: Record, ): Effect.Effect => { const attemptWrite = ( remainingConflictAttempts: number, remainingKekAttempts: number, ): Effect.Effect => Effect.gen(function* () { const existing = yield* loadSecretObject(client, name); if (existing) { yield* client.updateObject({ id: existing.id, value, versionCheck: existing.metadata.versionId, }); return; } yield* client.createObject({ name, value, context }); }).pipe( Effect.catch((error: WorkOSVaultClientError) => { if (remainingConflictAttempts > 1 && isStatusError(error, 409)) { return attemptWrite(remainingConflictAttempts - 1, remainingKekAttempts); } if (remainingKekAttempts > 1 && isKekNotReadyError(error)) { console.warn( `[workos-vault] KEK not ready for object=${name} — ` + `retrying in ${KEK_NOT_READY_BACKOFF_MS}ms ` + `(${MAX_KEK_NOT_READY_ATTEMPTS - remainingKekAttempts + 1}/${MAX_KEK_NOT_READY_ATTEMPTS})`, ); return Effect.sleep(KEK_NOT_READY_BACKOFF_MS).pipe( Effect.flatMap(() => attemptWrite(remainingConflictAttempts, remainingKekAttempts - 1)), ); } if (isKekNotReadyError(error)) { console.error( `[workos-vault] KEK still not ready after ${MAX_KEK_NOT_READY_ATTEMPTS} attempts ` + `for object=${name}; giving up.`, ); } return Effect.fail(error); }), ); return attemptWrite(MAX_WRITE_ATTEMPTS, MAX_KEK_NOT_READY_ATTEMPTS); }; const deleteSecretValue = ( client: WorkOSVaultClient, name: string, ): Effect.Effect => Effect.gen(function* () { const existing = yield* loadSecretObject(client, name); if (!existing) return false; yield* client.deleteObject({ id: existing.id }); return true; }); // --------------------------------------------------------------------------- // makeWorkOSVaultCredentialProvider — builds a CredentialProvider backed by // WorkOS Vault for values and the plugin's own metadata table for // names/purpose/createdAt. // // The provider sees an opaque `ProviderItemId` plus the request's `owner` // binding (tenant + subject). It derives the vault object name and KEK context // from both, so a credential's object is scoped to its partition. The // connection row that references the id owns the (tenant, owner, subject) // partition. `delete` returns void; absence is not an error. // --------------------------------------------------------------------------- export interface WorkOSVaultCredentialProviderOptions { readonly client: WorkOSVaultClient; readonly store: WorkosVaultStore; /** The request's owner binding (tenant + subject). Scopes the vault object * name and KEK context so credentials cannot collide across partitions. */ readonly owner: OwnerBinding; readonly objectPrefix?: string; } export const makeWorkOSVaultCredentialProvider = ( options: WorkOSVaultCredentialProviderOptions, ): CredentialProvider => { const prefix = options.objectPrefix ?? DEFAULT_OBJECT_PREFIX; const { client, store, owner } = options; const nameFor = (id: ProviderItemId): Effect.Effect => secretObjectName(prefix, id, owner); return { key: WORKOS_VAULT_PROVIDER_KEY, writable: true, get: (id: ProviderItemId) => Effect.gen(function* () { const meta = yield* store.get(id); if (!meta) return null; const object = yield* loadSecretObject(client, yield* nameFor(id)).pipe( Effect.mapError( (error: WorkOSVaultClientError) => new StorageError({ message: "WorkOS Vault secret read failed", cause: error, }), ), ); if (!object || !object.value) return null; return object.value; }), has: (id: ProviderItemId) => store.get(id).pipe(Effect.map((meta) => meta !== null)), set: (id: ProviderItemId, value: string) => Effect.gen(function* () { const existing = yield* store.get(id); yield* upsertSecretValue( client, yield* nameFor(id), value, vaultContextFor(id, owner), ).pipe( Effect.mapError( (error: WorkOSVaultClientError) => new StorageError({ message: "WorkOS Vault secret write failed", cause: error, }), ), ); yield* store.upsert({ id, name: existing?.name ?? id, purpose: existing?.purpose ?? null, created_at: existing?.created_at ?? new Date(), }); }), delete: (id: ProviderItemId) => Effect.gen(function* () { const meta = yield* store.get(id); if (!meta) return; yield* deleteSecretValue(client, yield* nameFor(id)).pipe( Effect.mapError( (error: WorkOSVaultClientError) => new StorageError({ message: "WorkOS Vault secret delete failed", cause: error, }), ), ); yield* store.remove(id); }), list: () => store .list() .pipe( Effect.map((rows: readonly MetadataRow[]) => rows.map((r: MetadataRow) => ({ id: ProviderItemId.make(r.id), name: r.name })), ), ), }; };