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;}> {} /** * The requested key id is not an org-owned key of this organization — it does * not exist, belongs to a member rather than the org, or belongs to a different * org in the same WorkOS workspace. Distinct from `ApiKeyManagementError` (an * upstream failure) so callers can answer "not found" without a 500. */export class OrgApiKeyNotFound extends Data.TaggedError("OrgApiKeyNotFound")<{ readonly keyId: string;}> {} // 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>; /** * Revoke an org-owned key. Ownership is resolved against the ORG's own key * listing — never the caller's personal keys — so a member's user key id * (or another org's key id, since the WorkOS workspace key is * workspace-wide) cannot be destroyed through this path. */ readonly revokeOrgKey: (input: { readonly organizationId: string; readonly keyId: string; }) => Effect.Effect<void, ApiKeyManagementError | OrgApiKeyNotFound>; }>()("@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" })); }), ), // Ownership is checked against the ORG's decoded key listing before the // delete goes out. `deleteApiKey` is scope-blind — handed any key id it // would happily destroy a member's personal credential, or a key // belonging to a sibling org in the same workspace. The listing already // drops both of those (`orgListFromResponse` filters on owner type AND // organization), so filtering through it is the whole authorization. revokeOrgKey: ({ organizationId, keyId }) => Effect.gen(function* () { const response = yield* workos .listOrgApiKeys(organizationId) .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))); const owned = orgListFromResponse(response, organizationId); if (!owned.some((key) => key.id === keyId)) { return yield* new OrgApiKeyNotFound({ keyId }); } yield* workos .deleteApiKey(keyId) .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause }))); }), }; }), );}
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;
}> {}
/**
* The requested key id is not an org-owned key of this organization — it does
* not exist, belongs to a member rather than the org, or belongs to a different
* org in the same WorkOS workspace. Distinct from `ApiKeyManagementError` (an
* upstream failure) so callers can answer "not found" without a 500.
*/
export class OrgApiKeyNotFound extends Data.TaggedError("OrgApiKeyNotFound")<{
readonly keyId: string;
}> {}
// 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>;
/**
* Revoke an org-owned key. Ownership is resolved against the ORG's own key
* listing — never the caller's personal keys — so a member's user key id
* (or another org's key id, since the WorkOS workspace key is
* workspace-wide) cannot be destroyed through this path.
*/
readonly revokeOrgKey: (input: {
readonly organizationId: string;
readonly keyId: string;
}) => Effect.Effect<void, ApiKeyManagementError | OrgApiKeyNotFound>;
}
>()("@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" }));
}),
),
// Ownership is checked against the ORG's decoded key listing before the
// delete goes out. `deleteApiKey` is scope-blind — handed any key id it
// would happily destroy a member's personal credential, or a key
// belonging to a sibling org in the same workspace. The listing already
// drops both of those (`orgListFromResponse` filters on owner type AND
// organization), so filtering through it is the whole authorization.
revokeOrgKey: ({ organizationId, keyId }) =>
Effect.gen(function* () {
const response = yield* workos
.listOrgApiKeys(organizationId)
.pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
const owned = orgListFromResponse(response, organizationId);
if (!owned.some((key) => key.id === keyId)) {
return yield* new OrgApiKeyNotFound({ keyId });
}
yield* workos
.deleteApiKey(keyId)
.pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
}),
};
}),
);
}