diff --git a/.changeset/member-directory-readers.md b/.changeset/member-directory-readers.md new file mode 100644 index 0000000000..6cc71965cc --- /dev/null +++ b/.changeset/member-directory-readers.md @@ -0,0 +1,10 @@ +--- +"@executor-js/cloud": patch +"@executor-js/api": patch +"@executor-js/react": patch +"@executor-js/sdk": patch +--- + +Member lists, the admin users page, and seat counts on cloud now read from the local membership mirror through the shared `MemberDirectory` seam instead of fanning out one WorkOS read per member. The admin users page gains an email/name search. + +**Deploy prerequisite (cloud):** `bun run --cwd apps/cloud db:backfill-workos-mirror:prod` must complete before this build is deployed, and its printed membership count should match WorkOS. Until the backfill has stamped the mirror's marker, seat reporting to Autumn is skipped with a warning (never a partial count) and member lists show only members who have signed in since the mirror shipped. diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts index 1e8075c866..a8b631f170 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -5,6 +5,7 @@ import { AccountProvider, makeAccountApiLayer, requestScopedMiddleware, + type MemberDirectory, } from "@executor-js/api/server"; import { ApiKeyService } from "../auth/api-keys"; @@ -46,7 +47,8 @@ import { AccountCaller, workosAccountProvider } from "./workos-account-service"; // Builds the WorkOS `AccountProvider` per request, providing it to the handler. // Long-lived `WorkOSClient | AutumnService` come from the surrounding context // (Autumn provided by `makeAccountApiLive` for the seat-gate); the per-request -// `UserStoreService` is supplied by the combined `rsLive` layer. +// `UserStoreService` / `WorkOsMirror` / `MemberDirectory` are supplied by the +// combined `rsLive` layer. // `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`. const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()( Effect.gen(function* () { @@ -97,11 +99,11 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi * (the seat-gate) stays a residual requirement, satisfied by the app `boot`. */ export const workosAccountMiddleware = ( - rsLive: Layer.Layer, + rsLive: Layer.Layer, ) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; export const makeAccountApiLive = ( - rsLive: Layer.Layer, + rsLive: Layer.Layer, ) => { // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it // closes over the per-request postgres socket), so it can't be a self- diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index 10353ccecd..db322ba1bb 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { AccountProvider } from "@executor-js/api/server"; +import { AccountProvider, MemberDirectory } from "@executor-js/api/server"; import { AccountError, AccountForbidden } from "@executor-js/api"; import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; @@ -147,6 +147,14 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ organizationBackfilledAt: () => Effect.die("revoke does not report seats"), }); +// Revoke lists no members either. +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: () => Effect.die("revoke does not read the member directory"), + members: () => Effect.die("revoke does not read the member directory"), + membersById: () => Effect.die("revoke does not read the member directory"), + findByEmail: () => Effect.die("revoke does not read the member directory"), +}); + const stubAutumn = Layer.succeed(AutumnService)({ use: () => Effect.die("revoke does not touch billing"), ensureCustomer: () => Effect.die("revoke does not touch billing"), @@ -185,6 +193,7 @@ const providerWith = (accountId: string) => { stubWorkOS, stubUsers, stubMirror, + stubDirectory, stubApiKeys, stubAutumn, Layer.succeed(AccountCaller)({ session: session(accountId) }), diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts index beb9ef86a3..5fadfb938f 100644 --- a/apps/cloud/src/account/workos-account-service.ts +++ b/apps/cloud/src/account/workos-account-service.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer } from "effect"; -import { AccountProvider, type AccountHeaders } from "@executor-js/api/server"; +import { AccountProvider, MemberDirectory, type AccountHeaders } from "@executor-js/api/server"; import { AccountError, AccountForbidden, @@ -12,6 +12,7 @@ import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; import type { Session } from "../auth/middleware"; import { WorkOSClient } from "../auth/workos"; +import { ensureOrganizationBackfilled, mirrorInvitedMember } from "../auth/mirror-feeders"; import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror"; import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; import { AutumnService } from "../extensions/billing/service"; @@ -66,7 +67,13 @@ const toAccountError = () => Effect.fail(new AccountError({ message: "Account re export const workosAccountProvider: Layer.Layer< AccountProvider, never, - WorkOSClient | UserStoreService | WorkOsMirror | ApiKeyService | AutumnService | AccountCaller + | WorkOSClient + | UserStoreService + | WorkOsMirror + | MemberDirectory + | ApiKeyService + | AutumnService + | AccountCaller > = Layer.effect(AccountProvider)( Effect.gen(function* () { const workos = yield* WorkOSClient; @@ -77,6 +84,10 @@ export const workosAccountProvider: Layer.Layer< // written through to the local mirror so the member list and the seat // count read the change without waiting for the Events reconciler. const mirror = yield* WorkOsMirror; + // Membership READS come from the mirror through the shared directory: the + // member list and the seat count are one local query each, never a + // WorkOS read per member. + const directory = yield* MemberDirectory; // The caller, resolved once per request by the cookie-only session // middleware (account-api.ts) — the same credential `SessionAuthLive` @@ -85,10 +96,12 @@ export const workosAccountProvider: Layer.Layer< const caller = yield* AccountCaller; // Capture the resolved service context once so the method bodies — which - // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) — - // can be erased to `R = never`, as the neutral AccountProvider shape - // requires. Provided per method below. - const ctx = yield* Effect.context(); + // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`), + // the mirror feeders, and the seat reporter — can be erased to `R = never`, + // as the neutral AccountProvider shape requires. Provided per method below. + const ctx = yield* Effect.context< + WorkOSClient | UserStoreService | AutumnService | MemberDirectory | WorkOsMirror + >(); // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly // as the old inline `requireSession` did. @@ -145,7 +158,14 @@ export const workosAccountProvider: Layer.Layer< return membership; }); - // Mirror of org/handlers `getMemberSeats` — live seat usage from WorkOS. + // Seat usage: memberships from the local directory (active + pending, the + // `members` default), pending invitations live from WorkOS — invitations + // are not mirrored. The directory is trusted for a COUNT only once this + // organization's membership list has been scanned from WorkOS in full: + // login and write-through record single memberships, so an organization + // the one-off backfill did not cover holds a partial list, and counting + // it would admit invitations past the plan limit. The scan runs here, + // once, when the organization's mark is missing. const getMemberSeats = (organizationId: string) => Effect.gen(function* () { const customer = yield* autumn.use((client) => @@ -154,15 +174,16 @@ export const workosAccountProvider: Layer.Layer< const planId = selectActiveMemberLimitPlan(customer.subscriptions); const limit = getMemberLimitForPlan(planId); - // `listOrgMembers` returns active members AND pending memberships (an - // invited user shows up as status "pending"); `listPendingInvitations` + yield* ensureOrganizationBackfilled(organizationId).pipe(Effect.provideContext(ctx)); + // The directory reports active members AND pending memberships (an + // invited user is mirrored with status "pending"); `listPendingInvitations` // returns the same invited users again. `countSeatsUsed` dedupes them // so an outstanding invite is not counted twice. - const memberships = yield* workos.listOrgMembers(organizationId); + const memberships = yield* directory.members(organizationId); const invitations = yield* workos.listPendingInvitations(organizationId); return { - used: countSeatsUsed(memberships.data, invitations.data.length), + used: countSeatsUsed(memberships, invitations.data.length), granted: limit ?? 0, unlimited: limit === null, }; @@ -328,29 +349,23 @@ export const workosAccountProvider: Layer.Layer< Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })), ); - const memberships = yield* workos - .listOrgMembers(org.id) - .pipe(Effect.catchTag("WorkOSError", toAccountError)); - - const members = yield* Effect.all( - memberships.data.map((m) => - Effect.gen(function* () { - const user = yield* workos.getUser(m.userId); - return { - id: m.id, - userId: m.userId, - email: user.email, - name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, - avatarUrl: user.profilePictureUrl ?? null, - role: m.role?.slug ?? "member", - status: m.status, - lastActiveAt: user.lastSignInAt ?? null, - isCurrentUser: m.userId === session.accountId, - }; - }), - ), - { concurrency: 5 }, - ).pipe(Effect.catchTag("WorkOSError", toAccountError)); + // One directory read (active + pending, ordered by email) with the + // profile already joined — no per-member WorkOS user fetch. + const directoryMembers = yield* directory + .members(org.id) + .pipe(Effect.catchTag("MemberDirectoryError", toAccountError)); + + const members = directoryMembers.map((m) => ({ + id: m.membershipId, + userId: m.accountId, + email: m.email, + name: m.name, + avatarUrl: m.avatarUrl, + role: m.role, + status: m.status, + lastActiveAt: m.lastActiveAt === null ? null : new Date(m.lastActiveAt).toISOString(), + isCurrentUser: m.accountId === session.accountId, + })); return { members, seats }; }), @@ -378,6 +393,23 @@ export const workosAccountProvider: Layer.Layer< ...(body.roleSlug ? { roleSlug: body.roleSlug } : {}), }) .pipe(Effect.catchTag("WorkOSError", toAccountError)); + // Write-through: WorkOS creates a PENDING membership for the invitee + // alongside the invitation, and the member list (the "Invited" row + // and its revoke button) reads memberships from the mirror only, so + // the row must land now — the Events reconciler is not on this path. + const mirrored = yield* mirrorInvitedMember(org.id, invitation.email).pipe( + Effect.provideContext(ctx), + Effect.catchTags({ + WorkOSError: toAccountError, + WorkOsMirrorError: toAccountError, + }), + ); + if (!mirrored) { + yield* Effect.logWarning("inviteMember: no pending membership for the invitee yet", { + organizationId: org.id, + invitationId: invitation.id, + }); + } return { id: invitation.id, email: invitation.email }; }), diff --git a/apps/cloud/src/admin/admin-users-api.ts b/apps/cloud/src/admin/admin-users-api.ts index cae66fcb5f..00c9a0e4d9 100644 --- a/apps/cloud/src/admin/admin-users-api.ts +++ b/apps/cloud/src/admin/admin-users-api.ts @@ -25,28 +25,24 @@ // every query by that tenant. // --------------------------------------------------------------------------- -import { env } from "cloudflare:workers"; import { HttpRouter } from "effect/unstable/http"; -import { Context, Effect, Layer, Option } from "effect"; +import { Effect, Layer } from "effect"; import { AdminUsersProvider, DbProvider, HostConfig, + MemberDirectory, PluginsProvider, + adminUserDirectoryFromMembers, getAdminUser, listAdminUserConnections, listAdminUsers, listAdminUsersWithConnections, makeAdminUsersApiLayer, makePlatformExecutor, - normalizeAdminUserEmail, platformViewOf, requestScopedMiddleware, - type AdminEmailResolver, - type AdminIdentityDirectory, - type AdminUserDirectory, - type AdminUserIdentity, type AdminUsersHeaders, } from "@executor-js/api/server"; import { @@ -122,124 +118,6 @@ const authorizeTenant = ( return org.id; }); -/** - * How many user-detail reads run at once. Matches the account plane's own - * member listing (`workos-account-service.ts`), which fans out the same way for - * the same reason. - */ -const IDENTITY_CONCURRENCY = 5; - -/** - * Cloud's member directory: `externalId` → email/name. - * - * THE JOIN KEY is the membership's `userId` — the WorkOS `user_...` that - * `workos-auth-provider.ts` binds as `accountId` on every credential path, and - * therefore what the subject table records in `external_id`. The membership's - * own `id` is an `om_...` row id and joins to nothing. - * - * WHY THIS IS TWO CALLS AND NOT ONE. The membership list is read once per - * request and is the authority on who belongs to the org, but WorkOS's - * `listOrganizationMemberships` carries no user detail and offers no - * include/expand — email and name only exist on the user resource. The SDK does - * expose a batched `listUsers({ organizationId })`, but the pinned - * `@executor-js/emulate` WorkOS emulator serves only `GET - * /user_management/users/:id`, so taking that path would leave every cloud e2e - * user unnamed. So: ONE membership read per request, then user detail fetched - * only for the ids ON THIS PAGE — never for the whole org, and never once per - * row of some larger list. An id that is not an active/pending member is not - * fetched at all and reports absent identity, which is the honest answer for a - * member who left while their connections remain. - */ -const identityDirectory = - (organizationId: string, context: Context.Context): AdminIdentityDirectory => - (externalIds) => - Effect.gen(function* () { - const workos = yield* WorkOSClient; - const memberships = yield* workos.listOrgMembers(organizationId); - const wanted = new Set(externalIds); - const memberIds = memberships.data - .map((membership) => membership.userId) - .filter((userId) => wanted.has(userId)); - - const resolved = yield* Effect.all( - memberIds.map((userId) => - workos.getUser(userId).pipe( - Effect.map( - (user) => - [ - userId, - { - email: user.email, - displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, - }, - ] as const, - ), - // One unreadable user must not cost the whole page its names. - Effect.catchCause(() => Effect.succeed(null)), - ), - ), - { concurrency: IDENTITY_CONCURRENCY }, - ); - - const identities = new Map(); - for (const entry of resolved) if (entry) identities.set(entry[0], entry[1]); - return identities; - }).pipe(Effect.provideContext(context)); - -/** - * Cloud's REVERSE directory lookup: email → the WorkOS `user_...` id. - * - * Production asks WorkOS for the email AND organization in one request. Both - * filters matter: email makes the lookup indexed rather than one `getUser` - * request per member, while organization keeps the reverse lookup bound to the - * same tenant as the platform view. - * - * The pinned `@executor-js/emulate` WorkOS emulator has no list-users route. - * `WORKOS_API_URL` is the explicit test/dev emulator override, so that path - * retains the membership scan until the emulator supports the production - * query. The fallback still starts from the tenant's membership list and can - * never return a user from another organization. - * - * CASING: WorkOS preserves whatever casing an email was created with (and the - * emulator compares byte-exact), so the directory value is normalized here - * before comparison, against an argument the seam already normalized. - */ -export const emailResolver = - (organizationId: string, context: Context.Context): AdminEmailResolver => - (email) => - Effect.gen(function* () { - const workos = yield* WorkOSClient; - - if (!env.WORKOS_API_URL) { - const users = yield* workos.listUsers({ email, organizationId }); - return users.data[0]?.id ?? null; - } - - const memberships = yield* workos.listOrgMembers(organizationId); - const userIds = memberships.data.map((membership) => membership.userId); - - // Emulator compatibility only. Short-circuit once the normalized email - // matches so the fallback makes as few unsupported-detail reads as it can. - const match = yield* Effect.findFirst(userIds, (userId) => - workos.getUser(userId).pipe( - Effect.map((user) => normalizeAdminUserEmail(user.email ?? "") === email), - // One unreadable user must not fail the whole lookup — it simply - // cannot be the match. - Effect.catchCause(() => Effect.succeed(false)), - ), - ); - return Option.getOrNull(match); - }).pipe(Effect.provideContext(context)); - -/** Both directions of cloud's directory, built once per authorized request. */ -const userDirectory = ( - organizationId: string, - context: Context.Context, -): AdminUserDirectory => ({ - identities: identityDirectory(organizationId, context), - resolveEmail: emailResolver(organizationId, context), -}); - /** * Authorize, then run `body` against the tenant's platform view. * @@ -264,8 +142,9 @@ const withPlatformView = new AdminUsersError({ message: "Failed to open the platform view" })), ); - // The authorized tenant is handed to the body so an identity join reads the - // SAME org the reads are scoped to — never one named by client input. + // The authorized tenant is handed to the body so the directory reads the + // SAME org the storage reads are scoped to — never one named by client + // input. return yield* Effect.ensuring( body(executor, organizationId), executor.close().pipe(Effect.ignore), @@ -275,22 +154,40 @@ const withPlatformView = = Layer.effect(AdminUsersProvider)( Effect.gen(function* () { const context = yield* Effect.context< WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig >(); + const directory = yield* MemberDirectory; + // The authorized tenant is what scopes the directory, so every read below + // asks the same org the platform view was opened for. + const userDirectory = (organizationId: string) => + adminUserDirectoryFromMembers(directory, organizationId); return AdminUsersProvider.of({ listUsers: (headers, options) => withPlatformView(headers, (executor, organizationId) => platformViewOf(executor).pipe( Effect.flatMap((admin) => - listAdminUsers(admin, options, userDirectory(organizationId, context)), + listAdminUsers(admin, options, userDirectory(organizationId)), ), ), ).pipe(Effect.provideContext(context)), @@ -298,7 +195,7 @@ export const workosAdminUsersProvider: Layer.Layer< withPlatformView(headers, (executor, organizationId) => platformViewOf(executor).pipe( Effect.flatMap((admin) => - listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)), + listAdminUsersWithConnections(admin, options, userDirectory(organizationId)), ), ), ).pipe(Effect.provideContext(context)), @@ -312,7 +209,7 @@ export const workosAdminUsersProvider: Layer.Layer< withPlatformView(headers, (executor, organizationId) => platformViewOf(executor).pipe( Effect.flatMap((admin) => - getAdminUser(admin, identifier, userDirectory(organizationId, context)), + getAdminUser(admin, identifier, userDirectory(organizationId)), ), ), ).pipe(Effect.provideContext(context)), @@ -322,8 +219,9 @@ export const workosAdminUsersProvider: Layer.Layer< // Builds the provider per request, providing it to the handlers. Long-lived // `WorkOSClient | ApiKeyService` come from the surrounding boot context; the -// per-request `DbService`/`UserStoreService` (and the execution seams built -// over them) are supplied by the combined `requestScopedMiddleware`. +// per-request `DbService`/`UserStoreService`/`MemberDirectory` (and the +// execution seams built over them) are supplied by the combined +// `requestScopedMiddleware`. const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()( Effect.gen(function* () { const longLived = yield* Effect.context(); @@ -348,7 +246,7 @@ const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUser * `/api` prefix as the rest of the cloud router. */ export const makeCloudAdminUsersRoutes = ( - rsLive: Layer.Layer, + rsLive: Layer.Layer, options: Parameters[1] = {}, ) => makeAdminUsersApiLayer( diff --git a/apps/cloud/src/admin/admin-users-email.node.test.ts b/apps/cloud/src/admin/admin-users-email.node.test.ts deleted file mode 100644 index 463c83df09..0000000000 --- a/apps/cloud/src/admin/admin-users-email.node.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { env } from "cloudflare:workers"; -import { expect, it } from "@effect/vitest"; -import { Data, Effect, Layer } from "effect"; - -import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; -import { emailResolver } from "./admin-users-api"; - -// Cloud's REVERSE directory lookup: email -> the WorkOS `user_...` id that the -// subject table records in `external_id`. Production resolves it with one -// tenant-scoped list-users query. The WorkOS emulator lacks that route, so -// tests/dev retain the membership-backed scan exercised below. - -const ORG = "org_placeholder"; -const OTHER_ORG = "org_other"; - -class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{ - readonly userId: string; -}> {} - -const DIRECTORY = [ - // Same email in another tenant must never win either lookup path. - { id: "user_foreign", email: "ada@placeholder.test", organizationId: OTHER_ORG }, - // WorkOS preserves submitted casing, while the resolver seam is normalized. - { id: "user_ada", email: "Ada@Placeholder.test", organizationId: ORG }, - { id: "user_grace", email: "grace@placeholder.test", organizationId: ORG }, - { id: "user_nameless", email: null, organizationId: ORG }, -] as const; - -const stubWorkOS = (calls: string[], unreadableUserIds: ReadonlySet) => - Layer.succeed( - WorkOSClient, - new Proxy({} as WorkOSClientService, { - get: (_target, prop) => { - if (prop === "listUsers") { - return (params: { email: string; organizationId: string }) => { - calls.push(`listUsers:${params.organizationId}:${params.email}`); - return Effect.succeed({ - data: DIRECTORY.filter( - (user) => - user.organizationId === params.organizationId && - user.email?.toLowerCase() === params.email, - ), - }); - }; - } - if (prop === "listOrgMembers") { - return (organizationId: string) => { - calls.push(`listOrgMembers:${organizationId}`); - return Effect.succeed({ - data: DIRECTORY.filter((user) => user.organizationId === organizationId).map( - (user) => ({ userId: user.id, organizationId }), - ), - }); - }; - } - if (prop === "getUser") { - return (userId: string) => { - calls.push(`getUser:${userId}`); - if (unreadableUserIds.has(userId)) { - return Effect.fail(new WorkOSUnavailable({ userId })); - } - const user = DIRECTORY.find((candidate) => candidate.id === userId); - if (!user) return Effect.die(`unexpected user ${userId}`); - return Effect.succeed(user); - }; - } - return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); - }, - }), - ); - -const resolve = ( - email: string, - calls: string[], - emulator = false, - unreadableUserIds: ReadonlySet = new Set(), -) => { - const previousApiUrl = env.WORKOS_API_URL; - return Effect.gen(function* () { - yield* Effect.sync(() => - Object.assign(env, { - WORKOS_API_URL: emulator ? "http://workos-emulator.invalid" : undefined, - }), - ); - const context = yield* Effect.context(); - return yield* emailResolver(ORG, context)(email); - }).pipe( - Effect.provide(stubWorkOS(calls, unreadableUserIds)), - Effect.ensuring(Effect.sync(() => Object.assign(env, { WORKOS_API_URL: previousApiUrl }))), - ); -}; - -it.effect("resolves an email with one tenant-scoped WorkOS query", () => - Effect.gen(function* () { - const calls: string[] = []; - expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada"); - expect(calls).toEqual([`listUsers:${ORG}:ada@placeholder.test`]); - }), -); - -it.effect("returns null from one query when the organization has no matching email", () => - Effect.gen(function* () { - const calls: string[] = []; - expect(yield* resolve("nobody@placeholder.test", calls)).toBeNull(); - expect(calls).toEqual([`listUsers:${ORG}:nobody@placeholder.test`]); - }), -); - -it.effect("keeps the emulator fallback tenant-scoped and case-insensitive", () => - Effect.gen(function* () { - const calls: string[] = []; - expect(yield* resolve("ada@placeholder.test", calls, true)).toBe("user_ada"); - expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada"]); - expect(calls).not.toContain("getUser:user_foreign"); - expect(calls.some((call) => call.startsWith("listUsers:"))).toBe(false); - }), -); - -it.effect("lets the emulator fallback continue past one unreadable member", () => - Effect.gen(function* () { - const calls: string[] = []; - expect(yield* resolve("grace@placeholder.test", calls, true, new Set(["user_ada"]))).toBe( - "user_grace", - ); - expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada", "getUser:user_grace"]); - }), -); diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index bcc5c5221d..f0875cc1dc 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -2,10 +2,15 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServer } from "effect/unstable/http"; import { Layer } from "effect"; -import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api/server"; +import { + makeProtectedApiLayer, + requestScopedMiddleware, + type MemberDirectory, +} from "@executor-js/api/server"; import { SessionAuthLive } from "../auth/middleware-live"; import { UserStoreService } from "../auth/context"; +import { cloudMemberDirectoryLayer } from "../auth/member-directory"; import { WorkOsMirror } from "../auth/workos-mirror"; import { CloudAuthPublicHandlers, @@ -27,12 +32,17 @@ import { CoreSharedServices } from "../auth/workos"; const DbLive = DbService.Live; const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); const WorkOsMirrorLive = WorkOsMirror.Live.pipe(Layer.provide(DbLive)); +// The shared `MemberDirectory` read seam over the membership mirror — the +// same per-request socket the mirror writes through. +const MemberDirectoryLive = cloudMemberDirectoryLayer.pipe(Layer.provide(DbLive)); // Per-request layer. Anything that opens an I/O object (postgres.js socket, // fetch stream readers, anything backed by a `Writable`) MUST live here — // `provideRequestScoped` rebuilds it per request so Cloudflare Workers' // I/O isolation is satisfied. See `api.request-scope.test.ts`. -export const RequestScopedServicesLive = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive); +export const RequestScopedServicesLive: Layer.Layer< + DbService | UserStoreService | WorkOsMirror | MemberDirectory +> = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive, MemberDirectoryLive); // Boot-scoped layer. Built once at worker boot, reused across requests. // Safe for config, in-memory caches, the global tracer provider, and @@ -57,7 +67,7 @@ export const BootSharedServices = Layer.mergeAll( // handler reads it for the free-organizations-per-user limit gate — one of the // few app-only billing touchpoints. (It is NOT on the neutral boot core.) export const makeNonProtectedApiLive = ( - rsLive: Layer.Layer, + rsLive: Layer.Layer, ) => HttpApiBuilder.layer(NonProtectedApi).pipe( Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), diff --git a/apps/cloud/src/api/router.ts b/apps/cloud/src/api/router.ts index 227dc20b22..8c80825ef2 100644 --- a/apps/cloud/src/api/router.ts +++ b/apps/cloud/src/api/router.ts @@ -1,7 +1,11 @@ import { Layer } from "effect"; import { HttpRouter } from "effect/unstable/http"; -import { RouterConfigLive, requestScopedMiddleware } from "@executor-js/api/server"; +import { + RouterConfigLive, + requestScopedMiddleware, + type MemberDirectory, +} from "@executor-js/api/server"; import { UserStoreService } from "../auth/context"; import { WorkOsMirror } from "../auth/workos-mirror"; @@ -31,7 +35,7 @@ import { makeProtectedApiLive } from "./protected"; // assert per-request semantics — see // `apps/cloud/src/api.request-scope.node.test.ts`. export const makeApiLive = ( - requestScopedLive: Layer.Layer, + requestScopedLive: Layer.Layer, ) => { const BillingRoutesLive = AutumnRoutesLive.pipe( Layer.provide(requestScopedMiddleware(requestScopedLive).layer), diff --git a/apps/cloud/src/auth/mirror-feeders.node.test.ts b/apps/cloud/src/auth/mirror-feeders.node.test.ts index 3381943557..a344f4553c 100644 --- a/apps/cloud/src/auth/mirror-feeders.node.test.ts +++ b/apps/cloud/src/auth/mirror-feeders.node.test.ts @@ -12,11 +12,20 @@ // - the callback picks the landing org from that same list: a returnTo // slug or last-org cookie lands only in an ACTIVE membership, an unknown // or pending one falls through +// - `inviteMember` mirrors the PENDING membership WorkOS created for the +// invitee (found by email among the org's pending memberships), so the +// member list shows the invite and can revoke it // - `removeMember` tombstones the mirror row after the WorkOS delete, // stamped with the membership's last WorkOS state (never a local clock), // so a replay of the membership as it was before the delete cannot // restore it while a replacement WorkOS created meanwhile is accepted // - `updateMemberRole` writes the role WorkOS returned +// - the seat gate trusts the mirror's count only for an organization whose +// membership list was scanned from WorkOS in full: an unmarked one is +// scanned first (once), so a partial mirror never admits an invite past +// the plan limit +// - the seat reporter scans an unmarked organization before counting and +// never re-scans a marked one // - the backfill mirrors every org's members and counts what it wrote, // writes nothing on a dry run, converges on a re-run, tombstones a // membership WorkOS no longer lists — but never one written after its @@ -39,10 +48,11 @@ import { describe, expect, it } from "@effect/vitest"; import { sql } from "drizzle-orm"; -import { Effect, Exit, Fiber, Latch, Layer } from "effect"; +import { Effect, Exit, Fiber, Latch, Layer, Option } from "effect"; import { HttpRouter, HttpServer } from "effect/unstable/http"; import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { AccountForbidden } from "@executor-js/api"; import { AccountProvider, MemberDirectory, @@ -53,6 +63,7 @@ import { import { AccountCaller, workosAccountProvider } from "../account/workos-account-service"; import { RequestScopedServicesLive } from "../api/layers"; import { DbService } from "../db/db"; +import { forkReportMemberSeats } from "../extensions/billing/member-seats"; import { AutumnService } from "../extensions/billing/service"; import { ApiKeyService } from "./api-keys"; import { UserStoreService } from "./context"; @@ -210,13 +221,21 @@ describe("login callback", () => { listMetadata: { before: null, after: null }, }); }, - // The forked seat recount after login. - listOrgMembers: () => - Effect.succeed({ + // The landing org's seat recount scans the org from WorkOS the first + // time it is counted (its per-org backfill mark is missing); the + // scan lists the org's members and fetches each user. + listOrgMembers: (organizationId) => { + calls.push(`listOrgMembers:${organizationId}`); + return Effect.succeed({ object: "list" as const, - data: [] as never[], + data: listed.filter((m) => m.organizationId === organizationId) as never[], listMetadata: { before: null, after: null }, - }), + }); + }, + getUser: (id) => { + calls.push(`getUser:${id}`); + return Effect.succeed(workosUser(id) as never); + }, refreshSession: (_sealed, organizationId) => { refreshedInto.push(organizationId); return Effect.succeed("sealed-refreshed"); @@ -268,7 +287,17 @@ describe("login callback", () => { const response = await handler(callbackRequest({})); expect(response.status).toBe(302); - expect(calls, "one membership list for the whole callback").toEqual([ + expect( + calls, + "one membership list for the callback itself; the landing org, never scanned, is scanned once for its seat count", + ).toEqual([ + `listUserMemberships:${userId}`, + `listOrgMembers:${activeOrg}`, + `getUser:${userId}`, + ]); + calls.length = 0; + expect((await handler(callbackRequest({}))).status).toBe(302); + expect(calls, "a second sign-in lists memberships only: the org is now marked").toEqual([ `listUserMemberships:${userId}`, ]); @@ -542,7 +571,14 @@ describe("account service writes through to the mirror", () => { * Provided around the WHOLE test body so the postgres socket outlives the * provider call under test. */ - const providerLayer = (org: string, deleted: string[]) => { + const providerLayer = ( + org: string, + deleted: string[], + options: { + readonly workos?: Partial; + readonly autumn?: Layer.Layer; + } = {}, + ) => { const list = (data: readonly unknown[]) => Effect.succeed({ object: "list" as const, @@ -550,6 +586,7 @@ describe("account service writes through to the mirror", () => { listMetadata: { before: null, after: null }, }); const workos = stubWorkOS({ + ...options.workos, listUserMemberships: (userId) => list([workosMembership(userId, org)]), getUserOrgMembership: (organizationId, userId) => Effect.succeed( @@ -571,7 +608,6 @@ describe("account service writes through to the mirror", () => { updatedAt: T2, }) as never, ), - listOrgMembers: () => list([]), }); // The test database serves ONE connection at a time, so the seed, the // provider, and the directory read all share this layer's socket. @@ -585,7 +621,7 @@ describe("account service writes through to the mirror", () => { Layer.mergeAll( workos, stubApiKeys, - stubAutumn, + options.autumn ?? stubAutumn, Layer.succeed(AccountCaller)({ session: session(ADMIN) }), ), ), @@ -595,7 +631,13 @@ describe("account service writes through to the mirror", () => { }; // TARGET as an existing member of `org`, seeded through the live mirror. - const seedTarget = (org: string) => + // The org is marked backfilled (as the one-off backfill leaves every org) + // unless a test wants the unscanned state, so a seat count reads the mirror + // rather than scanning WorkOS. + const seedTarget = ( + org: string, + options: { readonly backfilled: boolean } = { backfilled: true }, + ) => Effect.gen(function* () { const users = yield* UserStoreService; const mirror = yield* WorkOsMirror; @@ -614,11 +656,175 @@ describe("account service writes through to the mirror", () => { status: "active", updatedAt: new Date(T1), }); + if (options.backfilled) { + // An empty listing at T1 (nothing to tombstone: TARGET's row is + // stamped T1, not before it) marks the org scanned as of T1. + yield* mirror.applyOrganizationScan({ + organizationId: org, + listedAt: new Date(T1), + members: [], + }); + } }); const membersOf = (org: string) => Effect.flatMap(MemberDirectory.asEffect(), (directory) => directory.members(org)); + it.effect("inviteMember mirrors the pending membership WorkOS created for the invitee", () => { + const org = freshId("org"); + // Two people are already invited; the new invitee is a third pending + // membership, and only their user carries the invited address — with + // different casing than the admin typed, as WorkOS may store it. + const earlier = [freshId("user"), freshId("user")]; + const invitee = freshId("user"); + const invitedEmail = `${invitee}@placeholder.test`; + const userCalls: string[] = []; + // The plan gate reads the customer's plan before inviting: an unlimited + // plan so the seat cap never interferes with what is under test. + const teamAutumn = Layer.succeed(AutumnService)({ + use: () => + Effect.succeed({ + subscriptions: [{ planId: "team", status: "active" }], + } as never), + ensureCustomer: () => Effect.void, + checkExecutionBalance: () => Effect.die("invite does not check balances"), + trackExecution: () => Effect.void, + setMemberSeats: () => Effect.void, + }); + const layer = providerLayer(org, [], { + autumn: teamAutumn, + workos: { + listPendingInvitations: () => + Effect.succeed({ + object: "list" as const, + data: [] as never[], + listMetadata: { before: null, after: null }, + }), + sendInvitation: ({ email }) => + Effect.succeed({ + id: `invitation_${invitee}`, + email: email.toUpperCase(), + } as never), + listOrgMembers: (organizationId, statuses) => { + expect(organizationId).toBe(org); + expect(statuses, "only the pending set is listed").toEqual(["pending"]); + return Effect.succeed({ + object: "list" as const, + data: [...earlier, invitee].map((userId) => + workosMembership(userId, org, { status: "pending" }), + ) as never[], + listMetadata: { before: null, after: null }, + }); + }, + getUser: (userId) => + Effect.sync(() => { + userCalls.push(userId); + return workosUser(userId, { + firstName: "Invited", + lastName: "Person", + }) as never; + }), + }, + }); + return Effect.gen(function* () { + yield* seedTarget(org); + const account = yield* AccountProvider; + + const result = yield* account.inviteMember( + { [ORG_SELECTOR_HEADER]: org }, + { email: invitedEmail }, + ); + + expect(result.id).toBe(`invitation_${invitee}`); + const members = yield* membersOf(org); + const pending = members.find((m) => m.status === "pending"); + expect(pending, "the invitee appears as a pending member").toMatchObject({ + accountId: invitee, + membershipId: `om_${invitee}_${org}`, + email: invitedEmail, + name: "Invited Person", + role: "member", + }); + expect( + members.filter((m) => m.status === "pending"), + "only the invitee's pending membership is mirrored, not the other pending ones", + ).toHaveLength(1); + expect( + userCalls.sort(), + "one getUser per pending membership, bounded to the pending set", + ).toEqual([...earlier, invitee].sort()); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "inviteMember scans an organization the backfill never covered before counting its seats, once", + () => { + const org = freshId("org"); + const listed: string[] = []; + // A free plan (limit 3). The mirror holds ONE member of the org (TARGET) + // and the org is unmarked; WorkOS lists three. Only a count taken after + // the scan refuses the invite. + const freeAutumn = Layer.succeed(AutumnService)({ + use: () => Effect.succeed({ subscriptions: [] } as never), + ensureCustomer: () => Effect.void, + checkExecutionBalance: () => Effect.die("invite does not check balances"), + trackExecution: () => Effect.void, + setMemberSeats: () => Effect.void, + }); + const others = [freshId("user"), freshId("user")]; + const layer = providerLayer(org, [], { + autumn: freeAutumn, + workos: { + listPendingInvitations: () => + Effect.succeed({ + object: "list" as const, + data: [] as never[], + listMetadata: { before: null, after: null }, + }), + listOrgMembers: (organizationId, statuses) => { + listed.push(organizationId); + expect(statuses, "the scan lists every status, inactive included").toEqual([ + "active", + "pending", + "inactive", + ]); + return Effect.succeed({ + object: "list" as const, + data: [TARGET, ...others].map((userId) => workosMembership(userId, org)) as never[], + listMetadata: { before: null, after: null }, + }); + }, + getUser: (userId) => Effect.succeed(workosUser(userId) as never), + sendInvitation: () => + Effect.die("the plan gate refuses before WorkOS is asked to invite"), + }, + }); + return Effect.gen(function* () { + yield* seedTarget(org, { backfilled: false }); + const account = yield* AccountProvider; + const invite = () => + Effect.flip( + account.inviteMember({ [ORG_SELECTOR_HEADER]: org }, { email: "new@placeholder.test" }), + ); + + const error = yield* invite(); + expect(error).toBeInstanceOf(AccountForbidden); + expect(error).toMatchObject({ + message: expect.stringContaining("Your plan includes 3 members"), + }); + expect(listed, "the org was scanned from WorkOS before it was counted").toEqual([org]); + expect( + (yield* membersOf(org)).map((m) => m.accountId).sort(), + "and the scan filled the mirror", + ).toEqual([TARGET, ...others].sort()); + + const again = yield* invite(); + expect(again).toBeInstanceOf(AccountForbidden); + expect(listed, "a marked org is never scanned again").toEqual([org]); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("removeMember tombstones the mirror row after the WorkOS delete", () => { const org = freshId("org"); const deleted: string[] = []; @@ -688,6 +894,149 @@ describe("account service writes through to the mirror", () => { }); }); +describe("seat reporter", () => { + /** + * A `WorkOsMirror` answering the per-org backfill mark and recording the + * scan a reporter applies; every other operation is out of its reach. + */ + const recordingMirror = (backfilledAt: Date | null, writes: string[]) => + Layer.succeed(WorkOsMirror)({ + upsertUser: () => Effect.die("the seat reporter scans, it does not upsert one by one"), + upsertMembership: () => Effect.die("the seat reporter scans, it does not upsert one by one"), + deleteMembership: () => Effect.die("the seat reporter does not delete"), + deleteUser: () => Effect.die("the seat reporter does not delete"), + getCursor: () => Effect.die("the seat reporter does not read the cursor"), + applyPage: () => Effect.die("the seat reporter does not move the cursor"), + applyOrganizationScan: (scan) => + Effect.sync(() => { + writes.push( + `applyOrganizationScan:${scan.organizationId}:${scan.members + .map((member) => member.membership.id) + .join(",")}`, + ); + return Option.some({ + usersWritten: scan.members.length, + membershipsWritten: scan.members.length, + membershipsTombstoned: 0, + }); + }), + replayBoundary: () => Effect.die("the seat reporter does not run the reconciler"), + setReplayBoundary: () => Effect.die("the seat reporter does not record the boundary"), + backfillCompletedAt: () => Effect.die("the seat reporter does not check mirror readiness"), + markBackfillCompleted: () => Effect.die("the seat reporter does not record the completion"), + drainedAt: () => Effect.die("the seat reporter does not check mirror readiness"), + markDrained: () => Effect.die("the seat reporter does not run the reconciler"), + organizationBackfilledAt: () => Effect.succeed(backfilledAt), + } satisfies WorkOsMirrorShape); + + /** A directory holding `active` active members and one pending one. */ + const directoryWith = (org: string, active: number) => + Layer.succeed(MemberDirectory)({ + membership: () => Effect.die("the seat reporter lists, it does not look up"), + membersById: () => Effect.die("the seat reporter lists, it does not look up"), + findByEmail: () => Effect.die("the seat reporter lists, it does not look up"), + members: (organizationId, query) => { + expect(organizationId).toBe(org); + expect(query?.statuses, "billed seats are active members only").toEqual(["active"]); + return Effect.succeed( + Array.from({ length: active }, (_, i) => ({ + accountId: `user_${i}`, + membershipId: `om_${i}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "member", + status: "active" as const, + lastActiveAt: null, + })), + ); + }, + }); + + const report = ( + org: string, + backfilledAt: Date | null, + active: number, + workos: Partial = {}, + ) => + Effect.gen(function* () { + const reported: { organizationId: string; seats: number }[] = []; + const writes: string[] = []; + const recording = Layer.succeed(AutumnService)({ + use: () => Effect.die("the seat reporter sets seats, it does not read"), + ensureCustomer: () => Effect.void, + checkExecutionBalance: () => Effect.die("the seat reporter does not check balances"), + trackExecution: () => Effect.void, + setMemberSeats: (organizationId, seats) => + Effect.sync(() => { + reported.push({ organizationId, seats }); + }), + }); + yield* forkReportMemberSeats(org).pipe( + Effect.provide( + Layer.mergeAll( + recordingMirror(backfilledAt, writes), + directoryWith(org, active), + recording, + stubWorkOS(workos), + ), + ), + ); + // The Autumn call is forked; it is synchronous here, so it has landed. + return { reported, writes }; + }); + + it.effect( + "sets the active member count of a scanned organization without touching WorkOS", + () => { + const org = freshId("org"); + return Effect.gen(function* () { + const { reported, writes } = yield* report(org, new Date(T1), 3); + expect(reported).toEqual([{ organizationId: org, seats: 3 }]); + expect(writes, "a marked organization is not scanned").toEqual([]); + }); + }, + ); + + it.effect("scans an organization the backfill never covered before counting it", () => { + const org = freshId("org"); + const member = freshId("user"); + return Effect.gen(function* () { + const { reported, writes } = yield* report(org, null, 2, { + listOrgMembers: (organizationId, statuses) => { + expect(organizationId).toBe(org); + // Inactive memberships included: a scan that skipped them would + // tombstone them under their ids and refuse their reactivation. + expect(statuses).toEqual(["active", "pending", "inactive"]); + return Effect.succeed({ + object: "list" as const, + data: [workosMembership(member, org)] as never[], + listMetadata: { before: null, after: null }, + }); + }, + getUser: (userId) => Effect.succeed(workosUser(userId) as never), + }); + expect( + writes, + "the scan fills the mirror and marks the organization, then the count is read", + ).toEqual([`applyOrganizationScan:${org}:om_${member}_${org}`]); + expect(reported).toEqual([{ organizationId: org, seats: 2 }]); + }); + }); + + it.effect("pushes no count when the scan fails: a partial count is never billed", () => { + const org = freshId("org"); + return Effect.gen(function* () { + const { reported, writes } = yield* report(org, null, 2, { + listOrgMembers: () => Effect.fail(new WorkOSError({ status: 503 })), + }); + expect(reported).toEqual([]); + expect(writes, "nothing is marked").toEqual([]); + }); + }); +}); + describe("backfill", () => { /** A fake WorkOS holding `orgs` → members, counting `getUser` calls. */ const source = (orgs: ReadonlyMap, userCalls: string[]) => ({ diff --git a/apps/cloud/src/auth/mirror-feeders.ts b/apps/cloud/src/auth/mirror-feeders.ts index 0dc825e9b3..3b7ab3ec11 100644 --- a/apps/cloud/src/auth/mirror-feeders.ts +++ b/apps/cloud/src/auth/mirror-feeders.ts @@ -5,14 +5,21 @@ // Each feeder takes the WorkOS payload the caller ALREADY holds (the // authenticated user, the membership list the callback fetches to pick a // landing org, the membership a write returned) so feeding the mirror never -// adds a WorkOS read. Mirror failures fail the request: the mirror is the -// membership read path, so a login that could not record its memberships is -// not a login that finished. +// adds a WorkOS read — except the two writes whose WorkOS response is not the +// membership they changed: invitation acceptance (`auth/handlers.ts` reads +// the activated membership back) and sending an invitation +// (`mirrorInvitedMember` below reads the pending one WorkOS created). Both +// are rare, admin-driven paths. Mirror failures fail the request: the mirror +// is the membership read path, so a login that could not record its +// memberships is not a login that finished. // --------------------------------------------------------------------------- import { Effect } from "effect"; +import { normalizeAdminUserEmail } from "@executor-js/api/server"; + import { UserStoreService } from "./context"; +import { WorkOSClient } from "./workos"; import { WorkOsMirror, mirrorMembershipFromWorkOs, @@ -20,6 +27,7 @@ import { type WorkOsMembershipPayload, type WorkOsUserPayload, } from "./workos-mirror"; +import { backfillOrganization } from "./workos-mirror-backfill"; /** * A membership as WorkOS lists it for a user: carries the organization's name, @@ -78,3 +86,101 @@ export const mirrorMembership = (membership: WorkOsMembershipPayload) => Effect.flatMap(WorkOsMirror.asEffect(), (mirror) => mirror.upsertMembership(mirrorMembershipFromWorkOs(membership)), ); + +// Bounded fan-out for the per-invitee `getUser` calls, matching the backfill: +// enough to overlap WorkOS round-trips, low enough to stay clear of its rate +// limit. +const USER_FETCH_CONCURRENCY = 5; + +/** + * Record the PENDING membership WorkOS creates for an invitee the moment an + * organization invites them — the row the member list shows as "Invited" and + * the admin revokes an outstanding invite through. `sendInvitation` returns + * the invitation, not that membership, so this reads it back: it lists the + * organization's pending memberships (WorkOS has no lookup by email that the + * emulator serves) and fetches their users, five at a time, until one carries + * the invited email. Bounded by the pending set, so an organization with + * many active members pays nothing per member. + * + * `false` when no pending membership carried the email — WorkOS created none + * (the address may already hold a membership) or has not yet — which the + * caller treats as a warning, not a failure: the Events reconciler lands + * whatever WorkOS did create. + */ +export const mirrorInvitedMember = Effect.fn("workos_mirror.invitedMember")(function* ( + organizationId: string, + invitedEmail: string, +) { + const workos = yield* WorkOSClient; + const mirror = yield* WorkOsMirror; + const wanted = normalizeAdminUserEmail(invitedEmail); + const pending = yield* workos.listOrgMembers(organizationId, ["pending"]); + for (let start = 0; start < pending.data.length; start += USER_FETCH_CONCURRENCY) { + const batch = pending.data.slice(start, start + USER_FETCH_CONCURRENCY); + const candidates = yield* Effect.forEach( + batch, + (membership) => + Effect.map(workos.getUser(membership.userId), (user) => ({ + membership, + user, + })), + { concurrency: USER_FETCH_CONCURRENCY }, + ); + const match = candidates.find( + (candidate) => normalizeAdminUserEmail(candidate.user.email) === wanted, + ); + if (match === undefined) continue; + yield* mirror.upsertUser(mirrorUserFromWorkOs(match.user)); + yield* mirror.upsertMembership(mirrorMembershipFromWorkOs(match.membership)); + return true; + } + return false; +}); + +/** + * Make sure the organization's membership list has been scanned from WorkOS + * in full before a COUNT read from the mirror is trusted. Login records only + * the caller's own memberships and write-through only the one it changed, + * so an organization the one-off backfill did not cover — mirrored lazily + * by a request, or created after the backfill ran — holds a partial list + * until it is scanned. The per-organization mark + * (`organizations.backfilled_at`) says whether that scan has happened; when + * it is missing, this runs the scan now (`backfillOrganization`: one + * membership listing plus one `getUser` per member, then the mark), so the + * caller's count is complete. Returns `true` when a scan ran. A scan that + * fails marks nothing, so the next count tries again. + */ +export const ensureOrganizationBackfilled = Effect.fn("workos_mirror.ensureOrganizationBackfilled")( + function* (organizationId: string) { + const mirror = yield* WorkOsMirror; + const backfilledAt = yield* mirror.organizationBackfilledAt(organizationId); + if (backfilledAt !== null) return false; + const workos = yield* WorkOSClient; + yield* Effect.logInfo( + "workos_mirror: organization not yet backfilled; scanning it from WorkOS", + { + organizationId, + }, + ); + yield* backfillOrganization( + { + // EVERY status, as the scan source requires: the scan tombstones + // whatever its listing lacks, and a tombstone is keyed to the + // membership id for good — so a listing that skipped the inactive + // ones (the wrapper's active + pending default, the seat-occupying + // set) would tombstone a membership WorkOS merely deactivated and + // refuse its reactivation under the same id forever. + listOrgMembers: (id) => + Effect.map( + workos.listOrgMembers(id, ["active", "pending", "inactive"]), + (list) => list.data, + ), + getUser: (id) => workos.getUser(id), + }, + mirror, + organizationId, + { dryRun: false }, + ); + return true; + }, +); diff --git a/apps/cloud/src/auth/workos-callback-state.node.test.ts b/apps/cloud/src/auth/workos-callback-state.node.test.ts index a62f840b67..a80d4a710b 100644 --- a/apps/cloud/src/auth/workos-callback-state.node.test.ts +++ b/apps/cloud/src/auth/workos-callback-state.node.test.ts @@ -16,6 +16,8 @@ import { HttpRouter, HttpServer } from "effect/unstable/http"; import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpApi } from "effect/unstable/httpapi"; +import { MemberDirectory } from "@executor-js/api/server"; + import { CloudAuthPublicHandlers } from "./handlers"; import { CloudAuthPublicApi } from "./api"; import { UserStoreService } from "./context"; @@ -50,9 +52,6 @@ const stubWorkOS = Layer.succeed( if (prop === "listUserMemberships") { return () => Effect.succeed({ data: [] }); } - if (prop === "listOrgMembers") { - return () => Effect.succeed({ data: [{ status: "active" }] }); - } return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, }), @@ -109,7 +108,9 @@ const stubUsers = Layer.succeed(UserStoreService)({ }); // The callback records the sign-in (user + memberships) in the membership -// mirror; every other mirror operation is out of this route's reach. +// mirror, and its forked seat recount reads the backfill marker and the +// landed org's active members from it; every other operation is out of this +// route's reach. const stubMirror = Layer.succeed(WorkOsMirror)({ upsertUser: () => Effect.succeed(true), upsertMembership: () => Effect.succeed(true), @@ -124,7 +125,27 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ markBackfillCompleted: () => Effect.die("the callback does not run the backfill"), drainedAt: () => Effect.die("the callback does not check mirror readiness"), markDrained: () => Effect.die("the callback does not run the reconciler"), - organizationBackfilledAt: () => Effect.die("the callback does not report seats"), + organizationBackfilledAt: () => Effect.succeed(new Date()), +}); + +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: () => Effect.die("the callback does not look up one membership"), + membersById: () => Effect.die("the callback does not batch members"), + findByEmail: () => Effect.die("the callback does not resolve emails"), + members: (organizationId) => + Effect.succeed([ + { + accountId: STUB_USER_ID, + membershipId: `om_${STUB_USER_ID}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "member", + status: "active" as const, + lastActiveAt: null, + }, + ]), }); // Only the public group is under test; the session group (and its SessionAuth @@ -137,6 +158,7 @@ const App = HttpApiBuilder.layer(PublicApi).pipe( Layer.provide(stubWorkOS), Layer.provide(stubUsers), Layer.provide(stubMirror), + Layer.provide(stubDirectory), Layer.provide(AutumnService.Default), Layer.provide(HttpServer.layerServices), ); diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index dcaaa723da..e8bf57c569 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -9,6 +9,7 @@ import { WorkOS, type Event as WorkOSEvent, type EventName as WorkOSEventName, + type OrganizationMembershipStatus, } from "@workos-inc/node/worker"; import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto"; import { decodeJwt, jwtVerify } from "jose"; @@ -665,13 +666,21 @@ const make = Effect.gen(function* () { deleteApiKey: (id: string) => use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)), - /** List organization memberships with user details. */ - listOrgMembers: (organizationId: string) => + /** + * An organization's memberships, all pages. Defaults to active + pending + * (the seat-occupying set); pass `statuses` to narrow — the invite + * write-through lists only `pending` to find the membership WorkOS + * created for the invitee. + */ + listOrgMembers: ( + organizationId: string, + statuses: readonly OrganizationMembershipStatus[] = ["active", "pending"], + ) => use("userManagement.listOrganizationMemberships", async (wos) => collectWorkOSList( await wos.userManagement.listOrganizationMemberships({ organizationId, - statuses: ["active", "pending"], + statuses: [...statuses], }), ), ), @@ -691,17 +700,6 @@ const make = Effect.gen(function* () { getUser: (userId: string) => use("userManagement.getUser", (wos) => wos.userManagement.getUser(userId)), - /** List users matching an email within one organization. */ - listUsers: (params: { email: string; organizationId: string }) => - use("userManagement.listUsers", async (wos) => - collectWorkOSList( - await wos.userManagement.listUsers({ - email: params.email, - organizationId: params.organizationId, - }), - ), - ), - /** Send an organization invitation. */ sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => use("userManagement.sendInvitation", (wos) => diff --git a/apps/cloud/src/extensions/billing/member-seats.ts b/apps/cloud/src/extensions/billing/member-seats.ts index 75c1d7e00d..7ed5f2ab19 100644 --- a/apps/cloud/src/extensions/billing/member-seats.ts +++ b/apps/cloud/src/extensions/billing/member-seats.ts @@ -1,11 +1,16 @@ // --------------------------------------------------------------------------- -// Seat-count reporting — the WorkOS → Autumn reconciliation for seat billing +// Seat-count reporting — the membership mirror → Autumn reconciliation for +// seat billing // --------------------------------------------------------------------------- import { Effect } from "effect"; import { waitUntil } from "cloudflare:workers"; -import { WorkOSClient } from "../../auth/workos"; +import { MemberDirectory } from "@executor-js/api/server"; + +import { ensureOrganizationBackfilled } from "../../auth/mirror-feeders"; +import type { WorkOSClient } from "../../auth/workos"; +import type { WorkOsMirror } from "../../auth/workos-mirror"; import { AutumnService } from "./service"; /** @@ -16,39 +21,54 @@ import { AutumnService } from "./service"; * Seats change through paths the app never sees a mutation for (invitation * acceptance in AuthKit, SSO JIT provisioning, join by domain, WorkOS * dashboard edits), so this reconciles from a full recount rather than - * tracking deltas. It runs after in-app membership mutations AND on every - * login callback, so drift from out-of-band changes heals on the next - * sign-in. Fire-and-forget-safe: errors are logged, never surfaced. + * tracking deltas. The count comes from the local membership mirror through + * the shared `MemberDirectory`: every in-app membership mutation writes + * through to the mirror BEFORE calling this, and out-of-band changes land via + * login and the Events reconciler, so the recount reads the change on the + * next sign-in exactly as it did against WorkOS — without a WorkOS read. + * + * The Autumn call runs off the calling request's critical path: Cloudflare + * owns its promise through `waitUntil`, so the recount can finish after the + * response, and billing never stalls or fails a user-facing request. Errors + * are logged, never surfaced. + * + * The count is a PARTIAL one until THIS organization's membership list has + * been scanned from WorkOS in full (the one-off backfill, or the on-demand + * scan below): before that, the mirror holds only the members who signed in + * or were changed since the mirror shipped. Because the Autumn write is an + * authoritative SET, pushing a partial count would under-bill the + * organization, so the recount first makes sure the organization is + * backfilled (`ensureOrganizationBackfilled`: a scan runs now when its + * per-organization mark is missing) and only then counts. The plan gate + * (`reserveMemberSlot`) goes through the same step, so it never admits an + * invite past the plan limit on a partial mirror. + * + * The COUNT is read inline, not in the fork: `MemberDirectory` is per-request + * (it holds the request's postgres socket, which Cloudflare Workers' I/O + * isolation ties to the request), so a forked fiber reading it could outlive + * the socket. One indexed local query is cheap enough to pay inline; only the + * Autumn call — over the boot-scoped `AutumnService` — is forked, so the + * forked fiber captures nothing request-scoped. */ -export const reportMemberSeats = ( +export const forkReportMemberSeats = ( organizationId: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { - const workos = yield* WorkOSClient; + const directory = yield* MemberDirectory; const autumn = yield* AutumnService; - const memberships = yield* workos.listOrgMembers(organizationId); - const seats = memberships.data.filter((m) => m.status === "active").length; - yield* autumn.setMemberSeats(organizationId, seats); + yield* ensureOrganizationBackfilled(organizationId); + const seats = yield* directory + .members(organizationId, { statuses: ["active"] }) + .pipe(Effect.map((members) => members.length)); + yield* Effect.sync(() => { + waitUntil(Effect.runPromise(autumn.setMemberSeats(organizationId, seats))); + }); }).pipe( Effect.catch((error) => - Effect.logWarning("reportMemberSeats: seat recount failed", { organizationId, error }), + Effect.logWarning("reportMemberSeats: seat recount failed", { + organizationId, + error, + }), ), Effect.withSpan("billing.reportMemberSeats"), ); - -/** - * Fork `reportMemberSeats` off the calling request, mirroring how execution - * tracking is forked: billing must never stall or fail a user-facing - * request. Cloudflare owns the promise through waitUntil, so the recount can - * finish after the response. Only boot-scoped WorkOS and Autumn services are - * captured. - */ -export const forkReportMemberSeats = ( - organizationId: string, -): Effect.Effect => - Effect.gen(function* () { - const ctx = yield* Effect.context(); - yield* Effect.sync(() => { - waitUntil(Effect.runPromiseWith(ctx)(reportMemberSeats(organizationId))); - }); - }); diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index bd4b1d4c12..2f30bceaab 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -28,7 +28,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; import { AccountApi, AdminUsersApi } from "@executor-js/api"; -import { requestScopedMiddleware } from "@executor-js/api/server"; +import { requestScopedMiddleware, type MemberDirectory } from "@executor-js/api/server"; import { UserStoreService } from "../auth/context"; import { WorkOsMirror } from "../auth/workos-mirror"; @@ -79,7 +79,7 @@ const spec = OpenApi.fromApi(CloudOpenApi); * core. */ export const makeCloudExtensionRoutes = ( - rsLive: Layer.Layer, + rsLive: Layer.Layer, ) => { // Session routes (login / callback / me / switch-org / …). Handlers yield // `UserStoreService` directly; the per-request DB combine keeps the postgres diff --git a/apps/host-selfhost/src/account/better-auth-account-provider.ts b/apps/host-selfhost/src/account/better-auth-account-provider.ts index c7ed75a30e..885ed74eee 100644 --- a/apps/host-selfhost/src/account/better-auth-account-provider.ts +++ b/apps/host-selfhost/src/account/better-auth-account-provider.ts @@ -145,7 +145,7 @@ export const betterAuthAccountProvider: Layer.Layer ({ id: member.id, userId: member.userId, - email: member.user?.email ?? "", + email: member.user?.email ?? null, name: member.user?.name ?? null, avatarUrl: member.user?.image ?? null, role: member.role, diff --git a/apps/host-selfhost/src/admin/admin-users-api.ts b/apps/host-selfhost/src/admin/admin-users-api.ts index 38f767a168..8a4d458c52 100644 --- a/apps/host-selfhost/src/admin/admin-users-api.ts +++ b/apps/host-selfhost/src/admin/admin-users-api.ts @@ -16,7 +16,11 @@ // // The READ half is identical to cloud's: a subject-less, tenant-reach executor // from `makePlatformExecutor`, projected by the shared `admin/reads`. Self-host -// is single-tenant, so the tenant is always the boot-seeded org. +// is single-tenant, so the tenant is always the boot-seeded org. Identity +// (email/name per row), the `?email=` resolver and the `?search=` match all +// come from the shared `MemberDirectory` — here Better Auth's `member` + `user` +// tables through its own adapter (`auth/member-directory.ts`), the SAME read +// the MCP plane makes, so no plane keeps its own join. // --------------------------------------------------------------------------- import { HttpRouter } from "effect/unstable/http"; @@ -26,18 +30,17 @@ import { AdminUsersProvider, DbProvider, HostConfig, + MemberDirectory, PluginsProvider, + adminUserDirectoryFromMembers, getAdminUser, listAdminUserConnections, listAdminUsers, listAdminUsersWithConnections, makeAdminUsersApiLayer, makePlatformExecutor, - normalizeAdminUserEmail, platformViewOf, requestScopedMiddleware, - type AdminUserDirectory, - type AdminUserIdentity, type AdminUsersHeaders, } from "@executor-js/api/server"; import { @@ -67,70 +70,6 @@ const requireAdmin = (headers: AdminUsersHeaders) => ), ); -/** - * Self-host's member directory: `externalId` → email/name. - * - * THE JOIN KEY is `member.userId`, the Better Auth `user.id` — precisely what - * `auth/identity.ts` binds as `accountId` and therefore what the subject table - * records in `external_id`. `member.id` is the organization `member` ROW id and - * joins to nothing; the two look alike, so the choice is pinned here and in the - * node test rather than left to a reader. - * - * One `listMembers` call per request: Better Auth's organization plugin already - * attaches the `user` row to each member, so email and name arrive with the - * membership and no per-user lookup is needed. The requested ids are not passed - * to the call — the plugin offers no id filter, and a single-instance member - * list is small — but the caller only reads the ids it asked for. - * - * Runs as the CALLER, using their own admin headers, so this reads exactly the - * directory that session is already entitled to on `/account/members`. - */ -const listMembers = (auth: BetterAuthHandle["auth"], headers: AdminUsersHeaders) => - Effect.tryPromise(() => auth.api.listMembers({ headers: new Headers(headers) })); - -/** - * Both directions of self-host's directory, over the SAME single `listMembers` - * read. - * - * The reverse (email → `user.id`) needs no extra call and no new permission: - * the organization plugin already attaches the `user` row to each member, so - * the email is sitting beside the id the forward join uses. Better Auth - * lower-cases every email it writes, but the directory value is normalized - * anyway so this host cannot answer differently from cloud if that ever - * changes. - * - * A member with no `user.email` cannot match — `null` is not an address, and - * coercing it to "" would let an empty `?email=` select an arbitrary row. - */ -const userDirectory = ( - auth: BetterAuthHandle["auth"], - headers: AdminUsersHeaders, -): AdminUserDirectory => ({ - identities: () => - listMembers(auth, headers).pipe( - Effect.map((result) => { - const identities = new Map(); - for (const member of result.members) { - identities.set(member.userId, { - email: member.user?.email ?? null, - displayName: member.user?.name ?? null, - }); - } - return identities; - }), - ), - resolveEmail: (email) => - listMembers(auth, headers).pipe( - Effect.map( - (result) => - result.members.find((member) => { - const stored = member.user?.email; - return stored != null && normalizeAdminUserEmail(stored) === email; - })?.userId ?? null, - ), - ), -}); - const withPlatformView = ( headers: AdminUsersHeaders, organizationId: string, @@ -153,24 +92,25 @@ const withPlatformView = = Layer.effect(AdminUsersProvider)( Effect.gen(function* () { const context = yield* Effect.context(); - const { auth, organizationId } = yield* BetterAuth; + const { organizationId } = yield* BetterAuth; + // Scoped to the INSTANCE's org — the same one the platform view is opened + // for, never the caller's `activeOrganizationId` (see require-admin.ts). + const directory = adminUserDirectoryFromMembers(yield* MemberDirectory, organizationId); return AdminUsersProvider.of({ listUsers: (headers, options) => withPlatformView(headers, organizationId, (executor) => platformViewOf(executor).pipe( - Effect.flatMap((admin) => listAdminUsers(admin, options, userDirectory(auth, headers))), + Effect.flatMap((admin) => listAdminUsers(admin, options, directory)), ), ).pipe(Effect.provideContext(context)), listUsersWithConnections: (headers, options) => withPlatformView(headers, organizationId, (executor) => platformViewOf(executor).pipe( - Effect.flatMap((admin) => - listAdminUsersWithConnections(admin, options, userDirectory(auth, headers)), - ), + Effect.flatMap((admin) => listAdminUsersWithConnections(admin, options, directory)), ), ).pipe(Effect.provideContext(context)), listUserConnections: (headers, externalId) => @@ -182,9 +122,7 @@ export const betterAuthAdminUsersProvider: Layer.Layer< getUser: (headers, identifier) => withPlatformView(headers, organizationId, (executor) => platformViewOf(executor).pipe( - Effect.flatMap((admin) => - getAdminUser(admin, identifier, userDirectory(auth, headers)), - ), + Effect.flatMap((admin) => getAdminUser(admin, identifier, directory)), ), ).pipe(Effect.provideContext(context)), }); @@ -193,6 +131,9 @@ export const betterAuthAdminUsersProvider: Layer.Layer< export interface SelfHostAdminUsersApiDeps { readonly betterAuth: BetterAuthHandle; + /** The boot-built `MemberDirectory` (see `resolveAuthProviders`), so this + * plane reads the same directory instance every other plane does. */ + readonly memberDirectory: Layer.Layer; readonly db: SelfHostDbHandle; readonly mountPrefix: `/${string}`; } @@ -206,6 +147,7 @@ export interface SelfHostAdminUsersApiDeps { */ export const makeSelfHostAdminUsersApiLayer = ({ betterAuth, + memberDirectory, db, mountPrefix, }: SelfHostAdminUsersApiDeps) => { @@ -214,6 +156,7 @@ export const makeSelfHostAdminUsersApiLayer = ({ ); const provider = betterAuthAdminUsersProvider.pipe( Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), + Layer.provide(memberDirectory), Layer.provide(SelfHostDbProvider), Layer.provide(SelfHostPluginsProvider), Layer.provide(SelfHostHostConfig), diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index a2341702a8..18bcdf9fc2 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -73,7 +73,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // ---- auth providers --------------------------------------------------- // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account // API + MCP OAuth seam, all over the shared libSQL handle. - const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle); + const { identityLayer, memberDirectoryLayer, authHandler, betterAuth } = + await resolveAuthProviders(dbHandle); // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config); @@ -130,7 +131,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // Tenant-wide admin users API (/api/admin/users*): the owner's view of // who uses this instance and what they've connected. Owner/admin-gated, // same as the invite routes above. - makeSelfHostAdminUsersApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + makeSelfHostAdminUsersApiLayer({ + betterAuth, + memberDirectory: memberDirectoryLayer, + db: dbHandle, + mountPrefix: "/api", + }), // Public system API: /api/health + /api/setup-status (unauthenticated). makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). @@ -141,11 +147,14 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // The boot-scoped context provideMerge'd under everything: the long-lived DB // handle (read by the DbProvider seam, Better Auth, and the MCP store) + the // resolved identity (captured once by the execution middleware + MCP auth) + // + the member directory (the shared membership read seam, boot-scoped + // beside identity because Better Auth's handle is an app singleton) // + the artifact-usage observer (this HTTP plane is the console UI's data // layer, so operations it serves file as `via: "ui"`). boot: Layer.mergeAll( Layer.succeed(SelfHostDb)(dbHandle), identityLayer, + memberDirectoryLayer, Layer.succeed(ArtifactUsageObserver)((action) => selfHostAnalytics.record(`artifact_${action}`, { via: "ui" }), ), diff --git a/apps/host-selfhost/src/auth/index.ts b/apps/host-selfhost/src/auth/index.ts index bf9a4b5839..1005970c21 100644 --- a/apps/host-selfhost/src/auth/index.ts +++ b/apps/host-selfhost/src/auth/index.ts @@ -1,24 +1,28 @@ import { Layer } from "effect"; -import { IdentityProvider } from "@executor-js/api/server"; +import { IdentityProvider, MemberDirectory } from "@executor-js/api/server"; import { loadConfig } from "../config"; import type { SelfHostDbHandle } from "../db/self-host-db"; import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; import { betterAuthIdentityLayer } from "./identity"; +import { betterAuthMemberDirectoryLayer } from "./member-directory"; import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; import { rewriteInvalidOrigin } from "./invalid-origin-help"; export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; export { betterAuthIdentityLayer } from "./identity"; +export { betterAuthMemberDirectoryLayer } from "./member-directory"; // --------------------------------------------------------------------------- // Resolve the self-host auth providers. // // Build the Better Auth instance over the shared libSQL file, expose its -// `IdentityProvider` (cookie/bearer/api-key) and its web handler (mounted at -// /api/auth/*). Returns the live `BetterAuthHandle` so the composition root can -// build the account API and the Better Auth MCP OAuth seam. +// `IdentityProvider` (cookie/bearer/api-key), its `MemberDirectory` (the +// shared membership read seam over the org plugin's tables) and its web +// handler (mounted at /api/auth/*). Returns the live `BetterAuthHandle` so the +// composition root can build the account API and the Better Auth MCP OAuth +// seam. // // This is the one and only production auth path. Tests that need a fake identity // (single-admin / header-driven) compose `ExecutorApp.make` directly through @@ -29,6 +33,8 @@ export { betterAuthIdentityLayer } from "./identity"; export interface ResolvedAuthProviders { /** The resolved Better Auth `IdentityProvider` seam (cookie/bearer/api-key). */ readonly identityLayer: Layer.Layer; + /** The resolved Better Auth `MemberDirectory` seam (org members + users). */ + readonly memberDirectoryLayer: Layer.Layer; /** Better Auth's web handler (`/api/auth/*`). */ readonly authHandler: (request: Request) => Promise; /** The live Better Auth handle (account API + Better Auth MCP OAuth seam). */ @@ -78,6 +84,7 @@ export const resolveAuthProviders = async ( return { identityLayer: betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)), + memberDirectoryLayer: betterAuthMemberDirectoryLayer.pipe(Layer.provide(betterAuthLayer)), authHandler, betterAuth, }; diff --git a/packages/core/api/src/account/api.ts b/packages/core/api/src/account/api.ts index 01581b783a..87ad648b9e 100644 --- a/packages/core/api/src/account/api.ts +++ b/packages/core/api/src/account/api.ts @@ -109,10 +109,17 @@ export const OrgApiKeysResponse = Schema.Struct({ apiKeys: Schema.Array(ApiKeySummary), }); +/** + * One member of the caller's organization, as the host's member directory + * reports them. `email` is nullable: a host can hold a membership whose + * profile it has not yet learned (cloud mirrors the membership before the + * user record lands), and reporting `""` for that would let the UI render an + * empty address as if it were one. + */ export const OrgMember = Schema.Struct({ id: Schema.String, userId: Schema.String, - email: Schema.String, + email: Schema.NullOr(Schema.String), name: Schema.NullOr(Schema.String), avatarUrl: Schema.NullOr(Schema.String), role: Schema.String, diff --git a/packages/core/api/src/admin/admin-users.test.ts b/packages/core/api/src/admin/admin-users.test.ts index aec566c295..b2c73c8e7f 100644 --- a/packages/core/api/src/admin/admin-users.test.ts +++ b/packages/core/api/src/admin/admin-users.test.ts @@ -395,6 +395,7 @@ const A1_EMAIL = "a1@users.test"; const stubUserDirectory = (options: { readonly seen?: string[][]; readonly resolved?: string[]; + readonly searched?: string[]; }): AdminUserDirectory => ({ identities: (externalIds) => { options.seen?.push([...externalIds]); @@ -405,6 +406,13 @@ const stubUserDirectory = (options: { // Compares a NORMALIZED stored value, the rule both real hosts follow. return Effect.succeed(A1_EMAIL_STORED.toLowerCase() === email ? USER_A1 : null); }, + search: (term) => { + options.searched?.push(term); + // The one member the directory knows, matched on the normalized email or + // the display name — the substring rule both real hosts apply. + const haystack = [A1_EMAIL_STORED.toLowerCase(), "user a1"]; + return Effect.succeed(haystack.some((value) => value.includes(term)) ? [USER_A1] : []); + }, }); /** The failure a host's directory raises — WorkOS or Better Auth being @@ -1022,6 +1030,102 @@ describe("admin users API", () => { ), ); + // ── ?search= ────────────────────────────────────────────────────────────── + + it.effect("filters the bulk lists by a name or email substring, case-insensitively", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const searched: string[] = []; + const web = yield* webHandlerFor( + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize, { + ...stubUserDirectory({ searched }), + }), + ); + + // Part of the address, typed in the wrong case and with stray spaces: + // the handler normalizes it before the directory sees it. + const byEmail = yield* jsonOf( + yield* get(web, `/admin/users?search=${encodeURIComponent(" A1@USERS ")}`, ORG_A), + ); + expect(byEmail.users.map((user) => user.externalId)).toEqual([USER_A1]); + expect(byEmail.users[0]?.email, "the page still carries identity").toBe(A1_EMAIL_STORED); + + // Part of the name, on the joined view. + const byName = yield* jsonOf( + yield* get(web, "/admin/users/with-connections?search=User%20a1", ORG_A), + ); + expect(byName.users.map((user) => user.externalId)).toEqual([USER_A1]); + expect(byName.users[0]?.connections.map((c) => c.integration)).toEqual(["github"]); + + // No match is an empty page, never the unfiltered tenant. + const nobody = yield* jsonOf( + yield* get(web, "/admin/users?search=nobody", ORG_A), + ); + expect(nobody.users).toEqual([]); + + expect(searched, "one directory search per request, normalized").toEqual([ + "a1@users", + "user a1", + "nobody", + ]); + }), + ), + ); + + it.effect("a blank search is no filter at all", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const searched: string[] = []; + const web = yield* webHandlerFor( + stubProvider( + (tenant) => platformExecutorFor(db, tenant), + headerAuthorize, + stubUserDirectory({ searched }), + ), + ); + + const body = yield* jsonOf(yield* get(web, "/admin/users?search=%20%20", ORG_A)); + expect(body.users.map((user) => user.externalId)).toEqual([USER_A1, USER_A2]); + expect(searched, "the directory is never asked to match whitespace").toEqual([]); + }), + ), + ); + + it.effect("returns an empty page for a search no host directory can answer", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + // A directory with identities only: it cannot search, so a search + // filter must select nothing rather than hand back the whole tenant. + const web = yield* webHandlerFor( + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize, { + identities: stubDirectory([]), + }), + ); + + const body = yield* jsonOf(yield* get(web, "/admin/users?search=a1", ORG_A)); + expect(body.users).toEqual([]); + }), + ), + ); + + it.effect("500s when the directory search fails, rather than reporting no match", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const web = yield* webHandlerFor( + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize, { + search: () => Effect.fail(new DirectoryUnavailable({ message: "down" })), + }), + ); + + expect((yield* get(web, "/admin/users?search=a1", ORG_A)).status).toBe(500); + }), + ), + ); + // A resolver OUTAGE must not read as "no such user": that is a wrong answer an // operator would act on. Contrast with the identity join, which degrades to // unnamed rows precisely because it is decoration. @@ -1096,8 +1200,8 @@ const A_SUBJECT: AdminSubject = { /** An `ExecutorAdmin` that answers everything and records the reads it was * asked for, so a test can assert the call the filter chose. */ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ - listSubjects: () => { - calls.push("listSubjects"); + listSubjects: (options) => { + calls.push(`listSubjects:${options?.externalIds?.join(",") ?? "*"}`); return Effect.succeed([A_SUBJECT]); }, getSubject: () => { @@ -1108,8 +1212,8 @@ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ calls.push("listSubjectConnections"); return Effect.succeed([]); }, - listSubjectsWithConnections: () => { - calls.push("listSubjectsWithConnections"); + listSubjectsWithConnections: (options) => { + calls.push(`listSubjectsWithConnections:${options?.externalIds?.join(",") ?? "*"}`); return Effect.succeed([{ ...A_SUBJECT, connections: [] }]); }, getSubjectWithConnections: () => { @@ -1187,7 +1291,56 @@ describe("admin users reads — the ?email= filter is applied before the read", const calls: string[] = []; yield* listUsersWithConnections(recordingAdmin(calls), { limit: 50 }, stubUserDirectory({})); - expect(calls).toEqual(["listSubjectsWithConnections"]); + expect(calls).toEqual(["listSubjectsWithConnections:*"]); + }), + ); +}); + +// --------------------------------------------------------------------------- +// `?search=` is FILTER-THEN-PAGE through storage: the directory names the +// matching principals, and the paged read carries exactly that set as its +// `externalIds` filter — never a page scan that is filtered afterwards. +// --------------------------------------------------------------------------- + +describe("admin users reads — the ?search= filter pages the directory's matches", () => { + it.effect("hands the matched ids to the paged read, on both views", () => + Effect.gen(function* () { + const calls: string[] = []; + const admin = recordingAdmin(calls); + + yield* listUsers(admin, { search: "a1" }, stubUserDirectory({})); + yield* listUsersWithConnections(admin, { search: "user", limit: 10 }, stubUserDirectory({})); + + expect(calls).toEqual([`listSubjects:${USER_A1}`, `listSubjectsWithConnections:${USER_A1}`]); + }), + ); + + it.effect("issues NO storage read when the directory matches nobody", () => + Effect.gen(function* () { + const calls: string[] = []; + const body = yield* listUsersWithConnections( + recordingAdmin(calls), + { search: "nobody" }, + stubUserDirectory({}), + ); + + expect(calls).toEqual([]); + expect(body.users).toEqual([]); + }), + ); + + it.effect("lets an exact email win over a search term", () => + Effect.gen(function* () { + const calls: string[] = []; + const searched: string[] = []; + yield* listUsers( + recordingAdmin(calls), + { email: A1_EMAIL, search: "anything" }, + stubUserDirectory({ searched }), + ); + + expect(calls, "the keyed read, not a search").toEqual(["getSubject"]); + expect(searched).toEqual([]); }), ); }); diff --git a/packages/core/api/src/admin/api.ts b/packages/core/api/src/admin/api.ts index 69e76db94d..acda949904 100644 --- a/packages/core/api/src/admin/api.ts +++ b/packages/core/api/src/admin/api.ts @@ -261,6 +261,15 @@ const AdminUserIdentifierParams = { identifier: Schema.String }; // handler seam (`normalizeEmail`), which is also where the single-user path // parameter is normalized, so both entry points share ONE rule rather than a // schema transform on one and hand-rolled code on the other. +// +// `search` is the SUBSTRING counterpart: a case-insensitive match over each +// member's email and name in the host's directory, for the operator who knows +// a person's name or part of an address rather than the exact one. Like +// `email` it narrows the fixed list shape and is applied BEFORE paging (the +// directory names the matching principals; storage pages that set), so a +// window on a searched list is a window on the matches. A blank term is no +// filter. When both filters are present `email` wins: it names one principal, +// and there is nothing left for a search to narrow. const AdminListQuery = Schema.Struct({ limit: Schema.optional( Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })), @@ -272,6 +281,7 @@ const AdminListQuery = Schema.Struct({ ), ), email: Schema.optional(Schema.String), + search: Schema.optional(Schema.String), }); // --------------------------------------------------------------------------- diff --git a/packages/core/api/src/admin/handlers.ts b/packages/core/api/src/admin/handlers.ts index f5d6ccc8b9..88625b7551 100644 --- a/packages/core/api/src/admin/handlers.ts +++ b/packages/core/api/src/admin/handlers.ts @@ -2,6 +2,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerRequest } from "effect/unstable/http"; import { Effect } from "effect"; +import { normalizeMemberSearch } from "../server/member-directory"; import { AdminUsersHttpApi } from "./api"; import { normalizeEmail } from "./reads"; import { AdminUsersProvider, type AdminUsersHeaders, type AdminUsersListOptions } from "./service"; @@ -24,15 +25,23 @@ const requestHeaders = Effect.map( // than an explicit `undefined` overriding them. // `email` is normalized here rather than in the contract schema, so the filter // and the single-user path parameter share ONE rule (`normalizeEmail`). +// `search` gets the directory's own rule (`normalizeMemberSearch`: the same +// trim + lower-case, and a blank term is no filter at all — dropped here so a +// provider never sees `search: ""`). const listOptions = (query: { readonly limit?: number | undefined; readonly offset?: number | undefined; readonly email?: string | undefined; -}): AdminUsersListOptions => ({ - ...(query.limit === undefined ? {} : { limit: query.limit }), - ...(query.offset === undefined ? {} : { offset: query.offset }), - ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), -}); + readonly search?: string | undefined; +}): AdminUsersListOptions => { + const search = normalizeMemberSearch(query.search); + return { + ...(query.limit === undefined ? {} : { limit: query.limit }), + ...(query.offset === undefined ? {} : { offset: query.offset }), + ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), + ...(search === undefined ? {} : { search }), + }; +}; export const AdminUsersHandlers = HttpApiBuilder.group( AdminUsersHttpApi, diff --git a/packages/core/api/src/admin/member-directory.ts b/packages/core/api/src/admin/member-directory.ts index e90ad89346..df7c9beb37 100644 --- a/packages/core/api/src/admin/member-directory.ts +++ b/packages/core/api/src/admin/member-directory.ts @@ -10,21 +10,26 @@ import { MemberStatus, type MemberDirectoryShape } from "../server/member-direct import type { AdminUserDirectory, AdminUserIdentity } from "./reads"; /** - * Both directions of the admin plane's directory over one org's + * Every direction of the admin plane's directory over one org's * {@link MemberDirectoryShape}. * * `identities` is one batched `membersById` read for the page of ids (never a * lookup per user); a member the org does not hold reports absent identity. * `resolveEmail` receives the already-normalized email the contract promises * and answers with the host principal id, or `null` when no member has it. + * `search` is one `members` read for the term, answering with the matching + * principal ids in directory order. * - * Both read ANY membership status, not the directory's active + pending - * default: this plane reports footprint, not current access. A member who was - * removed while their connections remain must still be named on the users - * page and findable by the address an operator has for them. + * Every direction reads ANY membership status — the same reach `membersById` + * and `findByEmail` have by contract, and `search` asks for explicitly rather + * than taking `members`' active + pending default. This plane reports + * footprint, not current access: a member who was deactivated while their + * connections remain must still be findable by the address or name an + * operator has for them, exactly as `?email=` already finds them. * - * Both fail with `MemberDirectoryError`, which the shared reads treat as a - * decorative-join outage (identities) or surface as a failed read (resolve). + * All fail with `MemberDirectoryError`, which the shared reads treat as a + * decorative-join outage (identities) or surface as a failed read (resolve, + * search). */ export const adminUserDirectoryFromMembers = ( directory: MemberDirectoryShape, @@ -47,4 +52,8 @@ export const adminUserDirectoryFromMembers = ( directory .findByEmail(organizationId, email, MemberStatus.literals) .pipe(Effect.map((member) => (member === null ? null : member.accountId))), + search: (term) => + directory + .members(organizationId, { search: term, statuses: MemberStatus.literals }) + .pipe(Effect.map((members) => members.map((member) => member.accountId))), }); diff --git a/packages/core/api/src/admin/reads.ts b/packages/core/api/src/admin/reads.ts index 96b6f2bbdc..6110686268 100644 --- a/packages/core/api/src/admin/reads.ts +++ b/packages/core/api/src/admin/reads.ts @@ -16,6 +16,7 @@ import { Effect } from "effect"; import type { AdminConnection, + AdminListSubjectsOptions, AdminSubject, AdminSubjectWithConnections, Executor, @@ -114,12 +115,27 @@ export type AdminIdentityDirectory = ( */ export type AdminEmailResolver = (email: string) => Effect.Effect; -/** Both directions of a host's member directory. Optional as a whole (a host - * with no directory reports unnamed rows and cannot resolve emails), and - * optional per direction. */ +/** + * 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; + +/** 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 @@ -292,6 +308,72 @@ const selectByEmail = ( 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 = ( + directory: AdminUserDirectory, + term: string, + read: (externalIds: readonly string[]) => Effect.Effect, +): Effect.Effect => + 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 = ( + directory: AdminUserDirectory, + options: AdminUsersListOptions, + reads: { + readonly page: ( + paging: AdminListSubjectsOptions, + ) => Effect.Effect; + readonly one: (externalId: string) => Effect.Effect; + }, +): Effect.Effect => { + 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, @@ -299,12 +381,10 @@ export const listUsers = ( ): Effect.Effect => Effect.gen(function* () { const dir = asDirectory(directory); - const subjects = - options.email === undefined - ? yield* admin.listSubjects(options).pipe(Effect.mapError(readFailed("users"))) - : yield* selectByEmail(dir, options.email, options, (externalId) => - admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))), - ); + 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( @@ -321,14 +401,12 @@ export const listUsersWithConnections = ( ): Effect.Effect => Effect.gen(function* () { const dir = asDirectory(directory); - const subjects = - options.email === undefined - ? yield* admin - .listSubjectsWithConnections(options) - .pipe(Effect.mapError(readFailed("users"))) - : yield* selectByEmail(dir, options.email, options, (externalId) => - admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))), - ); + 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), diff --git a/packages/core/api/src/admin/service.ts b/packages/core/api/src/admin/service.ts index d314e12d3b..97bfac3854 100644 --- a/packages/core/api/src/admin/service.ts +++ b/packages/core/api/src/admin/service.ts @@ -31,12 +31,15 @@ import { export type AdminUsersHeaders = Record; /** Paging and filtering, mirroring the SDK's `AdminListSubjectsOptions` plus - * the contract's `?email=`. The email arrives already trimmed and lower-cased - * by the contract schema, so a provider never re-normalizes it. */ + * the contract's `?email=` and `?search=`. Both filters arrive already + * trimmed and lower-cased by the handler seam (a blank search is omitted + * entirely), so a provider never re-normalizes them. `email` names ONE + * principal and wins when both are present. */ export interface AdminUsersListOptions { readonly limit?: number; readonly offset?: number; readonly email?: string; + readonly search?: string; } type User = typeof AdminUserResponse.Type; diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index b5974de98d..bba228e10f 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -40,6 +40,7 @@ export { normalizeEmail as normalizeAdminUserEmail, type AdminEmailResolver, type AdminIdentityDirectory, + type AdminMemberSearch, type AdminUserDirectory, type AdminUserIdentity, } from "./admin/reads"; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index b01c62dbc7..cd89ab9075 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -593,6 +593,14 @@ export interface AdminSubjectWithConnections extends AdminSubject { export interface AdminListSubjectsOptions { readonly limit?: number; readonly offset?: number; + /** + * Keep only subjects whose `external_id` is in this set — the host's answer + * to a directory search (name or email), paged through storage rather than + * in memory. An EMPTY set matches nothing; `undefined` is no filter. Paging + * applies to the filtered set: "filter, then page", the same order the + * `?email=` read follows. + */ + readonly externalIds?: readonly string[]; } /** @@ -7006,8 +7014,18 @@ export const createExecutor = b("external_id", "in", [...externalIds]) }), // Oldest first, ties broken on the unique key so the order is // total and paging can't repeat or skip a row. orderBy: [ diff --git a/packages/core/sdk/src/platform-view.test.ts b/packages/core/sdk/src/platform-view.test.ts index 12cee13c8b..9c5ef821f3 100644 --- a/packages/core/sdk/src/platform-view.test.ts +++ b/packages/core/sdk/src/platform-view.test.ts @@ -424,6 +424,61 @@ const expectWriteRefused = ( Effect.orDie, ); +describe("platform view — admin.listSubjects externalIds filter", () => { + it.effect("keeps only the named ids, still ordered and paged through storage", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); + + const only = yield* admin.listSubjects({ externalIds: [SUBJECT_B] }); + expect(only.map((entry) => entry.externalId)).toEqual([SUBJECT_B]); + + // Ids the tenant does not hold are simply absent — including another + // tenant's subject, which the policy keeps out regardless of the filter. + const mixed = yield* admin.listSubjects({ + externalIds: [SUBJECT_B, "user_nobody", "user_elsewhere", SUBJECT_A], + }); + expect(mixed.map((entry) => entry.externalId).sort()).toEqual([SUBJECT_A, SUBJECT_B]); + + // "Filter, then page": the window applies to the filtered set. + const all = yield* admin.listSubjects(); + const second = yield* admin.listSubjects({ + externalIds: [SUBJECT_A, SUBJECT_B], + limit: 1, + offset: 1, + }); + expect(second.map((entry) => entry.externalId)).toEqual([all[1]?.externalId]); + }), + ), + ); + + it.effect("an empty id set matches nothing, on both list reads", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); + + expect(yield* admin.listSubjects({ externalIds: [] })).toEqual([]); + expect(yield* admin.listSubjectsWithConnections({ externalIds: [] })).toEqual([]); + }), + ), + ); + + it.effect("the joined read filters the same way and still joins connections", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); + + const rows = yield* admin.listSubjectsWithConnections({ externalIds: [SUBJECT_A] }); + expect(rows.map((entry) => entry.externalId)).toEqual([SUBJECT_A]); + expect(rows[0]?.connections.length).toBeGreaterThan(0); + }), + ), + ); +}); + describe("platform view — read-only across every surface", () => { it.effect("refuses org-row writes through policies and oauth", () => withDb((db) => diff --git a/packages/react/src/api/admin-atoms.tsx b/packages/react/src/api/admin-atoms.tsx index e72bac7812..3f97740edf 100644 --- a/packages/react/src/api/admin-atoms.tsx +++ b/packages/react/src/api/admin-atoms.tsx @@ -10,10 +10,11 @@ import { ReactivityKey } from "./reactivity-keys"; // rejects writes at tenant reach), so there are no mutations here and every // atom carries the same reactivity key. // -// Paging is part of the atom identity, so each page is its own cache entry and -// stepping back to a visited page is instant. `Atom.family` (not a bare arrow) -// because the page component re-derives the key object on every render — a -// fresh atom per render would refetch in a loop. +// Paging and the search term are part of the atom identity, so each page of +// each search is its own cache entry and stepping back to a visited page is +// instant. `Atom.family` (not a bare arrow) because the page component +// re-derives the key object on every render — a fresh atom per render would +// refetch in a loop. // --------------------------------------------------------------------------- /** How many users one page of the list shows. Well inside the contract's @@ -24,6 +25,10 @@ export const ADMIN_USERS_PAGE_SIZE = 25; export interface AdminUsersPage { readonly limit: number; readonly offset: number; + /** The `?search=` term (name or email substring), already debounced by the + * page. `""` is no filter and is sent as no param at all, so the unfiltered + * list keeps one cache identity regardless of how the term was cleared. */ + readonly search: string; } /** @@ -35,7 +40,11 @@ export interface AdminUsersPage { */ export const adminUsersWithConnectionsAtom = Atom.family((page: AdminUsersPage) => AdminApiClient.query("adminUsers", "listUsersWithConnections", { - query: { limit: page.limit + 1, offset: page.offset }, + query: { + limit: page.limit + 1, + offset: page.offset, + ...(page.search === "" ? {} : { search: page.search }), + }, timeToLive: "30 seconds", reactivityKeys: [ReactivityKey.adminUsers], }), diff --git a/packages/react/src/pages/admin-users.tsx b/packages/react/src/pages/admin-users.tsx index 49fae31d68..d081606325 100644 --- a/packages/react/src/pages/admin-users.tsx +++ b/packages/react/src/pages/admin-users.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { useParams } from "@tanstack/react-router"; +import { SearchIcon, XIcon } from "lucide-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; @@ -18,6 +19,7 @@ import { ownerLabel } from "../api/owner-display"; import { Button } from "../components/button"; import { CopyButton } from "../components/copy-button"; import { ErrorState } from "../components/error-state"; +import { Input } from "../components/input"; import { IntegrationFavicon, integrationInferredUrl, @@ -521,14 +523,94 @@ function UserDetail(props: { }); } +// ── Search ────────────────────────────────────────────────────────────────── + +/** How long the typed term settles before it becomes a request. Long enough + * that a typed name is one query rather than one per keystroke, short enough + * to read as immediate. */ +const SEARCH_DEBOUNCE_MS = 250; + +/** + * The search box: what is typed, and the settled term the list actually asks + * for. Two values because the request is debounced, and the input must keep + * echoing keystrokes while the term catches up. Clearing bypasses the debounce + * — an emptied box should show everyone at once, not after a pause. + */ +const useDebouncedSearch = (): { + readonly typed: string; + readonly term: string; + readonly setTyped: (value: string) => void; + readonly clear: () => void; +} => { + const [typed, setTypedState] = useState(""); + const [term, setTerm] = useState(""); + + useEffect(() => { + if (typed === term) return; + const handle = setTimeout(() => setTerm(typed), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [typed, term]); + + return { + typed, + term, + setTyped: setTypedState, + clear: () => { + setTypedState(""); + setTerm(""); + }, + }; +}; + +function UserSearch(props: { + readonly value: string; + readonly onChange: (value: string) => void; + readonly onClear: () => void; +}) { + return ( +
+ + props.onChange((event.target as HTMLInputElement).value)} + onKeyDown={(event) => { + if (event.key === "Escape" && props.value !== "") props.onClear(); + }} + placeholder="Search by name or email" + aria-label="Search users by name or email" + className="h-9 pl-9 pr-9 text-sm [&::-webkit-search-cancel-button]:hidden" + /> + {props.value !== "" && ( + + )} +
+ ); +} + // ── Page ──────────────────────────────────────────────────────────────────── export function AdminUsersPage() { useExecutorDocumentTitle("Users"); const [offset, setOffset] = useState(0); const [selected, setSelected] = useState(null); + const search = useDebouncedSearch(); - const page = { limit: ADMIN_USERS_PAGE_SIZE, offset }; + // A new term is a new list, so it starts on its first page: an offset kept + // from a broader list would land past the end of a narrower one. + const page = { limit: ADMIN_USERS_PAGE_SIZE, offset, search: search.term }; const result = useAtomValue(adminUsersWithConnectionsAtom(page)); const refresh = useAtomRefresh(adminUsersWithConnectionsAtom(page)); const catalog = useCatalogRows(); @@ -555,10 +637,24 @@ export function AdminUsersPage() { ); + const searching = search.term !== ""; + return ( {header} + { + search.setTyped(value); + setOffset(0); + }} + onClear={() => { + search.clear(); + setOffset(0); + }} + /> + {isAsyncResultLoading(result) ? loading : AsyncResult.match(result, { @@ -572,6 +668,25 @@ export function AdminUsersPage() { onSuccess: ({ value }) => { const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE); + if (rows.length === 0 && searching && offset === 0) { + return ( +
+

No users match

+

+ Nobody in this workspace has a name or email containing “ + {search.term}”. Only people who have reached the workspace or connected + an account are listed. +

+ +
+ ); + } + if (rows.length === 0) { return (
diff --git a/packages/react/src/pages/org.tsx b/packages/react/src/pages/org.tsx index 69e7a5aad0..b23ec30cf0 100644 --- a/packages/react/src/pages/org.tsx +++ b/packages/react/src/pages/org.tsx @@ -69,7 +69,7 @@ import { isAsyncResultLoading } from "../lib/async-result"; type MemberData = { id: string; - email: string; + email: string | null; name: string | null; avatarUrl: string | null; role: string; @@ -80,6 +80,23 @@ type MemberData = { type RoleData = { slug: string; name: string }; +/** What a member row is called: name, else email, else the one thing every + * member has — a membership id — so a profile the host has not learned yet + * still renders as a row an admin can act on. */ +const memberLabel = (member: MemberData): string => member.name ?? member.email ?? member.id; + +const memberInitials = (member: MemberData): string => { + if (member.name) { + return member.name + .split(" ") + .map((n: string) => n[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + } + return (member.email?.[0] ?? "?").toUpperCase(); +}; + type InviteState = { email: string; roleSlug: string; @@ -314,7 +331,7 @@ export function OrgPage(props: { const filtered = search ? members.filter( (m: MemberData) => - m.email.toLowerCase().includes(search.toLowerCase()) || + (m.email?.toLowerCase().includes(search.toLowerCase()) ?? false) || (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false), ) : members; @@ -338,21 +355,14 @@ export function OrgPage(props: { ) : (
- {member.name - ? member.name - .split(" ") - .map((n: string) => n[0]) - .join("") - .slice(0, 2) - .toUpperCase() - : member.email[0]!.toUpperCase()} + {memberInitials(member)}
)}

- {member.name ?? member.email} + {memberLabel(member)}

{member.isCurrentUser && ( You @@ -361,7 +371,7 @@ export function OrgPage(props: { Invited )}
- {member.name && ( + {member.name && member.email && (

{member.email}

@@ -421,7 +431,7 @@ export function OrgPage(props: { onClick={() => setRemovingMember({ id: member.id, - name: member.name ?? member.email, + name: memberLabel(member), }) } >