Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/member-directory-readers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@executor-js/cloud": patch
"@executor-js/api": patch
"@executor-js/react": patch
"@executor-js/sdk": patch
---

Member lists, the admin users page, and seat counts on cloud now read from the local membership mirror through the shared `MemberDirectory` seam instead of fanning out one WorkOS read per member. The admin users page gains an email/name search.

**Deploy prerequisite (cloud):** `bun run --cwd apps/cloud db:backfill-workos-mirror:prod` must complete before this build is deployed, and its printed membership count should match WorkOS. Until the backfill has stamped the mirror's marker, seat reporting to Autumn is skipped with a warning (never a partial count) and member lists show only members who have signed in since the mirror shipped.
8 changes: 5 additions & 3 deletions apps/cloud/src/account/account-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AccountProvider,
makeAccountApiLayer,
requestScopedMiddleware,
type MemberDirectory,
} from "@executor-js/api/server";

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

export const makeAccountApiLive = (
rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>,
rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>,
) => {
// Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it
// closes over the per-request postgres socket), so it can't be a self-
Expand Down
11 changes: 10 additions & 1 deletion apps/cloud/src/account/org-api-key-revoke.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect, Layer } from "effect";

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

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

// Revoke lists no members either.
const stubDirectory = Layer.succeed(MemberDirectory)({
membership: () => Effect.die("revoke does not read the member directory"),
members: () => Effect.die("revoke does not read the member directory"),
membersById: () => Effect.die("revoke does not read the member directory"),
findByEmail: () => Effect.die("revoke does not read the member directory"),
});

const stubAutumn = Layer.succeed(AutumnService)({
use: () => Effect.die("revoke does not touch billing"),
ensureCustomer: () => Effect.die("revoke does not touch billing"),
Expand Down Expand Up @@ -185,6 +193,7 @@ const providerWith = (accountId: string) => {
stubWorkOS,
stubUsers,
stubMirror,
stubDirectory,
stubApiKeys,
stubAutumn,
Layer.succeed(AccountCaller)({ session: session(accountId) }),
Expand Down
100 changes: 66 additions & 34 deletions apps/cloud/src/account/workos-account-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Context, Effect, Layer } from "effect";

import { AccountProvider, type AccountHeaders } from "@executor-js/api/server";
import { AccountProvider, MemberDirectory, type AccountHeaders } from "@executor-js/api/server";
import {
AccountError,
AccountForbidden,
Expand All @@ -12,6 +12,7 @@ import { ApiKeyService } from "../auth/api-keys";
import { UserStoreService } from "../auth/context";
import type { Session } from "../auth/middleware";
import { WorkOSClient } from "../auth/workos";
import { ensureOrganizationBackfilled, mirrorInvitedMember } from "../auth/mirror-feeders";
import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror";
import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";
import { AutumnService } from "../extensions/billing/service";
Expand Down Expand Up @@ -66,7 +67,13 @@ const toAccountError = () => Effect.fail(new AccountError({ message: "Account re
export const workosAccountProvider: Layer.Layer<
AccountProvider,
never,
WorkOSClient | UserStoreService | WorkOsMirror | ApiKeyService | AutumnService | AccountCaller
| WorkOSClient
| UserStoreService
| WorkOsMirror
| MemberDirectory
| ApiKeyService
| AutumnService
| AccountCaller
> = Layer.effect(AccountProvider)(
Effect.gen(function* () {
const workos = yield* WorkOSClient;
Expand All @@ -77,6 +84,10 @@ export const workosAccountProvider: Layer.Layer<
// written through to the local mirror so the member list and the seat
// count read the change without waiting for the Events reconciler.
const mirror = yield* WorkOsMirror;
// Membership READS come from the mirror through the shared directory: the
// member list and the seat count are one local query each, never a
// WorkOS read per member.
const directory = yield* MemberDirectory;

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

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

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

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

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

return {
used: countSeatsUsed(memberships.data, invitations.data.length),
used: countSeatsUsed(memberships, invitations.data.length),
granted: limit ?? 0,
unlimited: limit === null,
};
Expand Down Expand Up @@ -328,29 +349,23 @@ export const workosAccountProvider: Layer.Layer<
Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })),
);

const memberships = yield* workos
.listOrgMembers(org.id)
.pipe(Effect.catchTag("WorkOSError", toAccountError));

const members = yield* Effect.all(
memberships.data.map((m) =>
Effect.gen(function* () {
const user = yield* workos.getUser(m.userId);
return {
id: m.id,
userId: m.userId,
email: user.email,
name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null,
avatarUrl: user.profilePictureUrl ?? null,
role: m.role?.slug ?? "member",
status: m.status,
lastActiveAt: user.lastSignInAt ?? null,
isCurrentUser: m.userId === session.accountId,
};
}),
),
{ concurrency: 5 },
).pipe(Effect.catchTag("WorkOSError", toAccountError));
// One directory read (active + pending, ordered by email) with the
// profile already joined — no per-member WorkOS user fetch.
const directoryMembers = yield* directory
.members(org.id)
.pipe(Effect.catchTag("MemberDirectoryError", toAccountError));

const members = directoryMembers.map((m) => ({
id: m.membershipId,
userId: m.accountId,
email: m.email,
name: m.name,
avatarUrl: m.avatarUrl,
role: m.role,
status: m.status,
lastActiveAt: m.lastActiveAt === null ? null : new Date(m.lastActiveAt).toISOString(),
isCurrentUser: m.accountId === session.accountId,
}));

return { members, seats };
}),
Expand Down Expand Up @@ -378,6 +393,23 @@ export const workosAccountProvider: Layer.Layer<
...(body.roleSlug ? { roleSlug: body.roleSlug } : {}),
})
.pipe(Effect.catchTag("WorkOSError", toAccountError));
// Write-through: WorkOS creates a PENDING membership for the invitee
// alongside the invitation, and the member list (the "Invited" row
// and its revoke button) reads memberships from the mirror only, so
// the row must land now — the Events reconciler is not on this path.
const mirrored = yield* mirrorInvitedMember(org.id, invitation.email).pipe(
Effect.provideContext(ctx),
Effect.catchTags({
WorkOSError: toAccountError,
WorkOsMirrorError: toAccountError,
}),
);
if (!mirrored) {
yield* Effect.logWarning("inviteMember: no pending membership for the invitee yet", {
organizationId: org.id,
invitationId: invitation.id,
});
}
return { id: invitation.id, email: invitation.email };
}),

Expand Down
Loading
Loading