Unified · Split
| @@ -4,7 +4,7 @@ import { · 3 unmodified lines | |||
| 4 | NotFoundException, | 4 | NotFoundException, |
| 5 | WorkOS as WorkOSClient, | 5 | WorkOS as WorkOSClient, |
| 6 | } from "@workos-inc/node/worker"; | 6 | } from "@workos-inc/node/worker"; |
| 7 | import { Data, Effect, Result } from "effect"; | 7 | import { Data, Effect, Option, Result, Schema } from "effect"; |
| 8 | 8 | ||
| 9 | export interface WorkOSVaultObjectMetadata { | 9 | export interface WorkOSVaultObjectMetadata { |
| 10 | readonly context: Record<string, unknown>; | 10 | readonly context: Record<string, unknown>; |
| @@ -20,44 +20,49 @@ export interface WorkOSVaultObject { · 9 unmodified lines | |||
| 20 | readonly value?: string; | 20 | readonly value?: string; |
| 21 | } | 21 | } |
| 22 | 22 | ||
| 23 | // Minimal shape carrying an HTTP-style status code. Production WorkOS errors | 23 | const WORKOS_KEK_NOT_READY_MESSAGE = |
| 24 | // (`GenericServerException`/`NotFoundException`) and test fakes both populate | 24 | "KEK was created but is not yet ready. This request can be retried."; |
| 25 | // a numeric `status`, so the boundary normalises against this named type | ||
| 26 | // rather than probing arbitrary unknown shapes. | ||
| 27 | interface ErrorWithStatus extends Error { | ||
| 28 | readonly status: number; | ||
| 29 | } | ||
| 30 | 25 | ||
| 31 | const isErrorWithStatus = (cause: unknown): cause is ErrorWithStatus => | 26 | const CauseWithStatusSchema = Schema.Struct({ |
| 32 | cause instanceof Error && typeof (cause as ErrorWithStatus).status === "number"; | 27 | status: Schema.Number, |
| 28 | }); | ||
| 33 | 29 | ||
| 34 | const statusFromWorkOSCause = (cause: unknown): number | undefined => { | 30 | const statusFromWorkOSCause = (cause: unknown): number | undefined => { |
| 35 | if (cause instanceof GenericServerException || cause instanceof NotFoundException) { | 31 | if (cause instanceof GenericServerException || cause instanceof NotFoundException) { |
| 36 | return cause.status; | 32 | return cause.status; |
| 37 | } | 33 | } |
| 38 | if (isErrorWithStatus(cause)) return cause.status; | 34 | return Option.match(Schema.decodeUnknownOption(CauseWithStatusSchema)(cause), { |
| 39 | return undefined; | 35 | onNone: () => undefined, |
| 36 | onSome: (decoded) => decoded.status, | ||
| 37 | }); | ||
| 40 | }; | 38 | }; |
| 41 | 39 | ||
| 42 | const messageFromWorkOSCause = (cause: unknown): string => | 40 | const isKekNotReadyWorkOSCause = (cause: unknown): boolean => |
| 43 | cause instanceof Error ? cause.message : typeof cause === "string" ? cause : ""; | 41 | cause instanceof GenericServerException && |
| 42 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: WorkOS only exposes this retryable Vault condition through its SDK exception message | ||
| 43 | cause.message.endsWith(WORKOS_KEK_NOT_READY_MESSAGE); | ||
| 44 | 44 | ||
| 45 | export class WorkOSVaultClientError extends Data.TaggedError("WorkOSVaultClientError")<{ | 45 | export class WorkOSVaultClientError extends Data.TaggedError("WorkOSVaultClientError")<{ |
| 46 | readonly cause: unknown; | 46 | readonly cause: unknown; |
| 47 | readonly message: string; | 47 | readonly message: string; |
| 48 | readonly operation: string; | 48 | readonly operation: string; |
| 49 | readonly retryKind?: "kek_not_ready"; | ||
| 49 | readonly status?: number; | 50 | readonly status?: number; |
| 50 | }> { | 51 | }> { |
| 51 | constructor(options: { | 52 | constructor(options: { |
| 52 | readonly cause: unknown; | 53 | readonly cause: unknown; |
| 53 | readonly message?: string; | 54 | readonly message?: string; |
| 54 | readonly operation: string; | 55 | readonly operation: string; |
| 56 | readonly retryKind?: "kek_not_ready"; | ||
| 55 | readonly status?: number; | 57 | readonly status?: number; |
| 56 | }) { | 58 | }) { |
| 57 | super({ | 59 | super({ |
| 58 | cause: options.cause, | 60 | cause: options.cause, |
| 59 | message: options.message ?? messageFromWorkOSCause(options.cause), | 61 | message: options.message ?? `WorkOS Vault ${options.operation} failed`, |
| 60 | operation: options.operation, | 62 | operation: options.operation, |
| 63 | retryKind: | ||
| 64 | options.retryKind ?? | ||
| 65 | (isKekNotReadyWorkOSCause(options.cause) ? "kek_not_ready" : undefined), | ||
| 61 | status: options.status ?? statusFromWorkOSCause(options.cause), | 66 | status: options.status ?? statusFromWorkOSCause(options.cause), |
| 62 | }); | 67 | }); |
| 63 | } | 68 | } |
| @@ -158,7 +158,7 @@ const isStatusError = (error: WorkOSVaultClientError, status: number): boolean = · 157 unmodified lines | |||
| 158 | error.status === status; | 158 | error.status === status; |
| 159 | 159 | ||
| 160 | const isKekNotReadyError = (error: WorkOSVaultClientError): boolean => | 160 | const isKekNotReadyError = (error: WorkOSVaultClientError): boolean => |
| 161 | error.message.includes("KEK was created but is not yet ready"); | 161 | error.retryKind === "kek_not_ready"; |
| 162 | 162 | ||
| 163 | // Default context builder. Each semantic piece of a scope id lives in | 163 | // Default context builder. Each semantic piece of a scope id lives in |
| 164 | // its own vault-context key so WorkOS's KEK matcher sees individual | 164 | // its own vault-context key so WorkOS's KEK matcher sees individual |
| @@ -302,9 +302,6 @@ const deleteSecretValue = ( · 137 unmodified lines | |||
| 302 | return true; | 302 | return true; |
| 303 | }); | 303 | }); |
| 304 | 304 | ||
| 305 | const formatVaultError = (error: WorkOSVaultClientError): StorageError => | ||
| 306 | new StorageError({ message: error.message, cause: error.cause }); | ||
| 307 | |||
| 308 | // --------------------------------------------------------------------------- | 305 | // --------------------------------------------------------------------------- |
| 309 | // makeWorkOSVaultSecretProvider — builds a SecretProvider backed by | 306 | // makeWorkOSVaultSecretProvider — builds a SecretProvider backed by |
| 310 | // WorkOS Vault for values and the plugin's own metadata table for | 307 | // WorkOS Vault for values and the plugin's own metadata table for |
| @@ -343,7 +340,13 @@ export const makeWorkOSVaultSecretProvider = ( · 32 unmodified lines | |||
| 343 | const meta = yield* store.get(id, scope); | 340 | const meta = yield* store.get(id, scope); |
| 344 | if (!meta) return null; | 341 | if (!meta) return null; |
| 345 | const object = yield* loadSecretObject(client, prefix, scope, id).pipe( | 342 | const object = yield* loadSecretObject(client, prefix, scope, id).pipe( |
| 346 | Effect.mapError(formatVaultError), | 343 | Effect.mapError( |
| 344 | (error) => | ||
| 345 | new StorageError({ | ||
| 346 | message: "WorkOS Vault secret read failed", | ||
| 347 | cause: error, | ||
| 348 | }), | ||
| 349 | ), | ||
| 347 | ); | 350 | ); |
| 348 | if (!object || !object.value) return null; | 351 | if (!object || !object.value) return null; |
| 349 | return object.value; | 352 | return object.value; |
| @@ -353,7 +356,13 @@ export const makeWorkOSVaultSecretProvider = ( · 3 unmodified lines | |||
| 353 | Effect.gen(function* () { | 356 | Effect.gen(function* () { |
| 354 | const existing = yield* store.get(id, scope); | 357 | const existing = yield* store.get(id, scope); |
| 355 | yield* upsertSecretValue(client, prefix, scope, id, value, contextForScope).pipe( | 358 | yield* upsertSecretValue(client, prefix, scope, id, value, contextForScope).pipe( |
| 356 | Effect.mapError(formatVaultError), | 359 | Effect.mapError( |
| 360 | (error) => | ||
| 361 | new StorageError({ | ||
| 362 | message: "WorkOS Vault secret write failed", | ||
| 363 | cause: error, | ||
| 364 | }), | ||
| 365 | ), | ||
| 357 | ); | 366 | ); |
| 358 | yield* store.upsert({ | 367 | yield* store.upsert({ |
| 359 | id, | 368 | id, |
| @@ -369,7 +378,13 @@ export const makeWorkOSVaultSecretProvider = ( · 9 unmodified lines | |||
| 369 | const meta = yield* store.get(id, scope); | 378 | const meta = yield* store.get(id, scope); |
| 370 | if (!meta) return false; | 379 | if (!meta) return false; |
| 371 | yield* deleteSecretValue(client, prefix, scope, id).pipe( | 380 | yield* deleteSecretValue(client, prefix, scope, id).pipe( |
| 372 | Effect.mapError(formatVaultError), | 381 | Effect.mapError( |
| 382 | (error) => | ||
| 383 | new StorageError({ | ||
| 384 | message: "WorkOS Vault secret delete failed", | ||
| 385 | cause: error, | ||
| 386 | }), | ||
| 387 | ), | ||
| 373 | ); | 388 | ); |
| 374 | yield* store.remove(id, scope); | 389 | yield* store.remove(id, scope); |
| 375 | return true; | 390 | return true; |