// ---------------------------------------------------------------------------// The READ half of the admin users surface, shared by every host.//// A host's `AdminUsersProvider` owns AUTHORIZATION (who may look) and delegates// the actual reads here, so the projection from the SDK's platform view onto// the wire shapes exists exactly ONCE. That matters because the projection IS// the field-discipline boundary: if each host mapped rows itself, a credential// field could leak into one host's responses and not the other's.//// Every mapping below is explicit rather than a spread. A spread would silently// forward any field a future SDK change adds to `AdminSubject`/`AdminConnection`// — the opposite of an allowlist.// --------------------------------------------------------------------------- import { Effect } from "effect"; import type { AdminConnection, AdminListSubjectsOptions, AdminSubject, AdminSubjectWithConnections, Executor, ExecutorAdmin,} from "@executor-js/sdk"; import { AdminUserNotFound, AdminUsersError, type AdminUserConnectionsResponse, type AdminUserResponse, type AdminUsersResponse, type AdminUsersWithConnectionsResponse,} from "./api";import type { AdminUsersListOptions } from "./service"; /** * Narrow an executor to its platform view. `admin` is present only when the * executor was built with `platformView: true`; a host that reaches these reads * with a product-view executor is a wiring bug, so it fails loudly as a 500 * rather than silently returning an empty tenant (which would read as "this * owner has no users"). */export const platformViewOf = (executor: Executor): Effect.Effect<ExecutorAdmin, AdminUsersError> => executor.admin ? Effect.succeed(executor.admin) : Effect.fail( new AdminUsersError({ message: "Admin reads require an executor built with the platform view enabled", }), ); // Storage faults are the only failure these reads can raise. They surface as a// 500 with a flat message — the cause carries the detail into the host's error// capture, and is deliberately not echoed to an API consumer.const readFailed = (what: string) => () => new AdminUsersError({ message: `Failed to list ${what}` }); // ---------------------------------------------------------------------------// Host identity join// --------------------------------------------------------------------------- /** What a host's member directory can say about one principal. Both halves are * independently nullable: a directory can hold an email with no name. */export interface AdminUserIdentity { readonly email: string | null; readonly displayName: string | null;} /** * The ONE email normalization rule: shared by the `?email=` filter, the * single-user path parameter, and every host's own resolver (which must apply * it to the DIRECTORY value before comparing). * * Case-insensitive because the two hosts disagree about what they store: * Better Auth lower-cases every email it writes, while WorkOS preserves the * casing it was given. Lower-casing BOTH sides is the only rule that answers * the same way on both. The local part of an address is technically * case-sensitive per RFC 5321, but no directory this reads treats it that way, * and an operator typing "Alice@..." means the same person. */export const normalizeEmail = (email: string): string => email.trim().toLowerCase(); /** * A host's member directory, keyed by the SAME id space the subject table * records in `external_id`. * * That id is the host-auth principal id — cloud binds the WorkOS `user_...`, * self-host binds the Better Auth `user.id` — so the join key on both hosts is * the member list's `userId`, NEVER its `id` (a membership-row id: `om_...` on * cloud, the `member` row on self-host, both of which join to nothing). * * Called ONCE per request with the whole page of ids, so a host implements it * as one directory read joined in memory rather than a lookup per user. A host * that cannot resolve identities at all simply passes no directory, and every * row reports absent identity. */export type AdminIdentityDirectory = ( externalIds: readonly string[],) => Effect.Effect<ReadonlyMap<string, AdminUserIdentity>, unknown>; /** * The REVERSE of the join above: email → the host-auth principal id, so an * operator can name a user by the identifier they actually have. * * The email arrives already trimmed and lower-cased by the contract, and an * implementation must compare against a lower-cased directory value — neither * host guarantees the casing it stores, so case-insensitivity is the seam's * rule and not a per-host accident. * * `null` means the directory knows no such member. That is NOT the same as * "not a user of this tenant": a resolved id may still have no subject row, * which the reads below treat as absent (see `getUser`). Failures are the * caller's to interpret — unlike the decorative identity join, a lookup that * cannot run must not silently become "no such user". */export type AdminEmailResolver = (email: string) => Effect.Effect<string | null, unknown>; /** * The directory's SEARCH: a normalized term (trimmed + lower-cased, the same * rule `normalizeEmail` applies) → the host-auth principal ids of every member * whose email or name contains it, in the directory's own order. * * Unlike `resolveEmail` this names a SET, and the reads page that set through * storage rather than in memory: the ids go into the SDK's `externalIds` * filter and the caller's `limit`/`offset` apply there. An empty result means * no member matches, and costs no storage read. Failures are the caller's to * interpret on the same terms as `resolveEmail` — a search that cannot run * must not quietly become "nobody matches". */export type AdminMemberSearch = (term: string) => Effect.Effect<readonly string[], unknown>; /** Every direction of a host's member directory. Optional as a whole (a host * with no directory reports unnamed rows and cannot resolve emails or search), * and optional per direction. */export interface AdminUserDirectory { readonly identities?: AdminIdentityDirectory; readonly resolveEmail?: AdminEmailResolver; readonly search?: AdminMemberSearch;} /** Identity is decoration on an operator view, not part of the answer: a * directory outage must degrade to unnamed rows, never fail the read the * operator actually asked for. Logged once per failed page — the cause carries * the detail, and the caller learns nothing from it. */const resolveIdentities = ( directory: AdminIdentityDirectory | undefined, externalIds: readonly string[],): Effect.Effect<ReadonlyMap<string, AdminUserIdentity>> => { if (!directory || externalIds.length === 0) return Effect.succeed(new Map()); return directory(externalIds).pipe( Effect.catchCause((cause) => Effect.as( Effect.logWarning("admin users: host identity lookup failed; reporting users unnamed", { cause, }), new Map<string, AdminUserIdentity>(), ), ), );}; const ABSENT_IDENTITY: AdminUserIdentity = { email: null, displayName: null }; /** * `AdminSubject` → the public `AdminUser` shape. * * `createdAt` crosses the wire as epoch ms (the SDK hands back a `Date`), which * matches how every other "when did this happen" field on the API is carried * and keeps the whole response JSON-native. * * `email`/`displayName` come from the host directory rather than storage, so an * id the directory doesn't know — a member who left the org while their * connections remain, or a host sentinel like "local" that names no member — * reports absent identity beside otherwise complete row data. */const toUser = (subject: AdminSubject, identities: ReadonlyMap<string, AdminUserIdentity>) => { const identity = identities.get(subject.externalId) ?? ABSENT_IDENTITY; return { externalId: subject.externalId, createdAt: subject.createdAt.getTime(), lastSeenAt: subject.lastSeenAt, status: subject.status, email: identity.email, displayName: identity.displayName, };}; /** * The stored health verdict → the admin plane's `AdminConnectionHealth`. * * NESTED OBJECTS GET THE SAME TREATMENT AS COLUMNS. `HealthCheckResult` is the * owner's view of their own probe and carries content taken from the UPSTREAM * provider's response: `identity` (the value at the configured `identityField` * — usually the account's email at the provider), `detail` (a raw diagnostic * that can quote an upstream error body), and `responseSample` (a bounded * sample of the real response body's scalar leaves). This plane reports on * OTHER people's connections, so none of that may travel; forwarding * `connection.lastHealth` whole is exactly the leak this function exists to * prevent — the column allowlist below would still have "passed" while every * upstream field rode along inside it. * * Two fields survive, both of them verdicts ABOUT the connection rather than * content FROM it: `status` and `checkedAt`. Written as an explicit * construction, never a spread, for the same reason the row mappings are. */const toHealth = (health: AdminConnection["lastHealth"]) => health === null ? null : { status: health.status, checkedAt: health.checkedAt }; /** * `AdminConnection` → the public `AdminUserConnection` shape. * * `subject` is dropped: on `/admin/users/:externalId/connections` it is the * path parameter, and on the joined view it is the enclosing user's own id, so * carrying it would be redundant on both. Everything kept here is either an * identifier or a health/access summary — never credential material, and never * upstream account content (see `toHealth`). */const toConnection = (connection: AdminConnection) => ({ owner: connection.owner, integration: connection.integration, name: connection.name, oauthScope: connection.oauthScope, lastHealth: toHealth(connection.lastHealth),}); const toUserWithConnections = ( subject: AdminSubjectWithConnections, identities: ReadonlyMap<string, AdminUserIdentity>,) => ({ ...toUser(subject, identities), connections: subject.connections.map(toConnection),}); /** * Accept the legacy one-way directory or the two-way one, so a host that only * joins identities keeps working unchanged. */const asDirectory = ( directory: AdminIdentityDirectory | AdminUserDirectory | undefined,): AdminUserDirectory => directory === undefined ? {} : typeof directory === "function" ? { identities: directory } : directory; /** * Turn an `?email=` filter into the id to keep, or `null` for "nothing can * match" (unknown email, or a host with no resolver — an email filter no host * can answer must return nothing, never an unfiltered page). * * A resolver FAILURE is a 500, not an empty page: silently reporting "no such * user" because a directory was unreachable is the kind of wrong answer an * operator would act on. */const resolveEmailFilter = ( directory: AdminUserDirectory, email: string,): Effect.Effect<string | null, AdminUsersError> => { const resolve = directory.resolveEmail; if (!resolve) return Effect.succeed(null); return resolve(email).pipe( Effect.mapError(() => new AdminUsersError({ message: "Failed to resolve the email address" })), );}; /** * Apply the caller's paging window to rows the email filter ALREADY selected. * * An `?email=` read resolves to at most one subject, so the window can only * keep that row (`offset: 0`) or drop it. It is applied rather than ignored so * the endpoint keeps ONE set of paging semantics, instead of growing a second * set that appears only when a filter is present. */const pageOf = <T>(rows: readonly T[], options: AdminUsersListOptions): readonly T[] => { const offset = Math.max(Math.floor(options.offset ?? 0), 0); if (options.limit === undefined) return rows.slice(offset); return rows.slice(offset, offset + Math.max(Math.floor(options.limit), 1));}; /** * The `?email=` read, as a KEYED lookup. * * The filter names ONE principal, so it resolves to an id and reads that id * directly. Reading a page and discarding the rest — which is what this did * before — made the joined view pull a full default page (100 subjects AND * their connections) to answer with a single row. * * ORDER OF OPERATIONS IS THE FIX, and it also settles what paging means on a * filtered result: the window applies to the selected row rather than to the * scan the row was found in. That is how any "filter, then page" read behaves, * and the only reading that survives the scan going away. * * A `read` that answers `null` is a resolved id with no subject row — the same * "absent" the single-user path reports, not a storage fault. */const selectByEmail = <T>( directory: AdminUserDirectory, email: string, options: AdminUsersListOptions, read: (externalId: string) => Effect.Effect<T | null, AdminUsersError>,): Effect.Effect<readonly T[], AdminUsersError> => Effect.gen(function* () { // An email no directory can resolve matches nothing, and costs no read. const wanted = yield* resolveEmailFilter(directory, email); if (wanted === null) return []; const row = yield* read(wanted); return row === null ? [] : pageOf([row], options); }); /** * The `?search=` read: FILTER by the directory, then PAGE through storage. * * The term names a SET of principals rather than one, so unlike `?email=` it * cannot become a keyed read — but it still must not become a page-then-filter * scan, which on a large tenant would page past every unmatched subject before * finding the first match. So the directory answers with the matching ids and * storage pages exactly that set (`externalIds` + the caller's window), which * keeps "filter, then page" as the one paging rule every filtered list here * follows. * * A host with no search direction answers nothing, for the same reason an * unanswerable `?email=` does: a filter no host can apply must return an empty * page, never an unfiltered one. A search FAILURE is a 500 on the same terms as * a resolver failure. */const selectBySearch = <T>( directory: AdminUserDirectory, term: string, read: (externalIds: readonly string[]) => Effect.Effect<readonly T[], AdminUsersError>,): Effect.Effect<readonly T[], AdminUsersError> => Effect.gen(function* () { const search = directory.search; if (!search) return []; const wanted = yield* search(term).pipe( Effect.mapError(() => new AdminUsersError({ message: "Failed to search the directory" })), ); // Nobody matches: an empty page, and no storage read for an `in ()` that // could not match anyway. if (wanted.length === 0) return []; return yield* read(wanted); }); /** * Which filtered read a list request takes. `email` names ONE principal and * wins when both are present: a keyed read is the more specific answer, and * a search term beside an exact address has nothing left to narrow. */const selectSubjects = <T>( directory: AdminUserDirectory, options: AdminUsersListOptions, reads: { readonly page: ( paging: AdminListSubjectsOptions, ) => Effect.Effect<readonly T[], AdminUsersError>; readonly one: (externalId: string) => Effect.Effect<T | null, AdminUsersError>; },): Effect.Effect<readonly T[], AdminUsersError> => { if (options.email !== undefined) { return selectByEmail(directory, options.email, options, reads.one); } if (options.search !== undefined) { return selectBySearch(directory, options.search, (externalIds) => reads.page({ ...pagingOf(options), externalIds }), ); } return reads.page(pagingOf(options));}; /** Only the paging window — never the filters — reaches the SDK: the filters * are resolved here, and the SDK's own `externalIds` is set by this file. */const pagingOf = (options: AdminUsersListOptions): AdminListSubjectsOptions => ({ ...(options.limit === undefined ? {} : { limit: options.limit }), ...(options.offset === undefined ? {} : { offset: options.offset }),}); export const listUsers = ( admin: ExecutorAdmin, options: AdminUsersListOptions, directory?: AdminIdentityDirectory | AdminUserDirectory,): Effect.Effect<typeof AdminUsersResponse.Type, AdminUsersError> => Effect.gen(function* () { const dir = asDirectory(directory); const subjects = yield* selectSubjects(dir, options, { page: (paging) => admin.listSubjects(paging).pipe(Effect.mapError(readFailed("users"))), one: (externalId) => admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))), }); // One directory read for the page that was actually returned, joined in // memory — never a lookup per user. const identities = yield* resolveIdentities( dir.identities, subjects.map((subject) => subject.externalId), ); return { users: subjects.map((subject) => toUser(subject, identities)) }; }); export const listUsersWithConnections = ( admin: ExecutorAdmin, options: AdminUsersListOptions, directory?: AdminIdentityDirectory | AdminUserDirectory,): Effect.Effect<typeof AdminUsersWithConnectionsResponse.Type, AdminUsersError> => Effect.gen(function* () { const dir = asDirectory(directory); const subjects = yield* selectSubjects(dir, options, { page: (paging) => admin.listSubjectsWithConnections(paging).pipe(Effect.mapError(readFailed("users"))), one: (externalId) => admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))), }); const identities = yield* resolveIdentities( dir.identities, subjects.map((subject) => subject.externalId), ); return { users: subjects.map((subject) => toUserWithConnections(subject, identities)) }; }); /** * One user by `externalId` OR email, with connections. * * RESOLUTION ORDER. An identifier containing "@" is tried as an email first; * if the directory knows no such member, or the id it resolves to has no * subject row, the identifier is then tried as a literal `externalId`. That * fallback is what keeps `externalId` opaque: no host mints an id containing * "@" today, but the contract promises nothing may parse the id, so the "@" * test is only ever a routing hint that costs one extra keyed read in a case * that does not currently arise. * * A directory hit with no subject row is `AdminUserNotFound`, the same as an * unknown identifier — the plane reports footprint, not org membership. See * the `getUser` contract docs for why. */export const getUser = ( admin: ExecutorAdmin, identifier: string, directory?: AdminIdentityDirectory | AdminUserDirectory,): Effect.Effect<typeof AdminUserResponse.Type, AdminUsersError | AdminUserNotFound> => Effect.gen(function* () { const dir = asDirectory(directory); const read = (externalId: string) => admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("user"))); const subject = yield* identifier.includes("@") ? Effect.gen(function* () { const resolved = yield* resolveEmailFilter(dir, normalizeEmail(identifier)); const byEmail = resolved === null ? null : yield* read(resolved); return byEmail ?? (yield* read(identifier)); }) : read(identifier); if (subject === null) return yield* new AdminUserNotFound(); // Identity is joined through the SAME batched seam the list uses, so the // single read reports exactly the fields the list would for this row. const identities = yield* resolveIdentities(dir.identities, [subject.externalId]); return { user: toUserWithConnections(subject, identities) }; }); export const listUserConnections = ( admin: ExecutorAdmin, externalId: string,): Effect.Effect<typeof AdminUserConnectionsResponse.Type, AdminUsersError> => admin.listSubjectConnections(externalId).pipe( Effect.map((connections) => ({ connections: connections.map(toConnection) })), Effect.mapError(readFailed("connections")), );
// ---------------------------------------------------------------------------
// The READ half of the admin users surface, shared by every host.
//
// A host's `AdminUsersProvider` owns AUTHORIZATION (who may look) and delegates
// the actual reads here, so the projection from the SDK's platform view onto
// the wire shapes exists exactly ONCE. That matters because the projection IS
// the field-discipline boundary: if each host mapped rows itself, a credential
// field could leak into one host's responses and not the other's.
//
// Every mapping below is explicit rather than a spread. A spread would silently
// forward any field a future SDK change adds to `AdminSubject`/`AdminConnection`
// — the opposite of an allowlist.
// ---------------------------------------------------------------------------
import { Effect } from "effect";
import type {
AdminConnection,
AdminListSubjectsOptions,
AdminSubject,
AdminSubjectWithConnections,
Executor,
ExecutorAdmin,
} from "@executor-js/sdk";
import {
AdminUserNotFound,
AdminUsersError,
type AdminUserConnectionsResponse,
type AdminUserResponse,
type AdminUsersResponse,
type AdminUsersWithConnectionsResponse,
} from "./api";
import type { AdminUsersListOptions } from "./service";
/**
* Narrow an executor to its platform view. `admin` is present only when the
* executor was built with `platformView: true`; a host that reaches these reads
* with a product-view executor is a wiring bug, so it fails loudly as a 500
* rather than silently returning an empty tenant (which would read as "this
* owner has no users").
*/
export const platformViewOf = (executor: Executor): Effect.Effect<ExecutorAdmin, AdminUsersError> =>
executor.admin
? Effect.succeed(executor.admin)
: Effect.fail(
new AdminUsersError({
message: "Admin reads require an executor built with the platform view enabled",
}),
);
// Storage faults are the only failure these reads can raise. They surface as a
// 500 with a flat message — the cause carries the detail into the host's error
// capture, and is deliberately not echoed to an API consumer.
const readFailed = (what: string) => () =>
new AdminUsersError({ message: `Failed to list ${what}` });
// ---------------------------------------------------------------------------
// Host identity join
// ---------------------------------------------------------------------------
/** What a host's member directory can say about one principal. Both halves are
* independently nullable: a directory can hold an email with no name. */
export interface AdminUserIdentity {
readonly email: string | null;
readonly displayName: string | null;
}
/**
* The ONE email normalization rule: shared by the `?email=` filter, the
* single-user path parameter, and every host's own resolver (which must apply
* it to the DIRECTORY value before comparing).
*
* Case-insensitive because the two hosts disagree about what they store:
* Better Auth lower-cases every email it writes, while WorkOS preserves the
* casing it was given. Lower-casing BOTH sides is the only rule that answers
* the same way on both. The local part of an address is technically
* case-sensitive per RFC 5321, but no directory this reads treats it that way,
* and an operator typing "Alice@..." means the same person.
*/
export const normalizeEmail = (email: string): string => email.trim().toLowerCase();
/**
* A host's member directory, keyed by the SAME id space the subject table
* records in `external_id`.
*
* That id is the host-auth principal id — cloud binds the WorkOS `user_...`,
* self-host binds the Better Auth `user.id` — so the join key on both hosts is
* the member list's `userId`, NEVER its `id` (a membership-row id: `om_...` on
* cloud, the `member` row on self-host, both of which join to nothing).
*
* Called ONCE per request with the whole page of ids, so a host implements it
* as one directory read joined in memory rather than a lookup per user. A host
* that cannot resolve identities at all simply passes no directory, and every
* row reports absent identity.
*/
export type AdminIdentityDirectory = (
externalIds: readonly string[],
) => Effect.Effect<ReadonlyMap<string, AdminUserIdentity>, unknown>;
/**
* The REVERSE of the join above: email → the host-auth principal id, so an
* operator can name a user by the identifier they actually have.
*
* The email arrives already trimmed and lower-cased by the contract, and an
* implementation must compare against a lower-cased directory value — neither
* host guarantees the casing it stores, so case-insensitivity is the seam's
* rule and not a per-host accident.
*
* `null` means the directory knows no such member. That is NOT the same as
* "not a user of this tenant": a resolved id may still have no subject row,
* which the reads below treat as absent (see `getUser`). Failures are the
* caller's to interpret — unlike the decorative identity join, a lookup that
* cannot run must not silently become "no such user".
*/
export type AdminEmailResolver = (email: string) => Effect.Effect<string | null, unknown>;
/**
* The directory's SEARCH: a normalized term (trimmed + lower-cased, the same
* rule `normalizeEmail` applies) → the host-auth principal ids of every member
* whose email or name contains it, in the directory's own order.
*
* Unlike `resolveEmail` this names a SET, and the reads page that set through
* storage rather than in memory: the ids go into the SDK's `externalIds`
* filter and the caller's `limit`/`offset` apply there. An empty result means
* no member matches, and costs no storage read. Failures are the caller's to
* interpret on the same terms as `resolveEmail` — a search that cannot run
* must not quietly become "nobody matches".
*/
export type AdminMemberSearch = (term: string) => Effect.Effect<readonly string[], unknown>;
/** Every direction of a host's member directory. Optional as a whole (a host
* with no directory reports unnamed rows and cannot resolve emails or search),
* and optional per direction. */
export interface AdminUserDirectory {
readonly identities?: AdminIdentityDirectory;
readonly resolveEmail?: AdminEmailResolver;
readonly search?: AdminMemberSearch;
}
/** Identity is decoration on an operator view, not part of the answer: a
* directory outage must degrade to unnamed rows, never fail the read the
* operator actually asked for. Logged once per failed page — the cause carries
* the detail, and the caller learns nothing from it. */
const resolveIdentities = (
directory: AdminIdentityDirectory | undefined,
externalIds: readonly string[],
): Effect.Effect<ReadonlyMap<string, AdminUserIdentity>> => {
if (!directory || externalIds.length === 0) return Effect.succeed(new Map());
return directory(externalIds).pipe(
Effect.catchCause((cause) =>
Effect.as(
Effect.logWarning("admin users: host identity lookup failed; reporting users unnamed", {
cause,
}),
new Map<string, AdminUserIdentity>(),
),
),
);
};
const ABSENT_IDENTITY: AdminUserIdentity = { email: null, displayName: null };
/**
* `AdminSubject` → the public `AdminUser` shape.
*
* `createdAt` crosses the wire as epoch ms (the SDK hands back a `Date`), which
* matches how every other "when did this happen" field on the API is carried
* and keeps the whole response JSON-native.
*
* `email`/`displayName` come from the host directory rather than storage, so an
* id the directory doesn't know — a member who left the org while their
* connections remain, or a host sentinel like "local" that names no member —
* reports absent identity beside otherwise complete row data.
*/
const toUser = (subject: AdminSubject, identities: ReadonlyMap<string, AdminUserIdentity>) => {
const identity = identities.get(subject.externalId) ?? ABSENT_IDENTITY;
return {
externalId: subject.externalId,
createdAt: subject.createdAt.getTime(),
lastSeenAt: subject.lastSeenAt,
status: subject.status,
email: identity.email,
displayName: identity.displayName,
};
};
/**
* The stored health verdict → the admin plane's `AdminConnectionHealth`.
*
* NESTED OBJECTS GET THE SAME TREATMENT AS COLUMNS. `HealthCheckResult` is the
* owner's view of their own probe and carries content taken from the UPSTREAM
* provider's response: `identity` (the value at the configured `identityField`
* — usually the account's email at the provider), `detail` (a raw diagnostic
* that can quote an upstream error body), and `responseSample` (a bounded
* sample of the real response body's scalar leaves). This plane reports on
* OTHER people's connections, so none of that may travel; forwarding
* `connection.lastHealth` whole is exactly the leak this function exists to
* prevent — the column allowlist below would still have "passed" while every
* upstream field rode along inside it.
*
* Two fields survive, both of them verdicts ABOUT the connection rather than
* content FROM it: `status` and `checkedAt`. Written as an explicit
* construction, never a spread, for the same reason the row mappings are.
*/
const toHealth = (health: AdminConnection["lastHealth"]) =>
health === null ? null : { status: health.status, checkedAt: health.checkedAt };
/**
* `AdminConnection` → the public `AdminUserConnection` shape.
*
* `subject` is dropped: on `/admin/users/:externalId/connections` it is the
* path parameter, and on the joined view it is the enclosing user's own id, so
* carrying it would be redundant on both. Everything kept here is either an
* identifier or a health/access summary — never credential material, and never
* upstream account content (see `toHealth`).
*/
const toConnection = (connection: AdminConnection) => ({
owner: connection.owner,
integration: connection.integration,
name: connection.name,
oauthScope: connection.oauthScope,
lastHealth: toHealth(connection.lastHealth),
});
const toUserWithConnections = (
subject: AdminSubjectWithConnections,
identities: ReadonlyMap<string, AdminUserIdentity>,
) => ({
...toUser(subject, identities),
connections: subject.connections.map(toConnection),
});
/**
* Accept the legacy one-way directory or the two-way one, so a host that only
* joins identities keeps working unchanged.
*/
const asDirectory = (
directory: AdminIdentityDirectory | AdminUserDirectory | undefined,
): AdminUserDirectory =>
directory === undefined
? {}
: typeof directory === "function"
? { identities: directory }
: directory;
/**
* Turn an `?email=` filter into the id to keep, or `null` for "nothing can
* match" (unknown email, or a host with no resolver — an email filter no host
* can answer must return nothing, never an unfiltered page).
*
* A resolver FAILURE is a 500, not an empty page: silently reporting "no such
* user" because a directory was unreachable is the kind of wrong answer an
* operator would act on.
*/
const resolveEmailFilter = (
directory: AdminUserDirectory,
email: string,
): Effect.Effect<string | null, AdminUsersError> => {
const resolve = directory.resolveEmail;
if (!resolve) return Effect.succeed(null);
return resolve(email).pipe(
Effect.mapError(() => new AdminUsersError({ message: "Failed to resolve the email address" })),
);
};
/**
* Apply the caller's paging window to rows the email filter ALREADY selected.
*
* An `?email=` read resolves to at most one subject, so the window can only
* keep that row (`offset: 0`) or drop it. It is applied rather than ignored so
* the endpoint keeps ONE set of paging semantics, instead of growing a second
* set that appears only when a filter is present.
*/
const pageOf = <T>(rows: readonly T[], options: AdminUsersListOptions): readonly T[] => {
const offset = Math.max(Math.floor(options.offset ?? 0), 0);
if (options.limit === undefined) return rows.slice(offset);
return rows.slice(offset, offset + Math.max(Math.floor(options.limit), 1));
};
/**
* The `?email=` read, as a KEYED lookup.
*
* The filter names ONE principal, so it resolves to an id and reads that id
* directly. Reading a page and discarding the rest — which is what this did
* before — made the joined view pull a full default page (100 subjects AND
* their connections) to answer with a single row.
*
* ORDER OF OPERATIONS IS THE FIX, and it also settles what paging means on a
* filtered result: the window applies to the selected row rather than to the
* scan the row was found in. That is how any "filter, then page" read behaves,
* and the only reading that survives the scan going away.
*
* A `read` that answers `null` is a resolved id with no subject row — the same
* "absent" the single-user path reports, not a storage fault.
*/
const selectByEmail = <T>(
directory: AdminUserDirectory,
email: string,
options: AdminUsersListOptions,
read: (externalId: string) => Effect.Effect<T | null, AdminUsersError>,
): Effect.Effect<readonly T[], AdminUsersError> =>
Effect.gen(function* () {
// An email no directory can resolve matches nothing, and costs no read.
const wanted = yield* resolveEmailFilter(directory, email);
if (wanted === null) return [];
const row = yield* read(wanted);
return row === null ? [] : pageOf([row], options);
});
/**
* The `?search=` read: FILTER by the directory, then PAGE through storage.
*
* The term names a SET of principals rather than one, so unlike `?email=` it
* cannot become a keyed read — but it still must not become a page-then-filter
* scan, which on a large tenant would page past every unmatched subject before
* finding the first match. So the directory answers with the matching ids and
* storage pages exactly that set (`externalIds` + the caller's window), which
* keeps "filter, then page" as the one paging rule every filtered list here
* follows.
*
* A host with no search direction answers nothing, for the same reason an
* unanswerable `?email=` does: a filter no host can apply must return an empty
* page, never an unfiltered one. A search FAILURE is a 500 on the same terms as
* a resolver failure.
*/
const selectBySearch = <T>(
directory: AdminUserDirectory,
term: string,
read: (externalIds: readonly string[]) => Effect.Effect<readonly T[], AdminUsersError>,
): Effect.Effect<readonly T[], AdminUsersError> =>
Effect.gen(function* () {
const search = directory.search;
if (!search) return [];
const wanted = yield* search(term).pipe(
Effect.mapError(() => new AdminUsersError({ message: "Failed to search the directory" })),
);
// Nobody matches: an empty page, and no storage read for an `in ()` that
// could not match anyway.
if (wanted.length === 0) return [];
return yield* read(wanted);
});
/**
* Which filtered read a list request takes. `email` names ONE principal and
* wins when both are present: a keyed read is the more specific answer, and
* a search term beside an exact address has nothing left to narrow.
*/
const selectSubjects = <T>(
directory: AdminUserDirectory,
options: AdminUsersListOptions,
reads: {
readonly page: (
paging: AdminListSubjectsOptions,
) => Effect.Effect<readonly T[], AdminUsersError>;
readonly one: (externalId: string) => Effect.Effect<T | null, AdminUsersError>;
},
): Effect.Effect<readonly T[], AdminUsersError> => {
if (options.email !== undefined) {
return selectByEmail(directory, options.email, options, reads.one);
}
if (options.search !== undefined) {
return selectBySearch(directory, options.search, (externalIds) =>
reads.page({ ...pagingOf(options), externalIds }),
);
}
return reads.page(pagingOf(options));
};
/** Only the paging window — never the filters — reaches the SDK: the filters
* are resolved here, and the SDK's own `externalIds` is set by this file. */
const pagingOf = (options: AdminUsersListOptions): AdminListSubjectsOptions => ({
...(options.limit === undefined ? {} : { limit: options.limit }),
...(options.offset === undefined ? {} : { offset: options.offset }),
});
export const listUsers = (
admin: ExecutorAdmin,
options: AdminUsersListOptions,
directory?: AdminIdentityDirectory | AdminUserDirectory,
): Effect.Effect<typeof AdminUsersResponse.Type, AdminUsersError> =>
Effect.gen(function* () {
const dir = asDirectory(directory);
const subjects = yield* selectSubjects(dir, options, {
page: (paging) => admin.listSubjects(paging).pipe(Effect.mapError(readFailed("users"))),
one: (externalId) => admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))),
});
// One directory read for the page that was actually returned, joined in
// memory — never a lookup per user.
const identities = yield* resolveIdentities(
dir.identities,
subjects.map((subject) => subject.externalId),
);
return { users: subjects.map((subject) => toUser(subject, identities)) };
});
export const listUsersWithConnections = (
admin: ExecutorAdmin,
options: AdminUsersListOptions,
directory?: AdminIdentityDirectory | AdminUserDirectory,
): Effect.Effect<typeof AdminUsersWithConnectionsResponse.Type, AdminUsersError> =>
Effect.gen(function* () {
const dir = asDirectory(directory);
const subjects = yield* selectSubjects(dir, options, {
page: (paging) =>
admin.listSubjectsWithConnections(paging).pipe(Effect.mapError(readFailed("users"))),
one: (externalId) =>
admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))),
});
const identities = yield* resolveIdentities(
dir.identities,
subjects.map((subject) => subject.externalId),
);
return { users: subjects.map((subject) => toUserWithConnections(subject, identities)) };
});
/**
* One user by `externalId` OR email, with connections.
*
* RESOLUTION ORDER. An identifier containing "@" is tried as an email first;
* if the directory knows no such member, or the id it resolves to has no
* subject row, the identifier is then tried as a literal `externalId`. That
* fallback is what keeps `externalId` opaque: no host mints an id containing
* "@" today, but the contract promises nothing may parse the id, so the "@"
* test is only ever a routing hint that costs one extra keyed read in a case
* that does not currently arise.
*
* A directory hit with no subject row is `AdminUserNotFound`, the same as an
* unknown identifier — the plane reports footprint, not org membership. See
* the `getUser` contract docs for why.
*/
export const getUser = (
admin: ExecutorAdmin,
identifier: string,
directory?: AdminIdentityDirectory | AdminUserDirectory,
): Effect.Effect<typeof AdminUserResponse.Type, AdminUsersError | AdminUserNotFound> =>
Effect.gen(function* () {
const dir = asDirectory(directory);
const read = (externalId: string) =>
admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("user")));
const subject = yield* identifier.includes("@")
? Effect.gen(function* () {
const resolved = yield* resolveEmailFilter(dir, normalizeEmail(identifier));
const byEmail = resolved === null ? null : yield* read(resolved);
return byEmail ?? (yield* read(identifier));
})
: read(identifier);
if (subject === null) return yield* new AdminUserNotFound();
// Identity is joined through the SAME batched seam the list uses, so the
// single read reports exactly the fields the list would for this row.
const identities = yield* resolveIdentities(dir.identities, [subject.externalId]);
return { user: toUserWithConnections(subject, identities) };
});
export const listUserConnections = (
admin: ExecutorAdmin,
externalId: string,
): Effect.Effect<typeof AdminUserConnectionsResponse.Type, AdminUsersError> =>
admin.listSubjectConnections(externalId).pipe(
Effect.map((connections) => ({ connections: connections.map(toConnection) })),
Effect.mapError(readFailed("connections")),
);