Skip to content

Commit be77521

Browse files
authored
Read members from the directory and add email search (#2025)
1 parent a97a20c commit be77521

31 files changed

Lines changed: 1278 additions & 509 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@executor-js/cloud": patch
3+
"@executor-js/api": patch
4+
"@executor-js/react": patch
5+
"@executor-js/sdk": patch
6+
---
7+
8+
Member lists, the admin users page, and seat counts on cloud now read from the local membership mirror through the shared `MemberDirectory` seam instead of fanning out one WorkOS read per member. The admin users page gains an email/name search.
9+
10+
**Deploy prerequisite (cloud):** `bun run --cwd apps/cloud db:backfill-workos-mirror:prod` must complete before this build is deployed, and its printed membership count should match WorkOS. Until the backfill has stamped the mirror's marker, seat reporting to Autumn is skipped with a warning (never a partial count) and member lists show only members who have signed in since the mirror shipped.

apps/cloud/src/account/account-api.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
AccountProvider,
66
makeAccountApiLayer,
77
requestScopedMiddleware,
8+
type MemberDirectory,
89
} from "@executor-js/api/server";
910

1011
import { ApiKeyService } from "../auth/api-keys";
@@ -46,7 +47,8 @@ import { AccountCaller, workosAccountProvider } from "./workos-account-service";
4647
// Builds the WorkOS `AccountProvider` per request, providing it to the handler.
4748
// Long-lived `WorkOSClient | AutumnService` come from the surrounding context
4849
// (Autumn provided by `makeAccountApiLive` for the seat-gate); the per-request
49-
// `UserStoreService` is supplied by the combined `rsLive` layer.
50+
// `UserStoreService` / `WorkOsMirror` / `MemberDirectory` are supplied by the
51+
// combined `rsLive` layer.
5052
// `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`.
5153
const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()(
5254
Effect.gen(function* () {
@@ -97,11 +99,11 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi
9799
* (the seat-gate) stays a residual requirement, satisfied by the app `boot`.
98100
*/
99101
export const workosAccountMiddleware = (
100-
rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,
102+
rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
101103
) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer;
102104

103105
export const makeAccountApiLive = (
104-
rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,
106+
rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
105107
) => {
106108
// Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it
107109
// closes over the per-request postgres socket), so it can't be a self-

apps/cloud/src/account/org-api-key-revoke.node.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from "@effect/vitest";
22
import { Effect, Layer } from "effect";
33

4-
import { AccountProvider } from "@executor-js/api/server";
4+
import { AccountProvider, MemberDirectory } from "@executor-js/api/server";
55
import { AccountError, AccountForbidden } from "@executor-js/api";
66

77
import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys";
@@ -147,6 +147,14 @@ const stubMirror = Layer.succeed(WorkOsMirror)({
147147
organizationBackfilledAt: () => Effect.die("revoke does not report seats"),
148148
});
149149

150+
// Revoke lists no members either.
151+
const stubDirectory = Layer.succeed(MemberDirectory)({
152+
membership: () => Effect.die("revoke does not read the member directory"),
153+
members: () => Effect.die("revoke does not read the member directory"),
154+
membersById: () => Effect.die("revoke does not read the member directory"),
155+
findByEmail: () => Effect.die("revoke does not read the member directory"),
156+
});
157+
150158
const stubAutumn = Layer.succeed(AutumnService)({
151159
use: () => Effect.die("revoke does not touch billing"),
152160
ensureCustomer: () => Effect.die("revoke does not touch billing"),
@@ -185,6 +193,7 @@ const providerWith = (accountId: string) => {
185193
stubWorkOS,
186194
stubUsers,
187195
stubMirror,
196+
stubDirectory,
188197
stubApiKeys,
189198
stubAutumn,
190199
Layer.succeed(AccountCaller)({ session: session(accountId) }),

apps/cloud/src/account/workos-account-service.ts

Lines changed: 66 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Context, Effect, Layer } from "effect";
22

3-
import { AccountProvider, type AccountHeaders } from "@executor-js/api/server";
3+
import { AccountProvider, MemberDirectory, type AccountHeaders } from "@executor-js/api/server";
44
import {
55
AccountError,
66
AccountForbidden,
@@ -12,6 +12,7 @@ import { ApiKeyService } from "../auth/api-keys";
1212
import { UserStoreService } from "../auth/context";
1313
import type { Session } from "../auth/middleware";
1414
import { WorkOSClient } from "../auth/workos";
15+
import { ensureOrganizationBackfilled, mirrorInvitedMember } from "../auth/mirror-feeders";
1516
import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror";
1617
import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
1718
import { AutumnService } from "../extensions/billing/service";
@@ -66,7 +67,13 @@ const toAccountError = () => Effect.fail(new AccountError({ message: "Account re
6667
export const workosAccountProvider: Layer.Layer<
6768
AccountProvider,
6869
never,
69-
WorkOSClient | UserStoreService | WorkOsMirror | ApiKeyService | AutumnService | AccountCaller
70+
| WorkOSClient
71+
| UserStoreService
72+
| WorkOsMirror
73+
| MemberDirectory
74+
| ApiKeyService
75+
| AutumnService
76+
| AccountCaller
7077
> = Layer.effect(AccountProvider)(
7178
Effect.gen(function* () {
7279
const workos = yield* WorkOSClient;
@@ -77,6 +84,10 @@ export const workosAccountProvider: Layer.Layer<
7784
// written through to the local mirror so the member list and the seat
7885
// count read the change without waiting for the Events reconciler.
7986
const mirror = yield* WorkOsMirror;
87+
// Membership READS come from the mirror through the shared directory: the
88+
// member list and the seat count are one local query each, never a
89+
// WorkOS read per member.
90+
const directory = yield* MemberDirectory;
8091

8192
// The caller, resolved once per request by the cookie-only session
8293
// middleware (account-api.ts) — the same credential `SessionAuthLive`
@@ -85,10 +96,12 @@ export const workosAccountProvider: Layer.Layer<
8596
const caller = yield* AccountCaller;
8697

8798
// Capture the resolved service context once so the method bodies — which
88-
// call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) —
89-
// can be erased to `R = never`, as the neutral AccountProvider shape
90-
// requires. Provided per method below.
91-
const ctx = yield* Effect.context<WorkOSClient | UserStoreService | AutumnService>();
99+
// call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`),
100+
// the mirror feeders, and the seat reporter — can be erased to `R = never`,
101+
// as the neutral AccountProvider shape requires. Provided per method below.
102+
const ctx = yield* Effect.context<
103+
WorkOSClient | UserStoreService | AutumnService | MemberDirectory | WorkOsMirror
104+
>();
92105

93106
// Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly
94107
// as the old inline `requireSession` did.
@@ -145,7 +158,14 @@ export const workosAccountProvider: Layer.Layer<
145158
return membership;
146159
});
147160

148-
// Mirror of org/handlers `getMemberSeats` — live seat usage from WorkOS.
161+
// Seat usage: memberships from the local directory (active + pending, the
162+
// `members` default), pending invitations live from WorkOS — invitations
163+
// are not mirrored. The directory is trusted for a COUNT only once this
164+
// organization's membership list has been scanned from WorkOS in full:
165+
// login and write-through record single memberships, so an organization
166+
// the one-off backfill did not cover holds a partial list, and counting
167+
// it would admit invitations past the plan limit. The scan runs here,
168+
// once, when the organization's mark is missing.
149169
const getMemberSeats = (organizationId: string) =>
150170
Effect.gen(function* () {
151171
const customer = yield* autumn.use((client) =>
@@ -154,15 +174,16 @@ export const workosAccountProvider: Layer.Layer<
154174
const planId = selectActiveMemberLimitPlan(customer.subscriptions);
155175
const limit = getMemberLimitForPlan(planId);
156176

157-
// `listOrgMembers` returns active members AND pending memberships (an
158-
// invited user shows up as status "pending"); `listPendingInvitations`
177+
yield* ensureOrganizationBackfilled(organizationId).pipe(Effect.provideContext(ctx));
178+
// The directory reports active members AND pending memberships (an
179+
// invited user is mirrored with status "pending"); `listPendingInvitations`
159180
// returns the same invited users again. `countSeatsUsed` dedupes them
160181
// so an outstanding invite is not counted twice.
161-
const memberships = yield* workos.listOrgMembers(organizationId);
182+
const memberships = yield* directory.members(organizationId);
162183
const invitations = yield* workos.listPendingInvitations(organizationId);
163184

164185
return {
165-
used: countSeatsUsed(memberships.data, invitations.data.length),
186+
used: countSeatsUsed(memberships, invitations.data.length),
166187
granted: limit ?? 0,
167188
unlimited: limit === null,
168189
};
@@ -328,29 +349,23 @@ export const workosAccountProvider: Layer.Layer<
328349
Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })),
329350
);
330351

331-
const memberships = yield* workos
332-
.listOrgMembers(org.id)
333-
.pipe(Effect.catchTag("WorkOSError", toAccountError));
334-
335-
const members = yield* Effect.all(
336-
memberships.data.map((m) =>
337-
Effect.gen(function* () {
338-
const user = yield* workos.getUser(m.userId);
339-
return {
340-
id: m.id,
341-
userId: m.userId,
342-
email: user.email,
343-
name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null,
344-
avatarUrl: user.profilePictureUrl ?? null,
345-
role: m.role?.slug ?? "member",
346-
status: m.status,
347-
lastActiveAt: user.lastSignInAt ?? null,
348-
isCurrentUser: m.userId === session.accountId,
349-
};
350-
}),
351-
),
352-
{ concurrency: 5 },
353-
).pipe(Effect.catchTag("WorkOSError", toAccountError));
352+
// One directory read (active + pending, ordered by email) with the
353+
// profile already joined — no per-member WorkOS user fetch.
354+
const directoryMembers = yield* directory
355+
.members(org.id)
356+
.pipe(Effect.catchTag("MemberDirectoryError", toAccountError));
357+
358+
const members = directoryMembers.map((m) => ({
359+
id: m.membershipId,
360+
userId: m.accountId,
361+
email: m.email,
362+
name: m.name,
363+
avatarUrl: m.avatarUrl,
364+
role: m.role,
365+
status: m.status,
366+
lastActiveAt: m.lastActiveAt === null ? null : new Date(m.lastActiveAt).toISOString(),
367+
isCurrentUser: m.accountId === session.accountId,
368+
}));
354369

355370
return { members, seats };
356371
}),
@@ -378,6 +393,23 @@ export const workosAccountProvider: Layer.Layer<
378393
...(body.roleSlug ? { roleSlug: body.roleSlug } : {}),
379394
})
380395
.pipe(Effect.catchTag("WorkOSError", toAccountError));
396+
// Write-through: WorkOS creates a PENDING membership for the invitee
397+
// alongside the invitation, and the member list (the "Invited" row
398+
// and its revoke button) reads memberships from the mirror only, so
399+
// the row must land now — the Events reconciler is not on this path.
400+
const mirrored = yield* mirrorInvitedMember(org.id, invitation.email).pipe(
401+
Effect.provideContext(ctx),
402+
Effect.catchTags({
403+
WorkOSError: toAccountError,
404+
WorkOsMirrorError: toAccountError,
405+
}),
406+
);
407+
if (!mirrored) {
408+
yield* Effect.logWarning("inviteMember: no pending membership for the invitee yet", {
409+
organizationId: org.id,
410+
invitationId: invitation.id,
411+
});
412+
}
381413
return { id: invitation.id, email: invitation.email };
382414
}),
383415

0 commit comments

Comments
 (0)