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
8Member 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
5 AccountProvider,5 AccountProvider,
6 makeAccountApiLayer,6 makeAccountApiLayer,
7 requestScopedMiddleware,7 requestScopedMiddleware,
8 type MemberDirectory,
8} from "@executor-js/api/server";9} from "@executor-js/api/server";
9 10
10import { ApiKeyService } from "../auth/api-keys";11import { ApiKeyService } from "../auth/api-keys";
@@ -46,7 +47,8 @@ import { AccountCaller, workosAccountProvider } from "./workos-account-service"; · 35 unmodified lines
46// Builds the WorkOS `AccountProvider` per request, providing it to the handler.47// Builds the WorkOS `AccountProvider` per request, providing it to the handler.
47// Long-lived `WorkOSClient | AutumnService` come from the surrounding context48// Long-lived `WorkOSClient | AutumnService` come from the surrounding context
48// (Autumn provided by `makeAccountApiLive` for the seat-gate); the per-request49// (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.
50// `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`.52// `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`.
51const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()(53const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()(
52 Effect.gen(function* () {54 Effect.gen(function* () {
@@ -97,11 +99,11 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi · 44 unmodified lines
97 * (the seat-gate) stays a residual requirement, satisfied by the app `boot`.99 * (the seat-gate) stays a residual requirement, satisfied by the app `boot`.
98 */100 */
99export const workosAccountMiddleware = (101export const workosAccountMiddleware = (
100 rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,102 rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
101) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer;103) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer;
102 104
103export const makeAccountApiLive = (105export const makeAccountApiLive = (
104 rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,106 rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
105) => {107) => {
106 // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it108 // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it
107 // closes over the per-request postgres socket), so it can't be a self-109 // 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 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";2import { Effect, Layer } from "effect";
3 3
4import { AccountProvider } from "@executor-js/api/server";4import { AccountProvider, MemberDirectory } from "@executor-js/api/server";
5import { AccountError, AccountForbidden } from "@executor-js/api";5import { AccountError, AccountForbidden } from "@executor-js/api";
6 6
7import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";7import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";
@@ -147,6 +147,14 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ · 139 unmodified lines
147 organizationBackfilledAt: () => Effect.die("revoke does not report seats"),147 organizationBackfilledAt: () => Effect.die("revoke does not report seats"),
148});148});
149 149
150// Revoke lists no members either.
151const 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
150const stubAutumn = Layer.succeed(AutumnService)({158const stubAutumn = Layer.succeed(AutumnService)({
151 use: () => Effect.die("revoke does not touch billing"),159 use: () => Effect.die("revoke does not touch billing"),
152 ensureCustomer: () => Effect.die("revoke does not touch billing"),160 ensureCustomer: () => Effect.die("revoke does not touch billing"),
@@ -185,6 +193,7 @@ const providerWith = (accountId: string) => { · 32 unmodified lines
185 stubWorkOS,193 stubWorkOS,
186 stubUsers,194 stubUsers,
187 stubMirror,195 stubMirror,
196 stubDirectory,
188 stubApiKeys,197 stubApiKeys,
189 stubAutumn,198 stubAutumn,
190 Layer.succeed(AccountCaller)({ session: session(accountId) }),199 Layer.succeed(AccountCaller)({ session: session(accountId) }),
apps/cloud/src/account/workos-account-service.ts+66−34
@@ -1,6 +1,6 @@
1import { Context, Effect, Layer } from "effect";1import { Context, Effect, Layer } from "effect";
2 2
3import { AccountProvider, type AccountHeaders } from "@executor-js/api/server";3import { AccountProvider, MemberDirectory, type AccountHeaders } from "@executor-js/api/server";
4import {4import {
5 AccountError,5 AccountError,
6 AccountForbidden,6 AccountForbidden,
@@ -12,6 +12,7 @@ import { ApiKeyService } from "../auth/api-keys"; · 5 unmodified lines
12import { UserStoreService } from "../auth/context";12import { UserStoreService } from "../auth/context";
13import type { Session } from "../auth/middleware";13import type { Session } from "../auth/middleware";
14import { WorkOSClient } from "../auth/workos";14import { WorkOSClient } from "../auth/workos";
15import { ensureOrganizationBackfilled, mirrorInvitedMember } from "../auth/mirror-feeders";
15import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror";16import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror";
16import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";17import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
17import { AutumnService } from "../extensions/billing/service";18import { AutumnService } from "../extensions/billing/service";
@@ -66,7 +67,13 @@ const toAccountError = () => Effect.fail(new AccountError({ message: "Account re · 48 unmodified lines
66export const workosAccountProvider: Layer.Layer<67export const workosAccountProvider: Layer.Layer<
67 AccountProvider,68 AccountProvider,
68 never,69 never,
69 WorkOSClient | UserStoreService | WorkOsMirror | ApiKeyService | AutumnService | AccountCaller70 | WorkOSClient
71 | UserStoreService
72 | WorkOsMirror
73 | MemberDirectory
74 | ApiKeyService
75 | AutumnService
76 | AccountCaller
70> = Layer.effect(AccountProvider)(77> = Layer.effect(AccountProvider)(
71 Effect.gen(function* () {78 Effect.gen(function* () {
72 const workos = yield* WorkOSClient;79 const workos = yield* WorkOSClient;
@@ -77,6 +84,10 @@ export const workosAccountProvider: Layer.Layer< · 4 unmodified lines
77 // written through to the local mirror so the member list and the seat84 // written through to the local mirror so the member list and the seat
78 // count read the change without waiting for the Events reconciler.85 // count read the change without waiting for the Events reconciler.
79 const mirror = yield* WorkOsMirror;86 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;
80 91
81 // The caller, resolved once per request by the cookie-only session92 // The caller, resolved once per request by the cookie-only session
82 // middleware (account-api.ts) — the same credential `SessionAuthLive`93 // middleware (account-api.ts) — the same credential `SessionAuthLive`
@@ -85,10 +96,12 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines
85 const caller = yield* AccountCaller;96 const caller = yield* AccountCaller;
86 97
87 // Capture the resolved service context once so the method bodies — which98 // Capture the resolved service context once so the method bodies — which
88 // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) —99 // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`),
89 // can be erased to `R = never`, as the neutral AccountProvider shape100 // the mirror feeders, and the seat reporter — can be erased to `R = never`,
90 // requires. Provided per method below.101 // as the neutral AccountProvider shape requires. Provided per method below.
91 const ctx = yield* Effect.context<WorkOSClient | UserStoreService | AutumnService>();102 const ctx = yield* Effect.context<
103 WorkOSClient | UserStoreService | AutumnService | MemberDirectory | WorkOsMirror
104 >();
92 105
93 // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly106 // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly
94 // as the old inline `requireSession` did.107 // as the old inline `requireSession` did.
@@ -145,7 +158,14 @@ export const workosAccountProvider: Layer.Layer< · 50 unmodified lines
145 return membership;158 return membership;
146 });159 });
147 160
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.
149 const getMemberSeats = (organizationId: string) =>169 const getMemberSeats = (organizationId: string) =>
150 Effect.gen(function* () {170 Effect.gen(function* () {
151 const customer = yield* autumn.use((client) =>171 const customer = yield* autumn.use((client) =>
@@ -154,15 +174,16 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines
154 const planId = selectActiveMemberLimitPlan(customer.subscriptions);174 const planId = selectActiveMemberLimitPlan(customer.subscriptions);
155 const limit = getMemberLimitForPlan(planId);175 const limit = getMemberLimitForPlan(planId);
156 176
157 // `listOrgMembers` returns active members AND pending memberships (an177 yield* ensureOrganizationBackfilled(organizationId).pipe(Effect.provideContext(ctx));
158 // invited user shows up as status "pending"); `listPendingInvitations`178 // The directory reports active members AND pending memberships (an
179 // invited user is mirrored with status "pending"); `listPendingInvitations`
159 // returns the same invited users again. `countSeatsUsed` dedupes them180 // returns the same invited users again. `countSeatsUsed` dedupes them
160 // so an outstanding invite is not counted twice.181 // so an outstanding invite is not counted twice.
161 const memberships = yield* workos.listOrgMembers(organizationId);182 const memberships = yield* directory.members(organizationId);
162 const invitations = yield* workos.listPendingInvitations(organizationId);183 const invitations = yield* workos.listPendingInvitations(organizationId);
163 184
164 return {185 return {
165 used: countSeatsUsed(memberships.data, invitations.data.length),186 used: countSeatsUsed(memberships, invitations.data.length),
166 granted: limit ?? 0,187 granted: limit ?? 0,
167 unlimited: limit === null,188 unlimited: limit === null,
168 };189 };
@@ -328,29 +349,23 @@ export const workosAccountProvider: Layer.Layer< · 159 unmodified lines
328 Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })),349 Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })),
329 );350 );
330 351
331 const memberships = yield* workos352 // One directory read (active + pending, ordered by email) with the
332 .listOrgMembers(org.id)353 // profile already joined — no per-member WorkOS user fetch.
333 .pipe(Effect.catchTag("WorkOSError", toAccountError));354 const directoryMembers = yield* directory
334 355 .members(org.id)
335 const members = yield* Effect.all(356 .pipe(Effect.catchTag("MemberDirectoryError", toAccountError));
336 memberships.data.map((m) =>357
337 Effect.gen(function* () {358 const members = directoryMembers.map((m) => ({
338 const user = yield* workos.getUser(m.userId);359 id: m.membershipId,
339 return {360 userId: m.accountId,
340 id: m.id,361 email: m.email,
341 userId: m.userId,362 name: m.name,
342 email: user.email,363 avatarUrl: m.avatarUrl,
343 name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null,364 role: m.role,
344 avatarUrl: user.profilePictureUrl ?? null,365 status: m.status,
345 role: m.role?.slug ?? "member",366 lastActiveAt: m.lastActiveAt === null ? null : new Date(m.lastActiveAt).toISOString(),
346 status: m.status,367 isCurrentUser: m.accountId === session.accountId,
347 lastActiveAt: user.lastSignInAt ?? null,368 }));
348 isCurrentUser: m.userId === session.accountId,
349 };
350 }),
351 ),
352 { concurrency: 5 },
353 ).pipe(Effect.catchTag("WorkOSError", toAccountError));
354 369
355 return { members, seats };370 return { members, seats };
356 }),371 }),
@@ -378,6 +393,23 @@ export const workosAccountProvider: Layer.Layer< · 21 unmodified lines
378 ...(body.roleSlug ? { roleSlug: body.roleSlug } : {}),393 ...(body.roleSlug ? { roleSlug: body.roleSlug } : {}),
379 })394 })
380 .pipe(Effect.catchTag("WorkOSError", toAccountError));395 .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 }
381 return { id: invitation.id, email: invitation.email };413 return { id: invitation.id, email: invitation.email };
382 }),414 }),
383 415
apps/cloud/src/admin/admin-users-api.ts+32−134
@@ -25,28 +25,24 @@ · 24 unmodified lines
25// every query by that tenant.25// every query by that tenant.
26// ---------------------------------------------------------------------------26// ---------------------------------------------------------------------------
27 27
28import { env } from "cloudflare:workers";
29import { HttpRouter } from "effect/unstable/http";28import { HttpRouter } from "effect/unstable/http";
30import { Context, Effect, Layer, Option } from "effect";29import { Effect, Layer } from "effect";
31 30
32import {31import {
33 AdminUsersProvider,32 AdminUsersProvider,
34 DbProvider,33 DbProvider,
35 HostConfig,34 HostConfig,
35 MemberDirectory,
36 PluginsProvider,36 PluginsProvider,
37 adminUserDirectoryFromMembers,
37 getAdminUser,38 getAdminUser,
38 listAdminUserConnections,39 listAdminUserConnections,
39 listAdminUsers,40 listAdminUsers,
40 listAdminUsersWithConnections,41 listAdminUsersWithConnections,
41 makeAdminUsersApiLayer,42 makeAdminUsersApiLayer,
42 makePlatformExecutor,43 makePlatformExecutor,
43 normalizeAdminUserEmail,
44 platformViewOf,44 platformViewOf,
45 requestScopedMiddleware,45 requestScopedMiddleware,
46 type AdminEmailResolver,
47 type AdminIdentityDirectory,
48 type AdminUserDirectory,
49 type AdminUserIdentity,
50 type AdminUsersHeaders,46 type AdminUsersHeaders,
51} from "@executor-js/api/server";47} from "@executor-js/api/server";
52import {48import {
@@ -122,124 +118,6 @@ const authorizeTenant = ( · 69 unmodified lines
122 return org.id;118 return org.id;
123 });119 });
124 120
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 */
130const 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 */
153const 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 */
207export 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. */
235const userDirectory = (
236 organizationId: string,
237 context: Context.Context<WorkOSClient>,
238): AdminUserDirectory => ({
239 identities: identityDirectory(organizationId, context),
240 resolveEmail: emailResolver(organizationId, context),
241});
242
243/**121/**
244 * Authorize, then run `body` against the tenant's platform view.122 * Authorize, then run `body` against the tenant's platform view.
245 *123 *
@@ -264,8 +142,9 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 18 unmodified lines
264 const executor = yield* makePlatformExecutor(organizationId).pipe(142 const executor = yield* makePlatformExecutor(organizationId).pipe(
265 Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })),143 Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })),
266 );144 );
267 // The authorized tenant is handed to the body so an identity join reads the145 // The authorized tenant is handed to the body so the directory reads the
268 // SAME org the reads are scoped to — never one named by client input.146 // SAME org the storage reads are scoped to — never one named by client
147 // input.
269 return yield* Effect.ensuring(148 return yield* Effect.ensuring(
270 body(executor, organizationId),149 body(executor, organizationId),
271 executor.close().pipe(Effect.ignore),150 executor.close().pipe(Effect.ignore),
@@ -275,22 +154,40 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 3 unmodified lines
275/**154/**
276 * Cloud's `AdminUsersProvider`, built per request so the platform executor155 * Cloud's `AdminUsersProvider`, built per request so the platform executor
277 * closes over the per-request postgres socket.156 * 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.
278 */164 */
279export const workosAdminUsersProvider: Layer.Layer<165export const workosAdminUsersProvider: Layer.Layer<
280 AdminUsersProvider,166 AdminUsersProvider,
281 never,167 never,
282 WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig168 | WorkOSClient
169 | ApiKeyService
170 | UserStoreService
171 | MemberDirectory
172 | DbProvider
173 | PluginsProvider
174 | HostConfig
283> = Layer.effect(AdminUsersProvider)(175> = Layer.effect(AdminUsersProvider)(
284 Effect.gen(function* () {176 Effect.gen(function* () {
285 const context = yield* Effect.context<177 const context = yield* Effect.context<
286 WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig178 WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig
287 >();179 >();
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);
288 return AdminUsersProvider.of({185 return AdminUsersProvider.of({
289 listUsers: (headers, options) =>186 listUsers: (headers, options) =>
290 withPlatformView(headers, (executor, organizationId) =>187 withPlatformView(headers, (executor, organizationId) =>
291 platformViewOf(executor).pipe(188 platformViewOf(executor).pipe(
292 Effect.flatMap((admin) =>189 Effect.flatMap((admin) =>
293 listAdminUsers(admin, options, userDirectory(organizationId, context)),190 listAdminUsers(admin, options, userDirectory(organizationId)),
294 ),191 ),
295 ),192 ),
296 ).pipe(Effect.provideContext(context)),193 ).pipe(Effect.provideContext(context)),
@@ -298,7 +195,7 @@ export const workosAdminUsersProvider: Layer.Layer< · 1 unmodified line
298 withPlatformView(headers, (executor, organizationId) =>195 withPlatformView(headers, (executor, organizationId) =>
299 platformViewOf(executor).pipe(196 platformViewOf(executor).pipe(
300 Effect.flatMap((admin) =>197 Effect.flatMap((admin) =>
301 listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)),198 listAdminUsersWithConnections(admin, options, userDirectory(organizationId)),
302 ),199 ),
303 ),200 ),
304 ).pipe(Effect.provideContext(context)),201 ).pipe(Effect.provideContext(context)),
@@ -312,7 +209,7 @@ export const workosAdminUsersProvider: Layer.Layer< · 7 unmodified lines
312 withPlatformView(headers, (executor, organizationId) =>209 withPlatformView(headers, (executor, organizationId) =>
313 platformViewOf(executor).pipe(210 platformViewOf(executor).pipe(
314 Effect.flatMap((admin) =>211 Effect.flatMap((admin) =>
315 getAdminUser(admin, identifier, userDirectory(organizationId, context)),212 getAdminUser(admin, identifier, userDirectory(organizationId)),
316 ),213 ),
317 ),214 ),
318 ).pipe(Effect.provideContext(context)),215 ).pipe(Effect.provideContext(context)),
@@ -322,8 +219,9 @@ export const workosAdminUsersProvider: Layer.Layer< · 3 unmodified lines
322 219
323// Builds the provider per request, providing it to the handlers. Long-lived220// Builds the provider per request, providing it to the handlers. Long-lived
324// `WorkOSClient | ApiKeyService` come from the surrounding boot context; the221// `WorkOSClient | ApiKeyService` come from the surrounding boot context; the
325// per-request `DbService`/`UserStoreService` (and the execution seams built222// per-request `DbService`/`UserStoreService`/`MemberDirectory` (and the
326// over them) are supplied by the combined `requestScopedMiddleware`.223// execution seams built over them) are supplied by the combined
224// `requestScopedMiddleware`.
327const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()(225const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()(
328 Effect.gen(function* () {226 Effect.gen(function* () {
329 const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>();227 const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>();
@@ -348,7 +246,7 @@ const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUser · 18 unmodified lines
348 * `/api` prefix as the rest of the cloud router.246 * `/api` prefix as the rest of the cloud router.
349 */247 */
350export const makeCloudAdminUsersRoutes = (248export const makeCloudAdminUsersRoutes = (
351 rsLive: Layer.Layer<DbService | UserStoreService>,249 rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory>,
352 options: Parameters<typeof makeAdminUsersApiLayer>[1] = {},250 options: Parameters<typeof makeAdminUsersApiLayer>[1] = {},
353) =>251) =>
354 makeAdminUsersApiLayer(252 makeAdminUsersApiLayer(
apps/cloud/src/admin/admin-users-email.node.test.ts+0−127
@@ -1,127 +0,0 @@
1import { env } from "cloudflare:workers";
2import { expect, it } from "@effect/vitest";
3import { Data, Effect, Layer } from "effect";
4
5import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
6import { 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
13const ORG = "org_placeholder";
14const OTHER_ORG = "org_other";
15
16class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{
17 readonly userId: string;
18}> {}
19
20const 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
29const 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
72const 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
93it.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
101it.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
109it.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
119it.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
2import { HttpServer } from "effect/unstable/http";2import { HttpServer } from "effect/unstable/http";
3import { Layer } from "effect";3import { Layer } from "effect";
4 4
5import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api/server";5import {
6 makeProtectedApiLayer,
7 requestScopedMiddleware,
8 type MemberDirectory,
9} from "@executor-js/api/server";
6 10
7import { SessionAuthLive } from "../auth/middleware-live";11import { SessionAuthLive } from "../auth/middleware-live";
8import { UserStoreService } from "../auth/context";12import { UserStoreService } from "../auth/context";
13import { cloudMemberDirectoryLayer } from "../auth/member-directory";
9import { WorkOsMirror } from "../auth/workos-mirror";14import { WorkOsMirror } from "../auth/workos-mirror";
10import {15import {
11 CloudAuthPublicHandlers,16 CloudAuthPublicHandlers,
@@ -27,12 +32,17 @@ import { CoreSharedServices } from "../auth/workos"; · 15 unmodified lines
27const DbLive = DbService.Live;32const DbLive = DbService.Live;
28const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));33const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
29const WorkOsMirrorLive = WorkOsMirror.Live.pipe(Layer.provide(DbLive));34const 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.
37const MemberDirectoryLive = cloudMemberDirectoryLayer.pipe(Layer.provide(DbLive));
30 38
31// Per-request layer. Anything that opens an I/O object (postgres.js socket,39// Per-request layer. Anything that opens an I/O object (postgres.js socket,
32// fetch stream readers, anything backed by a `Writable`) MUST live here —40// fetch stream readers, anything backed by a `Writable`) MUST live here —
33// `provideRequestScoped` rebuilds it per request so Cloudflare Workers'41// `provideRequestScoped` rebuilds it per request so Cloudflare Workers'
34// I/O isolation is satisfied. See `api.request-scope.test.ts`.42// I/O isolation is satisfied. See `api.request-scope.test.ts`.
35export const RequestScopedServicesLive = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive);43export const RequestScopedServicesLive: Layer.Layer<
44 DbService | UserStoreService | WorkOsMirror | MemberDirectory
45> = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive, MemberDirectoryLive);
36 46
37// Boot-scoped layer. Built once at worker boot, reused across requests.47// Boot-scoped layer. Built once at worker boot, reused across requests.
38// Safe for config, in-memory caches, the global tracer provider, and48// Safe for config, in-memory caches, the global tracer provider, and
@@ -57,7 +67,7 @@ export const BootSharedServices = Layer.mergeAll( · 18 unmodified lines
57// handler reads it for the free-organizations-per-user limit gate — one of the67// handler reads it for the free-organizations-per-user limit gate — one of the
58// few app-only billing touchpoints. (It is NOT on the neutral boot core.)68// few app-only billing touchpoints. (It is NOT on the neutral boot core.)
59export const makeNonProtectedApiLive = (69export const makeNonProtectedApiLive = (
60 rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,70 rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
61) =>71) =>
62 HttpApiBuilder.layer(NonProtectedApi).pipe(72 HttpApiBuilder.layer(NonProtectedApi).pipe(
63 Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),73 Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)),
apps/cloud/src/api/router.ts+6−2
@@ -1,7 +1,11 @@
1import { Layer } from "effect";1import { Layer } from "effect";
2import { HttpRouter } from "effect/unstable/http";2import { HttpRouter } from "effect/unstable/http";
3 3
4import { RouterConfigLive, requestScopedMiddleware } from "@executor-js/api/server";4import {
5 RouterConfigLive,
6 requestScopedMiddleware,
7 type MemberDirectory,
8} from "@executor-js/api/server";
5 9
6import { UserStoreService } from "../auth/context";10import { UserStoreService } from "../auth/context";
7import { WorkOsMirror } from "../auth/workos-mirror";11import { WorkOsMirror } from "../auth/workos-mirror";
@@ -31,7 +35,7 @@ import { makeProtectedApiLive } from "./protected"; · 23 unmodified lines
31// assert per-request semantics — see35// assert per-request semantics — see
32// `apps/cloud/src/api.request-scope.node.test.ts`.36// `apps/cloud/src/api.request-scope.node.test.ts`.
33export const makeApiLive = (37export const makeApiLive = (
34 requestScopedLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,38 requestScopedLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
35) => {39) => {
36 const BillingRoutesLive = AutumnRoutesLive.pipe(40 const BillingRoutesLive = AutumnRoutesLive.pipe(
37 Layer.provide(requestScopedMiddleware(requestScopedLive).layer),41 Layer.provide(requestScopedMiddleware(requestScopedLive).layer),
apps/cloud/src/auth/mirror-feeders.node.test.ts+360−11
@@ -12,11 +12,20 @@ · 11 unmodified lines
12// - the callback picks the landing org from that same list: a returnTo12// - the callback picks the landing org from that same list: a returnTo
13// slug or last-org cookie lands only in an ACTIVE membership, an unknown13// slug or last-org cookie lands only in an ACTIVE membership, an unknown
14// or pending one falls through14// 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
15// - `removeMember` tombstones the mirror row after the WorkOS delete,18// - `removeMember` tombstones the mirror row after the WorkOS delete,
16// stamped with the membership's last WorkOS state (never a local clock),19// stamped with the membership's last WorkOS state (never a local clock),
17// so a replay of the membership as it was before the delete cannot20// so a replay of the membership as it was before the delete cannot
18// restore it while a replacement WorkOS created meanwhile is accepted21// restore it while a replacement WorkOS created meanwhile is accepted
19// - `updateMemberRole` writes the role WorkOS returned22// - `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
20// - the backfill mirrors every org's members and counts what it wrote,29// - the backfill mirrors every org's members and counts what it wrote,
21// writes nothing on a dry run, converges on a re-run, tombstones a30// writes nothing on a dry run, converges on a re-run, tombstones a
22// membership WorkOS no longer lists — but never one written after its31// membership WorkOS no longer lists — but never one written after its
@@ -39,10 +48,11 @@ · 16 unmodified lines
39 48
40import { describe, expect, it } from "@effect/vitest";49import { describe, expect, it } from "@effect/vitest";
41import { sql } from "drizzle-orm";50import { sql } from "drizzle-orm";
42import { Effect, Exit, Fiber, Latch, Layer } from "effect";51import { Effect, Exit, Fiber, Latch, Layer, Option } from "effect";
43import { HttpRouter, HttpServer } from "effect/unstable/http";52import { HttpRouter, HttpServer } from "effect/unstable/http";
44import { HttpApiBuilder } from "effect/unstable/httpapi";53import { HttpApiBuilder } from "effect/unstable/httpapi";
45 54
55import { AccountForbidden } from "@executor-js/api";
46import {56import {
47 AccountProvider,57 AccountProvider,
48 MemberDirectory,58 MemberDirectory,
@@ -53,6 +63,7 @@ import { · 4 unmodified lines
53import { AccountCaller, workosAccountProvider } from "../account/workos-account-service";63import { AccountCaller, workosAccountProvider } from "../account/workos-account-service";
54import { RequestScopedServicesLive } from "../api/layers";64import { RequestScopedServicesLive } from "../api/layers";
55import { DbService } from "../db/db";65import { DbService } from "../db/db";
66import { forkReportMemberSeats } from "../extensions/billing/member-seats";
56import { AutumnService } from "../extensions/billing/service";67import { AutumnService } from "../extensions/billing/service";
57import { ApiKeyService } from "./api-keys";68import { ApiKeyService } from "./api-keys";
58import { UserStoreService } from "./context";69import { UserStoreService } from "./context";
@@ -210,13 +221,21 @@ describe("login callback", () => { · 151 unmodified lines
210 listMetadata: { before: null, after: null },221 listMetadata: { before: null, after: null },
211 });222 });
212 },223 },
213 // The forked seat recount after login.224 // The landing org's seat recount scans the org from WorkOS the first
214 listOrgMembers: () =>225 // time it is counted (its per-org backfill mark is missing); the
215 Effect.succeed({226 // scan lists the org's members and fetches each user.
227 listOrgMembers: (organizationId) => {
228 calls.push(`listOrgMembers:${organizationId}`);
229 return Effect.succeed({
216 object: "list" as const,230 object: "list" as const,
217 data: [] as never[],231 data: listed.filter((m) => m.organizationId === organizationId) as never[],
218 listMetadata: { before: null, after: null },232 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 },
220 refreshSession: (_sealed, organizationId) => {239 refreshSession: (_sealed, organizationId) => {
221 refreshedInto.push(organizationId);240 refreshedInto.push(organizationId);
222 return Effect.succeed("sealed-refreshed");241 return Effect.succeed("sealed-refreshed");
@@ -268,7 +287,17 @@ describe("login callback", () => { · 45 unmodified lines
268 const response = await handler(callbackRequest({}));287 const response = await handler(callbackRequest({}));
269 288
270 expect(response.status).toBe(302);289 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([
272 `listUserMemberships:${userId}`,301 `listUserMemberships:${userId}`,
273 ]);302 ]);
274 303
@@ -542,7 +571,14 @@ describe("account service writes through to the mirror", () => { · 267 unmodified lines
542 * Provided around the WHOLE test body so the postgres socket outlives the571 * Provided around the WHOLE test body so the postgres socket outlives the
543 * provider call under test.572 * provider call under test.
544 */573 */
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 ) => {
546 const list = (data: readonly unknown[]) =>582 const list = (data: readonly unknown[]) =>
547 Effect.succeed({583 Effect.succeed({
548 object: "list" as const,584 object: "list" as const,
@@ -550,6 +586,7 @@ describe("account service writes through to the mirror", () => { · 1 unmodified line
550 listMetadata: { before: null, after: null },586 listMetadata: { before: null, after: null },
551 });587 });
552 const workos = stubWorkOS({588 const workos = stubWorkOS({
589 ...options.workos,
553 listUserMemberships: (userId) => list([workosMembership(userId, org)]),590 listUserMemberships: (userId) => list([workosMembership(userId, org)]),
554 getUserOrgMembership: (organizationId, userId) =>591 getUserOrgMembership: (organizationId, userId) =>
555 Effect.succeed(592 Effect.succeed(
@@ -571,7 +608,6 @@ describe("account service writes through to the mirror", () => { · 15 unmodified lines
571 updatedAt: T2,608 updatedAt: T2,
572 }) as never,609 }) as never,
573 ),610 ),
574 listOrgMembers: () => list([]),
575 });611 });
576 // The test database serves ONE connection at a time, so the seed, the612 // The test database serves ONE connection at a time, so the seed, the
577 // provider, and the directory read all share this layer's socket.613 // 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
585 Layer.mergeAll(621 Layer.mergeAll(
586 workos,622 workos,
587 stubApiKeys,623 stubApiKeys,
588 stubAutumn,624 options.autumn ?? stubAutumn,
589 Layer.succeed(AccountCaller)({ session: session(ADMIN) }),625 Layer.succeed(AccountCaller)({ session: session(ADMIN) }),
590 ),626 ),
591 ),627 ),
@@ -595,7 +631,13 @@ describe("account service writes through to the mirror", () => { · 3 unmodified lines
595 };631 };
596 632
597 // TARGET as an existing member of `org`, seeded through the live mirror.633 // 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 ) =>
599 Effect.gen(function* () {641 Effect.gen(function* () {
600 const users = yield* UserStoreService;642 const users = yield* UserStoreService;
601 const mirror = yield* WorkOsMirror;643 const mirror = yield* WorkOsMirror;
@@ -614,11 +656,175 @@ describe("account service writes through to the mirror", () => { · 12 unmodified lines
614 status: "active",656 status: "active",
615 updatedAt: new Date(T1),657 updatedAt: new Date(T1),
616 });658 });
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 }
617 });668 });
618 669
619 const membersOf = (org: string) =>670 const membersOf = (org: string) =>
620 Effect.flatMap(MemberDirectory.asEffect(), (directory) => directory.members(org));671 Effect.flatMap(MemberDirectory.asEffect(), (directory) => directory.members(org));
621 672
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
622 it.effect("removeMember tombstones the mirror row after the WorkOS delete", () => {828 it.effect("removeMember tombstones the mirror row after the WorkOS delete", () => {
623 const org = freshId("org");829 const org = freshId("org");
624 const deleted: string[] = [];830 const deleted: string[] = [];
@@ -688,6 +894,149 @@ describe("account service writes through to the mirror", () => { · 63 unmodified lines
688 });894 });
689});895});
690 896
897describe("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
691describe("backfill", () => {1040describe("backfill", () => {
692 /** A fake WorkOS holding `orgs` → members, counting `getUser` calls. */1041 /** A fake WorkOS holding `orgs` → members, counting `getUser` calls. */
693 const source = (orgs: ReadonlyMap<string, readonly FakeMembership[]>, userCalls: string[]) => ({1042 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
5// Each feeder takes the WorkOS payload the caller ALREADY holds (the5// Each feeder takes the WorkOS payload the caller ALREADY holds (the
6// authenticated user, the membership list the callback fetches to pick a6// authenticated user, the membership list the callback fetches to pick a
7// landing org, the membership a write returned) so feeding the mirror never7// 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 the8// adds a WorkOS read — except the two writes whose WorkOS response is not the
9// membership read path, so a login that could not record its memberships is9// membership they changed: invitation acceptance (`auth/handlers.ts` reads
10// not a login that finished.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.
11// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
12 16
13import { Effect } from "effect";17import { Effect } from "effect";
14 18
19import { normalizeAdminUserEmail } from "@executor-js/api/server";
20
15import { UserStoreService } from "./context";21import { UserStoreService } from "./context";
22import { WorkOSClient } from "./workos";
16import {23import {
17 WorkOsMirror,24 WorkOsMirror,
18 mirrorMembershipFromWorkOs,25 mirrorMembershipFromWorkOs,
@@ -20,6 +27,7 @@ import { · 1 unmodified line
20 type WorkOsMembershipPayload,27 type WorkOsMembershipPayload,
21 type WorkOsUserPayload,28 type WorkOsUserPayload,
22} from "./workos-mirror";29} from "./workos-mirror";
30import { backfillOrganization } from "./workos-mirror-backfill";
23 31
24/**32/**
25 * A membership as WorkOS lists it for a user: carries the organization's name,33 * 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
78 Effect.flatMap(WorkOsMirror.asEffect(), (mirror) =>86 Effect.flatMap(WorkOsMirror.asEffect(), (mirror) =>
79 mirror.upsertMembership(mirrorMembershipFromWorkOs(membership)),87 mirror.upsertMembership(mirrorMembershipFromWorkOs(membership)),
80 );88 );
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.
93const 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 */
110export 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 */
153export 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
16import { HttpApiBuilder } from "effect/unstable/httpapi";16import { HttpApiBuilder } from "effect/unstable/httpapi";
17import { HttpApi } from "effect/unstable/httpapi";17import { HttpApi } from "effect/unstable/httpapi";
18 18
19import { MemberDirectory } from "@executor-js/api/server";
20
19import { CloudAuthPublicHandlers } from "./handlers";21import { CloudAuthPublicHandlers } from "./handlers";
20import { CloudAuthPublicApi } from "./api";22import { CloudAuthPublicApi } from "./api";
21import { UserStoreService } from "./context";23import { UserStoreService } from "./context";
@@ -50,9 +52,6 @@ const stubWorkOS = Layer.succeed( · 28 unmodified lines
50 if (prop === "listUserMemberships") {52 if (prop === "listUserMemberships") {
51 return () => Effect.succeed({ data: [] });53 return () => Effect.succeed({ data: [] });
52 }54 }
53 if (prop === "listOrgMembers") {
54 return () => Effect.succeed({ data: [{ status: "active" }] });
55 }
56 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);55 return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
57 },56 },
58 }),57 }),
@@ -109,7 +108,9 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 50 unmodified lines
109});108});
110 109
111// The callback records the sign-in (user + memberships) in the membership110// 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.
113const stubMirror = Layer.succeed(WorkOsMirror)({114const stubMirror = Layer.succeed(WorkOsMirror)({
114 upsertUser: () => Effect.succeed(true),115 upsertUser: () => Effect.succeed(true),
115 upsertMembership: () => Effect.succeed(true),116 upsertMembership: () => Effect.succeed(true),
@@ -124,7 +125,27 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ · 8 unmodified lines
124 markBackfillCompleted: () => Effect.die("the callback does not run the backfill"),125 markBackfillCompleted: () => Effect.die("the callback does not run the backfill"),
125 drainedAt: () => Effect.die("the callback does not check mirror readiness"),126 drainedAt: () => Effect.die("the callback does not check mirror readiness"),
126 markDrained: () => Effect.die("the callback does not run the reconciler"),127 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
131const 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 ]),
128});149});
129 150
130// Only the public group is under test; the session group (and its SessionAuth151// 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
137 Layer.provide(stubWorkOS),158 Layer.provide(stubWorkOS),
138 Layer.provide(stubUsers),159 Layer.provide(stubUsers),
139 Layer.provide(stubMirror),160 Layer.provide(stubMirror),
161 Layer.provide(stubDirectory),
140 Layer.provide(AutumnService.Default),162 Layer.provide(AutumnService.Default),
141 Layer.provide(HttpServer.layerServices),163 Layer.provide(HttpServer.layerServices),
142);164);
apps/cloud/src/auth/workos.ts+12−14
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 WorkOS,9 WorkOS,
10 type Event as WorkOSEvent,10 type Event as WorkOSEvent,
11 type EventName as WorkOSEventName,11 type EventName as WorkOSEventName,
12 type OrganizationMembershipStatus,
12} from "@workos-inc/node/worker";13} from "@workos-inc/node/worker";
13import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto";14import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto";
14import { decodeJwt, jwtVerify } from "jose";15import { decodeJwt, jwtVerify } from "jose";
@@ -665,13 +666,21 @@ const make = Effect.gen(function* () { · 650 unmodified lines
665 deleteApiKey: (id: string) =>666 deleteApiKey: (id: string) =>
666 use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)),667 use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)),
667 668
668 /** List organization memberships with user details. */669 /**
669 listOrgMembers: (organizationId: string) =>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 ) =>
670 use("userManagement.listOrganizationMemberships", async (wos) =>679 use("userManagement.listOrganizationMemberships", async (wos) =>
671 collectWorkOSList(680 collectWorkOSList(
672 await wos.userManagement.listOrganizationMemberships({681 await wos.userManagement.listOrganizationMemberships({
673 organizationId,682 organizationId,
674 statuses: ["active", "pending"],683 statuses: [...statuses],
675 }),684 }),
676 ),685 ),
677 ),686 ),
@@ -691,17 +700,6 @@ const make = Effect.gen(function* () { · 13 unmodified lines
691 getUser: (userId: string) =>700 getUser: (userId: string) =>
692 use("userManagement.getUser", (wos) => wos.userManagement.getUser(userId)),701 use("userManagement.getUser", (wos) => wos.userManagement.getUser(userId)),
693 702
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
705 /** Send an organization invitation. */703 /** Send an organization invitation. */
706 sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) =>704 sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) =>
707 use("userManagement.sendInvitation", (wos) =>705 use("userManagement.sendInvitation", (wos) =>
apps/cloud/src/extensions/billing/member-seats.ts+49−29
@@ -1,11 +1,16 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Seat-count reporting — the WorkOS → Autumn reconciliation for seat billing2// Seat-count reporting — the membership mirror → Autumn reconciliation for
3// seat billing
3// ---------------------------------------------------------------------------4// ---------------------------------------------------------------------------
4 5
5import { Effect } from "effect";6import { Effect } from "effect";
6import { waitUntil } from "cloudflare:workers";7import { waitUntil } from "cloudflare:workers";
7 8
8import { WorkOSClient } from "../../auth/workos";9import { MemberDirectory } from "@executor-js/api/server";
10
11import { ensureOrganizationBackfilled } from "../../auth/mirror-feeders";
12import type { WorkOSClient } from "../../auth/workos";
13import type { WorkOsMirror } from "../../auth/workos-mirror";
9import { AutumnService } from "./service";14import { AutumnService } from "./service";
10 15
11/**16/**
@@ -16,39 +21,54 @@ import { AutumnService } from "./service"; · 4 unmodified lines
16 * Seats change through paths the app never sees a mutation for (invitation21 * Seats change through paths the app never sees a mutation for (invitation
17 * acceptance in AuthKit, SSO JIT provisioning, join by domain, WorkOS22 * acceptance in AuthKit, SSO JIT provisioning, join by domain, WorkOS
18 * dashboard edits), so this reconciles from a full recount rather than23 * dashboard edits), so this reconciles from a full recount rather than
19 * tracking deltas. It runs after in-app membership mutations AND on every24 * tracking deltas. The count comes from the local membership mirror through
20 * login callback, so drift from out-of-band changes heals on the next25 * the shared `MemberDirectory`: every in-app membership mutation writes
21 * sign-in. Fire-and-forget-safe: errors are logged, never surfaced.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.
22 */52 */
23export const reportMemberSeats = (53export const forkReportMemberSeats = (
24 organizationId: string,54 organizationId: string,
25): Effect.Effect<void, never, WorkOSClient | AutumnService> =>55): Effect.Effect<void, never, MemberDirectory | WorkOsMirror | WorkOSClient | AutumnService> =>
26 Effect.gen(function* () {56 Effect.gen(function* () {
27 const workos = yield* WorkOSClient;57 const directory = yield* MemberDirectory;
28 const autumn = yield* AutumnService;58 const autumn = yield* AutumnService;
29 const memberships = yield* workos.listOrgMembers(organizationId);59 yield* ensureOrganizationBackfilled(organizationId);
30 const seats = memberships.data.filter((m) => m.status === "active").length;60 const seats = yield* directory
31 yield* autumn.setMemberSeats(organizationId, seats);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 });
32 }).pipe(66 }).pipe(
33 Effect.catch((error) =>67 Effect.catch((error) =>
34 Effect.logWarning("reportMemberSeats: seat recount failed", { organizationId, error }),68 Effect.logWarning("reportMemberSeats: seat recount failed", {
69 organizationId,
70 error,
71 }),
35 ),72 ),
36 Effect.withSpan("billing.reportMemberSeats"),73 Effect.withSpan("billing.reportMemberSeats"),
37 );74 );
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 */
46export 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
28import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";28import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi";
29 29
30import { AccountApi, AdminUsersApi } from "@executor-js/api";30import { AccountApi, AdminUsersApi } from "@executor-js/api";
31import { requestScopedMiddleware } from "@executor-js/api/server";31import { requestScopedMiddleware, type MemberDirectory } from "@executor-js/api/server";
32 32
33import { UserStoreService } from "../auth/context";33import { UserStoreService } from "../auth/context";
34import { WorkOsMirror } from "../auth/workos-mirror";34import { WorkOsMirror } from "../auth/workos-mirror";
@@ -79,7 +79,7 @@ const spec = OpenApi.fromApi(CloudOpenApi); · 44 unmodified lines
79 * core.79 * core.
80 */80 */
81export const makeCloudExtensionRoutes = (81export const makeCloudExtensionRoutes = (
82 rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,82 rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
83) => {83) => {
84 // Session routes (login / callback / me / switch-org / …). Handlers yield84 // Session routes (login / callback / me / switch-org / …). Handlers yield
85 // `UserStoreService` directly; the per-request DB combine keeps the postgres85 // `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
145 const members = result.members.map((member) => ({145 const members = result.members.map((member) => ({
146 id: member.id,146 id: member.id,
147 userId: member.userId,147 userId: member.userId,
148 email: member.user?.email ?? "",148 email: member.user?.email ?? null,
149 name: member.user?.name ?? null,149 name: member.user?.name ?? null,
150 avatarUrl: member.user?.image ?? null,150 avatarUrl: member.user?.image ?? null,
151 role: member.role,151 role: member.role,
apps/host-selfhost/src/admin/admin-users-api.ts+20−77
@@ -16,7 +16,11 @@ · 15 unmodified lines
16//16//
17// The READ half is identical to cloud's: a subject-less, tenant-reach executor17// The READ half is identical to cloud's: a subject-less, tenant-reach executor
18// from `makePlatformExecutor`, projected by the shared `admin/reads`. Self-host18// 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.
20// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
21 25
22import { HttpRouter } from "effect/unstable/http";26import { HttpRouter } from "effect/unstable/http";
@@ -26,18 +30,17 @@ import { · 3 unmodified lines
26 AdminUsersProvider,30 AdminUsersProvider,
27 DbProvider,31 DbProvider,
28 HostConfig,32 HostConfig,
33 MemberDirectory,
29 PluginsProvider,34 PluginsProvider,
35 adminUserDirectoryFromMembers,
30 getAdminUser,36 getAdminUser,
31 listAdminUserConnections,37 listAdminUserConnections,
32 listAdminUsers,38 listAdminUsers,
33 listAdminUsersWithConnections,39 listAdminUsersWithConnections,
34 makeAdminUsersApiLayer,40 makeAdminUsersApiLayer,
35 makePlatformExecutor,41 makePlatformExecutor,
36 normalizeAdminUserEmail,
37 platformViewOf,42 platformViewOf,
38 requestScopedMiddleware,43 requestScopedMiddleware,
39 type AdminUserDirectory,
40 type AdminUserIdentity,
41 type AdminUsersHeaders,44 type AdminUsersHeaders,
42} from "@executor-js/api/server";45} from "@executor-js/api/server";
43import {46import {
@@ -67,70 +70,6 @@ const requireAdmin = (headers: AdminUsersHeaders) => · 23 unmodified lines
67 ),70 ),
68 );71 );
69 72
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 */
88const 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 */
105const 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
134const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>(73const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>(
135 headers: AdminUsersHeaders,74 headers: AdminUsersHeaders,
136 organizationId: string,75 organizationId: string,
@@ -153,24 +92,25 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 16 unmodified lines
153export const betterAuthAdminUsersProvider: Layer.Layer<92export const betterAuthAdminUsersProvider: Layer.Layer<
154 AdminUsersProvider,93 AdminUsersProvider,
155 never,94 never,
156 BetterAuth | DbProvider | PluginsProvider | HostConfig95 BetterAuth | MemberDirectory | DbProvider | PluginsProvider | HostConfig
157> = Layer.effect(AdminUsersProvider)(96> = Layer.effect(AdminUsersProvider)(
158 Effect.gen(function* () {97 Effect.gen(function* () {
159 const context = yield* Effect.context<BetterAuth | DbProvider | PluginsProvider | HostConfig>();98 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);
161 return AdminUsersProvider.of({103 return AdminUsersProvider.of({
162 listUsers: (headers, options) =>104 listUsers: (headers, options) =>
163 withPlatformView(headers, organizationId, (executor) =>105 withPlatformView(headers, organizationId, (executor) =>
164 platformViewOf(executor).pipe(106 platformViewOf(executor).pipe(
165 Effect.flatMap((admin) => listAdminUsers(admin, options, userDirectory(auth, headers))),107 Effect.flatMap((admin) => listAdminUsers(admin, options, directory)),
166 ),108 ),
167 ).pipe(Effect.provideContext(context)),109 ).pipe(Effect.provideContext(context)),
168 listUsersWithConnections: (headers, options) =>110 listUsersWithConnections: (headers, options) =>
169 withPlatformView(headers, organizationId, (executor) =>111 withPlatformView(headers, organizationId, (executor) =>
170 platformViewOf(executor).pipe(112 platformViewOf(executor).pipe(
171 Effect.flatMap((admin) =>113 Effect.flatMap((admin) => listAdminUsersWithConnections(admin, options, directory)),
172 listAdminUsersWithConnections(admin, options, userDirectory(auth, headers)),
173 ),
174 ),114 ),
175 ).pipe(Effect.provideContext(context)),115 ).pipe(Effect.provideContext(context)),
176 listUserConnections: (headers, externalId) =>116 listUserConnections: (headers, externalId) =>
@@ -182,9 +122,7 @@ export const betterAuthAdminUsersProvider: Layer.Layer< · 5 unmodified lines
182 getUser: (headers, identifier) =>122 getUser: (headers, identifier) =>
183 withPlatformView(headers, organizationId, (executor) =>123 withPlatformView(headers, organizationId, (executor) =>
184 platformViewOf(executor).pipe(124 platformViewOf(executor).pipe(
185 Effect.flatMap((admin) =>125 Effect.flatMap((admin) => getAdminUser(admin, identifier, directory)),
186 getAdminUser(admin, identifier, userDirectory(auth, headers)),
187 ),
188 ),126 ),
189 ).pipe(Effect.provideContext(context)),127 ).pipe(Effect.provideContext(context)),
190 });128 });
@@ -193,6 +131,9 @@ export const betterAuthAdminUsersProvider: Layer.Layer< · 2 unmodified lines
193 131
194export interface SelfHostAdminUsersApiDeps {132export interface SelfHostAdminUsersApiDeps {
195 readonly betterAuth: BetterAuthHandle;133 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>;
196 readonly db: SelfHostDbHandle;137 readonly db: SelfHostDbHandle;
197 readonly mountPrefix: `/${string}`;138 readonly mountPrefix: `/${string}`;
198}139}
@@ -206,6 +147,7 @@ export interface SelfHostAdminUsersApiDeps { · 7 unmodified lines
206 */147 */
207export const makeSelfHostAdminUsersApiLayer = ({148export const makeSelfHostAdminUsersApiLayer = ({
208 betterAuth,149 betterAuth,
150 memberDirectory,
209 db,151 db,
210 mountPrefix,152 mountPrefix,
211}: SelfHostAdminUsersApiDeps) => {153}: SelfHostAdminUsersApiDeps) => {
@@ -214,6 +156,7 @@ export const makeSelfHostAdminUsersApiLayer = ({ · 2 unmodified lines
214 );156 );
215 const provider = betterAuthAdminUsersProvider.pipe(157 const provider = betterAuthAdminUsersProvider.pipe(
216 Layer.provide(Layer.succeed(BetterAuth)(betterAuth)),158 Layer.provide(Layer.succeed(BetterAuth)(betterAuth)),
159 Layer.provide(memberDirectory),
217 Layer.provide(SelfHostDbProvider),160 Layer.provide(SelfHostDbProvider),
218 Layer.provide(SelfHostPluginsProvider),161 Layer.provide(SelfHostPluginsProvider),
219 Layer.provide(SelfHostHostConfig),162 Layer.provide(SelfHostHostConfig),
apps/host-selfhost/src/app.ts+11−2
@@ -73,7 +73,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 72 unmodified lines
73 // ---- auth providers ---------------------------------------------------73 // ---- auth providers ---------------------------------------------------
74 // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account74 // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account
75 // API + MCP OAuth seam, all over the shared libSQL handle.75 // 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);
77 78
78 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------79 // ---- the in-process MCP serving seams (+ shutdown hook) ----------------
79 const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config);80 const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config);
@@ -130,7 +131,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 50 unmodified lines
130 // Tenant-wide admin users API (/api/admin/users*): the owner's view of131 // Tenant-wide admin users API (/api/admin/users*): the owner's view of
131 // who uses this instance and what they've connected. Owner/admin-gated,132 // who uses this instance and what they've connected. Owner/admin-gated,
132 // same as the invite routes above.133 // 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 }),
134 // Public system API: /api/health + /api/setup-status (unauthenticated).140 // Public system API: /api/health + /api/setup-status (unauthenticated).
135 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),141 makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
136 // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths).142 // 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
141 // The boot-scoped context provideMerge'd under everything: the long-lived DB147 // The boot-scoped context provideMerge'd under everything: the long-lived DB
142 // handle (read by the DbProvider seam, Better Auth, and the MCP store) + the148 // handle (read by the DbProvider seam, Better Auth, and the MCP store) + the
143 // resolved identity (captured once by the execution middleware + MCP auth)149 // 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)
144 // + the artifact-usage observer (this HTTP plane is the console UI's data152 // + the artifact-usage observer (this HTTP plane is the console UI's data
145 // layer, so operations it serves file as `via: "ui"`).153 // layer, so operations it serves file as `via: "ui"`).
146 boot: Layer.mergeAll(154 boot: Layer.mergeAll(
147 Layer.succeed(SelfHostDb)(dbHandle),155 Layer.succeed(SelfHostDb)(dbHandle),
148 identityLayer,156 identityLayer,
157 memberDirectoryLayer,
149 Layer.succeed(ArtifactUsageObserver)((action) =>158 Layer.succeed(ArtifactUsageObserver)((action) =>
150 selfHostAnalytics.record(`artifact_${action}`, { via: "ui" }),159 selfHostAnalytics.record(`artifact_${action}`, { via: "ui" }),
151 ),160 ),
apps/host-selfhost/src/auth/index.ts+11−4
@@ -1,24 +1,28 @@
1import { Layer } from "effect";1import { Layer } from "effect";
2 2
3import { IdentityProvider } from "@executor-js/api/server";3import { IdentityProvider, MemberDirectory } from "@executor-js/api/server";
4 4
5import { loadConfig } from "../config";5import { loadConfig } from "../config";
6import type { SelfHostDbHandle } from "../db/self-host-db";6import type { SelfHostDbHandle } from "../db/self-host-db";
7import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";7import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
8import { betterAuthIdentityLayer } from "./identity";8import { betterAuthIdentityLayer } from "./identity";
9import { betterAuthMemberDirectoryLayer } from "./member-directory";
9import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent";10import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent";
10import { rewriteInvalidOrigin } from "./invalid-origin-help";11import { rewriteInvalidOrigin } from "./invalid-origin-help";
11 12
12export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";13export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
13export { betterAuthIdentityLayer } from "./identity";14export { betterAuthIdentityLayer } from "./identity";
15export { betterAuthMemberDirectoryLayer } from "./member-directory";
14 16
15// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
16// Resolve the self-host auth providers.18// Resolve the self-host auth providers.
17//19//
18// Build the Better Auth instance over the shared libSQL file, expose its20// Build the Better Auth instance over the shared libSQL file, expose its
19// `IdentityProvider` (cookie/bearer/api-key) and its web handler (mounted at21// `IdentityProvider` (cookie/bearer/api-key), its `MemberDirectory` (the
20// /api/auth/*). Returns the live `BetterAuthHandle` so the composition root can22// shared membership read seam over the org plugin's tables) and its web
21// build the account API and the Better Auth MCP OAuth seam.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.
22//26//
23// This is the one and only production auth path. Tests that need a fake identity27// This is the one and only production auth path. Tests that need a fake identity
24// (single-admin / header-driven) compose `ExecutorApp.make` directly through28// (single-admin / header-driven) compose `ExecutorApp.make` directly through
@@ -29,6 +33,8 @@ export { betterAuthIdentityLayer } from "./identity"; · 4 unmodified lines
29export interface ResolvedAuthProviders {33export interface ResolvedAuthProviders {
30 /** The resolved Better Auth `IdentityProvider` seam (cookie/bearer/api-key). */34 /** The resolved Better Auth `IdentityProvider` seam (cookie/bearer/api-key). */
31 readonly identityLayer: Layer.Layer<IdentityProvider>;35 readonly identityLayer: Layer.Layer<IdentityProvider>;
36 /** The resolved Better Auth `MemberDirectory` seam (org members + users). */
37 readonly memberDirectoryLayer: Layer.Layer<MemberDirectory>;
32 /** Better Auth's web handler (`/api/auth/*`). */38 /** Better Auth's web handler (`/api/auth/*`). */
33 readonly authHandler: (request: Request) => Promise<Response>;39 readonly authHandler: (request: Request) => Promise<Response>;
34 /** The live Better Auth handle (account API + Better Auth MCP OAuth seam). */40 /** The live Better Auth handle (account API + Better Auth MCP OAuth seam). */
@@ -78,6 +84,7 @@ export const resolveAuthProviders = async ( · 43 unmodified lines
78 84
79 return {85 return {
80 identityLayer: betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)),86 identityLayer: betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)),
87 memberDirectoryLayer: betterAuthMemberDirectoryLayer.pipe(Layer.provide(betterAuthLayer)),
81 authHandler,88 authHandler,
82 betterAuth,89 betterAuth,
83 };90 };
packages/core/api/src/account/api.ts+8−1
@@ -109,10 +109,17 @@ export const OrgApiKeysResponse = Schema.Struct({ · 108 unmodified lines
109 apiKeys: Schema.Array(ApiKeySummary),109 apiKeys: Schema.Array(ApiKeySummary),
110});110});
111 111
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 */
112export const OrgMember = Schema.Struct({119export const OrgMember = Schema.Struct({
113 id: Schema.String,120 id: Schema.String,
114 userId: Schema.String,121 userId: Schema.String,
115 email: Schema.String,122 email: Schema.NullOr(Schema.String),
116 name: Schema.NullOr(Schema.String),123 name: Schema.NullOr(Schema.String),
117 avatarUrl: Schema.NullOr(Schema.String),124 avatarUrl: Schema.NullOr(Schema.String),
118 role: Schema.String,125 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
395const stubUserDirectory = (options: {395const stubUserDirectory = (options: {
396 readonly seen?: string[][];396 readonly seen?: string[][];
397 readonly resolved?: string[];397 readonly resolved?: string[];
398 readonly searched?: string[];
398}): AdminUserDirectory => ({399}): AdminUserDirectory => ({
399 identities: (externalIds) => {400 identities: (externalIds) => {
400 options.seen?.push([...externalIds]);401 options.seen?.push([...externalIds]);
@@ -405,6 +406,13 @@ const stubUserDirectory = (options: { · 4 unmodified lines
405 // Compares a NORMALIZED stored value, the rule both real hosts follow.406 // Compares a NORMALIZED stored value, the rule both real hosts follow.
406 return Effect.succeed(A1_EMAIL_STORED.toLowerCase() === email ? USER_A1 : null);407 return Effect.succeed(A1_EMAIL_STORED.toLowerCase() === email ? USER_A1 : null);
407 },408 },
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 },
408});416});
409 417
410/** The failure a host's directory raises — WorkOS or Better Auth being418/** The failure a host's directory raises — WorkOS or Better Auth being
@@ -1022,6 +1030,102 @@ describe("admin users API", () => { · 611 unmodified lines
1022 ),1030 ),
1023 );1031 );
1024 1032
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
1025 // A resolver OUTAGE must not read as "no such user": that is a wrong answer an1129 // A resolver OUTAGE must not read as "no such user": that is a wrong answer an
1026 // operator would act on. Contrast with the identity join, which degrades to1130 // operator would act on. Contrast with the identity join, which degrades to
1027 // unnamed rows precisely because it is decoration.1131 // unnamed rows precisely because it is decoration.
@@ -1096,8 +1200,8 @@ const A_SUBJECT: AdminSubject = { · 68 unmodified lines
1096/** An `ExecutorAdmin` that answers everything and records the reads it was1200/** An `ExecutorAdmin` that answers everything and records the reads it was
1097 * asked for, so a test can assert the call the filter chose. */1201 * asked for, so a test can assert the call the filter chose. */
1098const recordingAdmin = (calls: string[]): ExecutorAdmin => ({1202const recordingAdmin = (calls: string[]): ExecutorAdmin => ({
1099 listSubjects: () => {1203 listSubjects: (options) => {
1100 calls.push("listSubjects");1204 calls.push(`listSubjects:${options?.externalIds?.join(",") ?? "*"}`);
1101 return Effect.succeed([A_SUBJECT]);1205 return Effect.succeed([A_SUBJECT]);
1102 },1206 },
1103 getSubject: () => {1207 getSubject: () => {
@@ -1108,8 +1212,8 @@ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ · 4 unmodified lines
1108 calls.push("listSubjectConnections");1212 calls.push("listSubjectConnections");
1109 return Effect.succeed([]);1213 return Effect.succeed([]);
1110 },1214 },
1111 listSubjectsWithConnections: () => {1215 listSubjectsWithConnections: (options) => {
1112 calls.push("listSubjectsWithConnections");1216 calls.push(`listSubjectsWithConnections:${options?.externalIds?.join(",") ?? "*"}`);
1113 return Effect.succeed([{ ...A_SUBJECT, connections: [] }]);1217 return Effect.succeed([{ ...A_SUBJECT, connections: [] }]);
1114 },1218 },
1115 getSubjectWithConnections: () => {1219 getSubjectWithConnections: () => {
@@ -1187,7 +1291,56 @@ describe("admin users reads — the ?email= filter is applied before the read", · 71 unmodified lines
1187 const calls: string[] = [];1291 const calls: string[] = [];
1188 yield* listUsersWithConnections(recordingAdmin(calls), { limit: 50 }, stubUserDirectory({}));1292 yield* listUsersWithConnections(recordingAdmin(calls), { limit: 50 }, stubUserDirectory({}));
1189 1293
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
1305describe("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([]);
1191 }),1344 }),
1192 );1345 );
1193});1346});
packages/core/api/src/admin/api.ts+10−0
@@ -261,6 +261,15 @@ const AdminUserIdentifierParams = { identifier: Schema.String }; · 260 unmodified lines
261// handler seam (`normalizeEmail`), which is also where the single-user path261// handler seam (`normalizeEmail`), which is also where the single-user path
262// parameter is normalized, so both entry points share ONE rule rather than a262// parameter is normalized, so both entry points share ONE rule rather than a
263// schema transform on one and hand-rolled code on the other.263// 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.
264const AdminListQuery = Schema.Struct({273const AdminListQuery = Schema.Struct({
265 limit: Schema.optional(274 limit: Schema.optional(
266 Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })),275 Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })),
@@ -272,6 +281,7 @@ const AdminListQuery = Schema.Struct({ · 5 unmodified lines
272 ),281 ),
273 ),282 ),
274 email: Schema.optional(Schema.String),283 email: Schema.optional(Schema.String),
284 search: Schema.optional(Schema.String),
275});285});
276 286
277// ---------------------------------------------------------------------------287// ---------------------------------------------------------------------------
packages/core/api/src/admin/handlers.ts+14−5
@@ -2,6 +2,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line
2import { HttpServerRequest } from "effect/unstable/http";2import { HttpServerRequest } from "effect/unstable/http";
3import { Effect } from "effect";3import { Effect } from "effect";
4 4
5import { normalizeMemberSearch } from "../server/member-directory";
5import { AdminUsersHttpApi } from "./api";6import { AdminUsersHttpApi } from "./api";
6import { normalizeEmail } from "./reads";7import { normalizeEmail } from "./reads";
7import { AdminUsersProvider, type AdminUsersHeaders, type AdminUsersListOptions } from "./service";8import { AdminUsersProvider, type AdminUsersHeaders, type AdminUsersListOptions } from "./service";
@@ -24,15 +25,23 @@ const requestHeaders = Effect.map( · 16 unmodified lines
24// than an explicit `undefined` overriding them.25// than an explicit `undefined` overriding them.
25// `email` is normalized here rather than in the contract schema, so the filter26// `email` is normalized here rather than in the contract schema, so the filter
26// and the single-user path parameter share ONE rule (`normalizeEmail`).27// 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: ""`).
27const listOptions = (query: {31const listOptions = (query: {
28 readonly limit?: number | undefined;32 readonly limit?: number | undefined;
29 readonly offset?: number | undefined;33 readonly offset?: number | undefined;
30 readonly email?: string | undefined;34 readonly email?: string | undefined;
31}): AdminUsersListOptions => ({35 readonly search?: string | undefined;
32 ...(query.limit === undefined ? {} : { limit: query.limit }),36}): AdminUsersListOptions => {
33 ...(query.offset === undefined ? {} : { offset: query.offset }),37 const search = normalizeMemberSearch(query.search);
34 ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }),38 return {
35});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};
36 45
37export const AdminUsersHandlers = HttpApiBuilder.group(46export const AdminUsersHandlers = HttpApiBuilder.group(
38 AdminUsersHttpApi,47 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
10import type { AdminUserDirectory, AdminUserIdentity } from "./reads";10import type { AdminUserDirectory, AdminUserIdentity } from "./reads";
11 11
12/**12/**
13 * Both directions of the admin plane's directory over one org's13 * Every direction of the admin plane's directory over one org's
14 * {@link MemberDirectoryShape}.14 * {@link MemberDirectoryShape}.
15 *15 *
16 * `identities` is one batched `membersById` read for the page of ids (never a16 * `identities` is one batched `membersById` read for the page of ids (never a
17 * lookup per user); a member the org does not hold reports absent identity.17 * lookup per user); a member the org does not hold reports absent identity.
18 * `resolveEmail` receives the already-normalized email the contract promises18 * `resolveEmail` receives the already-normalized email the contract promises
19 * and answers with the host principal id, or `null` when no member has it.19 * 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.
20 *22 *
21 * Both read ANY membership status, not the directory's active + pending23 * Every direction reads ANY membership status — the same reach `membersById`
22 * default: this plane reports footprint, not current access. A member who was24 * and `findByEmail` have by contract, and `search` asks for explicitly rather
23 * removed while their connections remain must still be named on the users25 * than taking `members`' active + pending default. This plane reports
24 * page and findable by the address an operator has for them.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.
25 *29 *
26 * Both fail with `MemberDirectoryError`, which the shared reads treat as a30 * All fail with `MemberDirectoryError`, which the shared reads treat as a
27 * decorative-join outage (identities) or surface as a failed read (resolve).31 * decorative-join outage (identities) or surface as a failed read (resolve,
32 * search).
28 */33 */
29export const adminUserDirectoryFromMembers = (34export const adminUserDirectoryFromMembers = (
30 directory: MemberDirectoryShape,35 directory: MemberDirectoryShape,
@@ -47,4 +52,8 @@ export const adminUserDirectoryFromMembers = ( · 16 unmodified lines
47 directory52 directory
48 .findByEmail(organizationId, email, MemberStatus.literals)53 .findByEmail(organizationId, email, MemberStatus.literals)
49 .pipe(Effect.map((member) => (member === null ? null : member.accountId))),54 .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))),
50});59});
packages/core/api/src/admin/reads.ts+95−17
@@ -16,6 +16,7 @@ import { Effect } from "effect"; · 15 unmodified lines
16 16
17import type {17import type {
18 AdminConnection,18 AdminConnection,
19 AdminListSubjectsOptions,
19 AdminSubject,20 AdminSubject,
20 AdminSubjectWithConnections,21 AdminSubjectWithConnections,
21 Executor,22 Executor,
@@ -114,12 +115,27 @@ export type AdminIdentityDirectory = ( · 92 unmodified lines
114 */115 */
115export type AdminEmailResolver = (email: string) => Effect.Effect<string | null, unknown>;116export type AdminEmailResolver = (email: string) => Effect.Effect<string | null, unknown>;
116 117
117/** Both directions of a host's member directory. Optional as a whole (a host118/**
118 * with no directory reports unnamed rows and cannot resolve emails), and119 * The directory's SEARCH: a normalized term (trimmed + lower-cased, the same
119 * optional per direction. */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 */
130export 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. */
120export interface AdminUserDirectory {135export interface AdminUserDirectory {
121 readonly identities?: AdminIdentityDirectory;136 readonly identities?: AdminIdentityDirectory;
122 readonly resolveEmail?: AdminEmailResolver;137 readonly resolveEmail?: AdminEmailResolver;
138 readonly search?: AdminMemberSearch;
123}139}
124 140
125/** Identity is decoration on an operator view, not part of the answer: a141/** Identity is decoration on an operator view, not part of the answer: a
@@ -292,6 +308,72 @@ const selectByEmail = <T>( · 166 unmodified lines
292 return row === null ? [] : pageOf([row], options);308 return row === null ? [] : pageOf([row], options);
293 });309 });
294 310
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 */
327const 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 */
349const 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. */
372const pagingOf = (options: AdminUsersListOptions): AdminListSubjectsOptions => ({
373 ...(options.limit === undefined ? {} : { limit: options.limit }),
374 ...(options.offset === undefined ? {} : { offset: options.offset }),
375});
376
295export const listUsers = (377export const listUsers = (
296 admin: ExecutorAdmin,378 admin: ExecutorAdmin,
297 options: AdminUsersListOptions,379 options: AdminUsersListOptions,
@@ -299,12 +381,10 @@ export const listUsers = ( · 1 unmodified line
299): Effect.Effect<typeof AdminUsersResponse.Type, AdminUsersError> =>381): Effect.Effect<typeof AdminUsersResponse.Type, AdminUsersError> =>
300 Effect.gen(function* () {382 Effect.gen(function* () {
301 const dir = asDirectory(directory);383 const dir = asDirectory(directory);
302 const subjects =384 const subjects = yield* selectSubjects(dir, options, {
303 options.email === undefined385 page: (paging) => admin.listSubjects(paging).pipe(Effect.mapError(readFailed("users"))),
304 ? yield* admin.listSubjects(options).pipe(Effect.mapError(readFailed("users")))386 one: (externalId) => admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))),
305 : yield* selectByEmail(dir, options.email, options, (externalId) =>387 });
306 admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))),
307 );
308 // One directory read for the page that was actually returned, joined in388 // One directory read for the page that was actually returned, joined in
309 // memory — never a lookup per user.389 // memory — never a lookup per user.
310 const identities = yield* resolveIdentities(390 const identities = yield* resolveIdentities(
@@ -321,14 +401,12 @@ export const listUsersWithConnections = ( · 10 unmodified lines
321): Effect.Effect<typeof AdminUsersWithConnectionsResponse.Type, AdminUsersError> =>401): Effect.Effect<typeof AdminUsersWithConnectionsResponse.Type, AdminUsersError> =>
322 Effect.gen(function* () {402 Effect.gen(function* () {
323 const dir = asDirectory(directory);403 const dir = asDirectory(directory);
324 const subjects =404 const subjects = yield* selectSubjects(dir, options, {
325 options.email === undefined405 page: (paging) =>
326 ? yield* admin406 admin.listSubjectsWithConnections(paging).pipe(Effect.mapError(readFailed("users"))),
327 .listSubjectsWithConnections(options)407 one: (externalId) =>
328 .pipe(Effect.mapError(readFailed("users")))408 admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))),
329 : yield* selectByEmail(dir, options.email, options, (externalId) =>409 });
330 admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))),
331 );
332 const identities = yield* resolveIdentities(410 const identities = yield* resolveIdentities(
333 dir.identities,411 dir.identities,
334 subjects.map((subject) => subject.externalId),412 subjects.map((subject) => subject.externalId),
packages/core/api/src/admin/service.ts+5−2
@@ -31,12 +31,15 @@ import { · 30 unmodified lines
31export type AdminUsersHeaders = Record<string, string>;31export type AdminUsersHeaders = Record<string, string>;
32 32
33/** Paging and filtering, mirroring the SDK's `AdminListSubjectsOptions` plus33/** Paging and filtering, mirroring the SDK's `AdminListSubjectsOptions` plus
34 * the contract's `?email=`. The email arrives already trimmed and lower-cased34 * the contract's `?email=` and `?search=`. Both filters arrive already
35 * by the contract schema, so a provider never re-normalizes it. */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. */
36export interface AdminUsersListOptions {38export interface AdminUsersListOptions {
37 readonly limit?: number;39 readonly limit?: number;
38 readonly offset?: number;40 readonly offset?: number;
39 readonly email?: string;41 readonly email?: string;
42 readonly search?: string;
40}43}
41 44
42type User = typeof AdminUserResponse.Type;45type User = typeof AdminUserResponse.Type;
packages/core/api/src/server.ts+1−0
@@ -40,6 +40,7 @@ export { · 39 unmodified lines
40 normalizeEmail as normalizeAdminUserEmail,40 normalizeEmail as normalizeAdminUserEmail,
41 type AdminEmailResolver,41 type AdminEmailResolver,
42 type AdminIdentityDirectory,42 type AdminIdentityDirectory,
43 type AdminMemberSearch,
43 type AdminUserDirectory,44 type AdminUserDirectory,
44 type AdminUserIdentity,45 type AdminUserIdentity,
45} from "./admin/reads";46} from "./admin/reads";
packages/core/sdk/src/executor.ts+18−0
@@ -593,6 +593,14 @@ export interface AdminSubjectWithConnections extends AdminSubject { · 592 unmodified lines
593export interface AdminListSubjectsOptions {593export interface AdminListSubjectsOptions {
594 readonly limit?: number;594 readonly limit?: number;
595 readonly offset?: number;595 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[];
596}604}
597 605
598/**606/**
@@ -7006,8 +7014,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6407 unmodified lines
7006 // them through independently produced a bare OFFSET, which SQLite7014 // them through independently produced a bare OFFSET, which SQLite
7007 // rejects outright.7015 // rejects outright.
7008 const { limit, offset } = normalizeAdminPaging(options);7016 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([]);
7009 return platformCore7021 return platformCore
7010 .findMany("subject", {7022 .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]) }),
7011 // Oldest first, ties broken on the unique key so the order is7029 // Oldest first, ties broken on the unique key so the order is
7012 // total and paging can't repeat or skip a row.7030 // total and paging can't repeat or skip a row.
7013 orderBy: [7031 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
424 Effect.orDie,424 Effect.orDie,
425 );425 );
426 426
427describe("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
427describe("platform view — read-only across every surface", () => {482describe("platform view — read-only across every surface", () => {
428 it.effect("refuses org-row writes through policies and oauth", () =>483 it.effect("refuses org-row writes through policies and oauth", () =>
429 withDb((db) =>484 withDb((db) =>
packages/react/src/api/admin-atoms.tsx+14−5
@@ -10,10 +10,11 @@ import { ReactivityKey } from "./reactivity-keys"; · 9 unmodified lines
10// rejects writes at tenant reach), so there are no mutations here and every10// rejects writes at tenant reach), so there are no mutations here and every
11// atom carries the same reactivity key.11// atom carries the same reactivity key.
12//12//
13// Paging is part of the atom identity, so each page is its own cache entry and13// Paging and the search term are part of the atom identity, so each page of
14// stepping back to a visited page is instant. `Atom.family` (not a bare arrow)14// each search is its own cache entry and stepping back to a visited page is
15// because the page component re-derives the key object on every render — a15// instant. `Atom.family` (not a bare arrow) because the page component
16// fresh atom per render would refetch in a loop.16// re-derives the key object on every render — a fresh atom per render would
17// refetch in a loop.
17// ---------------------------------------------------------------------------18// ---------------------------------------------------------------------------
18 19
19/** How many users one page of the list shows. Well inside the contract's20/** 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
24export interface AdminUsersPage {25export interface AdminUsersPage {
25 readonly limit: number;26 readonly limit: number;
26 readonly offset: number;27 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;
27}32}
28 33
29/**34/**
@@ -35,7 +40,11 @@ export interface AdminUsersPage { · 5 unmodified lines
35 */40 */
36export const adminUsersWithConnectionsAtom = Atom.family((page: AdminUsersPage) =>41export const adminUsersWithConnectionsAtom = Atom.family((page: AdminUsersPage) =>
37 AdminApiClient.query("adminUsers", "listUsersWithConnections", {42 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 },
39 timeToLive: "30 seconds",48 timeToLive: "30 seconds",
40 reactivityKeys: [ReactivityKey.adminUsers],49 reactivityKeys: [ReactivityKey.adminUsers],
41 }),50 }),
packages/react/src/pages/admin-users.tsx+117−2
@@ -1,6 +1,7 @@
1import { useState } from "react";1import { useEffect, useState } from "react";
2import { useAtomRefresh, useAtomValue } from "@effect/atom-react";2import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
3import { useParams } from "@tanstack/react-router";3import { useParams } from "@tanstack/react-router";
4import { SearchIcon, XIcon } from "lucide-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";5import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import * as Cause from "effect/Cause";6import * as Cause from "effect/Cause";
6import * as Option from "effect/Option";7import * as Option from "effect/Option";
@@ -18,6 +19,7 @@ import { ownerLabel } from "../api/owner-display"; · 11 unmodified lines
18import { Button } from "../components/button";19import { Button } from "../components/button";
19import { CopyButton } from "../components/copy-button";20import { CopyButton } from "../components/copy-button";
20import { ErrorState } from "../components/error-state";21import { ErrorState } from "../components/error-state";
22import { Input } from "../components/input";
21import {23import {
22 IntegrationFavicon,24 IntegrationFavicon,
23 integrationInferredUrl,25 integrationInferredUrl,
@@ -521,14 +523,94 @@ function UserDetail(props: { · 497 unmodified lines
521 });523 });
522}524}
523 525
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. */
531const 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 */
539const 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
565function 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
524// ── Page ────────────────────────────────────────────────────────────────────603// ── Page ────────────────────────────────────────────────────────────────────
525 604
526export function AdminUsersPage() {605export function AdminUsersPage() {
527 useExecutorDocumentTitle("Users");606 useExecutorDocumentTitle("Users");
528 const [offset, setOffset] = useState(0);607 const [offset, setOffset] = useState(0);
529 const [selected, setSelected] = useState<AdminUserRow | null>(null);608 const [selected, setSelected] = useState<AdminUserRow | null>(null);
609 const search = useDebouncedSearch();
530 610
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 };
532 const result = useAtomValue(adminUsersWithConnectionsAtom(page));614 const result = useAtomValue(adminUsersWithConnectionsAtom(page));
533 const refresh = useAtomRefresh(adminUsersWithConnectionsAtom(page));615 const refresh = useAtomRefresh(adminUsersWithConnectionsAtom(page));
534 const catalog = useCatalogRows();616 const catalog = useCatalogRows();
@@ -555,10 +637,24 @@ export function AdminUsersPage() { · 20 unmodified lines
555 </div>637 </div>
556 );638 );
557 639
640 const searching = search.term !== "";
641
558 return (642 return (
559 <PageContainer>643 <PageContainer>
560 {header}644 {header}
561 645
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
562 {isAsyncResultLoading(result)658 {isAsyncResultLoading(result)
563 ? loading659 ? loading
564 : AsyncResult.match(result, {660 : AsyncResult.match(result, {
@@ -572,6 +668,25 @@ export function AdminUsersPage() { · 7 unmodified lines
572 onSuccess: ({ value }) => {668 onSuccess: ({ value }) => {
573 const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE);669 const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE);
574 670
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
575 if (rows.length === 0) {690 if (rows.length === 0) {
576 return (691 return (
577 <div className="rounded-lg border border-dashed border-border bg-card p-8">692 <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
69 69
70type MemberData = {70type MemberData = {
71 id: string;71 id: string;
72 email: string;72 email: string | null;
73 name: string | null;73 name: string | null;
74 avatarUrl: string | null;74 avatarUrl: string | null;
75 role: string;75 role: string;
@@ -80,6 +80,23 @@ type MemberData = { · 4 unmodified lines
80 80
81type RoleData = { slug: string; name: string };81type RoleData = { slug: string; name: string };
82 82
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. */
86const memberLabel = (member: MemberData): string => member.name ?? member.email ?? member.id;
87
88const 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
83type InviteState = {100type InviteState = {
84 email: string;101 email: string;
85 roleSlug: string;102 roleSlug: string;
@@ -314,7 +331,7 @@ export function OrgPage(props: { · 228 unmodified lines
314 const filtered = search331 const filtered = search
315 ? members.filter(332 ? members.filter(
316 (m: MemberData) =>333 (m: MemberData) =>
317 m.email.toLowerCase().includes(search.toLowerCase()) ||334 (m.email?.toLowerCase().includes(search.toLowerCase()) ?? false) ||
318 (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false),335 (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false),
319 )336 )
320 : members;337 : members;
@@ -338,21 +355,14 @@ export function OrgPage(props: { · 17 unmodified lines
338 <img src={member.avatarUrl} alt="" className="size-8 rounded-full" />355 <img src={member.avatarUrl} alt="" className="size-8 rounded-full" />
339 ) : (356 ) : (
340 <div className="flex size-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">357 <div className="flex size-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
341 {member.name358 {memberInitials(member)}
342 ? member.name
343 .split(" ")
344 .map((n: string) => n[0])
345 .join("")
346 .slice(0, 2)
347 .toUpperCase()
348 : member.email[0]!.toUpperCase()}
349 </div>359 </div>
350 )}360 )}
351 361
352 <div className="min-w-0">362 <div className="min-w-0">
353 <div className="flex items-center gap-2">363 <div className="flex items-center gap-2">
354 <p className="truncate text-sm font-medium text-foreground leading-none">364 <p className="truncate text-sm font-medium text-foreground leading-none">
355 {member.name ?? member.email}365 {memberLabel(member)}
356 </p>366 </p>
357 {member.isCurrentUser && (367 {member.isCurrentUser && (
358 <Badge className="bg-muted text-muted-foreground">You</Badge>368 <Badge className="bg-muted text-muted-foreground">You</Badge>
@@ -361,7 +371,7 @@ export function OrgPage(props: { · 2 unmodified lines
361 <Badge className="bg-muted text-muted-foreground">Invited</Badge>371 <Badge className="bg-muted text-muted-foreground">Invited</Badge>
362 )}372 )}
363 </div>373 </div>
364 {member.name && (374 {member.name && member.email && (
365 <p className="mt-0.5 truncate text-xs text-muted-foreground leading-none">375 <p className="mt-0.5 truncate text-xs text-muted-foreground leading-none">
366 {member.email}376 {member.email}
367 </p>377 </p>
@@ -421,7 +431,7 @@ export function OrgPage(props: { · 53 unmodified lines
421 onClick={() =>431 onClick={() =>
422 setRemovingMember({432 setRemovingMember({
423 id: member.id,433 id: member.id,
424 name: member.name ?? member.email,434 name: memberLabel(member),
425 })435 })
426 }436 }
427 >437 >