11import { 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" ;
44import {
55 AccountError ,
66 AccountForbidden ,
@@ -12,6 +12,7 @@ import { ApiKeyService } from "../auth/api-keys";
1212import { UserStoreService } from "../auth/context" ;
1313import type { Session } from "../auth/middleware" ;
1414import { WorkOSClient } from "../auth/workos" ;
15+ import { ensureOrganizationBackfilled , mirrorInvitedMember } from "../auth/mirror-feeders" ;
1516import { WorkOsMirror , mirrorMembershipFromWorkOs } from "../auth/workos-mirror" ;
1617import { ORG_SELECTOR_HEADER , authorizeOrganizationSelector } from "../auth/organization" ;
1718import { AutumnService } from "../extensions/billing/service" ;
@@ -66,7 +67,13 @@ const toAccountError = () => Effect.fail(new AccountError({ message: "Account re
6667export 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