183 lines · 7.7 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Admin Users HTTP API — the public, tenant-wide operator surface.//// The product plane (`/api/*`) answers "what can I, this member, reach". This// one answers the OWNER's question: "who are my users, and what have they// connected". It reads the SDK's platform view (`executor.admin`, opt-in via// `platformView: true`), which is read-only by construction — the owner policy// rejects writes at `reach: "tenant"` — so every endpoint here is a GET.//// VOCABULARY: this is the translation seam. Internal code says subject / tenant// / reach; everything public-facing says users / owners. `AdminSubject// .externalId` becomes `externalId` on a `users` collection, and nothing below// leaks the word "subject".//// FIELD DISCIPLINE: the response shapes are 1:1 with the SDK's hand-picked// admin allowlist (`AdminSubject` / `AdminConnection`), NOT a projection of the// underlying rows. No credential material of any kind may appear — no item ids,// no refresh ids, no oauth client secrets, no tokens. `oauthScope` is a summary// of ACCESS (which scopes were granted), never a token. Adding a field here is// a deliberate act that has to be argued against `platform-view.test.ts`'s// forbidden-field list.//// Auth is applied by each host's own admin middleware (cloud: an org-scoped API// key or an admin session member; self-host: a Better Auth owner/admin member),// so this contract carries no provider-specific auth scheme — the same shape// the Account API uses.// --------------------------------------------------------------------------- import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";import { Schema } from "effect"; import { ConnectionName, HealthCheckResult, IntegrationSlug, Owner } from "@executor-js/sdk/shared"; // ---------------------------------------------------------------------------// Errors// --------------------------------------------------------------------------- export class AdminUsersError extends Schema.TaggedErrorClass<AdminUsersError>()(  "AdminUsersError",  { message: Schema.String },  { httpApiStatus: 500 },) {} export class AdminUsersUnauthorized extends Schema.TaggedErrorClass<AdminUsersUnauthorized>()(  "AdminUsersUnauthorized",  {},  { httpApiStatus: 401 },) {} export class AdminUsersForbidden extends Schema.TaggedErrorClass<AdminUsersForbidden>()(  "AdminUsersForbidden",  {},  { httpApiStatus: 403 },) {} // ---------------------------------------------------------------------------// Response schemas — the public projection of the SDK's admin shapes.// --------------------------------------------------------------------------- /** * One user of the tenant. Mirrors `AdminSubject` with the public vocabulary. * * `lastSeenAt` is epoch ms of the last sighting on the request path, and is * deliberately COARSE: the writer (`touchSubject`) only rewrites it past a * throttle interval (1 hour by default), so treat it as "active around then", * never as a precise last-activity timestamp. `null` means never seen on a * request — a user can earn a row at connection-create before any sighting. */export const AdminUser = Schema.Struct({  /** The host-auth principal id (cloud: the WorkOS user id). Opaque — it also   *  carries host sentinels like "local", so nothing may parse it. */  externalId: Schema.String,  /** Epoch ms the user was first recorded under this tenant. */  createdAt: Schema.Number,  lastSeenAt: Schema.NullOr(Schema.Number),  /** User lifecycle. Unconstrained until the lifecycle values are defined;   *  `null` means no state recorded (which is every row today). */  status: Schema.NullOr(Schema.String),}); /** * A connection as the platform view sees it: enough to answer "what has this * user connected, and is it healthy", and nothing that could resolve a * credential. Mirrors `AdminConnection`. */export const AdminUserConnection = Schema.Struct({  owner: Owner,  integration: IntegrationSlug,  name: ConnectionName,  /** The scope set the provider actually granted, space-delimited as recorded   *  at connect/refresh. Null for static credentials. A summary of ACCESS —   *  never a token. */  oauthScope: Schema.NullOr(Schema.String),  lastHealth: Schema.NullOr(HealthCheckResult),}); /** A user joined with every connection they own in the tenant. */export const AdminUserWithConnections = Schema.Struct({  ...AdminUser.fields,  connections: Schema.Array(AdminUserConnection),}); export const AdminUsersResponse = Schema.Struct({  users: Schema.Array(AdminUser),}); export const AdminUserConnectionsResponse = Schema.Struct({  connections: Schema.Array(AdminUserConnection),}); export const AdminUsersWithConnectionsResponse = Schema.Struct({  users: Schema.Array(AdminUserWithConnections),}); // ---------------------------------------------------------------------------// Params / query// --------------------------------------------------------------------------- const AdminUserParams = { externalId: Schema.String }; // Paging, mirroring `AdminListSubjectsOptions`. Query params arrive as strings,// so decode them here rather than making every handler interpret raw strings —// an out-of-range or non-numeric value is a 400 from the contract, not a// silently-ignored filter. The joined endpoint reads per-user connections, so// its page size is bounded harder than the flat list's.const AdminListQuery = Schema.Struct({  limit: Schema.optional(    Schema.FiniteFromString.check(Schema.isBetween({ minimum: 1, maximum: 500 })),  ),  offset: Schema.optional(    Schema.FiniteFromString.check(      Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),    ),  ),}); // ---------------------------------------------------------------------------// Group// --------------------------------------------------------------------------- /** * The admin users group, mounted at `/admin/*` by each host. * * The joined view is its OWN path (`/admin/users/with-connections`) rather than * an `?include=connections` flag on `/admin/users`, because the two return * different row shapes. A query flag that changes the response schema can't be * expressed as one typed endpoint — the repo's list endpoints use query params * only to FILTER a fixed shape (`/tools`, `/connections`), never to switch it. * It sorts before `/admin/users/:externalId/...` in the router either way, and * "with-connections" is not a valid opaque principal id. */export const AdminUsersApi = HttpApiGroup.make("adminUsers")  .add(    HttpApiEndpoint.get("listUsers", "/admin/users", {      query: AdminListQuery,      success: AdminUsersResponse,      error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden],    }),  )  .add(    HttpApiEndpoint.get("listUsersWithConnections", "/admin/users/with-connections", {      query: AdminListQuery,      success: AdminUsersWithConnectionsResponse,      error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden],    }),  )  .add(    HttpApiEndpoint.get("listUserConnections", "/admin/users/:externalId/connections", {      params: AdminUserParams,      success: AdminUserConnectionsResponse,      error: [AdminUsersError, AdminUsersUnauthorized, AdminUsersForbidden],    }),  ); /** * Standalone HttpApi wrapping just the admin users group. Hosts mount THIS as * an extension route (like the account API) rather than adding the group to * `CoreExecutorApi`: the core API is served behind the execution-stack * middleware, which binds a product-view executor to one acting subject. The * admin plane needs the opposite (no subject, tenant reach), so it gets its own * mount and its own per-host auth. */export const AdminUsersHttpApi = HttpApi.make("executor-admin-users").add(AdminUsersApi);