History

Read members from the directory and add email search (#2025)

Rhys Sullivan committed 1 month ago

Commit be775211 parent a97a20c31 files · +1278 −509Browse files

Unified · Split

31 files
  1. .changeset/member-directory-readers.md +10 −0
  2. apps/cloud/src/account/account-api.ts +5 −3
  3. apps/cloud/src/account/org-api-key-revoke.node.test.ts +10 −1
  4. apps/cloud/src/account/workos-account-service.ts +66 −34
  5. apps/cloud/src/admin/admin-users-api.ts +32 −134
  6. apps/cloud/src/admin/admin-users-email.node.test.ts +0 −127
  7. apps/cloud/src/api/layers.ts +13 −3
  8. apps/cloud/src/api/router.ts +6 −2
  9. apps/cloud/src/auth/mirror-feeders.node.test.ts +360 −11
  10. apps/cloud/src/auth/mirror-feeders.ts +109 −3
  11. apps/cloud/src/auth/workos-callback-state.node.test.ts +27 −5
  12. apps/cloud/src/auth/workos.ts +12 −14
  13. apps/cloud/src/extensions/billing/member-seats.ts +49 −29
  14. apps/cloud/src/extensions/routes.ts +2 −2
  15. apps/host-selfhost/src/account/better-auth-account-provider.ts +1 −1
  16. apps/host-selfhost/src/admin/admin-users-api.ts +20 −77
  17. apps/host-selfhost/src/app.ts +11 −2
  18. apps/host-selfhost/src/auth/index.ts +11 −4
  19. packages/core/api/src/account/api.ts +8 −1
  20. packages/core/api/src/admin/admin-users.test.ts +158 −5
  21. packages/core/api/src/admin/api.ts +10 −0
  22. packages/core/api/src/admin/handlers.ts +14 −5
  23. packages/core/api/src/admin/member-directory.ts +16 −7
  24. packages/core/api/src/admin/reads.ts +95 −17
  25. packages/core/api/src/admin/service.ts +5 −2
  26. packages/core/api/src/server.ts +1 −0
  27. packages/core/sdk/src/executor.ts +18 −0
  28. packages/core/sdk/src/platform-view.test.ts +55 −0
  29. packages/react/src/api/admin-atoms.tsx +14 −5
  30. packages/react/src/pages/admin-users.tsx +117 −2
  31. packages/react/src/pages/org.tsx +23 −13
.changeset/member-directory-readers.md+10−0
@@ -0,0 +1,10 @@
1+---
2+"@executor-js/cloud": patch
3+"@executor-js/api": patch
4+"@executor-js/react": patch
5+"@executor-js/sdk": patch
6+---
7+
8+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.
9+
10+**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.
apps/cloud/src/account/account-api.ts+5−3
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 AccountProvider,
66 makeAccountApiLayer,
77 requestScopedMiddleware,
8+ type MemberDirectory,
89 } from "@executor-js/api/server";
910
1011 import { ApiKeyService } from "../auth/api-keys";
@@ -46,7 +47,8 @@ import { AccountCaller, workosAccountProvider } from "./workos-account-service"; · 35 unmodified lines
4647 // Builds the WorkOS `AccountProvider` per request, providing it to the handler.
4748 // Long-lived `WorkOSClient | AutumnService` come from the surrounding context
4849 // (Autumn provided by `makeAccountApiLive` for the seat-gate); the per-request
49−// `UserStoreService` is supplied by the combined `rsLive` layer.
50+// `UserStoreService` / `WorkOsMirror` / `MemberDirectory` are supplied by the
51+// combined `rsLive` layer.
5052 // `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`.
5153 const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()(
5254 Effect.gen(function* () {
@@ -97,11 +99,11 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi · 44 unmodified lines
9799 * (the seat-gate) stays a residual requirement, satisfied by the app `boot`.
98100 */
99101 export const workosAccountMiddleware = (
100− rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,
102+ rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
101103 ) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer;
102104
103105 export const makeAccountApiLive = (
104− rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,
106+ rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
105107 ) => {
106108 // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it
107109 // closes over the per-request postgres socket), so it can't be a self-
apps/cloud/src/account/org-api-key-revoke.node.test.ts+10−1
@@ -1,7 +1,7 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { Effect, Layer } from "effect";
33
4−import { AccountProvider } from "@executor-js/api/server";
4+import { AccountProvider, MemberDirectory } from "@executor-js/api/server";
55 import { AccountError, AccountForbidden } from "@executor-js/api";
66
77 import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";
@@ -147,6 +147,14 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ · 139 unmodified lines
147147 organizationBackfilledAt: () => Effect.die("revoke does not report seats"),
148148 });
149149
150+// Revoke lists no members either.
151+const stubDirectory = Layer.succeed(MemberDirectory)({
152+ membership: () => Effect.die("revoke does not read the member directory"),
153+ members: () => Effect.die("revoke does not read the member directory"),
154+ membersById: () => Effect.die("revoke does not read the member directory"),
155+ findByEmail: () => Effect.die("revoke does not read the member directory"),
156+});
157+
150158 const stubAutumn = Layer.succeed(AutumnService)({
151159 use: () => Effect.die("revoke does not touch billing"),
152160 ensureCustomer: () => Effect.die("revoke does not touch billing"),
@@ -185,6 +193,7 @@ const providerWith = (accountId: string) => { · 32 unmodified lines
185193 stubWorkOS,
186194 stubUsers,
187195 stubMirror,
196+ stubDirectory,
188197 stubApiKeys,
189198 stubAutumn,
190199 Layer.succeed(AccountCaller)({ session: session(accountId) }),
apps/cloud/src/account/workos-account-service.ts+66−34
@@ -1,6 +1,6 @@
11 import { Context, Effect, Layer } from "effect";
22
3−import { AccountProvider, type AccountHeaders } from "@executor-js/api/server";
3+import { AccountProvider, MemberDirectory, type AccountHeaders } from "@executor-js/api/server";
44 import {
55 AccountError,
66 AccountForbidden,
@@ -12,6 +12,7 @@ import { ApiKeyService } from "../auth/api-keys"; · 5 unmodified lines
1212 import { UserStoreService } from "../auth/context";
1313 import type { Session } from "../auth/middleware";
1414 import { WorkOSClient } from "../auth/workos";
15+import { ensureOrganizationBackfilled, mirrorInvitedMember } from "../auth/mirror-feeders";
1516 import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror";
1617 import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
1718 import { AutumnService } from "../extensions/billing/service";
@@ -66,7 +67,13 @@ const toAccountError = () => Effect.fail(new AccountError({ message: "Account re · 48 unmodified lines
6667 export const workosAccountProvider: Layer.Layer<
6768 AccountProvider,
6869 never,
69− WorkOSClient | UserStoreService | WorkOsMirror | ApiKeyService | AutumnService | AccountCaller
70+ | WorkOSClient
71+ | UserStoreService
72+ | WorkOsMirror
73+ | MemberDirectory
74+ | ApiKeyService
75+ | AutumnService
76+ | AccountCaller
7077 > = Layer.effect(AccountProvider)(
7178 Effect.gen(function* () {
7279 const workos = yield* WorkOSClient;
@@ -77,6 +84,10 @@ export const workosAccountProvider: Layer.Layer< · 4 unmodified lines
7784 // written through to the local mirror so the member list and the seat
7885 // count read the change without waiting for the Events reconciler.
7986 const mirror = yield* WorkOsMirror;
87+ // Membership READS come from the mirror through the shared directory: the
88+ // member list and the seat count are one local query each, never a
89+ // WorkOS read per member.
90+ const directory = yield* MemberDirectory;
8091
8192 // The caller, resolved once per request by the cookie-only session
8293 // middleware (account-api.ts) — the same credential `SessionAuthLive`
@@ -85,10 +96,12 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines
8596 const caller = yield* AccountCaller;
8697
8798 // Capture the resolved service context once so the method bodies — which
88− // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) —
89− // can be erased to `R = never`, as the neutral AccountProvider shape
90− // requires. Provided per method below.
91− const ctx = yield* Effect.context<WorkOSClient | UserStoreService | AutumnService>();
99+ // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`),
100+ // the mirror feeders, and the seat reporter — can be erased to `R = never`,
101+ // as the neutral AccountProvider shape requires. Provided per method below.
102+ const ctx = yield* Effect.context<
103+ WorkOSClient | UserStoreService | AutumnService | MemberDirectory | WorkOsMirror
104+ >();
92105
93106 // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly
94107 // as the old inline `requireSession` did.
@@ -145,7 +158,14 @@ export const workosAccountProvider: Layer.Layer< · 50 unmodified lines
145158 return membership;
146159 });
147160
148− // Mirror of org/handlers `getMemberSeats` — live seat usage from WorkOS.
161+ // Seat usage: memberships from the local directory (active + pending, the
162+ // `members` default), pending invitations live from WorkOS — invitations
163+ // are not mirrored. The directory is trusted for a COUNT only once this
164+ // organization's membership list has been scanned from WorkOS in full:
165+ // login and write-through record single memberships, so an organization
166+ // the one-off backfill did not cover holds a partial list, and counting
167+ // it would admit invitations past the plan limit. The scan runs here,
168+ // once, when the organization's mark is missing.
149169 const getMemberSeats = (organizationId: string) =>
150170 Effect.gen(function* () {
151171 const customer = yield* autumn.use((client) =>
@@ -154,15 +174,16 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines
154174 const planId = selectActiveMemberLimitPlan(customer.subscriptions);
155175 const limit = getMemberLimitForPlan(planId);
156176
157− // `listOrgMembers` returns active members AND pending memberships (an
158− // invited user shows up as status "pending"); `listPendingInvitations`
177+ yield* ensureOrganizationBackfilled(organizationId).pipe(Effect.provideContext(ctx));
178+ // The directory reports active members AND pending memberships (an
179+ // invited user is mirrored with status "pending"); `listPendingInvitations`
159180 // returns the same invited users again. `countSeatsUsed` dedupes them
160181 // so an outstanding invite is not counted twice.
161− const memberships = yield* workos.listOrgMembers(organizationId);
182+ const memberships = yield* directory.members(organizationId);
162183 const invitations = yield* workos.listPendingInvitations(organizationId);
163184
164185 return {
165− used: countSeatsUsed(memberships.data, invitations.data.length),
186+ used: countSeatsUsed(memberships, invitations.data.length),
166187 granted: limit ?? 0,
167188 unlimited: limit === null,
168189 };
@@ -328,29 +349,23 @@ export const workosAccountProvider: Layer.Layer< · 159 unmodified lines
328349 Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })),
329350 );
330351
331− const memberships = yield* workos
332− .listOrgMembers(org.id)
333− .pipe(Effect.catchTag("WorkOSError", toAccountError));
334−
335− const members = yield* Effect.all(
336− memberships.data.map((m) =>
337− Effect.gen(function* () {
338− const user = yield* workos.getUser(m.userId);
339− return {
340− id: m.id,
341− userId: m.userId,
342− email: user.email,
343− name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null,
344− avatarUrl: user.profilePictureUrl ?? null,
345− role: m.role?.slug ?? "member",
346− status: m.status,
347− lastActiveAt: user.lastSignInAt ?? null,
348− isCurrentUser: m.userId === session.accountId,
349− };
350− }),
351− ),
352− { concurrency: 5 },
353− ).pipe(Effect.catchTag("WorkOSError", toAccountError));
352+ // One directory read (active + pending, ordered by email) with the
353+ // profile already joined — no per-member WorkOS user fetch.
354+ const directoryMembers = yield* directory
355+ .members(org.id)
356+ .pipe(Effect.catchTag("MemberDirectoryError", toAccountError));
357+
358+ const members = directoryMembers.map((m) => ({
359+ id: m.membershipId,
360+ userId: m.accountId,
361+ email: m.email,
362+ name: m.name,
363+ avatarUrl: m.avatarUrl,
364+ role: m.role,
365+ status: m.status,
366+ lastActiveAt: m.lastActiveAt === null ? null : new Date(m.lastActiveAt).toISOString(),
367+ isCurrentUser: m.accountId === session.accountId,
368+ }));
354369
355370 return { members, seats };
356371 }),
@@ -378,6 +393,23 @@ export const workosAccountProvider: Layer.Layer< · 21 unmodified lines
378393 ...(body.roleSlug ? { roleSlug: body.roleSlug } : {}),
379394 })
380395 .pipe(Effect.catchTag("WorkOSError", toAccountError));
396+ // Write-through: WorkOS creates a PENDING membership for the invitee
397+ // alongside the invitation, and the member list (the "Invited" row
398+ // and its revoke button) reads memberships from the mirror only, so
399+ // the row must land now — the Events reconciler is not on this path.
400+ const mirrored = yield* mirrorInvitedMember(org.id, invitation.email).pipe(
401+ Effect.provideContext(ctx),
402+ Effect.catchTags({
403+ WorkOSError: toAccountError,
404+ WorkOsMirrorError: toAccountError,
405+ }),
406+ );
407+ if (!mirrored) {
408+ yield* Effect.logWarning("inviteMember: no pending membership for the invitee yet", {
409+ organizationId: org.id,
410+ invitationId: invitation.id,
411+ });
412+ }
381413 return { id: invitation.id, email: invitation.email };
382414 }),
383415
apps/cloud/src/admin/admin-users-api.ts+32−134
@@ -25,28 +25,24 @@ · 24 unmodified lines
2525 // every query by that tenant.
2626 // ---------------------------------------------------------------------------
2727
28−import { env } from "cloudflare:workers";
2928 import { HttpRouter } from "effect/unstable/http";
30−import { Context, Effect, Layer, Option } from "effect";
29+import { Effect, Layer } from "effect";
3130
3231 import {
3332 AdminUsersProvider,
3433 DbProvider,
3534 HostConfig,
35+ MemberDirectory,
3636 PluginsProvider,
37+ adminUserDirectoryFromMembers,
3738 getAdminUser,
3839 listAdminUserConnections,
3940 listAdminUsers,
4041 listAdminUsersWithConnections,
4142 makeAdminUsersApiLayer,
4243 makePlatformExecutor,
43− normalizeAdminUserEmail,
4444 platformViewOf,
4545 requestScopedMiddleware,
46− type AdminEmailResolver,
47− type AdminIdentityDirectory,
48− type AdminUserDirectory,
49− type AdminUserIdentity,
5046 type AdminUsersHeaders,
5147 } from "@executor-js/api/server";
5248 import {
@@ -122,124 +118,6 @@ const authorizeTenant = ( · 69 unmodified lines
122118 return org.id;
123119 });
124120
125−/**
126− * How many user-detail reads run at once. Matches the account plane's own
127− * member listing (`workos-account-service.ts`), which fans out the same way for
128− * the same reason.
129− */
130−const IDENTITY_CONCURRENCY = 5;
131−
132−/**
133− * Cloud's member directory: `externalId` → email/name.
134− *
135− * THE JOIN KEY is the membership's `userId` — the WorkOS `user_...` that
136− * `workos-auth-provider.ts` binds as `accountId` on every credential path, and
137− * therefore what the subject table records in `external_id`. The membership's
138− * own `id` is an `om_...` row id and joins to nothing.
139− *
140− * WHY THIS IS TWO CALLS AND NOT ONE. The membership list is read once per
141− * request and is the authority on who belongs to the org, but WorkOS's
142− * `listOrganizationMemberships` carries no user detail and offers no
143− * include/expand — email and name only exist on the user resource. The SDK does
144− * expose a batched `listUsers({ organizationId })`, but the pinned
145− * `@executor-js/emulate` WorkOS emulator serves only `GET
146− * /user_management/users/:id`, so taking that path would leave every cloud e2e
147− * user unnamed. So: ONE membership read per request, then user detail fetched
148− * only for the ids ON THIS PAGE — never for the whole org, and never once per
149− * row of some larger list. An id that is not an active/pending member is not
150− * fetched at all and reports absent identity, which is the honest answer for a
151− * member who left while their connections remain.
152− */
153−const identityDirectory =
154− (organizationId: string, context: Context.Context<WorkOSClient>): AdminIdentityDirectory =>
155− (externalIds) =>
156− Effect.gen(function* () {
157− const workos = yield* WorkOSClient;
158− const memberships = yield* workos.listOrgMembers(organizationId);
159− const wanted = new Set(externalIds);
160− const memberIds = memberships.data
161− .map((membership) => membership.userId)
162− .filter((userId) => wanted.has(userId));
163−
164− const resolved = yield* Effect.all(
165− memberIds.map((userId) =>
166− workos.getUser(userId).pipe(
167− Effect.map(
168− (user) =>
169− [
170− userId,
171− {
172− email: user.email,
173− displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || null,
174− },
175− ] as const,
176− ),
177− // One unreadable user must not cost the whole page its names.
178− Effect.catchCause(() => Effect.succeed(null)),
179− ),
180− ),
181− { concurrency: IDENTITY_CONCURRENCY },
182− );
183−
184− const identities = new Map<string, AdminUserIdentity>();
185− for (const entry of resolved) if (entry) identities.set(entry[0], entry[1]);
186− return identities;
187− }).pipe(Effect.provideContext(context));
188−
189−/**
190− * Cloud's REVERSE directory lookup: email → the WorkOS `user_...` id.
191− *
192− * Production asks WorkOS for the email AND organization in one request. Both
193− * filters matter: email makes the lookup indexed rather than one `getUser`
194− * request per member, while organization keeps the reverse lookup bound to the
195− * same tenant as the platform view.
196− *
197− * The pinned `@executor-js/emulate` WorkOS emulator has no list-users route.
198− * `WORKOS_API_URL` is the explicit test/dev emulator override, so that path
199− * retains the membership scan until the emulator supports the production
200− * query. The fallback still starts from the tenant's membership list and can
201− * never return a user from another organization.
202− *
203− * CASING: WorkOS preserves whatever casing an email was created with (and the
204− * emulator compares byte-exact), so the directory value is normalized here
205− * before comparison, against an argument the seam already normalized.
206− */
207−export const emailResolver =
208− (organizationId: string, context: Context.Context<WorkOSClient>): AdminEmailResolver =>
209− (email) =>
210− Effect.gen(function* () {
211− const workos = yield* WorkOSClient;
212−
213− if (!env.WORKOS_API_URL) {
214− const users = yield* workos.listUsers({ email, organizationId });
215− return users.data[0]?.id ?? null;
216− }
217−
218− const memberships = yield* workos.listOrgMembers(organizationId);
219− const userIds = memberships.data.map((membership) => membership.userId);
220−
221− // Emulator compatibility only. Short-circuit once the normalized email
222− // matches so the fallback makes as few unsupported-detail reads as it can.
223− const match = yield* Effect.findFirst(userIds, (userId) =>
224− workos.getUser(userId).pipe(
225− Effect.map((user) => normalizeAdminUserEmail(user.email ?? "") === email),
226− // One unreadable user must not fail the whole lookup — it simply
227− // cannot be the match.
228− Effect.catchCause(() => Effect.succeed(false)),
229− ),
230− );
231− return Option.getOrNull(match);
232− }).pipe(Effect.provideContext(context));
233−
234−/** Both directions of cloud's directory, built once per authorized request. */
235−const userDirectory = (
236− organizationId: string,
237− context: Context.Context<WorkOSClient>,
238−): AdminUserDirectory => ({
239− identities: identityDirectory(organizationId, context),
240− resolveEmail: emailResolver(organizationId, context),
241−});
242−
243121 /**
244122 * Authorize, then run `body` against the tenant's platform view.
245123 *
@@ -264,8 +142,9 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 18 unmodified lines
264142 const executor = yield* makePlatformExecutor(organizationId).pipe(
265143 Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })),
266144 );
267− // The authorized tenant is handed to the body so an identity join reads the
268− // SAME org the reads are scoped to — never one named by client input.
145+ // The authorized tenant is handed to the body so the directory reads the
146+ // SAME org the storage reads are scoped to — never one named by client
147+ // input.
269148 return yield* Effect.ensuring(
270149 body(executor, organizationId),
271150 executor.close().pipe(Effect.ignore),
@@ -275,22 +154,40 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 3 unmodified lines
275154 /**
276155 * Cloud's `AdminUsersProvider`, built per request so the platform executor
277156 * closes over the per-request postgres socket.
157+ *
158+ * Identity (email/name per row), the `?email=` resolver and the `?search=`
159+ * match all come from the shared `MemberDirectory` — cloud's is the LOCAL
160+ * membership mirror (`auth/member-directory.ts`), so an admin page costs one
161+ * indexed query per direction and never a WorkOS read per member. The
162+ * directory is per-request too (it reads the same postgres socket), which is
163+ * why it is captured from the request context rather than at boot.
278164 */
279165 export const workosAdminUsersProvider: Layer.Layer<
280166 AdminUsersProvider,
281167 never,
282− WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
168+ | WorkOSClient
169+ | ApiKeyService
170+ | UserStoreService
171+ | MemberDirectory
172+ | DbProvider
173+ | PluginsProvider
174+ | HostConfig
283175 > = Layer.effect(AdminUsersProvider)(
284176 Effect.gen(function* () {
285177 const context = yield* Effect.context<
286178 WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
287179 >();
180+ const directory = yield* MemberDirectory;
181+ // The authorized tenant is what scopes the directory, so every read below
182+ // asks the same org the platform view was opened for.
183+ const userDirectory = (organizationId: string) =>
184+ adminUserDirectoryFromMembers(directory, organizationId);
288185 return AdminUsersProvider.of({
289186 listUsers: (headers, options) =>
290187 withPlatformView(headers, (executor, organizationId) =>
291188 platformViewOf(executor).pipe(
292189 Effect.flatMap((admin) =>
293− listAdminUsers(admin, options, userDirectory(organizationId, context)),
190+ listAdminUsers(admin, options, userDirectory(organizationId)),
294191 ),
295192 ),
296193 ).pipe(Effect.provideContext(context)),
@@ -298,7 +195,7 @@ export const workosAdminUsersProvider: Layer.Layer< · 1 unmodified line
298195 withPlatformView(headers, (executor, organizationId) =>
299196 platformViewOf(executor).pipe(
300197 Effect.flatMap((admin) =>
301− listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)),
198+ listAdminUsersWithConnections(admin, options, userDirectory(organizationId)),
302199 ),
303200 ),
304201 ).pipe(Effect.provideContext(context)),
@@ -312,7 +209,7 @@ export const workosAdminUsersProvider: Layer.Layer< · 7 unmodified lines
312209 withPlatformView(headers, (executor, organizationId) =>
313210 platformViewOf(executor).pipe(
314211 Effect.flatMap((admin) =>
315− getAdminUser(admin, identifier, userDirectory(organizationId, context)),
212+ getAdminUser(admin, identifier, userDirectory(organizationId)),
316213 ),
317214 ),
318215 ).pipe(Effect.provideContext(context)),
@@ -322,8 +219,9 @@ export const workosAdminUsersProvider: Layer.Layer< · 3 unmodified lines
322219
323220 // Builds the provider per request, providing it to the handlers. Long-lived
324221 // `WorkOSClient | ApiKeyService` come from the surrounding boot context; the
325−// per-request `DbService`/`UserStoreService` (and the execution seams built
326−// over them) are supplied by the combined `requestScopedMiddleware`.
222+// per-request `DbService`/`UserStoreService`/`MemberDirectory` (and the
223+// execution seams built over them) are supplied by the combined
224+// `requestScopedMiddleware`.
327225 const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()(
328226 Effect.gen(function* () {
329227 const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>();
@@ -348,7 +246,7 @@ const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUser · 18 unmodified lines
348246 * `/api` prefix as the rest of the cloud router.
349247 */
350248 export const makeCloudAdminUsersRoutes = (
351− rsLive: Layer.Layer<DbService | UserStoreService>,
249+ rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory>,
352250 options: Parameters<typeof makeAdminUsersApiLayer>[1] = {},
353251 ) =>
354252 makeAdminUsersApiLayer(
apps/cloud/src/admin/admin-users-email.node.test.ts+0−127
@@ -1,127 +0,0 @@
1−import { env } from "cloudflare:workers";
2−import { expect, it } from "@effect/vitest";
3−import { Data, Effect, Layer } from "effect";
4−
5−import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
6−import { emailResolver } from "./admin-users-api";
7−
8−// Cloud's REVERSE directory lookup: email -> the WorkOS `user_...` id that the
9−// subject table records in `external_id`. Production resolves it with one
10−// tenant-scoped list-users query. The WorkOS emulator lacks that route, so
11−// tests/dev retain the membership-backed scan exercised below.
12−
13−const ORG = "org_placeholder";
14−const OTHER_ORG = "org_other";
15−
16−class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{
17− readonly userId: string;
18−}> {}
19−
20−const DIRECTORY = [
21− // Same email in another tenant must never win either lookup path.
22− { id: "user_foreign", email: "ada@placeholder.test", organizationId: OTHER_ORG },
23− // WorkOS preserves submitted casing, while the resolver seam is normalized.
24− { id: "user_ada", email: "Ada@Placeholder.test", organizationId: ORG },
25− { id: "user_grace", email: "grace@placeholder.test", organizationId: ORG },
26− { id: "user_nameless", email: null, organizationId: ORG },
27−] as const;
28−
29−const stubWorkOS = (calls: string[], unreadableUserIds: ReadonlySet<string>) =>
30− Layer.succeed(
31− WorkOSClient,
32− new Proxy({} as WorkOSClientService, {
33− get: (_target, prop) => {
34− if (prop === "listUsers") {
35− return (params: { email: string; organizationId: string }) => {
36− calls.push(`listUsers:${params.organizationId}:${params.email}`);
37− return Effect.succeed({
38− data: DIRECTORY.filter(
39− (user) =>
40− user.organizationId === params.organizationId &&
41− user.email?.toLowerCase() === params.email,
42− ),
43− });
44− };
45− }
46− if (prop === "listOrgMembers") {
47− return (organizationId: string) => {
48− calls.push(`listOrgMembers:${organizationId}`);
49− return Effect.succeed({
50− data: DIRECTORY.filter((user) => user.organizationId === organizationId).map(
51− (user) => ({ userId: user.id, organizationId }),
52− ),
53− });
54− };
55− }
56− if (prop === "getUser") {
57− return (userId: string) => {
58− calls.push(`getUser:${userId}`);
59− if (unreadableUserIds.has(userId)) {
60− return Effect.fail(new WorkOSUnavailable({ userId }));
61− }
62− const user = DIRECTORY.find((candidate) => candidate.id === userId);
63− if (!user) return Effect.die(`unexpected user ${userId}`);
64− return Effect.succeed(user);
65− };
66− }
67− return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
68− },
69− }),
70− );
71−
72−const resolve = (
73− email: string,
74− calls: string[],
75− emulator = false,
76− unreadableUserIds: ReadonlySet<string> = new Set(),
77−) => {
78− const previousApiUrl = env.WORKOS_API_URL;
79− return Effect.gen(function* () {
80− yield* Effect.sync(() =>
81− Object.assign(env, {
82− WORKOS_API_URL: emulator ? "http://workos-emulator.invalid" : undefined,
83− }),
84− );
85− const context = yield* Effect.context<WorkOSClient>();
86− return yield* emailResolver(ORG, context)(email);
87− }).pipe(
88− Effect.provide(stubWorkOS(calls, unreadableUserIds)),
89− Effect.ensuring(Effect.sync(() => Object.assign(env, { WORKOS_API_URL: previousApiUrl }))),
90− );
91−};
92−
93−it.effect("resolves an email with one tenant-scoped WorkOS query", () =>
94− Effect.gen(function* () {
95− const calls: string[] = [];
96− expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada");
97− expect(calls).toEqual([`listUsers:${ORG}:ada@placeholder.test`]);
98− }),
99−);
100−
101−it.effect("returns null from one query when the organization has no matching email", () =>
102− Effect.gen(function* () {
103− const calls: string[] = [];
104− expect(yield* resolve("nobody@placeholder.test", calls)).toBeNull();
105− expect(calls).toEqual([`listUsers:${ORG}:nobody@placeholder.test`]);
106− }),
107−);
108−
109−it.effect("keeps the emulator fallback tenant-scoped and case-insensitive", () =>
110− Effect.gen(function* () {
111− const calls: string[] = [];
112− expect(yield* resolve("ada@placeholder.test", calls, true)).toBe("user_ada");
113− expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada"]);
114− expect(calls).not.toContain("getUser:user_foreign");
115− expect(calls.some((call) => call.startsWith("listUsers:"))).toBe(false);
116− }),
117−);
118−
119−it.effect("lets the emulator fallback continue past one unreadable member", () =>
120− Effect.gen(function* () {
121− const calls: string[] = [];
122− expect(yield* resolve("grace@placeholder.test", calls, true, new Set(["user_ada"]))).toBe(
123− "user_grace",
124− );
125− expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada", "getUser:user_grace"]);
126− }),
127−);
apps/cloud/src/api/layers.ts+13−3
@@ -2,10 +2,15 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line
22 import { HttpServer } from "effect/unstable/http";
33 import { Layer } from "effect";
44
5−import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api/server";
5+import {
6+ makeProtectedApiLayer,
7+ requestScopedMiddleware,
8+ type MemberDirectory,
9+} from "@executor-js/api/server";
610
711 import { SessionAuthLive } from "../auth/middleware-live";
812 import { UserStoreService } from "../auth/context";
13+import { cloudMemberDirectoryLayer } from "../auth/member-directory";
914 import { WorkOsMirror } from "../auth/workos-mirror";
1015 import {
1116 CloudAuthPublicHandlers,
@@ -27,12 +32,17 @@ import { CoreSharedServices } from "../auth/workos"; · 15 unmodified lines
2732 const DbLive = DbService.Live;
2833 const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
2934 const WorkOsMirrorLive = WorkOsMirror.Live.pipe(Layer.provide(DbLive));
35+// The shared `MemberDirectory` read seam over the membership mirror — the
36+// same per-request socket the mirror writes through.
37+const MemberDirectoryLive = cloudMemberDirectoryLayer.pipe(Layer.provide(DbLive));
3038
3139 // Per-request layer. Anything that opens an I/O object (postgres.js socket,
3240 // fetch stream readers, anything backed by a `Writable`) MUST live here —
3341 // `provideRequestScoped` rebuilds it per request so Cloudflare Workers'
3442 // I/O isolation is satisfied. See `api.request-scope.test.ts`.
35−export const RequestScopedServicesLive = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive);
43+export const RequestScopedServicesLive: Layer.Layer<
44+ DbService | UserStoreService | WorkOsMirror | MemberDirectory
45+> = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive, MemberDirectoryLive);
3646
3747 // Boot-scoped layer. Built once at worker boot, reused across requests.
3848 // Safe for config, in-memory caches, the global tracer provider, and
@@ -57,7 +67,7 @@ export const BootSharedServices = Layer.mergeAll( · 18 unmodified lines
5767 // handler reads it for the free-organizations-per-user limit gate — one of the
5868 // few app-only billing touchpoints. (It is NOT on the neutral boot core.)
5969 export const makeNonProtectedApiLive = (
60− rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,
70+ rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
6171 ) =>
6272 HttpApiBuilder.layer(NonProtectedApi).pipe(
6373 Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),
apps/cloud/src/api/router.ts+6−2
@@ -1,7 +1,11 @@
11 import { Layer } from "effect";
22 import { HttpRouter } from "effect/unstable/http";
33
4−import { RouterConfigLive, requestScopedMiddleware } from "@executor-js/api/server";
4+import {
5+ RouterConfigLive,
6+ requestScopedMiddleware,
7+ type MemberDirectory,
8+} from "@executor-js/api/server";
59
610 import { UserStoreService } from "../auth/context";
711 import { WorkOsMirror } from "../auth/workos-mirror";
@@ -31,7 +35,7 @@ import { makeProtectedApiLive } from "./protected"; · 23 unmodified lines
3135 // assert per-request semantics — see
3236 // `apps/cloud/src/api.request-scope.node.test.ts`.
3337 export const makeApiLive = (
34− requestScopedLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,
38+ requestScopedLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
3539 ) => {
3640 const BillingRoutesLive = AutumnRoutesLive.pipe(
3741 Layer.provide(requestScopedMiddleware(requestScopedLive).layer),
apps/cloud/src/auth/mirror-feeders.node.test.ts+360−11
@@ -12,11 +12,20 @@ · 11 unmodified lines
1212 // - the callback picks the landing org from that same list: a returnTo
1313 // slug or last-org cookie lands only in an ACTIVE membership, an unknown
1414 // or pending one falls through
15+// - `inviteMember` mirrors the PENDING membership WorkOS created for the
16+// invitee (found by email among the org's pending memberships), so the
17+// member list shows the invite and can revoke it
1518 // - `removeMember` tombstones the mirror row after the WorkOS delete,
1619 // stamped with the membership's last WorkOS state (never a local clock),
1720 // so a replay of the membership as it was before the delete cannot
1821 // restore it while a replacement WorkOS created meanwhile is accepted
1922 // - `updateMemberRole` writes the role WorkOS returned
23+// - the seat gate trusts the mirror's count only for an organization whose
24+// membership list was scanned from WorkOS in full: an unmarked one is
25+// scanned first (once), so a partial mirror never admits an invite past
26+// the plan limit
27+// - the seat reporter scans an unmarked organization before counting and
28+// never re-scans a marked one
2029 // - the backfill mirrors every org's members and counts what it wrote,
2130 // writes nothing on a dry run, converges on a re-run, tombstones a
2231 // membership WorkOS no longer lists — but never one written after its
@@ -39,10 +48,11 @@ · 16 unmodified lines
3948
4049 import { describe, expect, it } from "@effect/vitest";
4150 import { sql } from "drizzle-orm";
42−import { Effect, Exit, Fiber, Latch, Layer } from "effect";
51+import { Effect, Exit, Fiber, Latch, Layer, Option } from "effect";
4352 import { HttpRouter, HttpServer } from "effect/unstable/http";
4453 import { HttpApiBuilder } from "effect/unstable/httpapi";
4554
55+import { AccountForbidden } from "@executor-js/api";
4656 import {
4757 AccountProvider,
4858 MemberDirectory,
@@ -53,6 +63,7 @@ import { · 4 unmodified lines
5363 import { AccountCaller, workosAccountProvider } from "../account/workos-account-service";
5464 import { RequestScopedServicesLive } from "../api/layers";
5565 import { DbService } from "../db/db";
66+import { forkReportMemberSeats } from "../extensions/billing/member-seats";
5667 import { AutumnService } from "../extensions/billing/service";
5768 import { ApiKeyService } from "./api-keys";
5869 import { UserStoreService } from "./context";
@@ -210,13 +221,21 @@ describe("login callback", () => { · 151 unmodified lines
210221 listMetadata: { before: null, after: null },
211222 });
212223 },
213− // The forked seat recount after login.
214− listOrgMembers: () =>
215− Effect.succeed({
224+ // The landing org's seat recount scans the org from WorkOS the first
225+ // time it is counted (its per-org backfill mark is missing); the
226+ // scan lists the org's members and fetches each user.
227+ listOrgMembers: (organizationId) => {
228+ calls.push(`listOrgMembers:${organizationId}`);
229+ return Effect.succeed({
216230 object: "list" as const,
217− data: [] as never[],
231+ data: listed.filter((m) => m.organizationId === organizationId) as never[],
218232 listMetadata: { before: null, after: null },
219− }),
233+ });
234+ },
235+ getUser: (id) => {
236+ calls.push(`getUser:${id}`);
237+ return Effect.succeed(workosUser(id) as never);
238+ },
220239 refreshSession: (_sealed, organizationId) => {
221240 refreshedInto.push(organizationId);
222241 return Effect.succeed("sealed-refreshed");
@@ -268,7 +287,17 @@ describe("login callback", () => { · 45 unmodified lines
268287 const response = await handler(callbackRequest({}));
269288
270289 expect(response.status).toBe(302);
271− expect(calls, "one membership list for the whole callback").toEqual([
290+ expect(
291+ calls,
292+ "one membership list for the callback itself; the landing org, never scanned, is scanned once for its seat count",
293+ ).toEqual([
294+ `listUserMemberships:${userId}`,
295+ `listOrgMembers:${activeOrg}`,
296+ `getUser:${userId}`,
297+ ]);
298+ calls.length = 0;
299+ expect((await handler(callbackRequest({}))).status).toBe(302);
300+ expect(calls, "a second sign-in lists memberships only: the org is now marked").toEqual([
272301 `listUserMemberships:${userId}`,
273302 ]);
274303
@@ -542,7 +571,14 @@ describe("account service writes through to the mirror", () => { · 267 unmodified lines
542571 * Provided around the WHOLE test body so the postgres socket outlives the
543572 * provider call under test.
544573 */
545− const providerLayer = (org: string, deleted: string[]) => {
574+ const providerLayer = (
575+ org: string,
576+ deleted: string[],
577+ options: {
578+ readonly workos?: Partial<WorkOSClientService>;
579+ readonly autumn?: Layer.Layer<AutumnService>;
580+ } = {},
581+ ) => {
546582 const list = (data: readonly unknown[]) =>
547583 Effect.succeed({
548584 object: "list" as const,
@@ -550,6 +586,7 @@ describe("account service writes through to the mirror", () => { · 1 unmodified line
550586 listMetadata: { before: null, after: null },
551587 });
552588 const workos = stubWorkOS({
589+ ...options.workos,
553590 listUserMemberships: (userId) => list([workosMembership(userId, org)]),
554591 getUserOrgMembership: (organizationId, userId) =>
555592 Effect.succeed(
@@ -571,7 +608,6 @@ describe("account service writes through to the mirror", () => { · 15 unmodified lines
571608 updatedAt: T2,
572609 }) as never,
573610 ),
574− listOrgMembers: () => list([]),
575611 });
576612 // The test database serves ONE connection at a time, so the seed, the
577613 // provider, and the directory read all share this layer's socket.
@@ -585,7 +621,7 @@ describe("account service writes through to the mirror", () => { · 7 unmodified lines
585621 Layer.mergeAll(
586622 workos,
587623 stubApiKeys,
588− stubAutumn,
624+ options.autumn ?? stubAutumn,
589625 Layer.succeed(AccountCaller)({ session: session(ADMIN) }),
590626 ),
591627 ),
@@ -595,7 +631,13 @@ describe("account service writes through to the mirror", () => { · 3 unmodified lines
595631 };
596632
597633 // TARGET as an existing member of `org`, seeded through the live mirror.
598− const seedTarget = (org: string) =>
634+ // The org is marked backfilled (as the one-off backfill leaves every org)
635+ // unless a test wants the unscanned state, so a seat count reads the mirror
636+ // rather than scanning WorkOS.
637+ const seedTarget = (
638+ org: string,
639+ options: { readonly backfilled: boolean } = { backfilled: true },
640+ ) =>
599641 Effect.gen(function* () {
600642 const users = yield* UserStoreService;
601643 const mirror = yield* WorkOsMirror;
@@ -614,11 +656,175 @@ describe("account service writes through to the mirror", () => { · 12 unmodified lines
614656 status: "active",
615657 updatedAt: new Date(T1),
616658 });
659+ if (options.backfilled) {
660+ // An empty listing at T1 (nothing to tombstone: TARGET's row is
661+ // stamped T1, not before it) marks the org scanned as of T1.
662+ yield* mirror.applyOrganizationScan({
663+ organizationId: org,
664+ listedAt: new Date(T1),
665+ members: [],
666+ });
667+ }
617668 });
618669
619670 const membersOf = (org: string) =>
620671 Effect.flatMap(MemberDirectory.asEffect(), (directory) => directory.members(org));
621672
673+ it.effect("inviteMember mirrors the pending membership WorkOS created for the invitee", () => {
674+ const org = freshId("org");
675+ // Two people are already invited; the new invitee is a third pending
676+ // membership, and only their user carries the invited address — with
677+ // different casing than the admin typed, as WorkOS may store it.
678+ const earlier = [freshId("user"), freshId("user")];
679+ const invitee = freshId("user");
680+ const invitedEmail = `${invitee}@placeholder.test`;
681+ const userCalls: string[] = [];
682+ // The plan gate reads the customer's plan before inviting: an unlimited
683+ // plan so the seat cap never interferes with what is under test.
684+ const teamAutumn = Layer.succeed(AutumnService)({
685+ use: () =>
686+ Effect.succeed({
687+ subscriptions: [{ planId: "team", status: "active" }],
688+ } as never),
689+ ensureCustomer: () => Effect.void,
690+ checkExecutionBalance: () => Effect.die("invite does not check balances"),
691+ trackExecution: () => Effect.void,
692+ setMemberSeats: () => Effect.void,
693+ });
694+ const layer = providerLayer(org, [], {
695+ autumn: teamAutumn,
696+ workos: {
697+ listPendingInvitations: () =>
698+ Effect.succeed({
699+ object: "list" as const,
700+ data: [] as never[],
701+ listMetadata: { before: null, after: null },
702+ }),
703+ sendInvitation: ({ email }) =>
704+ Effect.succeed({
705+ id: `invitation_${invitee}`,
706+ email: email.toUpperCase(),
707+ } as never),
708+ listOrgMembers: (organizationId, statuses) => {
709+ expect(organizationId).toBe(org);
710+ expect(statuses, "only the pending set is listed").toEqual(["pending"]);
711+ return Effect.succeed({
712+ object: "list" as const,
713+ data: [...earlier, invitee].map((userId) =>
714+ workosMembership(userId, org, { status: "pending" }),
715+ ) as never[],
716+ listMetadata: { before: null, after: null },
717+ });
718+ },
719+ getUser: (userId) =>
720+ Effect.sync(() => {
721+ userCalls.push(userId);
722+ return workosUser(userId, {
723+ firstName: "Invited",
724+ lastName: "Person",
725+ }) as never;
726+ }),
727+ },
728+ });
729+ return Effect.gen(function* () {
730+ yield* seedTarget(org);
731+ const account = yield* AccountProvider;
732+
733+ const result = yield* account.inviteMember(
734+ { [ORG_SELECTOR_HEADER]: org },
735+ { email: invitedEmail },
736+ );
737+
738+ expect(result.id).toBe(`invitation_${invitee}`);
739+ const members = yield* membersOf(org);
740+ const pending = members.find((m) => m.status === "pending");
741+ expect(pending, "the invitee appears as a pending member").toMatchObject({
742+ accountId: invitee,
743+ membershipId: `om_${invitee}_${org}`,
744+ email: invitedEmail,
745+ name: "Invited Person",
746+ role: "member",
747+ });
748+ expect(
749+ members.filter((m) => m.status === "pending"),
750+ "only the invitee's pending membership is mirrored, not the other pending ones",
751+ ).toHaveLength(1);
752+ expect(
753+ userCalls.sort(),
754+ "one getUser per pending membership, bounded to the pending set",
755+ ).toEqual([...earlier, invitee].sort());
756+ }).pipe(Effect.provide(layer));
757+ });
758+
759+ it.effect(
760+ "inviteMember scans an organization the backfill never covered before counting its seats, once",
761+ () => {
762+ const org = freshId("org");
763+ const listed: string[] = [];
764+ // A free plan (limit 3). The mirror holds ONE member of the org (TARGET)
765+ // and the org is unmarked; WorkOS lists three. Only a count taken after
766+ // the scan refuses the invite.
767+ const freeAutumn = Layer.succeed(AutumnService)({
768+ use: () => Effect.succeed({ subscriptions: [] } as never),
769+ ensureCustomer: () => Effect.void,
770+ checkExecutionBalance: () => Effect.die("invite does not check balances"),
771+ trackExecution: () => Effect.void,
772+ setMemberSeats: () => Effect.void,
773+ });
774+ const others = [freshId("user"), freshId("user")];
775+ const layer = providerLayer(org, [], {
776+ autumn: freeAutumn,
777+ workos: {
778+ listPendingInvitations: () =>
779+ Effect.succeed({
780+ object: "list" as const,
781+ data: [] as never[],
782+ listMetadata: { before: null, after: null },
783+ }),
784+ listOrgMembers: (organizationId, statuses) => {
785+ listed.push(organizationId);
786+ expect(statuses, "the scan lists every status, inactive included").toEqual([
787+ "active",
788+ "pending",
789+ "inactive",
790+ ]);
791+ return Effect.succeed({
792+ object: "list" as const,
793+ data: [TARGET, ...others].map((userId) => workosMembership(userId, org)) as never[],
794+ listMetadata: { before: null, after: null },
795+ });
796+ },
797+ getUser: (userId) => Effect.succeed(workosUser(userId) as never),
798+ sendInvitation: () =>
799+ Effect.die("the plan gate refuses before WorkOS is asked to invite"),
800+ },
801+ });
802+ return Effect.gen(function* () {
803+ yield* seedTarget(org, { backfilled: false });
804+ const account = yield* AccountProvider;
805+ const invite = () =>
806+ Effect.flip(
807+ account.inviteMember({ [ORG_SELECTOR_HEADER]: org }, { email: "new@placeholder.test" }),
808+ );
809+
810+ const error = yield* invite();
811+ expect(error).toBeInstanceOf(AccountForbidden);
812+ expect(error).toMatchObject({
813+ message: expect.stringContaining("Your plan includes 3 members"),
814+ });
815+ expect(listed, "the org was scanned from WorkOS before it was counted").toEqual([org]);
816+ expect(
817+ (yield* membersOf(org)).map((m) => m.accountId).sort(),
818+ "and the scan filled the mirror",
819+ ).toEqual([TARGET, ...others].sort());
820+
821+ const again = yield* invite();
822+ expect(again).toBeInstanceOf(AccountForbidden);
823+ expect(listed, "a marked org is never scanned again").toEqual([org]);
824+ }).pipe(Effect.provide(layer));
825+ },
826+ );
827+
622828 it.effect("removeMember tombstones the mirror row after the WorkOS delete", () => {
623829 const org = freshId("org");
624830 const deleted: string[] = [];
@@ -688,6 +894,149 @@ describe("account service writes through to the mirror", () => { · 63 unmodified lines
688894 });
689895 });
690896
897+describe("seat reporter", () => {
898+ /**
899+ * A `WorkOsMirror` answering the per-org backfill mark and recording the
900+ * scan a reporter applies; every other operation is out of its reach.
901+ */
902+ const recordingMirror = (backfilledAt: Date | null, writes: string[]) =>
903+ Layer.succeed(WorkOsMirror)({
904+ upsertUser: () => Effect.die("the seat reporter scans, it does not upsert one by one"),
905+ upsertMembership: () => Effect.die("the seat reporter scans, it does not upsert one by one"),
906+ deleteMembership: () => Effect.die("the seat reporter does not delete"),
907+ deleteUser: () => Effect.die("the seat reporter does not delete"),
908+ getCursor: () => Effect.die("the seat reporter does not read the cursor"),
909+ applyPage: () => Effect.die("the seat reporter does not move the cursor"),
910+ applyOrganizationScan: (scan) =>
911+ Effect.sync(() => {
912+ writes.push(
913+ `applyOrganizationScan:${scan.organizationId}:${scan.members
914+ .map((member) => member.membership.id)
915+ .join(",")}`,
916+ );
917+ return Option.some({
918+ usersWritten: scan.members.length,
919+ membershipsWritten: scan.members.length,
920+ membershipsTombstoned: 0,
921+ });
922+ }),
923+ replayBoundary: () => Effect.die("the seat reporter does not run the reconciler"),
924+ setReplayBoundary: () => Effect.die("the seat reporter does not record the boundary"),
925+ backfillCompletedAt: () => Effect.die("the seat reporter does not check mirror readiness"),
926+ markBackfillCompleted: () => Effect.die("the seat reporter does not record the completion"),
927+ drainedAt: () => Effect.die("the seat reporter does not check mirror readiness"),
928+ markDrained: () => Effect.die("the seat reporter does not run the reconciler"),
929+ organizationBackfilledAt: () => Effect.succeed(backfilledAt),
930+ } satisfies WorkOsMirrorShape);
931+
932+ /** A directory holding `active` active members and one pending one. */
933+ const directoryWith = (org: string, active: number) =>
934+ Layer.succeed(MemberDirectory)({
935+ membership: () => Effect.die("the seat reporter lists, it does not look up"),
936+ membersById: () => Effect.die("the seat reporter lists, it does not look up"),
937+ findByEmail: () => Effect.die("the seat reporter lists, it does not look up"),
938+ members: (organizationId, query) => {
939+ expect(organizationId).toBe(org);
940+ expect(query?.statuses, "billed seats are active members only").toEqual(["active"]);
941+ return Effect.succeed(
942+ Array.from({ length: active }, (_, i) => ({
943+ accountId: `user_${i}`,
944+ membershipId: `om_${i}`,
945+ organizationId,
946+ email: null,
947+ name: null,
948+ avatarUrl: null,
949+ role: "member",
950+ status: "active" as const,
951+ lastActiveAt: null,
952+ })),
953+ );
954+ },
955+ });
956+
957+ const report = (
958+ org: string,
959+ backfilledAt: Date | null,
960+ active: number,
961+ workos: Partial<WorkOSClientService> = {},
962+ ) =>
963+ Effect.gen(function* () {
964+ const reported: { organizationId: string; seats: number }[] = [];
965+ const writes: string[] = [];
966+ const recording = Layer.succeed(AutumnService)({
967+ use: () => Effect.die("the seat reporter sets seats, it does not read"),
968+ ensureCustomer: () => Effect.void,
969+ checkExecutionBalance: () => Effect.die("the seat reporter does not check balances"),
970+ trackExecution: () => Effect.void,
971+ setMemberSeats: (organizationId, seats) =>
972+ Effect.sync(() => {
973+ reported.push({ organizationId, seats });
974+ }),
975+ });
976+ yield* forkReportMemberSeats(org).pipe(
977+ Effect.provide(
978+ Layer.mergeAll(
979+ recordingMirror(backfilledAt, writes),
980+ directoryWith(org, active),
981+ recording,
982+ stubWorkOS(workos),
983+ ),
984+ ),
985+ );
986+ // The Autumn call is forked; it is synchronous here, so it has landed.
987+ return { reported, writes };
988+ });
989+
990+ it.effect(
991+ "sets the active member count of a scanned organization without touching WorkOS",
992+ () => {
993+ const org = freshId("org");
994+ return Effect.gen(function* () {
995+ const { reported, writes } = yield* report(org, new Date(T1), 3);
996+ expect(reported).toEqual([{ organizationId: org, seats: 3 }]);
997+ expect(writes, "a marked organization is not scanned").toEqual([]);
998+ });
999+ },
1000+ );
1001+
1002+ it.effect("scans an organization the backfill never covered before counting it", () => {
1003+ const org = freshId("org");
1004+ const member = freshId("user");
1005+ return Effect.gen(function* () {
1006+ const { reported, writes } = yield* report(org, null, 2, {
1007+ listOrgMembers: (organizationId, statuses) => {
1008+ expect(organizationId).toBe(org);
1009+ // Inactive memberships included: a scan that skipped them would
1010+ // tombstone them under their ids and refuse their reactivation.
1011+ expect(statuses).toEqual(["active", "pending", "inactive"]);
1012+ return Effect.succeed({
1013+ object: "list" as const,
1014+ data: [workosMembership(member, org)] as never[],
1015+ listMetadata: { before: null, after: null },
1016+ });
1017+ },
1018+ getUser: (userId) => Effect.succeed(workosUser(userId) as never),
1019+ });
1020+ expect(
1021+ writes,
1022+ "the scan fills the mirror and marks the organization, then the count is read",
1023+ ).toEqual([`applyOrganizationScan:${org}:om_${member}_${org}`]);
1024+ expect(reported).toEqual([{ organizationId: org, seats: 2 }]);
1025+ });
1026+ });
1027+
1028+ it.effect("pushes no count when the scan fails: a partial count is never billed", () => {
1029+ const org = freshId("org");
1030+ return Effect.gen(function* () {
1031+ const { reported, writes } = yield* report(org, null, 2, {
1032+ listOrgMembers: () => Effect.fail(new WorkOSError({ status: 503 })),
1033+ });
1034+ expect(reported).toEqual([]);
1035+ expect(writes, "nothing is marked").toEqual([]);
1036+ });
1037+ });
1038+});
1039+
6911040 describe("backfill", () => {
6921041 /** A fake WorkOS holding `orgs` → members, counting `getUser` calls. */
6931042 const source = (orgs: ReadonlyMap<string, readonly FakeMembership[]>, userCalls: string[]) => ({
apps/cloud/src/auth/mirror-feeders.ts+109−3
@@ -5,14 +5,21 @@ · 4 unmodified lines
55 // Each feeder takes the WorkOS payload the caller ALREADY holds (the
66 // authenticated user, the membership list the callback fetches to pick a
77 // landing org, the membership a write returned) so feeding the mirror never
8−// adds a WorkOS read. Mirror failures fail the request: the mirror is the
9−// membership read path, so a login that could not record its memberships is
10−// not a login that finished.
8+// adds a WorkOS read — except the two writes whose WorkOS response is not the
9+// membership they changed: invitation acceptance (`auth/handlers.ts` reads
10+// the activated membership back) and sending an invitation
11+// (`mirrorInvitedMember` below reads the pending one WorkOS created). Both
12+// are rare, admin-driven paths. Mirror failures fail the request: the mirror
13+// is the membership read path, so a login that could not record its
14+// memberships is not a login that finished.
1115 // ---------------------------------------------------------------------------
1216
1317 import { Effect } from "effect";
1418
19+import { normalizeAdminUserEmail } from "@executor-js/api/server";
20+
1521 import { UserStoreService } from "./context";
22+import { WorkOSClient } from "./workos";
1623 import {
1724 WorkOsMirror,
1825 mirrorMembershipFromWorkOs,
@@ -20,6 +27,7 @@ import { · 1 unmodified line
2027 type WorkOsMembershipPayload,
2128 type WorkOsUserPayload,
2229 } from "./workos-mirror";
30+import { backfillOrganization } from "./workos-mirror-backfill";
2331
2432 /**
2533 * A membership as WorkOS lists it for a user: carries the organization's name,
@@ -78,3 +86,101 @@ export const mirrorMembership = (membership: WorkOsMembershipPayload) => · 52 unmodified lines
7886 Effect.flatMap(WorkOsMirror.asEffect(), (mirror) =>
7987 mirror.upsertMembership(mirrorMembershipFromWorkOs(membership)),
8088 );
89+
90+// Bounded fan-out for the per-invitee `getUser` calls, matching the backfill:
91+// enough to overlap WorkOS round-trips, low enough to stay clear of its rate
92+// limit.
93+const USER_FETCH_CONCURRENCY = 5;
94+
95+/**
96+ * Record the PENDING membership WorkOS creates for an invitee the moment an
97+ * organization invites them — the row the member list shows as "Invited" and
98+ * the admin revokes an outstanding invite through. `sendInvitation` returns
99+ * the invitation, not that membership, so this reads it back: it lists the
100+ * organization's pending memberships (WorkOS has no lookup by email that the
101+ * emulator serves) and fetches their users, five at a time, until one carries
102+ * the invited email. Bounded by the pending set, so an organization with
103+ * many active members pays nothing per member.
104+ *
105+ * `false` when no pending membership carried the email — WorkOS created none
106+ * (the address may already hold a membership) or has not yet — which the
107+ * caller treats as a warning, not a failure: the Events reconciler lands
108+ * whatever WorkOS did create.
109+ */
110+export const mirrorInvitedMember = Effect.fn("workos_mirror.invitedMember")(function* (
111+ organizationId: string,
112+ invitedEmail: string,
113+) {
114+ const workos = yield* WorkOSClient;
115+ const mirror = yield* WorkOsMirror;
116+ const wanted = normalizeAdminUserEmail(invitedEmail);
117+ const pending = yield* workos.listOrgMembers(organizationId, ["pending"]);
118+ for (let start = 0; start < pending.data.length; start += USER_FETCH_CONCURRENCY) {
119+ const batch = pending.data.slice(start, start + USER_FETCH_CONCURRENCY);
120+ const candidates = yield* Effect.forEach(
121+ batch,
122+ (membership) =>
123+ Effect.map(workos.getUser(membership.userId), (user) => ({
124+ membership,
125+ user,
126+ })),
127+ { concurrency: USER_FETCH_CONCURRENCY },
128+ );
129+ const match = candidates.find(
130+ (candidate) => normalizeAdminUserEmail(candidate.user.email) === wanted,
131+ );
132+ if (match === undefined) continue;
133+ yield* mirror.upsertUser(mirrorUserFromWorkOs(match.user));
134+ yield* mirror.upsertMembership(mirrorMembershipFromWorkOs(match.membership));
135+ return true;
136+ }
137+ return false;
138+});
139+
140+/**
141+ * Make sure the organization's membership list has been scanned from WorkOS
142+ * in full before a COUNT read from the mirror is trusted. Login records only
143+ * the caller's own memberships and write-through only the one it changed,
144+ * so an organization the one-off backfill did not cover — mirrored lazily
145+ * by a request, or created after the backfill ran — holds a partial list
146+ * until it is scanned. The per-organization mark
147+ * (`organizations.backfilled_at`) says whether that scan has happened; when
148+ * it is missing, this runs the scan now (`backfillOrganization`: one
149+ * membership listing plus one `getUser` per member, then the mark), so the
150+ * caller's count is complete. Returns `true` when a scan ran. A scan that
151+ * fails marks nothing, so the next count tries again.
152+ */
153+export const ensureOrganizationBackfilled = Effect.fn("workos_mirror.ensureOrganizationBackfilled")(
154+ function* (organizationId: string) {
155+ const mirror = yield* WorkOsMirror;
156+ const backfilledAt = yield* mirror.organizationBackfilledAt(organizationId);
157+ if (backfilledAt !== null) return false;
158+ const workos = yield* WorkOSClient;
159+ yield* Effect.logInfo(
160+ "workos_mirror: organization not yet backfilled; scanning it from WorkOS",
161+ {
162+ organizationId,
163+ },
164+ );
165+ yield* backfillOrganization(
166+ {
167+ // EVERY status, as the scan source requires: the scan tombstones
168+ // whatever its listing lacks, and a tombstone is keyed to the
169+ // membership id for good — so a listing that skipped the inactive
170+ // ones (the wrapper's active + pending default, the seat-occupying
171+ // set) would tombstone a membership WorkOS merely deactivated and
172+ // refuse its reactivation under the same id forever.
173+ listOrgMembers: (id) =>
174+ Effect.map(
175+ workos.listOrgMembers(id, ["active", "pending", "inactive"]),
176+ (list) => list.data,
177+ ),
178+ getUser: (id) => workos.getUser(id),
179+ },
180+ mirror,
181+ organizationId,
182+ { dryRun: false },
183+ );
184+ return true;
185+ },
186+);
apps/cloud/src/auth/workos-callback-state.node.test.ts+27−5
@@ -16,6 +16,8 @@ import { HttpRouter, HttpServer } from "effect/unstable/http"; · 15 unmodified lines
1616 import { HttpApiBuilder } from "effect/unstable/httpapi";
1717 import { HttpApi } from "effect/unstable/httpapi";
1818
19+import { MemberDirectory } from "@executor-js/api/server";
20+
1921 import { CloudAuthPublicHandlers } from "./handlers";
2022 import { CloudAuthPublicApi } from "./api";
2123 import { UserStoreService } from "./context";
@@ -50,9 +52,6 @@ const stubWorkOS = Layer.succeed( · 28 unmodified lines
5052 if (prop === "listUserMemberships") {
5153 return () => Effect.succeed({ data: [] });
5254 }
53− if (prop === "listOrgMembers") {
54− return () => Effect.succeed({ data: [{ status: "active" }] });
55− }
5655 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
5756 },
5857 }),
@@ -109,7 +108,9 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 50 unmodified lines
109108 });
110109
111110 // The callback records the sign-in (user + memberships) in the membership
112−// mirror; every other mirror operation is out of this route's reach.
111+// mirror, and its forked seat recount reads the backfill marker and the
112+// landed org's active members from it; every other operation is out of this
113+// route's reach.
113114 const stubMirror = Layer.succeed(WorkOsMirror)({
114115 upsertUser: () => Effect.succeed(true),
115116 upsertMembership: () => Effect.succeed(true),
@@ -124,7 +125,27 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ · 8 unmodified lines
124125 markBackfillCompleted: () => Effect.die("the callback does not run the backfill"),
125126 drainedAt: () => Effect.die("the callback does not check mirror readiness"),
126127 markDrained: () => Effect.die("the callback does not run the reconciler"),
127− organizationBackfilledAt: () => Effect.die("the callback does not report seats"),
128+ organizationBackfilledAt: () => Effect.succeed(new Date()),
129+});
130+
131+const stubDirectory = Layer.succeed(MemberDirectory)({
132+ membership: () => Effect.die("the callback does not look up one membership"),
133+ membersById: () => Effect.die("the callback does not batch members"),
134+ findByEmail: () => Effect.die("the callback does not resolve emails"),
135+ members: (organizationId) =>
136+ Effect.succeed([
137+ {
138+ accountId: STUB_USER_ID,
139+ membershipId: `om_${STUB_USER_ID}`,
140+ organizationId,
141+ email: null,
142+ name: null,
143+ avatarUrl: null,
144+ role: "member",
145+ status: "active" as const,
146+ lastActiveAt: null,
147+ },
148+ ]),
128149 });
129150
130151 // Only the public group is under test; the session group (and its SessionAuth
@@ -137,6 +158,7 @@ const App = HttpApiBuilder.layer(PublicApi).pipe( · 6 unmodified lines
137158 Layer.provide(stubWorkOS),
138159 Layer.provide(stubUsers),
139160 Layer.provide(stubMirror),
161+ Layer.provide(stubDirectory),
140162 Layer.provide(AutumnService.Default),
141163 Layer.provide(HttpServer.layerServices),
142164 );
apps/cloud/src/auth/workos.ts+12−14
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 WorkOS,
1010 type Event as WorkOSEvent,
1111 type EventName as WorkOSEventName,
12+ type OrganizationMembershipStatus,
1213 } from "@workos-inc/node/worker";
1314 import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto";
1415 import { decodeJwt, jwtVerify } from "jose";
@@ -665,13 +666,21 @@ const make = Effect.gen(function* () { · 650 unmodified lines
665666 deleteApiKey: (id: string) =>
666667 use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)),
667668
668− /** List organization memberships with user details. */
669− listOrgMembers: (organizationId: string) =>
669+ /**
670+ * An organization's memberships, all pages. Defaults to active + pending
671+ * (the seat-occupying set); pass `statuses` to narrow — the invite
672+ * write-through lists only `pending` to find the membership WorkOS
673+ * created for the invitee.
674+ */
675+ listOrgMembers: (
676+ organizationId: string,
677+ statuses: readonly OrganizationMembershipStatus[] = ["active", "pending"],
678+ ) =>
670679 use("userManagement.listOrganizationMemberships", async (wos) =>
671680 collectWorkOSList(
672681 await wos.userManagement.listOrganizationMemberships({
673682 organizationId,
674− statuses: ["active", "pending"],
683+ statuses: [...statuses],
675684 }),
676685 ),
677686 ),
@@ -691,17 +700,6 @@ const make = Effect.gen(function* () { · 13 unmodified lines
691700 getUser: (userId: string) =>
692701 use("userManagement.getUser", (wos) => wos.userManagement.getUser(userId)),
693702
694− /** List users matching an email within one organization. */
695− listUsers: (params: { email: string; organizationId: string }) =>
696− use("userManagement.listUsers", async (wos) =>
697− collectWorkOSList(
698− await wos.userManagement.listUsers({
699− email: params.email,
700− organizationId: params.organizationId,
701− }),
702− ),
703− ),
704−
705703 /** Send an organization invitation. */
706704 sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) =>
707705 use("userManagement.sendInvitation", (wos) =>
apps/cloud/src/extensions/billing/member-seats.ts+49−29
@@ -1,11 +1,16 @@
11 // ---------------------------------------------------------------------------
2−// Seat-count reporting — the WorkOS → Autumn reconciliation for seat billing
2+// Seat-count reporting — the membership mirror → Autumn reconciliation for
3+// seat billing
34 // ---------------------------------------------------------------------------
45
56 import { Effect } from "effect";
67 import { waitUntil } from "cloudflare:workers";
78
8−import { WorkOSClient } from "../../auth/workos";
9+import { MemberDirectory } from "@executor-js/api/server";
10+
11+import { ensureOrganizationBackfilled } from "../../auth/mirror-feeders";
12+import type { WorkOSClient } from "../../auth/workos";
13+import type { WorkOsMirror } from "../../auth/workos-mirror";
914 import { AutumnService } from "./service";
1015
1116 /**
@@ -16,39 +21,54 @@ import { AutumnService } from "./service"; · 4 unmodified lines
1621 * Seats change through paths the app never sees a mutation for (invitation
1722 * acceptance in AuthKit, SSO JIT provisioning, join by domain, WorkOS
1823 * dashboard edits), so this reconciles from a full recount rather than
19− * tracking deltas. It runs after in-app membership mutations AND on every
20− * login callback, so drift from out-of-band changes heals on the next
21− * sign-in. Fire-and-forget-safe: errors are logged, never surfaced.
24+ * tracking deltas. The count comes from the local membership mirror through
25+ * the shared `MemberDirectory`: every in-app membership mutation writes
26+ * through to the mirror BEFORE calling this, and out-of-band changes land via
27+ * login and the Events reconciler, so the recount reads the change on the
28+ * next sign-in exactly as it did against WorkOS — without a WorkOS read.
29+ *
30+ * The Autumn call runs off the calling request's critical path: Cloudflare
31+ * owns its promise through `waitUntil`, so the recount can finish after the
32+ * response, and billing never stalls or fails a user-facing request. Errors
33+ * are logged, never surfaced.
34+ *
35+ * The count is a PARTIAL one until THIS organization's membership list has
36+ * been scanned from WorkOS in full (the one-off backfill, or the on-demand
37+ * scan below): before that, the mirror holds only the members who signed in
38+ * or were changed since the mirror shipped. Because the Autumn write is an
39+ * authoritative SET, pushing a partial count would under-bill the
40+ * organization, so the recount first makes sure the organization is
41+ * backfilled (`ensureOrganizationBackfilled`: a scan runs now when its
42+ * per-organization mark is missing) and only then counts. The plan gate
43+ * (`reserveMemberSlot`) goes through the same step, so it never admits an
44+ * invite past the plan limit on a partial mirror.
45+ *
46+ * The COUNT is read inline, not in the fork: `MemberDirectory` is per-request
47+ * (it holds the request's postgres socket, which Cloudflare Workers' I/O
48+ * isolation ties to the request), so a forked fiber reading it could outlive
49+ * the socket. One indexed local query is cheap enough to pay inline; only the
50+ * Autumn call — over the boot-scoped `AutumnService` — is forked, so the
51+ * forked fiber captures nothing request-scoped.
2252 */
23−export const reportMemberSeats = (
53+export const forkReportMemberSeats = (
2454 organizationId: string,
25−): Effect.Effect<void, never, WorkOSClient | AutumnService> =>
55+): Effect.Effect<void, never, MemberDirectory | WorkOsMirror | WorkOSClient | AutumnService> =>
2656 Effect.gen(function* () {
27− const workos = yield* WorkOSClient;
57+ const directory = yield* MemberDirectory;
2858 const autumn = yield* AutumnService;
29− const memberships = yield* workos.listOrgMembers(organizationId);
30− const seats = memberships.data.filter((m) => m.status === "active").length;
31− yield* autumn.setMemberSeats(organizationId, seats);
59+ yield* ensureOrganizationBackfilled(organizationId);
60+ const seats = yield* directory
61+ .members(organizationId, { statuses: ["active"] })
62+ .pipe(Effect.map((members) => members.length));
63+ yield* Effect.sync(() => {
64+ waitUntil(Effect.runPromise(autumn.setMemberSeats(organizationId, seats)));
65+ });
3266 }).pipe(
3367 Effect.catch((error) =>
34− Effect.logWarning("reportMemberSeats: seat recount failed", { organizationId, error }),
68+ Effect.logWarning("reportMemberSeats: seat recount failed", {
69+ organizationId,
70+ error,
71+ }),
3572 ),
3673 Effect.withSpan("billing.reportMemberSeats"),
3774 );
38−
39−/**
40− * Fork `reportMemberSeats` off the calling request, mirroring how execution
41− * tracking is forked: billing must never stall or fail a user-facing
42− * request. Cloudflare owns the promise through waitUntil, so the recount can
43− * finish after the response. Only boot-scoped WorkOS and Autumn services are
44− * captured.
45− */
46−export const forkReportMemberSeats = (
47− organizationId: string,
48−): Effect.Effect<void, never, WorkOSClient | AutumnService> =>
49− Effect.gen(function* () {
50− const ctx = yield* Effect.context<WorkOSClient | AutumnService>();
51− yield* Effect.sync(() => {
52− waitUntil(Effect.runPromiseWith(ctx)(reportMemberSeats(organizationId)));
53− });
54− });
apps/cloud/src/extensions/routes.ts+2−2
@@ -28,7 +28,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 27 unmodified lines
2828 import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";
2929
3030 import { AccountApi, AdminUsersApi } from "@executor-js/api";
31−import { requestScopedMiddleware } from "@executor-js/api/server";
31+import { requestScopedMiddleware, type MemberDirectory } from "@executor-js/api/server";
3232
3333 import { UserStoreService } from "../auth/context";
3434 import { WorkOsMirror } from "../auth/workos-mirror";
@@ -79,7 +79,7 @@ const spec = OpenApi.fromApi(CloudOpenApi); · 44 unmodified lines
7979 * core.
8080 */
8181 export const makeCloudExtensionRoutes = (
82− rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,
82+ rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
8383 ) => {
8484 // Session routes (login / callback / me / switch-org / …). Handlers yield
8585 // `UserStoreService` directly; the per-request DB combine keeps the postgres
apps/host-selfhost/src/account/better-auth-account-provider.ts+1−1
@@ -145,7 +145,7 @@ export const betterAuthAccountProvider: Layer.Layer<AccountProvider, never, Bett · 144 unmodified lines
145145 const members = result.members.map((member) => ({
146146 id: member.id,
147147 userId: member.userId,
148− email: member.user?.email ?? "",
148+ email: member.user?.email ?? null,
149149 name: member.user?.name ?? null,
150150 avatarUrl: member.user?.image ?? null,
151151 role: member.role,
apps/host-selfhost/src/admin/admin-users-api.ts+20−77
@@ -16,7 +16,11 @@ · 15 unmodified lines
1616 //
1717 // The READ half is identical to cloud's: a subject-less, tenant-reach executor
1818 // from `makePlatformExecutor`, projected by the shared `admin/reads`. Self-host
19−// is single-tenant, so the tenant is always the boot-seeded org.
19+// is single-tenant, so the tenant is always the boot-seeded org. Identity
20+// (email/name per row), the `?email=` resolver and the `?search=` match all
21+// come from the shared `MemberDirectory` — here Better Auth's `member` + `user`
22+// tables through its own adapter (`auth/member-directory.ts`), the SAME read
23+// the MCP plane makes, so no plane keeps its own join.
2024 // ---------------------------------------------------------------------------
2125
2226 import { HttpRouter } from "effect/unstable/http";
@@ -26,18 +30,17 @@ import { · 3 unmodified lines
2630 AdminUsersProvider,
2731 DbProvider,
2832 HostConfig,
33+ MemberDirectory,
2934 PluginsProvider,
35+ adminUserDirectoryFromMembers,
3036 getAdminUser,
3137 listAdminUserConnections,
3238 listAdminUsers,
3339 listAdminUsersWithConnections,
3440 makeAdminUsersApiLayer,
3541 makePlatformExecutor,
36− normalizeAdminUserEmail,
3742 platformViewOf,
3843 requestScopedMiddleware,
39− type AdminUserDirectory,
40− type AdminUserIdentity,
4144 type AdminUsersHeaders,
4245 } from "@executor-js/api/server";
4346 import {
@@ -67,70 +70,6 @@ const requireAdmin = (headers: AdminUsersHeaders) => · 23 unmodified lines
6770 ),
6871 );
6972
70−/**
71− * Self-host's member directory: `externalId` → email/name.
72− *
73− * THE JOIN KEY is `member.userId`, the Better Auth `user.id` — precisely what
74− * `auth/identity.ts` binds as `accountId` and therefore what the subject table
75− * records in `external_id`. `member.id` is the organization `member` ROW id and
76− * joins to nothing; the two look alike, so the choice is pinned here and in the
77− * node test rather than left to a reader.
78− *
79− * One `listMembers` call per request: Better Auth's organization plugin already
80− * attaches the `user` row to each member, so email and name arrive with the
81− * membership and no per-user lookup is needed. The requested ids are not passed
82− * to the call — the plugin offers no id filter, and a single-instance member
83− * list is small — but the caller only reads the ids it asked for.
84− *
85− * Runs as the CALLER, using their own admin headers, so this reads exactly the
86− * directory that session is already entitled to on `/account/members`.
87− */
88−const listMembers = (auth: BetterAuthHandle["auth"], headers: AdminUsersHeaders) =>
89− Effect.tryPromise(() => auth.api.listMembers({ headers: new Headers(headers) }));
90−
91−/**
92− * Both directions of self-host's directory, over the SAME single `listMembers`
93− * read.
94− *
95− * The reverse (email → `user.id`) needs no extra call and no new permission:
96− * the organization plugin already attaches the `user` row to each member, so
97− * the email is sitting beside the id the forward join uses. Better Auth
98− * lower-cases every email it writes, but the directory value is normalized
99− * anyway so this host cannot answer differently from cloud if that ever
100− * changes.
101− *
102− * A member with no `user.email` cannot match — `null` is not an address, and
103− * coercing it to "" would let an empty `?email=` select an arbitrary row.
104− */
105−const userDirectory = (
106− auth: BetterAuthHandle["auth"],
107− headers: AdminUsersHeaders,
108−): AdminUserDirectory => ({
109− identities: () =>
110− listMembers(auth, headers).pipe(
111− Effect.map((result) => {
112− const identities = new Map<string, AdminUserIdentity>();
113− for (const member of result.members) {
114− identities.set(member.userId, {
115− email: member.user?.email ?? null,
116− displayName: member.user?.name ?? null,
117− });
118− }
119− return identities;
120− }),
121− ),
122− resolveEmail: (email) =>
123− listMembers(auth, headers).pipe(
124− Effect.map(
125− (result) =>
126− result.members.find((member) => {
127− const stored = member.user?.email;
128− return stored != null && normalizeAdminUserEmail(stored) === email;
129− })?.userId ?? null,
130− ),
131− ),
132−});
133−
13473 const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>(
13574 headers: AdminUsersHeaders,
13675 organizationId: string,
@@ -153,24 +92,25 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 16 unmodified lines
15392 export const betterAuthAdminUsersProvider: Layer.Layer<
15493 AdminUsersProvider,
15594 never,
156− BetterAuth | DbProvider | PluginsProvider | HostConfig
95+ BetterAuth | MemberDirectory | DbProvider | PluginsProvider | HostConfig
15796 > = Layer.effect(AdminUsersProvider)(
15897 Effect.gen(function* () {
15998 const context = yield* Effect.context<BetterAuth | DbProvider | PluginsProvider | HostConfig>();
160− const { auth, organizationId } = yield* BetterAuth;
99+ const { organizationId } = yield* BetterAuth;
100+ // Scoped to the INSTANCE's org — the same one the platform view is opened
101+ // for, never the caller's `activeOrganizationId` (see require-admin.ts).
102+ const directory = adminUserDirectoryFromMembers(yield* MemberDirectory, organizationId);
161103 return AdminUsersProvider.of({
162104 listUsers: (headers, options) =>
163105 withPlatformView(headers, organizationId, (executor) =>
164106 platformViewOf(executor).pipe(
165− Effect.flatMap((admin) => listAdminUsers(admin, options, userDirectory(auth, headers))),
107+ Effect.flatMap((admin) => listAdminUsers(admin, options, directory)),
166108 ),
167109 ).pipe(Effect.provideContext(context)),
168110 listUsersWithConnections: (headers, options) =>
169111 withPlatformView(headers, organizationId, (executor) =>
170112 platformViewOf(executor).pipe(
171− Effect.flatMap((admin) =>
172− listAdminUsersWithConnections(admin, options, userDirectory(auth, headers)),
173− ),
113+ Effect.flatMap((admin) => listAdminUsersWithConnections(admin, options, directory)),
174114 ),
175115 ).pipe(Effect.provideContext(context)),
176116 listUserConnections: (headers, externalId) =>
@@ -182,9 +122,7 @@ export const betterAuthAdminUsersProvider: Layer.Layer< · 5 unmodified lines
182122 getUser: (headers, identifier) =>
183123 withPlatformView(headers, organizationId, (executor) =>
184124 platformViewOf(executor).pipe(
185− Effect.flatMap((admin) =>
186− getAdminUser(admin, identifier, userDirectory(auth, headers)),
187− ),
125+ Effect.flatMap((admin) => getAdminUser(admin, identifier, directory)),
188126 ),
189127 ).pipe(Effect.provideContext(context)),
190128 });
@@ -193,6 +131,9 @@ export const betterAuthAdminUsersProvider: Layer.Layer< · 2 unmodified lines
193131
194132 export interface SelfHostAdminUsersApiDeps {
195133 readonly betterAuth: BetterAuthHandle;
134+ /** The boot-built `MemberDirectory` (see `resolveAuthProviders`), so this
135+ * plane reads the same directory instance every other plane does. */
136+ readonly memberDirectory: Layer.Layer<MemberDirectory>;
196137 readonly db: SelfHostDbHandle;
197138 readonly mountPrefix: `/${string}`;
198139 }
@@ -206,6 +147,7 @@ export interface SelfHostAdminUsersApiDeps { · 7 unmodified lines
206147 */
207148 export const makeSelfHostAdminUsersApiLayer = ({
208149 betterAuth,
150+ memberDirectory,
209151 db,
210152 mountPrefix,
211153 }: SelfHostAdminUsersApiDeps) => {
@@ -214,6 +156,7 @@ export const makeSelfHostAdminUsersApiLayer = ({ · 2 unmodified lines
214156 );
215157 const provider = betterAuthAdminUsersProvider.pipe(
216158 Layer.provide(Layer.succeed(BetterAuth)(betterAuth)),
159+ Layer.provide(memberDirectory),
217160 Layer.provide(SelfHostDbProvider),
218161 Layer.provide(SelfHostPluginsProvider),
219162 Layer.provide(SelfHostHostConfig),
apps/host-selfhost/src/app.ts+11−2
@@ -73,7 +73,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 72 unmodified lines
7373 // ---- auth providers ---------------------------------------------------
7474 // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account
7575 // API + MCP OAuth seam, all over the shared libSQL handle.
76− const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle);
76+ const { identityLayer, memberDirectoryLayer, authHandler, betterAuth } =
77+ await resolveAuthProviders(dbHandle);
7778
7879 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------
7980 const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config);
@@ -130,7 +131,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 50 unmodified lines
130131 // Tenant-wide admin users API (/api/admin/users*): the owner's view of
131132 // who uses this instance and what they've connected. Owner/admin-gated,
132133 // same as the invite routes above.
133− makeSelfHostAdminUsersApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
134+ makeSelfHostAdminUsersApiLayer({
135+ betterAuth,
136+ memberDirectory: memberDirectoryLayer,
137+ db: dbHandle,
138+ mountPrefix: "/api",
139+ }),
134140 // Public system API: /api/health + /api/setup-status (unauthenticated).
135141 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
136142 // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).
@@ -141,11 +147,14 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 4 unmodified lines
141147 // The boot-scoped context provideMerge'd under everything: the long-lived DB
142148 // handle (read by the DbProvider seam, Better Auth, and the MCP store) + the
143149 // resolved identity (captured once by the execution middleware + MCP auth)
150+ // + the member directory (the shared membership read seam, boot-scoped
151+ // beside identity because Better Auth's handle is an app singleton)
144152 // + the artifact-usage observer (this HTTP plane is the console UI's data
145153 // layer, so operations it serves file as `via: "ui"`).
146154 boot: Layer.mergeAll(
147155 Layer.succeed(SelfHostDb)(dbHandle),
148156 identityLayer,
157+ memberDirectoryLayer,
149158 Layer.succeed(ArtifactUsageObserver)((action) =>
150159 selfHostAnalytics.record(`artifact_${action}`, { via: "ui" }),
151160 ),
apps/host-selfhost/src/auth/index.ts+11−4
@@ -1,24 +1,28 @@
11 import { Layer } from "effect";
22
3−import { IdentityProvider } from "@executor-js/api/server";
3+import { IdentityProvider, MemberDirectory } from "@executor-js/api/server";
44
55 import { loadConfig } from "../config";
66 import type { SelfHostDbHandle } from "../db/self-host-db";
77 import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
88 import { betterAuthIdentityLayer } from "./identity";
9+import { betterAuthMemberDirectoryLayer } from "./member-directory";
910 import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent";
1011 import { rewriteInvalidOrigin } from "./invalid-origin-help";
1112
1213 export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
1314 export { betterAuthIdentityLayer } from "./identity";
15+export { betterAuthMemberDirectoryLayer } from "./member-directory";
1416
1517 // ---------------------------------------------------------------------------
1618 // Resolve the self-host auth providers.
1719 //
1820 // Build the Better Auth instance over the shared libSQL file, expose its
19−// `IdentityProvider` (cookie/bearer/api-key) and its web handler (mounted at
20−// /api/auth/*). Returns the live `BetterAuthHandle` so the composition root can
21−// build the account API and the Better Auth MCP OAuth seam.
21+// `IdentityProvider` (cookie/bearer/api-key), its `MemberDirectory` (the
22+// shared membership read seam over the org plugin's tables) and its web
23+// handler (mounted at /api/auth/*). Returns the live `BetterAuthHandle` so the
24+// composition root can build the account API and the Better Auth MCP OAuth
25+// seam.
2226 //
2327 // This is the one and only production auth path. Tests that need a fake identity
2428 // (single-admin / header-driven) compose `ExecutorApp.make` directly through
@@ -29,6 +33,8 @@ export { betterAuthIdentityLayer } from "./identity"; · 4 unmodified lines
2933 export interface ResolvedAuthProviders {
3034 /** The resolved Better Auth `IdentityProvider` seam (cookie/bearer/api-key). */
3135 readonly identityLayer: Layer.Layer<IdentityProvider>;
36+ /** The resolved Better Auth `MemberDirectory` seam (org members + users). */
37+ readonly memberDirectoryLayer: Layer.Layer<MemberDirectory>;
3238 /** Better Auth's web handler (`/api/auth/*`). */
3339 readonly authHandler: (request: Request) => Promise<Response>;
3440 /** The live Better Auth handle (account API + Better Auth MCP OAuth seam). */
@@ -78,6 +84,7 @@ export const resolveAuthProviders = async ( · 43 unmodified lines
7884
7985 return {
8086 identityLayer: betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)),
87+ memberDirectoryLayer: betterAuthMemberDirectoryLayer.pipe(Layer.provide(betterAuthLayer)),
8188 authHandler,
8289 betterAuth,
8390 };
packages/core/api/src/account/api.ts+8−1
@@ -109,10 +109,17 @@ export const OrgApiKeysResponse = Schema.Struct({ · 108 unmodified lines
109109 apiKeys: Schema.Array(ApiKeySummary),
110110 });
111111
112+/**
113+ * One member of the caller's organization, as the host's member directory
114+ * reports them. `email` is nullable: a host can hold a membership whose
115+ * profile it has not yet learned (cloud mirrors the membership before the
116+ * user record lands), and reporting `""` for that would let the UI render an
117+ * empty address as if it were one.
118+ */
112119 export const OrgMember = Schema.Struct({
113120 id: Schema.String,
114121 userId: Schema.String,
115− email: Schema.String,
122+ email: Schema.NullOr(Schema.String),
116123 name: Schema.NullOr(Schema.String),
117124 avatarUrl: Schema.NullOr(Schema.String),
118125 role: Schema.String,
packages/core/api/src/admin/admin-users.test.ts+158−5
@@ -395,6 +395,7 @@ const A1_EMAIL = "a1@users.test"; · 394 unmodified lines
395395 const stubUserDirectory = (options: {
396396 readonly seen?: string[][];
397397 readonly resolved?: string[];
398+ readonly searched?: string[];
398399 }): AdminUserDirectory => ({
399400 identities: (externalIds) => {
400401 options.seen?.push([...externalIds]);
@@ -405,6 +406,13 @@ const stubUserDirectory = (options: { · 4 unmodified lines
405406 // Compares a NORMALIZED stored value, the rule both real hosts follow.
406407 return Effect.succeed(A1_EMAIL_STORED.toLowerCase() === email ? USER_A1 : null);
407408 },
409+ search: (term) => {
410+ options.searched?.push(term);
411+ // The one member the directory knows, matched on the normalized email or
412+ // the display name — the substring rule both real hosts apply.
413+ const haystack = [A1_EMAIL_STORED.toLowerCase(), "user a1"];
414+ return Effect.succeed(haystack.some((value) => value.includes(term)) ? [USER_A1] : []);
415+ },
408416 });
409417
410418 /** The failure a host's directory raises — WorkOS or Better Auth being
@@ -1022,6 +1030,102 @@ describe("admin users API", () => { · 611 unmodified lines
10221030 ),
10231031 );
10241032
1033+ // ── ?search= ──────────────────────────────────────────────────────────────
1034+
1035+ it.effect("filters the bulk lists by a name or email substring, case-insensitively", () =>
1036+ withDb((db) =>
1037+ Effect.gen(function* () {
1038+ yield* seed(db);
1039+ const searched: string[] = [];
1040+ const web = yield* webHandlerFor(
1041+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize, {
1042+ ...stubUserDirectory({ searched }),
1043+ }),
1044+ );
1045+
1046+ // Part of the address, typed in the wrong case and with stray spaces:
1047+ // the handler normalizes it before the directory sees it.
1048+ const byEmail = yield* jsonOf<UsersBody>(
1049+ yield* get(web, `/admin/users?search=${encodeURIComponent(" A1@USERS ")}`, ORG_A),
1050+ );
1051+ expect(byEmail.users.map((user) => user.externalId)).toEqual([USER_A1]);
1052+ expect(byEmail.users[0]?.email, "the page still carries identity").toBe(A1_EMAIL_STORED);
1053+
1054+ // Part of the name, on the joined view.
1055+ const byName = yield* jsonOf<UsersWithConnectionsBody>(
1056+ yield* get(web, "/admin/users/with-connections?search=User%20a1", ORG_A),
1057+ );
1058+ expect(byName.users.map((user) => user.externalId)).toEqual([USER_A1]);
1059+ expect(byName.users[0]?.connections.map((c) => c.integration)).toEqual(["github"]);
1060+
1061+ // No match is an empty page, never the unfiltered tenant.
1062+ const nobody = yield* jsonOf<UsersBody>(
1063+ yield* get(web, "/admin/users?search=nobody", ORG_A),
1064+ );
1065+ expect(nobody.users).toEqual([]);
1066+
1067+ expect(searched, "one directory search per request, normalized").toEqual([
1068+ "a1@users",
1069+ "user a1",
1070+ "nobody",
1071+ ]);
1072+ }),
1073+ ),
1074+ );
1075+
1076+ it.effect("a blank search is no filter at all", () =>
1077+ withDb((db) =>
1078+ Effect.gen(function* () {
1079+ yield* seed(db);
1080+ const searched: string[] = [];
1081+ const web = yield* webHandlerFor(
1082+ stubProvider(
1083+ (tenant) => platformExecutorFor(db, tenant),
1084+ headerAuthorize,
1085+ stubUserDirectory({ searched }),
1086+ ),
1087+ );
1088+
1089+ const body = yield* jsonOf<UsersBody>(yield* get(web, "/admin/users?search=%20%20", ORG_A));
1090+ expect(body.users.map((user) => user.externalId)).toEqual([USER_A1, USER_A2]);
1091+ expect(searched, "the directory is never asked to match whitespace").toEqual([]);
1092+ }),
1093+ ),
1094+ );
1095+
1096+ it.effect("returns an empty page for a search no host directory can answer", () =>
1097+ withDb((db) =>
1098+ Effect.gen(function* () {
1099+ yield* seed(db);
1100+ // A directory with identities only: it cannot search, so a search
1101+ // filter must select nothing rather than hand back the whole tenant.
1102+ const web = yield* webHandlerFor(
1103+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize, {
1104+ identities: stubDirectory([]),
1105+ }),
1106+ );
1107+
1108+ const body = yield* jsonOf<UsersBody>(yield* get(web, "/admin/users?search=a1", ORG_A));
1109+ expect(body.users).toEqual([]);
1110+ }),
1111+ ),
1112+ );
1113+
1114+ it.effect("500s when the directory search fails, rather than reporting no match", () =>
1115+ withDb((db) =>
1116+ Effect.gen(function* () {
1117+ yield* seed(db);
1118+ const web = yield* webHandlerFor(
1119+ stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize, {
1120+ search: () => Effect.fail(new DirectoryUnavailable({ message: "down" })),
1121+ }),
1122+ );
1123+
1124+ expect((yield* get(web, "/admin/users?search=a1", ORG_A)).status).toBe(500);
1125+ }),
1126+ ),
1127+ );
1128+
10251129 // A resolver OUTAGE must not read as "no such user": that is a wrong answer an
10261130 // operator would act on. Contrast with the identity join, which degrades to
10271131 // unnamed rows precisely because it is decoration.
@@ -1096,8 +1200,8 @@ const A_SUBJECT: AdminSubject = { · 68 unmodified lines
10961200 /** An `ExecutorAdmin` that answers everything and records the reads it was
10971201 * asked for, so a test can assert the call the filter chose. */
10981202 const recordingAdmin = (calls: string[]): ExecutorAdmin => ({
1099− listSubjects: () => {
1100− calls.push("listSubjects");
1203+ listSubjects: (options) => {
1204+ calls.push(`listSubjects:${options?.externalIds?.join(",") ?? "*"}`);
11011205 return Effect.succeed([A_SUBJECT]);
11021206 },
11031207 getSubject: () => {
@@ -1108,8 +1212,8 @@ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ · 4 unmodified lines
11081212 calls.push("listSubjectConnections");
11091213 return Effect.succeed([]);
11101214 },
1111− listSubjectsWithConnections: () => {
1112− calls.push("listSubjectsWithConnections");
1215+ listSubjectsWithConnections: (options) => {
1216+ calls.push(`listSubjectsWithConnections:${options?.externalIds?.join(",") ?? "*"}`);
11131217 return Effect.succeed([{ ...A_SUBJECT, connections: [] }]);
11141218 },
11151219 getSubjectWithConnections: () => {
@@ -1187,7 +1291,56 @@ describe("admin users reads — the ?email= filter is applied before the read", · 71 unmodified lines
11871291 const calls: string[] = [];
11881292 yield* listUsersWithConnections(recordingAdmin(calls), { limit: 50 }, stubUserDirectory({}));
11891293
1190− expect(calls).toEqual(["listSubjectsWithConnections"]);
1294+ expect(calls).toEqual(["listSubjectsWithConnections:*"]);
1295+ }),
1296+ );
1297+});
1298+
1299+// ---------------------------------------------------------------------------
1300+// `?search=` is FILTER-THEN-PAGE through storage: the directory names the
1301+// matching principals, and the paged read carries exactly that set as its
1302+// `externalIds` filter — never a page scan that is filtered afterwards.
1303+// ---------------------------------------------------------------------------
1304+
1305+describe("admin users reads — the ?search= filter pages the directory's matches", () => {
1306+ it.effect("hands the matched ids to the paged read, on both views", () =>
1307+ Effect.gen(function* () {
1308+ const calls: string[] = [];
1309+ const admin = recordingAdmin(calls);
1310+
1311+ yield* listUsers(admin, { search: "a1" }, stubUserDirectory({}));
1312+ yield* listUsersWithConnections(admin, { search: "user", limit: 10 }, stubUserDirectory({}));
1313+
1314+ expect(calls).toEqual([`listSubjects:${USER_A1}`, `listSubjectsWithConnections:${USER_A1}`]);
1315+ }),
1316+ );
1317+
1318+ it.effect("issues NO storage read when the directory matches nobody", () =>
1319+ Effect.gen(function* () {
1320+ const calls: string[] = [];
1321+ const body = yield* listUsersWithConnections(
1322+ recordingAdmin(calls),
1323+ { search: "nobody" },
1324+ stubUserDirectory({}),
1325+ );
1326+
1327+ expect(calls).toEqual([]);
1328+ expect(body.users).toEqual([]);
1329+ }),
1330+ );
1331+
1332+ it.effect("lets an exact email win over a search term", () =>
1333+ Effect.gen(function* () {
1334+ const calls: string[] = [];
1335+ const searched: string[] = [];
1336+ yield* listUsers(
1337+ recordingAdmin(calls),
1338+ { email: A1_EMAIL, search: "anything" },
1339+ stubUserDirectory({ searched }),
1340+ );
1341+
1342+ expect(calls, "the keyed read, not a search").toEqual(["getSubject"]);
1343+ expect(searched).toEqual([]);
11911344 }),
11921345 );
11931346 });
packages/core/api/src/admin/api.ts+10−0
@@ -261,6 +261,15 @@ const AdminUserIdentifierParams = { identifier: Schema.String }; · 260 unmodified lines
261261 // handler seam (`normalizeEmail`), which is also where the single-user path
262262 // parameter is normalized, so both entry points share ONE rule rather than a
263263 // schema transform on one and hand-rolled code on the other.
264+//
265+// `search` is the SUBSTRING counterpart: a case-insensitive match over each
266+// member's email and name in the host's directory, for the operator who knows
267+// a person's name or part of an address rather than the exact one. Like
268+// `email` it narrows the fixed list shape and is applied BEFORE paging (the
269+// directory names the matching principals; storage pages that set), so a
270+// window on a searched list is a window on the matches. A blank term is no
271+// filter. When both filters are present `email` wins: it names one principal,
272+// and there is nothing left for a search to narrow.
264273 const AdminListQuery = Schema.Struct({
265274 limit: Schema.optional(
266275 Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })),
@@ -272,6 +281,7 @@ const AdminListQuery = Schema.Struct({ · 5 unmodified lines
272281 ),
273282 ),
274283 email: Schema.optional(Schema.String),
284+ search: Schema.optional(Schema.String),
275285 });
276286
277287 // ---------------------------------------------------------------------------
packages/core/api/src/admin/handlers.ts+14−5
@@ -2,6 +2,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line
22 import { HttpServerRequest } from "effect/unstable/http";
33 import { Effect } from "effect";
44
5+import { normalizeMemberSearch } from "../server/member-directory";
56 import { AdminUsersHttpApi } from "./api";
67 import { normalizeEmail } from "./reads";
78 import { AdminUsersProvider, type AdminUsersHeaders, type AdminUsersListOptions } from "./service";
@@ -24,15 +25,23 @@ const requestHeaders = Effect.map( · 16 unmodified lines
2425 // than an explicit `undefined` overriding them.
2526 // `email` is normalized here rather than in the contract schema, so the filter
2627 // and the single-user path parameter share ONE rule (`normalizeEmail`).
28+// `search` gets the directory's own rule (`normalizeMemberSearch`: the same
29+// trim + lower-case, and a blank term is no filter at all — dropped here so a
30+// provider never sees `search: ""`).
2731 const listOptions = (query: {
2832 readonly limit?: number | undefined;
2933 readonly offset?: number | undefined;
3034 readonly email?: string | undefined;
31−}): AdminUsersListOptions => ({
32− ...(query.limit === undefined ? {} : { limit: query.limit }),
33− ...(query.offset === undefined ? {} : { offset: query.offset }),
34− ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }),
35−});
35+ readonly search?: string | undefined;
36+}): AdminUsersListOptions => {
37+ const search = normalizeMemberSearch(query.search);
38+ return {
39+ ...(query.limit === undefined ? {} : { limit: query.limit }),
40+ ...(query.offset === undefined ? {} : { offset: query.offset }),
41+ ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }),
42+ ...(search === undefined ? {} : { search }),
43+ };
44+};
3645
3746 export const AdminUsersHandlers = HttpApiBuilder.group(
3847 AdminUsersHttpApi,
packages/core/api/src/admin/member-directory.ts+16−7
@@ -10,21 +10,26 @@ import { MemberStatus, type MemberDirectoryShape } from "../server/member-direct · 9 unmodified lines
1010 import type { AdminUserDirectory, AdminUserIdentity } from "./reads";
1111
1212 /**
13− * Both directions of the admin plane's directory over one org's
13+ * Every direction of the admin plane's directory over one org's
1414 * {@link MemberDirectoryShape}.
1515 *
1616 * `identities` is one batched `membersById` read for the page of ids (never a
1717 * lookup per user); a member the org does not hold reports absent identity.
1818 * `resolveEmail` receives the already-normalized email the contract promises
1919 * and answers with the host principal id, or `null` when no member has it.
20+ * `search` is one `members` read for the term, answering with the matching
21+ * principal ids in directory order.
2022 *
21− * Both read ANY membership status, not the directory's active + pending
22− * default: this plane reports footprint, not current access. A member who was
23− * removed while their connections remain must still be named on the users
24− * page and findable by the address an operator has for them.
23+ * Every direction reads ANY membership status — the same reach `membersById`
24+ * and `findByEmail` have by contract, and `search` asks for explicitly rather
25+ * than taking `members`' active + pending default. This plane reports
26+ * footprint, not current access: a member who was deactivated while their
27+ * connections remain must still be findable by the address or name an
28+ * operator has for them, exactly as `?email=` already finds them.
2529 *
26− * Both fail with `MemberDirectoryError`, which the shared reads treat as a
27− * decorative-join outage (identities) or surface as a failed read (resolve).
30+ * All fail with `MemberDirectoryError`, which the shared reads treat as a
31+ * decorative-join outage (identities) or surface as a failed read (resolve,
32+ * search).
2833 */
2934 export const adminUserDirectoryFromMembers = (
3035 directory: MemberDirectoryShape,
@@ -47,4 +52,8 @@ export const adminUserDirectoryFromMembers = ( · 16 unmodified lines
4752 directory
4853 .findByEmail(organizationId, email, MemberStatus.literals)
4954 .pipe(Effect.map((member) => (member === null ? null : member.accountId))),
55+ search: (term) =>
56+ directory
57+ .members(organizationId, { search: term, statuses: MemberStatus.literals })
58+ .pipe(Effect.map((members) => members.map((member) => member.accountId))),
5059 });
packages/core/api/src/admin/reads.ts+95−17
@@ -16,6 +16,7 @@ import { Effect } from "effect"; · 15 unmodified lines
1616
1717 import type {
1818 AdminConnection,
19+ AdminListSubjectsOptions,
1920 AdminSubject,
2021 AdminSubjectWithConnections,
2122 Executor,
@@ -114,12 +115,27 @@ export type AdminIdentityDirectory = ( · 92 unmodified lines
114115 */
115116 export type AdminEmailResolver = (email: string) => Effect.Effect<string | null, unknown>;
116117
117−/** Both directions of a host's member directory. Optional as a whole (a host
118− * with no directory reports unnamed rows and cannot resolve emails), and
119− * optional per direction. */
118+/**
119+ * The directory's SEARCH: a normalized term (trimmed + lower-cased, the same
120+ * rule `normalizeEmail` applies) → the host-auth principal ids of every member
121+ * whose email or name contains it, in the directory's own order.
122+ *
123+ * Unlike `resolveEmail` this names a SET, and the reads page that set through
124+ * storage rather than in memory: the ids go into the SDK's `externalIds`
125+ * filter and the caller's `limit`/`offset` apply there. An empty result means
126+ * no member matches, and costs no storage read. Failures are the caller's to
127+ * interpret on the same terms as `resolveEmail` — a search that cannot run
128+ * must not quietly become "nobody matches".
129+ */
130+export type AdminMemberSearch = (term: string) => Effect.Effect<readonly string[], unknown>;
131+
132+/** Every direction of a host's member directory. Optional as a whole (a host
133+ * with no directory reports unnamed rows and cannot resolve emails or search),
134+ * and optional per direction. */
120135 export interface AdminUserDirectory {
121136 readonly identities?: AdminIdentityDirectory;
122137 readonly resolveEmail?: AdminEmailResolver;
138+ readonly search?: AdminMemberSearch;
123139 }
124140
125141 /** Identity is decoration on an operator view, not part of the answer: a
@@ -292,6 +308,72 @@ const selectByEmail = <T>( · 166 unmodified lines
292308 return row === null ? [] : pageOf([row], options);
293309 });
294310
311+/**
312+ * The `?search=` read: FILTER by the directory, then PAGE through storage.
313+ *
314+ * The term names a SET of principals rather than one, so unlike `?email=` it
315+ * cannot become a keyed read — but it still must not become a page-then-filter
316+ * scan, which on a large tenant would page past every unmatched subject before
317+ * finding the first match. So the directory answers with the matching ids and
318+ * storage pages exactly that set (`externalIds` + the caller's window), which
319+ * keeps "filter, then page" as the one paging rule every filtered list here
320+ * follows.
321+ *
322+ * A host with no search direction answers nothing, for the same reason an
323+ * unanswerable `?email=` does: a filter no host can apply must return an empty
324+ * page, never an unfiltered one. A search FAILURE is a 500 on the same terms as
325+ * a resolver failure.
326+ */
327+const selectBySearch = <T>(
328+ directory: AdminUserDirectory,
329+ term: string,
330+ read: (externalIds: readonly string[]) => Effect.Effect<readonly T[], AdminUsersError>,
331+): Effect.Effect<readonly T[], AdminUsersError> =>
332+ Effect.gen(function* () {
333+ const search = directory.search;
334+ if (!search) return [];
335+ const wanted = yield* search(term).pipe(
336+ Effect.mapError(() => new AdminUsersError({ message: "Failed to search the directory" })),
337+ );
338+ // Nobody matches: an empty page, and no storage read for an `in ()` that
339+ // could not match anyway.
340+ if (wanted.length === 0) return [];
341+ return yield* read(wanted);
342+ });
343+
344+/**
345+ * Which filtered read a list request takes. `email` names ONE principal and
346+ * wins when both are present: a keyed read is the more specific answer, and
347+ * a search term beside an exact address has nothing left to narrow.
348+ */
349+const selectSubjects = <T>(
350+ directory: AdminUserDirectory,
351+ options: AdminUsersListOptions,
352+ reads: {
353+ readonly page: (
354+ paging: AdminListSubjectsOptions,
355+ ) => Effect.Effect<readonly T[], AdminUsersError>;
356+ readonly one: (externalId: string) => Effect.Effect<T | null, AdminUsersError>;
357+ },
358+): Effect.Effect<readonly T[], AdminUsersError> => {
359+ if (options.email !== undefined) {
360+ return selectByEmail(directory, options.email, options, reads.one);
361+ }
362+ if (options.search !== undefined) {
363+ return selectBySearch(directory, options.search, (externalIds) =>
364+ reads.page({ ...pagingOf(options), externalIds }),
365+ );
366+ }
367+ return reads.page(pagingOf(options));
368+};
369+
370+/** Only the paging window — never the filters — reaches the SDK: the filters
371+ * are resolved here, and the SDK's own `externalIds` is set by this file. */
372+const pagingOf = (options: AdminUsersListOptions): AdminListSubjectsOptions => ({
373+ ...(options.limit === undefined ? {} : { limit: options.limit }),
374+ ...(options.offset === undefined ? {} : { offset: options.offset }),
375+});
376+
295377 export const listUsers = (
296378 admin: ExecutorAdmin,
297379 options: AdminUsersListOptions,
@@ -299,12 +381,10 @@ export const listUsers = ( · 1 unmodified line
299381 ): Effect.Effect<typeof AdminUsersResponse.Type, AdminUsersError> =>
300382 Effect.gen(function* () {
301383 const dir = asDirectory(directory);
302− const subjects =
303− options.email === undefined
304− ? yield* admin.listSubjects(options).pipe(Effect.mapError(readFailed("users")))
305− : yield* selectByEmail(dir, options.email, options, (externalId) =>
306− admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))),
307− );
384+ const subjects = yield* selectSubjects(dir, options, {
385+ page: (paging) => admin.listSubjects(paging).pipe(Effect.mapError(readFailed("users"))),
386+ one: (externalId) => admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))),
387+ });
308388 // One directory read for the page that was actually returned, joined in
309389 // memory — never a lookup per user.
310390 const identities = yield* resolveIdentities(
@@ -321,14 +401,12 @@ export const listUsersWithConnections = ( · 10 unmodified lines
321401 ): Effect.Effect<typeof AdminUsersWithConnectionsResponse.Type, AdminUsersError> =>
322402 Effect.gen(function* () {
323403 const dir = asDirectory(directory);
324− const subjects =
325− options.email === undefined
326− ? yield* admin
327− .listSubjectsWithConnections(options)
328− .pipe(Effect.mapError(readFailed("users")))
329− : yield* selectByEmail(dir, options.email, options, (externalId) =>
330− admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))),
331− );
404+ const subjects = yield* selectSubjects(dir, options, {
405+ page: (paging) =>
406+ admin.listSubjectsWithConnections(paging).pipe(Effect.mapError(readFailed("users"))),
407+ one: (externalId) =>
408+ admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))),
409+ });
332410 const identities = yield* resolveIdentities(
333411 dir.identities,
334412 subjects.map((subject) => subject.externalId),
packages/core/api/src/admin/service.ts+5−2
@@ -31,12 +31,15 @@ import { · 30 unmodified lines
3131 export type AdminUsersHeaders = Record<string, string>;
3232
3333 /** Paging and filtering, mirroring the SDK's `AdminListSubjectsOptions` plus
34− * the contract's `?email=`. The email arrives already trimmed and lower-cased
35− * by the contract schema, so a provider never re-normalizes it. */
34+ * the contract's `?email=` and `?search=`. Both filters arrive already
35+ * trimmed and lower-cased by the handler seam (a blank search is omitted
36+ * entirely), so a provider never re-normalizes them. `email` names ONE
37+ * principal and wins when both are present. */
3638 export interface AdminUsersListOptions {
3739 readonly limit?: number;
3840 readonly offset?: number;
3941 readonly email?: string;
42+ readonly search?: string;
4043 }
4144
4245 type User = typeof AdminUserResponse.Type;
packages/core/api/src/server.ts+1−0
@@ -40,6 +40,7 @@ export { · 39 unmodified lines
4040 normalizeEmail as normalizeAdminUserEmail,
4141 type AdminEmailResolver,
4242 type AdminIdentityDirectory,
43+ type AdminMemberSearch,
4344 type AdminUserDirectory,
4445 type AdminUserIdentity,
4546 } from "./admin/reads";
packages/core/sdk/src/executor.ts+18−0
@@ -593,6 +593,14 @@ export interface AdminSubjectWithConnections extends AdminSubject { · 592 unmodified lines
593593 export interface AdminListSubjectsOptions {
594594 readonly limit?: number;
595595 readonly offset?: number;
596+ /**
597+ * Keep only subjects whose `external_id` is in this set — the host's answer
598+ * to a directory search (name or email), paged through storage rather than
599+ * in memory. An EMPTY set matches nothing; `undefined` is no filter. Paging
600+ * applies to the filtered set: "filter, then page", the same order the
601+ * `?email=` read follows.
602+ */
603+ readonly externalIds?: readonly string[];
596604 }
597605
598606 /**
@@ -7006,8 +7014,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6407 unmodified lines
70067014 // them through independently produced a bare OFFSET, which SQLite
70077015 // rejects outright.
70087016 const { limit, offset } = normalizeAdminPaging(options);
7017+ const externalIds = options?.externalIds;
7018+ // An empty id set is a query that cannot match; answer it without a
7019+ // round trip rather than emitting `in ()`, which some drivers reject.
7020+ if (externalIds !== undefined && externalIds.length === 0) return Effect.succeed([]);
70097021 return platformCore
70107022 .findMany("subject", {
7023+ // The id filter is the ONLY predicate here; the tenant clause is
7024+ // the policy's, added to every read the same way `getSubject`
7025+ // relies on it.
7026+ ...(externalIds === undefined
7027+ ? {}
7028+ : { where: (b: AnyCb) => b("external_id", "in", [...externalIds]) }),
70117029 // Oldest first, ties broken on the unique key so the order is
70127030 // total and paging can't repeat or skip a row.
70137031 orderBy: [
packages/core/sdk/src/platform-view.test.ts+55−0
@@ -424,6 +424,61 @@ const expectWriteRefused = <A, E extends { readonly _tag: string }>( · 423 unmodified lines
424424 Effect.orDie,
425425 );
426426
427+describe("platform view — admin.listSubjects externalIds filter", () => {
428+ it.effect("keeps only the named ids, still ordered and paged through storage", () =>
429+ withDb((db) =>
430+ Effect.gen(function* () {
431+ yield* seed(db);
432+ const admin = yield* requireAdmin(yield* makePlatformExecutor(db));
433+
434+ const only = yield* admin.listSubjects({ externalIds: [SUBJECT_B] });
435+ expect(only.map((entry) => entry.externalId)).toEqual([SUBJECT_B]);
436+
437+ // Ids the tenant does not hold are simply absent — including another
438+ // tenant's subject, which the policy keeps out regardless of the filter.
439+ const mixed = yield* admin.listSubjects({
440+ externalIds: [SUBJECT_B, "user_nobody", "user_elsewhere", SUBJECT_A],
441+ });
442+ expect(mixed.map((entry) => entry.externalId).sort()).toEqual([SUBJECT_A, SUBJECT_B]);
443+
444+ // "Filter, then page": the window applies to the filtered set.
445+ const all = yield* admin.listSubjects();
446+ const second = yield* admin.listSubjects({
447+ externalIds: [SUBJECT_A, SUBJECT_B],
448+ limit: 1,
449+ offset: 1,
450+ });
451+ expect(second.map((entry) => entry.externalId)).toEqual([all[1]?.externalId]);
452+ }),
453+ ),
454+ );
455+
456+ it.effect("an empty id set matches nothing, on both list reads", () =>
457+ withDb((db) =>
458+ Effect.gen(function* () {
459+ yield* seed(db);
460+ const admin = yield* requireAdmin(yield* makePlatformExecutor(db));
461+
462+ expect(yield* admin.listSubjects({ externalIds: [] })).toEqual([]);
463+ expect(yield* admin.listSubjectsWithConnections({ externalIds: [] })).toEqual([]);
464+ }),
465+ ),
466+ );
467+
468+ it.effect("the joined read filters the same way and still joins connections", () =>
469+ withDb((db) =>
470+ Effect.gen(function* () {
471+ yield* seed(db);
472+ const admin = yield* requireAdmin(yield* makePlatformExecutor(db));
473+
474+ const rows = yield* admin.listSubjectsWithConnections({ externalIds: [SUBJECT_A] });
475+ expect(rows.map((entry) => entry.externalId)).toEqual([SUBJECT_A]);
476+ expect(rows[0]?.connections.length).toBeGreaterThan(0);
477+ }),
478+ ),
479+ );
480+});
481+
427482 describe("platform view — read-only across every surface", () => {
428483 it.effect("refuses org-row writes through policies and oauth", () =>
429484 withDb((db) =>
packages/react/src/api/admin-atoms.tsx+14−5
@@ -10,10 +10,11 @@ import { ReactivityKey } from "./reactivity-keys"; · 9 unmodified lines
1010 // rejects writes at tenant reach), so there are no mutations here and every
1111 // atom carries the same reactivity key.
1212 //
13−// Paging is part of the atom identity, so each page is its own cache entry and
14−// stepping back to a visited page is instant. `Atom.family` (not a bare arrow)
15−// because the page component re-derives the key object on every render — a
16−// fresh atom per render would refetch in a loop.
13+// Paging and the search term are part of the atom identity, so each page of
14+// each search is its own cache entry and stepping back to a visited page is
15+// instant. `Atom.family` (not a bare arrow) because the page component
16+// re-derives the key object on every render — a fresh atom per render would
17+// refetch in a loop.
1718 // ---------------------------------------------------------------------------
1819
1920 /** 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; · 4 unmodified lines
2425 export interface AdminUsersPage {
2526 readonly limit: number;
2627 readonly offset: number;
28+ /** The `?search=` term (name or email substring), already debounced by the
29+ * page. `""` is no filter and is sent as no param at all, so the unfiltered
30+ * list keeps one cache identity regardless of how the term was cleared. */
31+ readonly search: string;
2732 }
2833
2934 /**
@@ -35,7 +40,11 @@ export interface AdminUsersPage { · 5 unmodified lines
3540 */
3641 export const adminUsersWithConnectionsAtom = Atom.family((page: AdminUsersPage) =>
3742 AdminApiClient.query("adminUsers", "listUsersWithConnections", {
38− query: { limit: page.limit + 1, offset: page.offset },
43+ query: {
44+ limit: page.limit + 1,
45+ offset: page.offset,
46+ ...(page.search === "" ? {} : { search: page.search }),
47+ },
3948 timeToLive: "30 seconds",
4049 reactivityKeys: [ReactivityKey.adminUsers],
4150 }),
packages/react/src/pages/admin-users.tsx+117−2
@@ -1,6 +1,7 @@
1−import { useState } from "react";
1+import { useEffect, useState } from "react";
22 import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
33 import { useParams } from "@tanstack/react-router";
4+import { SearchIcon, XIcon } from "lucide-react";
45 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
56 import * as Cause from "effect/Cause";
67 import * as Option from "effect/Option";
@@ -18,6 +19,7 @@ import { ownerLabel } from "../api/owner-display"; · 11 unmodified lines
1819 import { Button } from "../components/button";
1920 import { CopyButton } from "../components/copy-button";
2021 import { ErrorState } from "../components/error-state";
22+import { Input } from "../components/input";
2123 import {
2224 IntegrationFavicon,
2325 integrationInferredUrl,
@@ -521,14 +523,94 @@ function UserDetail(props: { · 497 unmodified lines
521523 });
522524 }
523525
526+// ── Search ──────────────────────────────────────────────────────────────────
527+
528+/** How long the typed term settles before it becomes a request. Long enough
529+ * that a typed name is one query rather than one per keystroke, short enough
530+ * to read as immediate. */
531+const SEARCH_DEBOUNCE_MS = 250;
532+
533+/**
534+ * The search box: what is typed, and the settled term the list actually asks
535+ * for. Two values because the request is debounced, and the input must keep
536+ * echoing keystrokes while the term catches up. Clearing bypasses the debounce
537+ * — an emptied box should show everyone at once, not after a pause.
538+ */
539+const useDebouncedSearch = (): {
540+ readonly typed: string;
541+ readonly term: string;
542+ readonly setTyped: (value: string) => void;
543+ readonly clear: () => void;
544+} => {
545+ const [typed, setTypedState] = useState("");
546+ const [term, setTerm] = useState("");
547+
548+ useEffect(() => {
549+ if (typed === term) return;
550+ const handle = setTimeout(() => setTerm(typed), SEARCH_DEBOUNCE_MS);
551+ return () => clearTimeout(handle);
552+ }, [typed, term]);
553+
554+ return {
555+ typed,
556+ term,
557+ setTyped: setTypedState,
558+ clear: () => {
559+ setTypedState("");
560+ setTerm("");
561+ },
562+ };
563+};
564+
565+function UserSearch(props: {
566+ readonly value: string;
567+ readonly onChange: (value: string) => void;
568+ readonly onClear: () => void;
569+}) {
570+ return (
571+ <div className="relative mb-4 min-w-0" data-slot="admin-users-search">
572+ <SearchIcon
573+ className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
574+ aria-hidden
575+ />
576+ <Input
577+ type="search"
578+ value={props.value}
579+ onChange={(event) => props.onChange((event.target as HTMLInputElement).value)}
580+ onKeyDown={(event) => {
581+ if (event.key === "Escape" && props.value !== "") props.onClear();
582+ }}
583+ placeholder="Search by name or email"
584+ aria-label="Search users by name or email"
585+ className="h-9 pl-9 pr-9 text-sm [&::-webkit-search-cancel-button]:hidden"
586+ />
587+ {props.value !== "" && (
588+ <Button
589+ type="button"
590+ variant="ghost"
591+ size="icon-xs"
592+ aria-label="Clear search"
593+ onClick={props.onClear}
594+ className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground"
595+ >
596+ <XIcon />
597+ </Button>
598+ )}
599+ </div>
600+ );
601+}
602+
524603 // ── Page ────────────────────────────────────────────────────────────────────
525604
526605 export function AdminUsersPage() {
527606 useExecutorDocumentTitle("Users");
528607 const [offset, setOffset] = useState(0);
529608 const [selected, setSelected] = useState<AdminUserRow | null>(null);
609+ const search = useDebouncedSearch();
530610
531− const page = { limit: ADMIN_USERS_PAGE_SIZE, offset };
611+ // A new term is a new list, so it starts on its first page: an offset kept
612+ // from a broader list would land past the end of a narrower one.
613+ const page = { limit: ADMIN_USERS_PAGE_SIZE, offset, search: search.term };
532614 const result = useAtomValue(adminUsersWithConnectionsAtom(page));
533615 const refresh = useAtomRefresh(adminUsersWithConnectionsAtom(page));
534616 const catalog = useCatalogRows();
@@ -555,10 +637,24 @@ export function AdminUsersPage() { · 20 unmodified lines
555637 </div>
556638 );
557639
640+ const searching = search.term !== "";
641+
558642 return (
559643 <PageContainer>
560644 {header}
561645
646+ <UserSearch
647+ value={search.typed}
648+ onChange={(value) => {
649+ search.setTyped(value);
650+ setOffset(0);
651+ }}
652+ onClear={() => {
653+ search.clear();
654+ setOffset(0);
655+ }}
656+ />
657+
562658 {isAsyncResultLoading(result)
563659 ? loading
564660 : AsyncResult.match(result, {
@@ -572,6 +668,25 @@ export function AdminUsersPage() { · 7 unmodified lines
572668 onSuccess: ({ value }) => {
573669 const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE);
574670
671+ if (rows.length === 0 && searching && offset === 0) {
672+ return (
673+ <div
674+ className="rounded-lg border border-dashed border-border bg-card p-8"
675+ data-slot="admin-users-no-match"
676+ >
677+ <h2 className="text-base font-semibold text-foreground">No users match</h2>
678+ <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
679+ Nobody in this workspace has a name or email containing &ldquo;
680+ {search.term}&rdquo;. Only people who have reached the workspace or connected
681+ an account are listed.
682+ </p>
683+ <Button variant="outline" size="sm" className="mt-4" onClick={search.clear}>
684+ Clear search
685+ </Button>
686+ </div>
687+ );
688+ }
689+
575690 if (rows.length === 0) {
576691 return (
577692 <div className="rounded-lg border border-dashed border-border bg-card p-8">
packages/react/src/pages/org.tsx+23−13
@@ -69,7 +69,7 @@ import { isAsyncResultLoading } from "../lib/async-result"; · 68 unmodified lines
6969
7070 type MemberData = {
7171 id: string;
72− email: string;
72+ email: string | null;
7373 name: string | null;
7474 avatarUrl: string | null;
7575 role: string;
@@ -80,6 +80,23 @@ type MemberData = { · 4 unmodified lines
8080
8181 type RoleData = { slug: string; name: string };
8282
83+/** What a member row is called: name, else email, else the one thing every
84+ * member has — a membership id — so a profile the host has not learned yet
85+ * still renders as a row an admin can act on. */
86+const memberLabel = (member: MemberData): string => member.name ?? member.email ?? member.id;
87+
88+const memberInitials = (member: MemberData): string => {
89+ if (member.name) {
90+ return member.name
91+ .split(" ")
92+ .map((n: string) => n[0])
93+ .join("")
94+ .slice(0, 2)
95+ .toUpperCase();
96+ }
97+ return (member.email?.[0] ?? "?").toUpperCase();
98+};
99+
83100 type InviteState = {
84101 email: string;
85102 roleSlug: string;
@@ -314,7 +331,7 @@ export function OrgPage(props: { · 228 unmodified lines
314331 const filtered = search
315332 ? members.filter(
316333 (m: MemberData) =>
317− m.email.toLowerCase().includes(search.toLowerCase()) ||
334+ (m.email?.toLowerCase().includes(search.toLowerCase()) ?? false) ||
318335 (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false),
319336 )
320337 : members;
@@ -338,21 +355,14 @@ export function OrgPage(props: { · 17 unmodified lines
338355 <img src={member.avatarUrl} alt="" className="size-8 rounded-full" />
339356 ) : (
340357 <div className="flex size-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
341− {member.name
342− ? member.name
343− .split(" ")
344− .map((n: string) => n[0])
345− .join("")
346− .slice(0, 2)
347− .toUpperCase()
348− : member.email[0]!.toUpperCase()}
358+ {memberInitials(member)}
349359 </div>
350360 )}
351361
352362 <div className="min-w-0">
353363 <div className="flex items-center gap-2">
354364 <p className="truncate text-sm font-medium text-foreground leading-none">
355− {member.name ?? member.email}
365+ {memberLabel(member)}
356366 </p>
357367 {member.isCurrentUser && (
358368 <Badge className="bg-muted text-muted-foreground">You</Badge>
@@ -361,7 +371,7 @@ export function OrgPage(props: { · 2 unmodified lines
361371 <Badge className="bg-muted text-muted-foreground">Invited</Badge>
362372 )}
363373 </div>
364− {member.name && (
374+ {member.name && member.email && (
365375 <p className="mt-0.5 truncate text-xs text-muted-foreground leading-none">
366376 {member.email}
367377 </p>
@@ -421,7 +431,7 @@ export function OrgPage(props: { · 53 unmodified lines
421431 onClick={() =>
422432 setRemovingMember({
423433 id: member.id,
424− name: member.name ?? member.email,
434+ name: memberLabel(member),
425435 })
426436 }
427437 >