346 lines · 13.4 KB · TypeScript
RawDownload
import { Context, Data, Effect, Layer, Option, Schema } from "effect"; import { ApiKeyManagementError } from "./errors";import { WorkOSClient } from "./workos"; /** * Which view a validated key resolves to. *   - `"user"` — the product view. The key belongs to a member; the request *     binds to that member as the acting subject. Every key today is this. *   - `"org"` — the PLATFORM view. The key belongs to the organization itself, *     so there is no acting member: the request gets tenant-wide READ-ONLY *     reach and no bound subject. Privileged; minted separately (PR 1.5). */export type ApiKeyScope = "user" | "org"; /** The owner an api key resolves to — NOT a full {@link Principal} (no email / * name / roles), so it carries an honest, distinct name. */export type ApiKeyOwner = {  readonly scope: ApiKeyScope;  /** The acting member for a `"user"` key; `null` for an `"org"` key, which   *  has no member behind it. Nullable rather than a sentinel so nothing can   *  accidentally bind an org key to a subject. */  readonly accountId: string | null;  readonly organizationId: string;  readonly keyId: string;}; export type ApiKeySummary = {  readonly id: string;  readonly name: string;  readonly obfuscatedValue: string;  readonly createdAt: string;  readonly updatedAt: string;  readonly lastUsedAt: string | null;}; export type CreatedApiKey = ApiKeySummary & {  readonly value: string;}; export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationError")<{  readonly cause: unknown;}> {} // WorkOS keys are owned by either a user or an organization. The user shape// carries the member id in `id` and the org in `organization_id`; the org shape// carries the ORG in `id` and no member at all. Both decode here — the// distinction is a branch (`ownerFromApiKey`), not a decode failure, so an// org-owned key is no longer indistinguishable from an invalid one.const UserApiKeyOwner = Schema.Struct({  type: Schema.Literal("user"),  id: Schema.String,  organizationId: Schema.optional(Schema.String),  organization_id: Schema.optional(Schema.String),}); const OrgApiKeyOwner = Schema.Struct({  type: Schema.Literal("organization"),  id: Schema.String,}); const WorkOSApiKeyOwner = Schema.Union([UserApiKeyOwner, OrgApiKeyOwner]); const ApiKey = Schema.Struct({  id: Schema.String,  owner: UserApiKeyOwner,  name: Schema.optional(Schema.String),  obfuscatedValue: Schema.optional(Schema.String),  obfuscated_value: Schema.optional(Schema.String),  createdAt: Schema.optional(Schema.String),  created_at: Schema.optional(Schema.String),  updatedAt: Schema.optional(Schema.String),  updated_at: Schema.optional(Schema.String),  lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)),  last_used_at: Schema.optional(Schema.NullOr(Schema.String)),}); // Validation accepts BOTH owner shapes. The user list/create paths below stay// user-only on purpose (they are the console's personal-key CRUD); the ORG// list/create paths are the separate, privileged surface beside them, gated to// admins at the account-provider boundary.const ValidatedApiKey = Schema.Struct({  id: Schema.String,  owner: WorkOSApiKeyOwner,}); const ValidateApiKeyResponse = Schema.Struct({  apiKey: Schema.NullOr(ValidatedApiKey),}); const RawCreatedApiKey = Schema.Struct({  id: Schema.String,  owner: UserApiKeyOwner,  name: Schema.optional(Schema.String),  obfuscatedValue: Schema.optional(Schema.String),  obfuscated_value: Schema.optional(Schema.String),  createdAt: Schema.optional(Schema.String),  created_at: Schema.optional(Schema.String),  updatedAt: Schema.optional(Schema.String),  updated_at: Schema.optional(Schema.String),  lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)),  last_used_at: Schema.optional(Schema.NullOr(Schema.String)),  value: Schema.String,}); const ListApiKeysResponse = Schema.Struct({  data: Schema.Array(ApiKey),}); // The ORG-owned mirror of `ApiKey` / `RawCreatedApiKey`. A separate shape// rather than a widened one: `summaryFromApiKey` reads the user shape's// `organization_id` to prove the key belongs to the caller's org, and an// org-owned row carries the org in `owner.id` instead. Merging them would mean// one nullable field standing for two different things.const OrgApiKey = Schema.Struct({  id: Schema.String,  owner: OrgApiKeyOwner,  name: Schema.optional(Schema.String),  obfuscatedValue: Schema.optional(Schema.String),  obfuscated_value: Schema.optional(Schema.String),  createdAt: Schema.optional(Schema.String),  created_at: Schema.optional(Schema.String),  updatedAt: Schema.optional(Schema.String),  updated_at: Schema.optional(Schema.String),  lastUsedAt: Schema.optional(Schema.NullOr(Schema.String)),  last_used_at: Schema.optional(Schema.NullOr(Schema.String)),}); const RawCreatedOrgApiKey = Schema.Struct({  ...OrgApiKey.fields,  value: Schema.String,}); // `data` is decoded element-by-element, NOT as `Schema.Array(OrgApiKey)`: WorkOS// may return user-owned keys alongside org-owned ones, and a whole-array decode// fails on the first non-org element — blanking the entire list rather than// dropping the one row that doesn't belong. Same per-row tolerance the user-key// path gets from `summaryFromApiKey` returning null.const ListOrgApiKeysResponse = Schema.Struct({  data: Schema.Array(Schema.Unknown),}); const CreateOrgApiKeyResponse = Schema.Union([  RawCreatedOrgApiKey,  Schema.Struct({ apiKey: RawCreatedOrgApiKey }),  Schema.Struct({ api_key: RawCreatedOrgApiKey }),]); const CreateApiKeyResponse = Schema.Union([  RawCreatedApiKey,  Schema.Struct({ apiKey: RawCreatedApiKey }),  Schema.Struct({ api_key: RawCreatedApiKey }),]); const decodeValidateApiKeyResponse = Schema.decodeUnknownOption(ValidateApiKeyResponse);const decodeListApiKeysResponse = Schema.decodeUnknownOption(ListApiKeysResponse);const decodeCreateApiKeyResponse = Schema.decodeUnknownOption(CreateApiKeyResponse);const decodeListOrgApiKeysResponse = Schema.decodeUnknownOption(ListOrgApiKeysResponse);const decodeOrgApiKey = Schema.decodeUnknownOption(OrgApiKey);const decodeCreateOrgApiKeyResponse = Schema.decodeUnknownOption(CreateOrgApiKeyResponse); const ownerFromApiKey = (apiKey: typeof ValidatedApiKey.Type): ApiKeyOwner | null => {  if (apiKey.owner.type === "organization") {    // An org-owned key IS the org: `owner.id` is the organization, and there    // is no member to act as. This resolves to the platform view.    return {      scope: "org",      accountId: null,      organizationId: apiKey.owner.id,      keyId: apiKey.id,    };  }  const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id;  if (!organizationId) return null;  return {    scope: "user",    accountId: apiKey.owner.id,    organizationId,    keyId: apiKey.id,  };}; const ownerFromResponse = (value: unknown): ApiKeyOwner | null =>  Option.match(decodeValidateApiKeyResponse(value), {    onNone: () => null,    onSome: ({ apiKey }) => (apiKey ? ownerFromApiKey(apiKey) : null),  }); const summaryFromApiKey = (apiKey: typeof ApiKey.Type): ApiKeySummary | null => {  const organizationId = apiKey.owner.organizationId ?? apiKey.owner.organization_id;  if (!organizationId) return null;  return {    id: apiKey.id,    name: apiKey.name ?? "API key",    obfuscatedValue: apiKey.obfuscatedValue ?? apiKey.obfuscated_value ?? "",    createdAt: apiKey.createdAt ?? apiKey.created_at ?? "",    updatedAt: apiKey.updatedAt ?? apiKey.updated_at ?? "",    lastUsedAt: apiKey.lastUsedAt ?? apiKey.last_used_at ?? null,  };}; const listFromResponse = (value: unknown): readonly ApiKeySummary[] =>  Option.match(decodeListApiKeysResponse(value), {    onNone: () => [],    onSome: ({ data }) =>      data.flatMap((apiKey) => {        const summary = summaryFromApiKey(apiKey);        return summary ? [summary] : [];      }),  }); // The org-owned mirror of `summaryFromApiKey`. `organizationId` is checked by// the CALLER against the org it asked for: WorkOS's workspace api key is// workspace-wide, so a response naming a different org than the one requested// must not be surfaced as that org's key.const summaryFromOrgApiKey = (  apiKey: typeof OrgApiKey.Type,  organizationId: string,): ApiKeySummary | null => {  if (apiKey.owner.id !== organizationId) return null;  return {    id: apiKey.id,    name: apiKey.name ?? "API key",    obfuscatedValue: apiKey.obfuscatedValue ?? apiKey.obfuscated_value ?? "",    createdAt: apiKey.createdAt ?? apiKey.created_at ?? "",    updatedAt: apiKey.updatedAt ?? apiKey.updated_at ?? "",    lastUsedAt: apiKey.lastUsedAt ?? apiKey.last_used_at ?? null,  };}; const orgListFromResponse = (value: unknown, organizationId: string): readonly ApiKeySummary[] =>  Option.match(decodeListOrgApiKeysResponse(value), {    onNone: () => [],    onSome: ({ data }) =>      data.flatMap((entry) =>        Option.match(decodeOrgApiKey(entry), {          // Not an org-owned key (a user key sharing the listing, or a shape we          // don't recognize) — skip the row, keep the rest.          onNone: () => [],          onSome: (apiKey) => {            const summary = summaryFromOrgApiKey(apiKey, organizationId);            return summary ? [summary] : [];          },        }),      ),  }); const orgCreatedFromResponse = (value: unknown, organizationId: string): CreatedApiKey | null =>  Option.match(decodeCreateOrgApiKeyResponse(value), {    onNone: () => null,    onSome: (response) => {      const apiKey =        "value" in response ? response : "apiKey" in response ? response.apiKey : response.api_key;      const summary = summaryFromOrgApiKey(apiKey, organizationId);      return summary ? { ...summary, value: apiKey.value } : null;    },  }); const createdFromResponse = (value: unknown): CreatedApiKey | null =>  Option.match(decodeCreateApiKeyResponse(value), {    onNone: () => null,    onSome: (response) => {      const apiKey =        "value" in response ? response : "apiKey" in response ? response.apiKey : response.api_key;      const summary = summaryFromApiKey(apiKey);      return summary ? { ...summary, value: apiKey.value } : null;    },  }); export class ApiKeyService extends Context.Service<  ApiKeyService,  {    readonly validate: (value: string) => Effect.Effect<ApiKeyOwner | null, ApiKeyValidationError>;    readonly listUserKeys: (input: {      readonly accountId: string;      readonly organizationId: string;    }) => Effect.Effect<readonly ApiKeySummary[], ApiKeyManagementError>;    readonly createUserKey: (input: {      readonly accountId: string;      readonly organizationId: string;      readonly name: string;    }) => Effect.Effect<CreatedApiKey, ApiKeyManagementError>;    readonly revokeUserKey: (input: {      readonly keyId: string;    }) => Effect.Effect<void, ApiKeyManagementError>;    /**     * The org-owned keys of an organization — the credentials that resolve to     * the read-only platform view. Privileged: callers gate on admin membership     * before reaching this.     */    readonly listOrgKeys: (input: {      readonly organizationId: string;    }) => Effect.Effect<readonly ApiKeySummary[], ApiKeyManagementError>;    readonly createOrgKey: (input: {      readonly organizationId: string;      readonly name: string;    }) => Effect.Effect<CreatedApiKey, ApiKeyManagementError>;  }>()("@executor-js/cloud/ApiKeyService") {  static WorkOS = Layer.effect(this)(    Effect.gen(function* () {      const workos = yield* WorkOSClient;      return {        validate: (value: string) =>          workos.validateApiKey(value).pipe(            Effect.map(ownerFromResponse),            Effect.mapError((cause) => new ApiKeyValidationError({ cause })),          ),        listUserKeys: ({ accountId, organizationId }) =>          workos.listUserApiKeys(accountId, organizationId).pipe(            Effect.map(listFromResponse),            Effect.mapError((cause) => new ApiKeyManagementError({ cause })),          ),        createUserKey: ({ accountId, organizationId, name }) =>          workos.createUserApiKey({ userId: accountId, organizationId, name }).pipe(            Effect.mapError((cause) => new ApiKeyManagementError({ cause })),            Effect.flatMap((response) => {              const created = createdFromResponse(response);              return created                ? Effect.succeed(created)                : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" }));            }),          ),        revokeUserKey: ({ keyId }) =>          workos            .deleteApiKey(keyId)            .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))),        listOrgKeys: ({ organizationId }) =>          workos.listOrgApiKeys(organizationId).pipe(            Effect.map((response) => orgListFromResponse(response, organizationId)),            Effect.mapError((cause) => new ApiKeyManagementError({ cause })),          ),        createOrgKey: ({ organizationId, name }) =>          workos.createOrgApiKey({ organizationId, name }).pipe(            Effect.mapError((cause) => new ApiKeyManagementError({ cause })),            Effect.flatMap((response) => {              const created = orgCreatedFromResponse(response, organizationId);              return created                ? Effect.succeed(created)                : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" }));            }),          ),      };    }),  );}