Unified · Split
| @@ -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. | |
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | ||
| 5 | 5 | AccountProvider, |
| 6 | 6 | makeAccountApiLayer, |
| 7 | 7 | requestScopedMiddleware, |
| 8 | + type MemberDirectory, | |
| 8 | 9 | } from "@executor-js/api/server"; |
| 9 | 10 | |
| 10 | 11 | import { ApiKeyService } from "../auth/api-keys"; |
| @@ -46,7 +47,8 @@ import { AccountCaller, workosAccountProvider } from "./workos-account-service"; · 35 unmodified lines | ||
| 46 | 47 | // Builds the WorkOS `AccountProvider` per request, providing it to the handler. |
| 47 | 48 | // Long-lived `WorkOSClient | AutumnService` come from the surrounding context |
| 48 | 49 | // (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 | 52 | // `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`. |
| 51 | 53 | const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()( |
| 52 | 54 | Effect.gen(function* () { |
| @@ -97,11 +99,11 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi · 44 unmodified lines | ||
| 97 | 99 | * (the seat-gate) stays a residual requirement, satisfied by the app `boot`. |
| 98 | 100 | */ |
| 99 | 101 | export const workosAccountMiddleware = ( |
| 100 | − rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>, | |
| 102 | + rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, | |
| 101 | 103 | ) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; |
| 102 | 104 | |
| 103 | 105 | export const makeAccountApiLive = ( |
| 104 | − rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>, | |
| 106 | + rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, | |
| 105 | 107 | ) => { |
| 106 | 108 | // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it |
| 107 | 109 | // closes over the per-request postgres socket), so it can't be a self- |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import { Effect, Layer } from "effect"; |
| 3 | 3 | |
| 4 | −import { AccountProvider } from "@executor-js/api/server"; | |
| 4 | +import { AccountProvider, MemberDirectory } from "@executor-js/api/server"; | |
| 5 | 5 | import { AccountError, AccountForbidden } from "@executor-js/api"; |
| 6 | 6 | |
| 7 | 7 | import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; |
| @@ -147,6 +147,14 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ · 139 unmodified lines | ||
| 147 | 147 | organizationBackfilledAt: () => Effect.die("revoke does not report seats"), |
| 148 | 148 | }); |
| 149 | 149 | |
| 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 | + | |
| 150 | 158 | const stubAutumn = Layer.succeed(AutumnService)({ |
| 151 | 159 | use: () => Effect.die("revoke does not touch billing"), |
| 152 | 160 | ensureCustomer: () => Effect.die("revoke does not touch billing"), |
| @@ -185,6 +193,7 @@ const providerWith = (accountId: string) => { · 32 unmodified lines | ||
| 185 | 193 | stubWorkOS, |
| 186 | 194 | stubUsers, |
| 187 | 195 | stubMirror, |
| 196 | + stubDirectory, | |
| 188 | 197 | stubApiKeys, |
| 189 | 198 | stubAutumn, |
| 190 | 199 | Layer.succeed(AccountCaller)({ session: session(accountId) }), |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | 2 | |
| 3 | −import { AccountProvider, type AccountHeaders } from "@executor-js/api/server"; | |
| 3 | +import { AccountProvider, MemberDirectory, type AccountHeaders } from "@executor-js/api/server"; | |
| 4 | 4 | import { |
| 5 | 5 | AccountError, |
| 6 | 6 | AccountForbidden, |
| @@ -12,6 +12,7 @@ import { ApiKeyService } from "../auth/api-keys"; · 5 unmodified lines | ||
| 12 | 12 | import { UserStoreService } from "../auth/context"; |
| 13 | 13 | import type { Session } from "../auth/middleware"; |
| 14 | 14 | import { WorkOSClient } from "../auth/workos"; |
| 15 | +import { ensureOrganizationBackfilled, mirrorInvitedMember } from "../auth/mirror-feeders"; | |
| 15 | 16 | import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror"; |
| 16 | 17 | import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; |
| 17 | 18 | import { AutumnService } from "../extensions/billing/service"; |
| @@ -66,7 +67,13 @@ const toAccountError = () => Effect.fail(new AccountError({ message: "Account re · 48 unmodified lines | ||
| 66 | 67 | export const workosAccountProvider: Layer.Layer< |
| 67 | 68 | AccountProvider, |
| 68 | 69 | never, |
| 69 | − WorkOSClient | UserStoreService | WorkOsMirror | ApiKeyService | AutumnService | AccountCaller | |
| 70 | + | WorkOSClient | |
| 71 | + | UserStoreService | |
| 72 | + | WorkOsMirror | |
| 73 | + | MemberDirectory | |
| 74 | + | ApiKeyService | |
| 75 | + | AutumnService | |
| 76 | + | AccountCaller | |
| 70 | 77 | > = Layer.effect(AccountProvider)( |
| 71 | 78 | Effect.gen(function* () { |
| 72 | 79 | const workos = yield* WorkOSClient; |
| @@ -77,6 +84,10 @@ export const workosAccountProvider: Layer.Layer< · 4 unmodified lines | ||
| 77 | 84 | // written through to the local mirror so the member list and the seat |
| 78 | 85 | // count read the change without waiting for the Events reconciler. |
| 79 | 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 | 92 | // The caller, resolved once per request by the cookie-only session |
| 82 | 93 | // middleware (account-api.ts) — the same credential `SessionAuthLive` |
| @@ -85,10 +96,12 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines | ||
| 85 | 96 | const caller = yield* AccountCaller; |
| 86 | 97 | |
| 87 | 98 | // 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 | + >(); | |
| 92 | 105 | |
| 93 | 106 | // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly |
| 94 | 107 | // as the old inline `requireSession` did. |
| @@ -145,7 +158,14 @@ export const workosAccountProvider: Layer.Layer< · 50 unmodified lines | ||
| 145 | 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 | 169 | const getMemberSeats = (organizationId: string) => |
| 150 | 170 | Effect.gen(function* () { |
| 151 | 171 | const customer = yield* autumn.use((client) => |
| @@ -154,15 +174,16 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines | ||
| 154 | 174 | const planId = selectActiveMemberLimitPlan(customer.subscriptions); |
| 155 | 175 | const limit = getMemberLimitForPlan(planId); |
| 156 | 176 | |
| 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` | |
| 159 | 180 | // returns the same invited users again. `countSeatsUsed` dedupes them |
| 160 | 181 | // so an outstanding invite is not counted twice. |
| 161 | − const memberships = yield* workos.listOrgMembers(organizationId); | |
| 182 | + const memberships = yield* directory.members(organizationId); | |
| 162 | 183 | const invitations = yield* workos.listPendingInvitations(organizationId); |
| 163 | 184 | |
| 164 | 185 | return { |
| 165 | − used: countSeatsUsed(memberships.data, invitations.data.length), | |
| 186 | + used: countSeatsUsed(memberships, invitations.data.length), | |
| 166 | 187 | granted: limit ?? 0, |
| 167 | 188 | unlimited: limit === null, |
| 168 | 189 | }; |
| @@ -328,29 +349,23 @@ export const workosAccountProvider: Layer.Layer< · 159 unmodified lines | ||
| 328 | 349 | Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })), |
| 329 | 350 | ); |
| 330 | 351 | |
| 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 | + })); | |
| 354 | 369 | |
| 355 | 370 | return { members, seats }; |
| 356 | 371 | }), |
| @@ -378,6 +393,23 @@ export const workosAccountProvider: Layer.Layer< · 21 unmodified lines | ||
| 378 | 393 | ...(body.roleSlug ? { roleSlug: body.roleSlug } : {}), |
| 379 | 394 | }) |
| 380 | 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 | 413 | return { id: invitation.id, email: invitation.email }; |
| 382 | 414 | }), |
| 383 | 415 | |
| @@ -25,28 +25,24 @@ · 24 unmodified lines | ||
| 25 | 25 | // every query by that tenant. |
| 26 | 26 | // --------------------------------------------------------------------------- |
| 27 | 27 | |
| 28 | −import { env } from "cloudflare:workers"; | |
| 29 | 28 | import { HttpRouter } from "effect/unstable/http"; |
| 30 | −import { Context, Effect, Layer, Option } from "effect"; | |
| 29 | +import { Effect, Layer } from "effect"; | |
| 31 | 30 | |
| 32 | 31 | import { |
| 33 | 32 | AdminUsersProvider, |
| 34 | 33 | DbProvider, |
| 35 | 34 | HostConfig, |
| 35 | + MemberDirectory, | |
| 36 | 36 | PluginsProvider, |
| 37 | + adminUserDirectoryFromMembers, | |
| 37 | 38 | getAdminUser, |
| 38 | 39 | listAdminUserConnections, |
| 39 | 40 | listAdminUsers, |
| 40 | 41 | listAdminUsersWithConnections, |
| 41 | 42 | makeAdminUsersApiLayer, |
| 42 | 43 | makePlatformExecutor, |
| 43 | − normalizeAdminUserEmail, | |
| 44 | 44 | platformViewOf, |
| 45 | 45 | requestScopedMiddleware, |
| 46 | − type AdminEmailResolver, | |
| 47 | − type AdminIdentityDirectory, | |
| 48 | − type AdminUserDirectory, | |
| 49 | − type AdminUserIdentity, | |
| 50 | 46 | type AdminUsersHeaders, |
| 51 | 47 | } from "@executor-js/api/server"; |
| 52 | 48 | import { |
| @@ -122,124 +118,6 @@ const authorizeTenant = ( · 69 unmodified lines | ||
| 122 | 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 | − */ | |
| 130 | −const IDENTITY_CONCURRENCY = 5; | |
| 131 | − | |
| 132 | −/** | |
| 133 | − * Cloud's member directory: `externalId` → email/name. | |
| 134 | − * | |
| 135 | − * THE JOIN KEY is the membership's `userId` — the WorkOS `user_...` that | |
| 136 | − * `workos-auth-provider.ts` binds as `accountId` on every credential path, and | |
| 137 | − * therefore what the subject table records in `external_id`. The membership's | |
| 138 | − * own `id` is an `om_...` row id and joins to nothing. | |
| 139 | − * | |
| 140 | − * WHY THIS IS TWO CALLS AND NOT ONE. The membership list is read once per | |
| 141 | − * request and is the authority on who belongs to the org, but WorkOS's | |
| 142 | − * `listOrganizationMemberships` carries no user detail and offers no | |
| 143 | − * include/expand — email and name only exist on the user resource. The SDK does | |
| 144 | − * expose a batched `listUsers({ organizationId })`, but the pinned | |
| 145 | − * `@executor-js/emulate` WorkOS emulator serves only `GET | |
| 146 | − * /user_management/users/:id`, so taking that path would leave every cloud e2e | |
| 147 | − * user unnamed. So: ONE membership read per request, then user detail fetched | |
| 148 | − * only for the ids ON THIS PAGE — never for the whole org, and never once per | |
| 149 | − * row of some larger list. An id that is not an active/pending member is not | |
| 150 | − * fetched at all and reports absent identity, which is the honest answer for a | |
| 151 | − * member who left while their connections remain. | |
| 152 | − */ | |
| 153 | −const identityDirectory = | |
| 154 | − (organizationId: string, context: Context.Context<WorkOSClient>): AdminIdentityDirectory => | |
| 155 | − (externalIds) => | |
| 156 | − Effect.gen(function* () { | |
| 157 | − const workos = yield* WorkOSClient; | |
| 158 | − const memberships = yield* workos.listOrgMembers(organizationId); | |
| 159 | − const wanted = new Set(externalIds); | |
| 160 | − const memberIds = memberships.data | |
| 161 | − .map((membership) => membership.userId) | |
| 162 | − .filter((userId) => wanted.has(userId)); | |
| 163 | − | |
| 164 | − const resolved = yield* Effect.all( | |
| 165 | − memberIds.map((userId) => | |
| 166 | − workos.getUser(userId).pipe( | |
| 167 | − Effect.map( | |
| 168 | − (user) => | |
| 169 | − [ | |
| 170 | − userId, | |
| 171 | − { | |
| 172 | − email: user.email, | |
| 173 | − displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, | |
| 174 | − }, | |
| 175 | − ] as const, | |
| 176 | − ), | |
| 177 | − // One unreadable user must not cost the whole page its names. | |
| 178 | − Effect.catchCause(() => Effect.succeed(null)), | |
| 179 | − ), | |
| 180 | − ), | |
| 181 | − { concurrency: IDENTITY_CONCURRENCY }, | |
| 182 | − ); | |
| 183 | − | |
| 184 | − const identities = new Map<string, AdminUserIdentity>(); | |
| 185 | − for (const entry of resolved) if (entry) identities.set(entry[0], entry[1]); | |
| 186 | − return identities; | |
| 187 | − }).pipe(Effect.provideContext(context)); | |
| 188 | − | |
| 189 | −/** | |
| 190 | − * Cloud's REVERSE directory lookup: email → the WorkOS `user_...` id. | |
| 191 | − * | |
| 192 | − * Production asks WorkOS for the email AND organization in one request. Both | |
| 193 | − * filters matter: email makes the lookup indexed rather than one `getUser` | |
| 194 | − * request per member, while organization keeps the reverse lookup bound to the | |
| 195 | − * same tenant as the platform view. | |
| 196 | − * | |
| 197 | − * The pinned `@executor-js/emulate` WorkOS emulator has no list-users route. | |
| 198 | − * `WORKOS_API_URL` is the explicit test/dev emulator override, so that path | |
| 199 | − * retains the membership scan until the emulator supports the production | |
| 200 | − * query. The fallback still starts from the tenant's membership list and can | |
| 201 | − * never return a user from another organization. | |
| 202 | − * | |
| 203 | − * CASING: WorkOS preserves whatever casing an email was created with (and the | |
| 204 | − * emulator compares byte-exact), so the directory value is normalized here | |
| 205 | − * before comparison, against an argument the seam already normalized. | |
| 206 | − */ | |
| 207 | −export const emailResolver = | |
| 208 | − (organizationId: string, context: Context.Context<WorkOSClient>): AdminEmailResolver => | |
| 209 | − (email) => | |
| 210 | − Effect.gen(function* () { | |
| 211 | − const workos = yield* WorkOSClient; | |
| 212 | − | |
| 213 | − if (!env.WORKOS_API_URL) { | |
| 214 | − const users = yield* workos.listUsers({ email, organizationId }); | |
| 215 | − return users.data[0]?.id ?? null; | |
| 216 | − } | |
| 217 | − | |
| 218 | − const memberships = yield* workos.listOrgMembers(organizationId); | |
| 219 | − const userIds = memberships.data.map((membership) => membership.userId); | |
| 220 | − | |
| 221 | − // Emulator compatibility only. Short-circuit once the normalized email | |
| 222 | − // matches so the fallback makes as few unsupported-detail reads as it can. | |
| 223 | − const match = yield* Effect.findFirst(userIds, (userId) => | |
| 224 | − workos.getUser(userId).pipe( | |
| 225 | − Effect.map((user) => normalizeAdminUserEmail(user.email ?? "") === email), | |
| 226 | − // One unreadable user must not fail the whole lookup — it simply | |
| 227 | − // cannot be the match. | |
| 228 | − Effect.catchCause(() => Effect.succeed(false)), | |
| 229 | − ), | |
| 230 | − ); | |
| 231 | − return Option.getOrNull(match); | |
| 232 | − }).pipe(Effect.provideContext(context)); | |
| 233 | − | |
| 234 | −/** Both directions of cloud's directory, built once per authorized request. */ | |
| 235 | −const userDirectory = ( | |
| 236 | − organizationId: string, | |
| 237 | − context: Context.Context<WorkOSClient>, | |
| 238 | −): AdminUserDirectory => ({ | |
| 239 | − identities: identityDirectory(organizationId, context), | |
| 240 | − resolveEmail: emailResolver(organizationId, context), | |
| 241 | −}); | |
| 242 | − | |
| 243 | 121 | /** |
| 244 | 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 | 142 | const executor = yield* makePlatformExecutor(organizationId).pipe( |
| 265 | 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 the | |
| 268 | − // SAME org the reads are scoped to — never one named by client input. | |
| 145 | + // The authorized tenant is handed to the body so the directory reads the | |
| 146 | + // SAME org the storage reads are scoped to — never one named by client | |
| 147 | + // input. | |
| 269 | 148 | return yield* Effect.ensuring( |
| 270 | 149 | body(executor, organizationId), |
| 271 | 150 | executor.close().pipe(Effect.ignore), |
| @@ -275,22 +154,40 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 3 unmodified lines | ||
| 275 | 154 | /** |
| 276 | 155 | * Cloud's `AdminUsersProvider`, built per request so the platform executor |
| 277 | 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 | */ |
| 279 | 165 | export const workosAdminUsersProvider: Layer.Layer< |
| 280 | 166 | AdminUsersProvider, |
| 281 | 167 | never, |
| 282 | − WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig | |
| 168 | + | WorkOSClient | |
| 169 | + | ApiKeyService | |
| 170 | + | UserStoreService | |
| 171 | + | MemberDirectory | |
| 172 | + | DbProvider | |
| 173 | + | PluginsProvider | |
| 174 | + | HostConfig | |
| 283 | 175 | > = Layer.effect(AdminUsersProvider)( |
| 284 | 176 | Effect.gen(function* () { |
| 285 | 177 | const context = yield* Effect.context< |
| 286 | 178 | 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 | 185 | return AdminUsersProvider.of({ |
| 289 | 186 | listUsers: (headers, options) => |
| 290 | 187 | withPlatformView(headers, (executor, organizationId) => |
| 291 | 188 | platformViewOf(executor).pipe( |
| 292 | 189 | Effect.flatMap((admin) => |
| 293 | − listAdminUsers(admin, options, userDirectory(organizationId, context)), | |
| 190 | + listAdminUsers(admin, options, userDirectory(organizationId)), | |
| 294 | 191 | ), |
| 295 | 192 | ), |
| 296 | 193 | ).pipe(Effect.provideContext(context)), |
| @@ -298,7 +195,7 @@ export const workosAdminUsersProvider: Layer.Layer< · 1 unmodified line | ||
| 298 | 195 | withPlatformView(headers, (executor, organizationId) => |
| 299 | 196 | platformViewOf(executor).pipe( |
| 300 | 197 | Effect.flatMap((admin) => |
| 301 | − listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)), | |
| 198 | + listAdminUsersWithConnections(admin, options, userDirectory(organizationId)), | |
| 302 | 199 | ), |
| 303 | 200 | ), |
| 304 | 201 | ).pipe(Effect.provideContext(context)), |
| @@ -312,7 +209,7 @@ export const workosAdminUsersProvider: Layer.Layer< · 7 unmodified lines | ||
| 312 | 209 | withPlatformView(headers, (executor, organizationId) => |
| 313 | 210 | platformViewOf(executor).pipe( |
| 314 | 211 | Effect.flatMap((admin) => |
| 315 | − getAdminUser(admin, identifier, userDirectory(organizationId, context)), | |
| 212 | + getAdminUser(admin, identifier, userDirectory(organizationId)), | |
| 316 | 213 | ), |
| 317 | 214 | ), |
| 318 | 215 | ).pipe(Effect.provideContext(context)), |
| @@ -322,8 +219,9 @@ export const workosAdminUsersProvider: Layer.Layer< · 3 unmodified lines | ||
| 322 | 219 | |
| 323 | 220 | // Builds the provider per request, providing it to the handlers. Long-lived |
| 324 | 221 | // `WorkOSClient | ApiKeyService` come from the surrounding boot context; the |
| 325 | −// per-request `DbService`/`UserStoreService` (and the execution seams built | |
| 326 | −// over them) are supplied by the combined `requestScopedMiddleware`. | |
| 222 | +// per-request `DbService`/`UserStoreService`/`MemberDirectory` (and the | |
| 223 | +// execution seams built over them) are supplied by the combined | |
| 224 | +// `requestScopedMiddleware`. | |
| 327 | 225 | const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()( |
| 328 | 226 | Effect.gen(function* () { |
| 329 | 227 | const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>(); |
| @@ -348,7 +246,7 @@ const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUser · 18 unmodified lines | ||
| 348 | 246 | * `/api` prefix as the rest of the cloud router. |
| 349 | 247 | */ |
| 350 | 248 | export const makeCloudAdminUsersRoutes = ( |
| 351 | − rsLive: Layer.Layer<DbService | UserStoreService>, | |
| 249 | + rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory>, | |
| 352 | 250 | options: Parameters<typeof makeAdminUsersApiLayer>[1] = {}, |
| 353 | 251 | ) => |
| 354 | 252 | makeAdminUsersApiLayer( |
| @@ -1,127 +0,0 @@ | ||
| 1 | −import { env } from "cloudflare:workers"; | |
| 2 | −import { expect, it } from "@effect/vitest"; | |
| 3 | −import { Data, Effect, Layer } from "effect"; | |
| 4 | − | |
| 5 | −import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | |
| 6 | −import { emailResolver } from "./admin-users-api"; | |
| 7 | − | |
| 8 | −// Cloud's REVERSE directory lookup: email -> the WorkOS `user_...` id that the | |
| 9 | −// subject table records in `external_id`. Production resolves it with one | |
| 10 | −// tenant-scoped list-users query. The WorkOS emulator lacks that route, so | |
| 11 | −// tests/dev retain the membership-backed scan exercised below. | |
| 12 | − | |
| 13 | −const ORG = "org_placeholder"; | |
| 14 | −const OTHER_ORG = "org_other"; | |
| 15 | − | |
| 16 | −class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{ | |
| 17 | − readonly userId: string; | |
| 18 | −}> {} | |
| 19 | − | |
| 20 | −const DIRECTORY = [ | |
| 21 | − // Same email in another tenant must never win either lookup path. | |
| 22 | − { id: "user_foreign", email: "ada@placeholder.test", organizationId: OTHER_ORG }, | |
| 23 | − // WorkOS preserves submitted casing, while the resolver seam is normalized. | |
| 24 | − { id: "user_ada", email: "Ada@Placeholder.test", organizationId: ORG }, | |
| 25 | − { id: "user_grace", email: "grace@placeholder.test", organizationId: ORG }, | |
| 26 | − { id: "user_nameless", email: null, organizationId: ORG }, | |
| 27 | −] as const; | |
| 28 | − | |
| 29 | −const stubWorkOS = (calls: string[], unreadableUserIds: ReadonlySet<string>) => | |
| 30 | − Layer.succeed( | |
| 31 | − WorkOSClient, | |
| 32 | − new Proxy({} as WorkOSClientService, { | |
| 33 | − get: (_target, prop) => { | |
| 34 | − if (prop === "listUsers") { | |
| 35 | − return (params: { email: string; organizationId: string }) => { | |
| 36 | − calls.push(`listUsers:${params.organizationId}:${params.email}`); | |
| 37 | − return Effect.succeed({ | |
| 38 | − data: DIRECTORY.filter( | |
| 39 | − (user) => | |
| 40 | − user.organizationId === params.organizationId && | |
| 41 | − user.email?.toLowerCase() === params.email, | |
| 42 | − ), | |
| 43 | − }); | |
| 44 | − }; | |
| 45 | − } | |
| 46 | − if (prop === "listOrgMembers") { | |
| 47 | − return (organizationId: string) => { | |
| 48 | − calls.push(`listOrgMembers:${organizationId}`); | |
| 49 | − return Effect.succeed({ | |
| 50 | − data: DIRECTORY.filter((user) => user.organizationId === organizationId).map( | |
| 51 | − (user) => ({ userId: user.id, organizationId }), | |
| 52 | − ), | |
| 53 | − }); | |
| 54 | − }; | |
| 55 | − } | |
| 56 | − if (prop === "getUser") { | |
| 57 | − return (userId: string) => { | |
| 58 | − calls.push(`getUser:${userId}`); | |
| 59 | − if (unreadableUserIds.has(userId)) { | |
| 60 | − return Effect.fail(new WorkOSUnavailable({ userId })); | |
| 61 | − } | |
| 62 | − const user = DIRECTORY.find((candidate) => candidate.id === userId); | |
| 63 | − if (!user) return Effect.die(`unexpected user ${userId}`); | |
| 64 | − return Effect.succeed(user); | |
| 65 | − }; | |
| 66 | − } | |
| 67 | − return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | |
| 68 | − }, | |
| 69 | − }), | |
| 70 | − ); | |
| 71 | − | |
| 72 | −const resolve = ( | |
| 73 | − email: string, | |
| 74 | − calls: string[], | |
| 75 | − emulator = false, | |
| 76 | − unreadableUserIds: ReadonlySet<string> = new Set(), | |
| 77 | −) => { | |
| 78 | − const previousApiUrl = env.WORKOS_API_URL; | |
| 79 | − return Effect.gen(function* () { | |
| 80 | − yield* Effect.sync(() => | |
| 81 | − Object.assign(env, { | |
| 82 | − WORKOS_API_URL: emulator ? "http://workos-emulator.invalid" : undefined, | |
| 83 | − }), | |
| 84 | − ); | |
| 85 | − const context = yield* Effect.context<WorkOSClient>(); | |
| 86 | − return yield* emailResolver(ORG, context)(email); | |
| 87 | − }).pipe( | |
| 88 | − Effect.provide(stubWorkOS(calls, unreadableUserIds)), | |
| 89 | − Effect.ensuring(Effect.sync(() => Object.assign(env, { WORKOS_API_URL: previousApiUrl }))), | |
| 90 | − ); | |
| 91 | −}; | |
| 92 | − | |
| 93 | −it.effect("resolves an email with one tenant-scoped WorkOS query", () => | |
| 94 | − Effect.gen(function* () { | |
| 95 | − const calls: string[] = []; | |
| 96 | − expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada"); | |
| 97 | − expect(calls).toEqual([`listUsers:${ORG}:ada@placeholder.test`]); | |
| 98 | − }), | |
| 99 | −); | |
| 100 | − | |
| 101 | −it.effect("returns null from one query when the organization has no matching email", () => | |
| 102 | − Effect.gen(function* () { | |
| 103 | − const calls: string[] = []; | |
| 104 | − expect(yield* resolve("nobody@placeholder.test", calls)).toBeNull(); | |
| 105 | − expect(calls).toEqual([`listUsers:${ORG}:nobody@placeholder.test`]); | |
| 106 | − }), | |
| 107 | −); | |
| 108 | − | |
| 109 | −it.effect("keeps the emulator fallback tenant-scoped and case-insensitive", () => | |
| 110 | − Effect.gen(function* () { | |
| 111 | − const calls: string[] = []; | |
| 112 | − expect(yield* resolve("ada@placeholder.test", calls, true)).toBe("user_ada"); | |
| 113 | − expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada"]); | |
| 114 | − expect(calls).not.toContain("getUser:user_foreign"); | |
| 115 | − expect(calls.some((call) => call.startsWith("listUsers:"))).toBe(false); | |
| 116 | − }), | |
| 117 | −); | |
| 118 | − | |
| 119 | −it.effect("lets the emulator fallback continue past one unreadable member", () => | |
| 120 | − Effect.gen(function* () { | |
| 121 | − const calls: string[] = []; | |
| 122 | − expect(yield* resolve("grace@placeholder.test", calls, true, new Set(["user_ada"]))).toBe( | |
| 123 | − "user_grace", | |
| 124 | − ); | |
| 125 | − expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada", "getUser:user_grace"]); | |
| 126 | − }), | |
| 127 | −); | |
| @@ -2,10 +2,15 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line | ||
| 2 | 2 | import { HttpServer } from "effect/unstable/http"; |
| 3 | 3 | import { Layer } from "effect"; |
| 4 | 4 | |
| 5 | −import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api/server"; | |
| 5 | +import { | |
| 6 | + makeProtectedApiLayer, | |
| 7 | + requestScopedMiddleware, | |
| 8 | + type MemberDirectory, | |
| 9 | +} from "@executor-js/api/server"; | |
| 6 | 10 | |
| 7 | 11 | import { SessionAuthLive } from "../auth/middleware-live"; |
| 8 | 12 | import { UserStoreService } from "../auth/context"; |
| 13 | +import { cloudMemberDirectoryLayer } from "../auth/member-directory"; | |
| 9 | 14 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| 10 | 15 | import { |
| 11 | 16 | CloudAuthPublicHandlers, |
| @@ -27,12 +32,17 @@ import { CoreSharedServices } from "../auth/workos"; · 15 unmodified lines | ||
| 27 | 32 | const DbLive = DbService.Live; |
| 28 | 33 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); |
| 29 | 34 | const WorkOsMirrorLive = WorkOsMirror.Live.pipe(Layer.provide(DbLive)); |
| 35 | +// The shared `MemberDirectory` read seam over the membership mirror — the | |
| 36 | +// same per-request socket the mirror writes through. | |
| 37 | +const MemberDirectoryLive = cloudMemberDirectoryLayer.pipe(Layer.provide(DbLive)); | |
| 30 | 38 | |
| 31 | 39 | // Per-request layer. Anything that opens an I/O object (postgres.js socket, |
| 32 | 40 | // fetch stream readers, anything backed by a `Writable`) MUST live here — |
| 33 | 41 | // `provideRequestScoped` rebuilds it per request so Cloudflare Workers' |
| 34 | 42 | // I/O isolation is satisfied. See `api.request-scope.test.ts`. |
| 35 | −export const RequestScopedServicesLive = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive); | |
| 43 | +export const RequestScopedServicesLive: Layer.Layer< | |
| 44 | + DbService | UserStoreService | WorkOsMirror | MemberDirectory | |
| 45 | +> = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive, MemberDirectoryLive); | |
| 36 | 46 | |
| 37 | 47 | // Boot-scoped layer. Built once at worker boot, reused across requests. |
| 38 | 48 | // Safe for config, in-memory caches, the global tracer provider, and |
| @@ -57,7 +67,7 @@ export const BootSharedServices = Layer.mergeAll( · 18 unmodified lines | ||
| 57 | 67 | // handler reads it for the free-organizations-per-user limit gate — one of the |
| 58 | 68 | // few app-only billing touchpoints. (It is NOT on the neutral boot core.) |
| 59 | 69 | export const makeNonProtectedApiLive = ( |
| 60 | − rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>, | |
| 70 | + rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, | |
| 61 | 71 | ) => |
| 62 | 72 | HttpApiBuilder.layer(NonProtectedApi).pipe( |
| 63 | 73 | Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), |
| @@ -1,7 +1,11 @@ | ||
| 1 | 1 | import { Layer } from "effect"; |
| 2 | 2 | import { HttpRouter } from "effect/unstable/http"; |
| 3 | 3 | |
| 4 | −import { RouterConfigLive, requestScopedMiddleware } from "@executor-js/api/server"; | |
| 4 | +import { | |
| 5 | + RouterConfigLive, | |
| 6 | + requestScopedMiddleware, | |
| 7 | + type MemberDirectory, | |
| 8 | +} from "@executor-js/api/server"; | |
| 5 | 9 | |
| 6 | 10 | import { UserStoreService } from "../auth/context"; |
| 7 | 11 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| @@ -31,7 +35,7 @@ import { makeProtectedApiLive } from "./protected"; · 23 unmodified lines | ||
| 31 | 35 | // assert per-request semantics — see |
| 32 | 36 | // `apps/cloud/src/api.request-scope.node.test.ts`. |
| 33 | 37 | export const makeApiLive = ( |
| 34 | − requestScopedLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>, | |
| 38 | + requestScopedLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, | |
| 35 | 39 | ) => { |
| 36 | 40 | const BillingRoutesLive = AutumnRoutesLive.pipe( |
| 37 | 41 | Layer.provide(requestScopedMiddleware(requestScopedLive).layer), |
| @@ -12,11 +12,20 @@ · 11 unmodified lines | ||
| 12 | 12 | // - the callback picks the landing org from that same list: a returnTo |
| 13 | 13 | // slug or last-org cookie lands only in an ACTIVE membership, an unknown |
| 14 | 14 | // 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 | 18 | // - `removeMember` tombstones the mirror row after the WorkOS delete, |
| 16 | 19 | // stamped with the membership's last WorkOS state (never a local clock), |
| 17 | 20 | // so a replay of the membership as it was before the delete cannot |
| 18 | 21 | // restore it while a replacement WorkOS created meanwhile is accepted |
| 19 | 22 | // - `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 | 29 | // - the backfill mirrors every org's members and counts what it wrote, |
| 21 | 30 | // writes nothing on a dry run, converges on a re-run, tombstones a |
| 22 | 31 | // membership WorkOS no longer lists — but never one written after its |
| @@ -39,10 +48,11 @@ · 16 unmodified lines | ||
| 39 | 48 | |
| 40 | 49 | import { describe, expect, it } from "@effect/vitest"; |
| 41 | 50 | import { sql } from "drizzle-orm"; |
| 42 | −import { Effect, Exit, Fiber, Latch, Layer } from "effect"; | |
| 51 | +import { Effect, Exit, Fiber, Latch, Layer, Option } from "effect"; | |
| 43 | 52 | import { HttpRouter, HttpServer } from "effect/unstable/http"; |
| 44 | 53 | import { HttpApiBuilder } from "effect/unstable/httpapi"; |
| 45 | 54 | |
| 55 | +import { AccountForbidden } from "@executor-js/api"; | |
| 46 | 56 | import { |
| 47 | 57 | AccountProvider, |
| 48 | 58 | MemberDirectory, |
| @@ -53,6 +63,7 @@ import { · 4 unmodified lines | ||
| 53 | 63 | import { AccountCaller, workosAccountProvider } from "../account/workos-account-service"; |
| 54 | 64 | import { RequestScopedServicesLive } from "../api/layers"; |
| 55 | 65 | import { DbService } from "../db/db"; |
| 66 | +import { forkReportMemberSeats } from "../extensions/billing/member-seats"; | |
| 56 | 67 | import { AutumnService } from "../extensions/billing/service"; |
| 57 | 68 | import { ApiKeyService } from "./api-keys"; |
| 58 | 69 | import { UserStoreService } from "./context"; |
| @@ -210,13 +221,21 @@ describe("login callback", () => { · 151 unmodified lines | ||
| 210 | 221 | listMetadata: { before: null, after: null }, |
| 211 | 222 | }); |
| 212 | 223 | }, |
| 213 | − // The forked seat recount after login. | |
| 214 | − listOrgMembers: () => | |
| 215 | − Effect.succeed({ | |
| 224 | + // The landing org's seat recount scans the org from WorkOS the first | |
| 225 | + // time it is counted (its per-org backfill mark is missing); the | |
| 226 | + // scan lists the org's members and fetches each user. | |
| 227 | + listOrgMembers: (organizationId) => { | |
| 228 | + calls.push(`listOrgMembers:${organizationId}`); | |
| 229 | + return Effect.succeed({ | |
| 216 | 230 | object: "list" as const, |
| 217 | − data: [] as never[], | |
| 231 | + data: listed.filter((m) => m.organizationId === organizationId) as never[], | |
| 218 | 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 | 239 | refreshSession: (_sealed, organizationId) => { |
| 221 | 240 | refreshedInto.push(organizationId); |
| 222 | 241 | return Effect.succeed("sealed-refreshed"); |
| @@ -268,7 +287,17 @@ describe("login callback", () => { · 45 unmodified lines | ||
| 268 | 287 | const response = await handler(callbackRequest({})); |
| 269 | 288 | |
| 270 | 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 | 301 | `listUserMemberships:${userId}`, |
| 273 | 302 | ]); |
| 274 | 303 | |
| @@ -542,7 +571,14 @@ describe("account service writes through to the mirror", () => { · 267 unmodified lines | ||
| 542 | 571 | * Provided around the WHOLE test body so the postgres socket outlives the |
| 543 | 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 | 582 | const list = (data: readonly unknown[]) => |
| 547 | 583 | Effect.succeed({ |
| 548 | 584 | object: "list" as const, |
| @@ -550,6 +586,7 @@ describe("account service writes through to the mirror", () => { · 1 unmodified line | ||
| 550 | 586 | listMetadata: { before: null, after: null }, |
| 551 | 587 | }); |
| 552 | 588 | const workos = stubWorkOS({ |
| 589 | + ...options.workos, | |
| 553 | 590 | listUserMemberships: (userId) => list([workosMembership(userId, org)]), |
| 554 | 591 | getUserOrgMembership: (organizationId, userId) => |
| 555 | 592 | Effect.succeed( |
| @@ -571,7 +608,6 @@ describe("account service writes through to the mirror", () => { · 15 unmodified lines | ||
| 571 | 608 | updatedAt: T2, |
| 572 | 609 | }) as never, |
| 573 | 610 | ), |
| 574 | − listOrgMembers: () => list([]), | |
| 575 | 611 | }); |
| 576 | 612 | // The test database serves ONE connection at a time, so the seed, the |
| 577 | 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 | 621 | Layer.mergeAll( |
| 586 | 622 | workos, |
| 587 | 623 | stubApiKeys, |
| 588 | − stubAutumn, | |
| 624 | + options.autumn ?? stubAutumn, | |
| 589 | 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 | 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 | 641 | Effect.gen(function* () { |
| 600 | 642 | const users = yield* UserStoreService; |
| 601 | 643 | const mirror = yield* WorkOsMirror; |
| @@ -614,11 +656,175 @@ describe("account service writes through to the mirror", () => { · 12 unmodified lines | ||
| 614 | 656 | status: "active", |
| 615 | 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 | 670 | const membersOf = (org: string) => |
| 620 | 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 | 828 | it.effect("removeMember tombstones the mirror row after the WorkOS delete", () => { |
| 623 | 829 | const org = freshId("org"); |
| 624 | 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 | |
| 897 | +describe("seat reporter", () => { | |
| 898 | + /** | |
| 899 | + * A `WorkOsMirror` answering the per-org backfill mark and recording the | |
| 900 | + * scan a reporter applies; every other operation is out of its reach. | |
| 901 | + */ | |
| 902 | + const recordingMirror = (backfilledAt: Date | null, writes: string[]) => | |
| 903 | + Layer.succeed(WorkOsMirror)({ | |
| 904 | + upsertUser: () => Effect.die("the seat reporter scans, it does not upsert one by one"), | |
| 905 | + upsertMembership: () => Effect.die("the seat reporter scans, it does not upsert one by one"), | |
| 906 | + deleteMembership: () => Effect.die("the seat reporter does not delete"), | |
| 907 | + deleteUser: () => Effect.die("the seat reporter does not delete"), | |
| 908 | + getCursor: () => Effect.die("the seat reporter does not read the cursor"), | |
| 909 | + applyPage: () => Effect.die("the seat reporter does not move the cursor"), | |
| 910 | + applyOrganizationScan: (scan) => | |
| 911 | + Effect.sync(() => { | |
| 912 | + writes.push( | |
| 913 | + `applyOrganizationScan:${scan.organizationId}:${scan.members | |
| 914 | + .map((member) => member.membership.id) | |
| 915 | + .join(",")}`, | |
| 916 | + ); | |
| 917 | + return Option.some({ | |
| 918 | + usersWritten: scan.members.length, | |
| 919 | + membershipsWritten: scan.members.length, | |
| 920 | + membershipsTombstoned: 0, | |
| 921 | + }); | |
| 922 | + }), | |
| 923 | + replayBoundary: () => Effect.die("the seat reporter does not run the reconciler"), | |
| 924 | + setReplayBoundary: () => Effect.die("the seat reporter does not record the boundary"), | |
| 925 | + backfillCompletedAt: () => Effect.die("the seat reporter does not check mirror readiness"), | |
| 926 | + markBackfillCompleted: () => Effect.die("the seat reporter does not record the completion"), | |
| 927 | + drainedAt: () => Effect.die("the seat reporter does not check mirror readiness"), | |
| 928 | + markDrained: () => Effect.die("the seat reporter does not run the reconciler"), | |
| 929 | + organizationBackfilledAt: () => Effect.succeed(backfilledAt), | |
| 930 | + } satisfies WorkOsMirrorShape); | |
| 931 | + | |
| 932 | + /** A directory holding `active` active members and one pending one. */ | |
| 933 | + const directoryWith = (org: string, active: number) => | |
| 934 | + Layer.succeed(MemberDirectory)({ | |
| 935 | + membership: () => Effect.die("the seat reporter lists, it does not look up"), | |
| 936 | + membersById: () => Effect.die("the seat reporter lists, it does not look up"), | |
| 937 | + findByEmail: () => Effect.die("the seat reporter lists, it does not look up"), | |
| 938 | + members: (organizationId, query) => { | |
| 939 | + expect(organizationId).toBe(org); | |
| 940 | + expect(query?.statuses, "billed seats are active members only").toEqual(["active"]); | |
| 941 | + return Effect.succeed( | |
| 942 | + Array.from({ length: active }, (_, i) => ({ | |
| 943 | + accountId: `user_${i}`, | |
| 944 | + membershipId: `om_${i}`, | |
| 945 | + organizationId, | |
| 946 | + email: null, | |
| 947 | + name: null, | |
| 948 | + avatarUrl: null, | |
| 949 | + role: "member", | |
| 950 | + status: "active" as const, | |
| 951 | + lastActiveAt: null, | |
| 952 | + })), | |
| 953 | + ); | |
| 954 | + }, | |
| 955 | + }); | |
| 956 | + | |
| 957 | + const report = ( | |
| 958 | + org: string, | |
| 959 | + backfilledAt: Date | null, | |
| 960 | + active: number, | |
| 961 | + workos: Partial<WorkOSClientService> = {}, | |
| 962 | + ) => | |
| 963 | + Effect.gen(function* () { | |
| 964 | + const reported: { organizationId: string; seats: number }[] = []; | |
| 965 | + const writes: string[] = []; | |
| 966 | + const recording = Layer.succeed(AutumnService)({ | |
| 967 | + use: () => Effect.die("the seat reporter sets seats, it does not read"), | |
| 968 | + ensureCustomer: () => Effect.void, | |
| 969 | + checkExecutionBalance: () => Effect.die("the seat reporter does not check balances"), | |
| 970 | + trackExecution: () => Effect.void, | |
| 971 | + setMemberSeats: (organizationId, seats) => | |
| 972 | + Effect.sync(() => { | |
| 973 | + reported.push({ organizationId, seats }); | |
| 974 | + }), | |
| 975 | + }); | |
| 976 | + yield* forkReportMemberSeats(org).pipe( | |
| 977 | + Effect.provide( | |
| 978 | + Layer.mergeAll( | |
| 979 | + recordingMirror(backfilledAt, writes), | |
| 980 | + directoryWith(org, active), | |
| 981 | + recording, | |
| 982 | + stubWorkOS(workos), | |
| 983 | + ), | |
| 984 | + ), | |
| 985 | + ); | |
| 986 | + // The Autumn call is forked; it is synchronous here, so it has landed. | |
| 987 | + return { reported, writes }; | |
| 988 | + }); | |
| 989 | + | |
| 990 | + it.effect( | |
| 991 | + "sets the active member count of a scanned organization without touching WorkOS", | |
| 992 | + () => { | |
| 993 | + const org = freshId("org"); | |
| 994 | + return Effect.gen(function* () { | |
| 995 | + const { reported, writes } = yield* report(org, new Date(T1), 3); | |
| 996 | + expect(reported).toEqual([{ organizationId: org, seats: 3 }]); | |
| 997 | + expect(writes, "a marked organization is not scanned").toEqual([]); | |
| 998 | + }); | |
| 999 | + }, | |
| 1000 | + ); | |
| 1001 | + | |
| 1002 | + it.effect("scans an organization the backfill never covered before counting it", () => { | |
| 1003 | + const org = freshId("org"); | |
| 1004 | + const member = freshId("user"); | |
| 1005 | + return Effect.gen(function* () { | |
| 1006 | + const { reported, writes } = yield* report(org, null, 2, { | |
| 1007 | + listOrgMembers: (organizationId, statuses) => { | |
| 1008 | + expect(organizationId).toBe(org); | |
| 1009 | + // Inactive memberships included: a scan that skipped them would | |
| 1010 | + // tombstone them under their ids and refuse their reactivation. | |
| 1011 | + expect(statuses).toEqual(["active", "pending", "inactive"]); | |
| 1012 | + return Effect.succeed({ | |
| 1013 | + object: "list" as const, | |
| 1014 | + data: [workosMembership(member, org)] as never[], | |
| 1015 | + listMetadata: { before: null, after: null }, | |
| 1016 | + }); | |
| 1017 | + }, | |
| 1018 | + getUser: (userId) => Effect.succeed(workosUser(userId) as never), | |
| 1019 | + }); | |
| 1020 | + expect( | |
| 1021 | + writes, | |
| 1022 | + "the scan fills the mirror and marks the organization, then the count is read", | |
| 1023 | + ).toEqual([`applyOrganizationScan:${org}:om_${member}_${org}`]); | |
| 1024 | + expect(reported).toEqual([{ organizationId: org, seats: 2 }]); | |
| 1025 | + }); | |
| 1026 | + }); | |
| 1027 | + | |
| 1028 | + it.effect("pushes no count when the scan fails: a partial count is never billed", () => { | |
| 1029 | + const org = freshId("org"); | |
| 1030 | + return Effect.gen(function* () { | |
| 1031 | + const { reported, writes } = yield* report(org, null, 2, { | |
| 1032 | + listOrgMembers: () => Effect.fail(new WorkOSError({ status: 503 })), | |
| 1033 | + }); | |
| 1034 | + expect(reported).toEqual([]); | |
| 1035 | + expect(writes, "nothing is marked").toEqual([]); | |
| 1036 | + }); | |
| 1037 | + }); | |
| 1038 | +}); | |
| 1039 | + | |
| 691 | 1040 | describe("backfill", () => { |
| 692 | 1041 | /** A fake WorkOS holding `orgs` → members, counting `getUser` calls. */ |
| 693 | 1042 | const source = (orgs: ReadonlyMap<string, readonly FakeMembership[]>, userCalls: string[]) => ({ |
| @@ -5,14 +5,21 @@ · 4 unmodified lines | ||
| 5 | 5 | // Each feeder takes the WorkOS payload the caller ALREADY holds (the |
| 6 | 6 | // authenticated user, the membership list the callback fetches to pick a |
| 7 | 7 | // landing org, the membership a write returned) so feeding the mirror never |
| 8 | −// adds a WorkOS read. Mirror failures fail the request: the mirror is the | |
| 9 | −// membership read path, so a login that could not record its memberships is | |
| 10 | −// not a login that finished. | |
| 8 | +// adds a WorkOS read — except the two writes whose WorkOS response is not the | |
| 9 | +// membership they changed: invitation acceptance (`auth/handlers.ts` reads | |
| 10 | +// the activated membership back) and sending an invitation | |
| 11 | +// (`mirrorInvitedMember` below reads the pending one WorkOS created). Both | |
| 12 | +// are rare, admin-driven paths. Mirror failures fail the request: the mirror | |
| 13 | +// is the membership read path, so a login that could not record its | |
| 14 | +// memberships is not a login that finished. | |
| 11 | 15 | // --------------------------------------------------------------------------- |
| 12 | 16 | |
| 13 | 17 | import { Effect } from "effect"; |
| 14 | 18 | |
| 19 | +import { normalizeAdminUserEmail } from "@executor-js/api/server"; | |
| 20 | + | |
| 15 | 21 | import { UserStoreService } from "./context"; |
| 22 | +import { WorkOSClient } from "./workos"; | |
| 16 | 23 | import { |
| 17 | 24 | WorkOsMirror, |
| 18 | 25 | mirrorMembershipFromWorkOs, |
| @@ -20,6 +27,7 @@ import { · 1 unmodified line | ||
| 20 | 27 | type WorkOsMembershipPayload, |
| 21 | 28 | type WorkOsUserPayload, |
| 22 | 29 | } from "./workos-mirror"; |
| 30 | +import { backfillOrganization } from "./workos-mirror-backfill"; | |
| 23 | 31 | |
| 24 | 32 | /** |
| 25 | 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 | 86 | Effect.flatMap(WorkOsMirror.asEffect(), (mirror) => |
| 79 | 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. | |
| 93 | +const USER_FETCH_CONCURRENCY = 5; | |
| 94 | + | |
| 95 | +/** | |
| 96 | + * Record the PENDING membership WorkOS creates for an invitee the moment an | |
| 97 | + * organization invites them — the row the member list shows as "Invited" and | |
| 98 | + * the admin revokes an outstanding invite through. `sendInvitation` returns | |
| 99 | + * the invitation, not that membership, so this reads it back: it lists the | |
| 100 | + * organization's pending memberships (WorkOS has no lookup by email that the | |
| 101 | + * emulator serves) and fetches their users, five at a time, until one carries | |
| 102 | + * the invited email. Bounded by the pending set, so an organization with | |
| 103 | + * many active members pays nothing per member. | |
| 104 | + * | |
| 105 | + * `false` when no pending membership carried the email — WorkOS created none | |
| 106 | + * (the address may already hold a membership) or has not yet — which the | |
| 107 | + * caller treats as a warning, not a failure: the Events reconciler lands | |
| 108 | + * whatever WorkOS did create. | |
| 109 | + */ | |
| 110 | +export const mirrorInvitedMember = Effect.fn("workos_mirror.invitedMember")(function* ( | |
| 111 | + organizationId: string, | |
| 112 | + invitedEmail: string, | |
| 113 | +) { | |
| 114 | + const workos = yield* WorkOSClient; | |
| 115 | + const mirror = yield* WorkOsMirror; | |
| 116 | + const wanted = normalizeAdminUserEmail(invitedEmail); | |
| 117 | + const pending = yield* workos.listOrgMembers(organizationId, ["pending"]); | |
| 118 | + for (let start = 0; start < pending.data.length; start += USER_FETCH_CONCURRENCY) { | |
| 119 | + const batch = pending.data.slice(start, start + USER_FETCH_CONCURRENCY); | |
| 120 | + const candidates = yield* Effect.forEach( | |
| 121 | + batch, | |
| 122 | + (membership) => | |
| 123 | + Effect.map(workos.getUser(membership.userId), (user) => ({ | |
| 124 | + membership, | |
| 125 | + user, | |
| 126 | + })), | |
| 127 | + { concurrency: USER_FETCH_CONCURRENCY }, | |
| 128 | + ); | |
| 129 | + const match = candidates.find( | |
| 130 | + (candidate) => normalizeAdminUserEmail(candidate.user.email) === wanted, | |
| 131 | + ); | |
| 132 | + if (match === undefined) continue; | |
| 133 | + yield* mirror.upsertUser(mirrorUserFromWorkOs(match.user)); | |
| 134 | + yield* mirror.upsertMembership(mirrorMembershipFromWorkOs(match.membership)); | |
| 135 | + return true; | |
| 136 | + } | |
| 137 | + return false; | |
| 138 | +}); | |
| 139 | + | |
| 140 | +/** | |
| 141 | + * Make sure the organization's membership list has been scanned from WorkOS | |
| 142 | + * in full before a COUNT read from the mirror is trusted. Login records only | |
| 143 | + * the caller's own memberships and write-through only the one it changed, | |
| 144 | + * so an organization the one-off backfill did not cover — mirrored lazily | |
| 145 | + * by a request, or created after the backfill ran — holds a partial list | |
| 146 | + * until it is scanned. The per-organization mark | |
| 147 | + * (`organizations.backfilled_at`) says whether that scan has happened; when | |
| 148 | + * it is missing, this runs the scan now (`backfillOrganization`: one | |
| 149 | + * membership listing plus one `getUser` per member, then the mark), so the | |
| 150 | + * caller's count is complete. Returns `true` when a scan ran. A scan that | |
| 151 | + * fails marks nothing, so the next count tries again. | |
| 152 | + */ | |
| 153 | +export const ensureOrganizationBackfilled = Effect.fn("workos_mirror.ensureOrganizationBackfilled")( | |
| 154 | + function* (organizationId: string) { | |
| 155 | + const mirror = yield* WorkOsMirror; | |
| 156 | + const backfilledAt = yield* mirror.organizationBackfilledAt(organizationId); | |
| 157 | + if (backfilledAt !== null) return false; | |
| 158 | + const workos = yield* WorkOSClient; | |
| 159 | + yield* Effect.logInfo( | |
| 160 | + "workos_mirror: organization not yet backfilled; scanning it from WorkOS", | |
| 161 | + { | |
| 162 | + organizationId, | |
| 163 | + }, | |
| 164 | + ); | |
| 165 | + yield* backfillOrganization( | |
| 166 | + { | |
| 167 | + // EVERY status, as the scan source requires: the scan tombstones | |
| 168 | + // whatever its listing lacks, and a tombstone is keyed to the | |
| 169 | + // membership id for good — so a listing that skipped the inactive | |
| 170 | + // ones (the wrapper's active + pending default, the seat-occupying | |
| 171 | + // set) would tombstone a membership WorkOS merely deactivated and | |
| 172 | + // refuse its reactivation under the same id forever. | |
| 173 | + listOrgMembers: (id) => | |
| 174 | + Effect.map( | |
| 175 | + workos.listOrgMembers(id, ["active", "pending", "inactive"]), | |
| 176 | + (list) => list.data, | |
| 177 | + ), | |
| 178 | + getUser: (id) => workos.getUser(id), | |
| 179 | + }, | |
| 180 | + mirror, | |
| 181 | + organizationId, | |
| 182 | + { dryRun: false }, | |
| 183 | + ); | |
| 184 | + return true; | |
| 185 | + }, | |
| 186 | +); | |
| @@ -16,6 +16,8 @@ import { HttpRouter, HttpServer } from "effect/unstable/http"; · 15 unmodified lines | ||
| 16 | 16 | import { HttpApiBuilder } from "effect/unstable/httpapi"; |
| 17 | 17 | import { HttpApi } from "effect/unstable/httpapi"; |
| 18 | 18 | |
| 19 | +import { MemberDirectory } from "@executor-js/api/server"; | |
| 20 | + | |
| 19 | 21 | import { CloudAuthPublicHandlers } from "./handlers"; |
| 20 | 22 | import { CloudAuthPublicApi } from "./api"; |
| 21 | 23 | import { UserStoreService } from "./context"; |
| @@ -50,9 +52,6 @@ const stubWorkOS = Layer.succeed( · 28 unmodified lines | ||
| 50 | 52 | if (prop === "listUserMemberships") { |
| 51 | 53 | return () => Effect.succeed({ data: [] }); |
| 52 | 54 | } |
| 53 | − if (prop === "listOrgMembers") { | |
| 54 | − return () => Effect.succeed({ data: [{ status: "active" }] }); | |
| 55 | − } | |
| 56 | 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 | 110 | // 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. | |
| 113 | 114 | const stubMirror = Layer.succeed(WorkOsMirror)({ |
| 114 | 115 | upsertUser: () => Effect.succeed(true), |
| 115 | 116 | upsertMembership: () => Effect.succeed(true), |
| @@ -124,7 +125,27 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ · 8 unmodified lines | ||
| 124 | 125 | markBackfillCompleted: () => Effect.die("the callback does not run the backfill"), |
| 125 | 126 | drainedAt: () => Effect.die("the callback does not check mirror readiness"), |
| 126 | 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 | + | |
| 131 | +const stubDirectory = Layer.succeed(MemberDirectory)({ | |
| 132 | + membership: () => Effect.die("the callback does not look up one membership"), | |
| 133 | + membersById: () => Effect.die("the callback does not batch members"), | |
| 134 | + findByEmail: () => Effect.die("the callback does not resolve emails"), | |
| 135 | + members: (organizationId) => | |
| 136 | + Effect.succeed([ | |
| 137 | + { | |
| 138 | + accountId: STUB_USER_ID, | |
| 139 | + membershipId: `om_${STUB_USER_ID}`, | |
| 140 | + organizationId, | |
| 141 | + email: null, | |
| 142 | + name: null, | |
| 143 | + avatarUrl: null, | |
| 144 | + role: "member", | |
| 145 | + status: "active" as const, | |
| 146 | + lastActiveAt: null, | |
| 147 | + }, | |
| 148 | + ]), | |
| 128 | 149 | }); |
| 129 | 150 | |
| 130 | 151 | // 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 | 158 | Layer.provide(stubWorkOS), |
| 138 | 159 | Layer.provide(stubUsers), |
| 139 | 160 | Layer.provide(stubMirror), |
| 161 | + Layer.provide(stubDirectory), | |
| 140 | 162 | Layer.provide(AutumnService.Default), |
| 141 | 163 | Layer.provide(HttpServer.layerServices), |
| 142 | 164 | ); |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | WorkOS, |
| 10 | 10 | type Event as WorkOSEvent, |
| 11 | 11 | type EventName as WorkOSEventName, |
| 12 | + type OrganizationMembershipStatus, | |
| 12 | 13 | } from "@workos-inc/node/worker"; |
| 13 | 14 | import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto"; |
| 14 | 15 | import { decodeJwt, jwtVerify } from "jose"; |
| @@ -665,13 +666,21 @@ const make = Effect.gen(function* () { · 650 unmodified lines | ||
| 665 | 666 | deleteApiKey: (id: string) => |
| 666 | 667 | use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)), |
| 667 | 668 | |
| 668 | − /** List organization memberships with user details. */ | |
| 669 | − listOrgMembers: (organizationId: string) => | |
| 669 | + /** | |
| 670 | + * An organization's memberships, all pages. Defaults to active + pending | |
| 671 | + * (the seat-occupying set); pass `statuses` to narrow — the invite | |
| 672 | + * write-through lists only `pending` to find the membership WorkOS | |
| 673 | + * created for the invitee. | |
| 674 | + */ | |
| 675 | + listOrgMembers: ( | |
| 676 | + organizationId: string, | |
| 677 | + statuses: readonly OrganizationMembershipStatus[] = ["active", "pending"], | |
| 678 | + ) => | |
| 670 | 679 | use("userManagement.listOrganizationMemberships", async (wos) => |
| 671 | 680 | collectWorkOSList( |
| 672 | 681 | await wos.userManagement.listOrganizationMemberships({ |
| 673 | 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 | 700 | getUser: (userId: string) => |
| 692 | 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 | 703 | /** Send an organization invitation. */ |
| 706 | 704 | sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => |
| 707 | 705 | use("userManagement.sendInvitation", (wos) => |
| @@ -1,11 +1,16 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | −// Seat-count reporting — the WorkOS → Autumn reconciliation for seat billing | |
| 2 | +// Seat-count reporting — the membership mirror → Autumn reconciliation for | |
| 3 | +// seat billing | |
| 3 | 4 | // --------------------------------------------------------------------------- |
| 4 | 5 | |
| 5 | 6 | import { Effect } from "effect"; |
| 6 | 7 | import { waitUntil } from "cloudflare:workers"; |
| 7 | 8 | |
| 8 | −import { WorkOSClient } from "../../auth/workos"; | |
| 9 | +import { MemberDirectory } from "@executor-js/api/server"; | |
| 10 | + | |
| 11 | +import { ensureOrganizationBackfilled } from "../../auth/mirror-feeders"; | |
| 12 | +import type { WorkOSClient } from "../../auth/workos"; | |
| 13 | +import type { WorkOsMirror } from "../../auth/workos-mirror"; | |
| 9 | 14 | import { AutumnService } from "./service"; |
| 10 | 15 | |
| 11 | 16 | /** |
| @@ -16,39 +21,54 @@ import { AutumnService } from "./service"; · 4 unmodified lines | ||
| 16 | 21 | * Seats change through paths the app never sees a mutation for (invitation |
| 17 | 22 | * acceptance in AuthKit, SSO JIT provisioning, join by domain, WorkOS |
| 18 | 23 | * dashboard edits), so this reconciles from a full recount rather than |
| 19 | − * tracking deltas. It runs after in-app membership mutations AND on every | |
| 20 | − * login callback, so drift from out-of-band changes heals on the next | |
| 21 | − * sign-in. Fire-and-forget-safe: errors are logged, never surfaced. | |
| 24 | + * tracking deltas. The count comes from the local membership mirror through | |
| 25 | + * the shared `MemberDirectory`: every in-app membership mutation writes | |
| 26 | + * through to the mirror BEFORE calling this, and out-of-band changes land via | |
| 27 | + * login and the Events reconciler, so the recount reads the change on the | |
| 28 | + * next sign-in exactly as it did against WorkOS — without a WorkOS read. | |
| 29 | + * | |
| 30 | + * The Autumn call runs off the calling request's critical path: Cloudflare | |
| 31 | + * owns its promise through `waitUntil`, so the recount can finish after the | |
| 32 | + * response, and billing never stalls or fails a user-facing request. Errors | |
| 33 | + * are logged, never surfaced. | |
| 34 | + * | |
| 35 | + * The count is a PARTIAL one until THIS organization's membership list has | |
| 36 | + * been scanned from WorkOS in full (the one-off backfill, or the on-demand | |
| 37 | + * scan below): before that, the mirror holds only the members who signed in | |
| 38 | + * or were changed since the mirror shipped. Because the Autumn write is an | |
| 39 | + * authoritative SET, pushing a partial count would under-bill the | |
| 40 | + * organization, so the recount first makes sure the organization is | |
| 41 | + * backfilled (`ensureOrganizationBackfilled`: a scan runs now when its | |
| 42 | + * per-organization mark is missing) and only then counts. The plan gate | |
| 43 | + * (`reserveMemberSlot`) goes through the same step, so it never admits an | |
| 44 | + * invite past the plan limit on a partial mirror. | |
| 45 | + * | |
| 46 | + * The COUNT is read inline, not in the fork: `MemberDirectory` is per-request | |
| 47 | + * (it holds the request's postgres socket, which Cloudflare Workers' I/O | |
| 48 | + * isolation ties to the request), so a forked fiber reading it could outlive | |
| 49 | + * the socket. One indexed local query is cheap enough to pay inline; only the | |
| 50 | + * Autumn call — over the boot-scoped `AutumnService` — is forked, so the | |
| 51 | + * forked fiber captures nothing request-scoped. | |
| 22 | 52 | */ |
| 23 | −export const reportMemberSeats = ( | |
| 53 | +export const forkReportMemberSeats = ( | |
| 24 | 54 | organizationId: string, |
| 25 | −): Effect.Effect<void, never, WorkOSClient | AutumnService> => | |
| 55 | +): Effect.Effect<void, never, MemberDirectory | WorkOsMirror | WorkOSClient | AutumnService> => | |
| 26 | 56 | Effect.gen(function* () { |
| 27 | − const workos = yield* WorkOSClient; | |
| 57 | + const directory = yield* MemberDirectory; | |
| 28 | 58 | const autumn = yield* AutumnService; |
| 29 | − const memberships = yield* workos.listOrgMembers(organizationId); | |
| 30 | − const seats = memberships.data.filter((m) => m.status === "active").length; | |
| 31 | − yield* autumn.setMemberSeats(organizationId, seats); | |
| 59 | + yield* ensureOrganizationBackfilled(organizationId); | |
| 60 | + const seats = yield* directory | |
| 61 | + .members(organizationId, { statuses: ["active"] }) | |
| 62 | + .pipe(Effect.map((members) => members.length)); | |
| 63 | + yield* Effect.sync(() => { | |
| 64 | + waitUntil(Effect.runPromise(autumn.setMemberSeats(organizationId, seats))); | |
| 65 | + }); | |
| 32 | 66 | }).pipe( |
| 33 | 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 | 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 | − */ | |
| 46 | −export const forkReportMemberSeats = ( | |
| 47 | − organizationId: string, | |
| 48 | −): Effect.Effect<void, never, WorkOSClient | AutumnService> => | |
| 49 | − Effect.gen(function* () { | |
| 50 | − const ctx = yield* Effect.context<WorkOSClient | AutumnService>(); | |
| 51 | − yield* Effect.sync(() => { | |
| 52 | − waitUntil(Effect.runPromiseWith(ctx)(reportMemberSeats(organizationId))); | |
| 53 | − }); | |
| 54 | − }); | |
| @@ -28,7 +28,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 27 unmodified lines | ||
| 28 | 28 | import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; |
| 29 | 29 | |
| 30 | 30 | import { AccountApi, AdminUsersApi } from "@executor-js/api"; |
| 31 | −import { requestScopedMiddleware } from "@executor-js/api/server"; | |
| 31 | +import { requestScopedMiddleware, type MemberDirectory } from "@executor-js/api/server"; | |
| 32 | 32 | |
| 33 | 33 | import { UserStoreService } from "../auth/context"; |
| 34 | 34 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| @@ -79,7 +79,7 @@ const spec = OpenApi.fromApi(CloudOpenApi); · 44 unmodified lines | ||
| 79 | 79 | * core. |
| 80 | 80 | */ |
| 81 | 81 | export const makeCloudExtensionRoutes = ( |
| 82 | − rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>, | |
| 82 | + rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, | |
| 83 | 83 | ) => { |
| 84 | 84 | // Session routes (login / callback / me / switch-org / …). Handlers yield |
| 85 | 85 | // `UserStoreService` directly; the per-request DB combine keeps the postgres |
| @@ -145,7 +145,7 @@ export const betterAuthAccountProvider: Layer.Layer<AccountProvider, never, Bett · 144 unmodified lines | ||
| 145 | 145 | const members = result.members.map((member) => ({ |
| 146 | 146 | id: member.id, |
| 147 | 147 | userId: member.userId, |
| 148 | − email: member.user?.email ?? "", | |
| 148 | + email: member.user?.email ?? null, | |
| 149 | 149 | name: member.user?.name ?? null, |
| 150 | 150 | avatarUrl: member.user?.image ?? null, |
| 151 | 151 | role: member.role, |
| @@ -16,7 +16,11 @@ · 15 unmodified lines | ||
| 16 | 16 | // |
| 17 | 17 | // The READ half is identical to cloud's: a subject-less, tenant-reach executor |
| 18 | 18 | // 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 | |
| 22 | 26 | import { HttpRouter } from "effect/unstable/http"; |
| @@ -26,18 +30,17 @@ import { · 3 unmodified lines | ||
| 26 | 30 | AdminUsersProvider, |
| 27 | 31 | DbProvider, |
| 28 | 32 | HostConfig, |
| 33 | + MemberDirectory, | |
| 29 | 34 | PluginsProvider, |
| 35 | + adminUserDirectoryFromMembers, | |
| 30 | 36 | getAdminUser, |
| 31 | 37 | listAdminUserConnections, |
| 32 | 38 | listAdminUsers, |
| 33 | 39 | listAdminUsersWithConnections, |
| 34 | 40 | makeAdminUsersApiLayer, |
| 35 | 41 | makePlatformExecutor, |
| 36 | − normalizeAdminUserEmail, | |
| 37 | 42 | platformViewOf, |
| 38 | 43 | requestScopedMiddleware, |
| 39 | − type AdminUserDirectory, | |
| 40 | − type AdminUserIdentity, | |
| 41 | 44 | type AdminUsersHeaders, |
| 42 | 45 | } from "@executor-js/api/server"; |
| 43 | 46 | import { |
| @@ -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 | − */ | |
| 88 | −const listMembers = (auth: BetterAuthHandle["auth"], headers: AdminUsersHeaders) => | |
| 89 | − Effect.tryPromise(() => auth.api.listMembers({ headers: new Headers(headers) })); | |
| 90 | − | |
| 91 | −/** | |
| 92 | − * Both directions of self-host's directory, over the SAME single `listMembers` | |
| 93 | − * read. | |
| 94 | − * | |
| 95 | − * The reverse (email → `user.id`) needs no extra call and no new permission: | |
| 96 | − * the organization plugin already attaches the `user` row to each member, so | |
| 97 | − * the email is sitting beside the id the forward join uses. Better Auth | |
| 98 | − * lower-cases every email it writes, but the directory value is normalized | |
| 99 | − * anyway so this host cannot answer differently from cloud if that ever | |
| 100 | − * changes. | |
| 101 | − * | |
| 102 | − * A member with no `user.email` cannot match — `null` is not an address, and | |
| 103 | − * coercing it to "" would let an empty `?email=` select an arbitrary row. | |
| 104 | − */ | |
| 105 | −const userDirectory = ( | |
| 106 | − auth: BetterAuthHandle["auth"], | |
| 107 | − headers: AdminUsersHeaders, | |
| 108 | −): AdminUserDirectory => ({ | |
| 109 | − identities: () => | |
| 110 | − listMembers(auth, headers).pipe( | |
| 111 | − Effect.map((result) => { | |
| 112 | − const identities = new Map<string, AdminUserIdentity>(); | |
| 113 | − for (const member of result.members) { | |
| 114 | − identities.set(member.userId, { | |
| 115 | − email: member.user?.email ?? null, | |
| 116 | − displayName: member.user?.name ?? null, | |
| 117 | − }); | |
| 118 | − } | |
| 119 | − return identities; | |
| 120 | − }), | |
| 121 | − ), | |
| 122 | − resolveEmail: (email) => | |
| 123 | − listMembers(auth, headers).pipe( | |
| 124 | − Effect.map( | |
| 125 | − (result) => | |
| 126 | − result.members.find((member) => { | |
| 127 | − const stored = member.user?.email; | |
| 128 | − return stored != null && normalizeAdminUserEmail(stored) === email; | |
| 129 | − })?.userId ?? null, | |
| 130 | − ), | |
| 131 | − ), | |
| 132 | −}); | |
| 133 | − | |
| 134 | 73 | const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>( |
| 135 | 74 | headers: AdminUsersHeaders, |
| 136 | 75 | organizationId: string, |
| @@ -153,24 +92,25 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 16 unmodified lines | ||
| 153 | 92 | export const betterAuthAdminUsersProvider: Layer.Layer< |
| 154 | 93 | AdminUsersProvider, |
| 155 | 94 | never, |
| 156 | − BetterAuth | DbProvider | PluginsProvider | HostConfig | |
| 95 | + BetterAuth | MemberDirectory | DbProvider | PluginsProvider | HostConfig | |
| 157 | 96 | > = Layer.effect(AdminUsersProvider)( |
| 158 | 97 | Effect.gen(function* () { |
| 159 | 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 | 103 | return AdminUsersProvider.of({ |
| 162 | 104 | listUsers: (headers, options) => |
| 163 | 105 | withPlatformView(headers, organizationId, (executor) => |
| 164 | 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 | 109 | ).pipe(Effect.provideContext(context)), |
| 168 | 110 | listUsersWithConnections: (headers, options) => |
| 169 | 111 | withPlatformView(headers, organizationId, (executor) => |
| 170 | 112 | platformViewOf(executor).pipe( |
| 171 | − Effect.flatMap((admin) => | |
| 172 | − listAdminUsersWithConnections(admin, options, userDirectory(auth, headers)), | |
| 173 | − ), | |
| 113 | + Effect.flatMap((admin) => listAdminUsersWithConnections(admin, options, directory)), | |
| 174 | 114 | ), |
| 175 | 115 | ).pipe(Effect.provideContext(context)), |
| 176 | 116 | listUserConnections: (headers, externalId) => |
| @@ -182,9 +122,7 @@ export const betterAuthAdminUsersProvider: Layer.Layer< · 5 unmodified lines | ||
| 182 | 122 | getUser: (headers, identifier) => |
| 183 | 123 | withPlatformView(headers, organizationId, (executor) => |
| 184 | 124 | platformViewOf(executor).pipe( |
| 185 | − Effect.flatMap((admin) => | |
| 186 | − getAdminUser(admin, identifier, userDirectory(auth, headers)), | |
| 187 | − ), | |
| 125 | + Effect.flatMap((admin) => getAdminUser(admin, identifier, directory)), | |
| 188 | 126 | ), |
| 189 | 127 | ).pipe(Effect.provideContext(context)), |
| 190 | 128 | }); |
| @@ -193,6 +131,9 @@ export const betterAuthAdminUsersProvider: Layer.Layer< · 2 unmodified lines | ||
| 193 | 131 | |
| 194 | 132 | export interface SelfHostAdminUsersApiDeps { |
| 195 | 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 | 137 | readonly db: SelfHostDbHandle; |
| 197 | 138 | readonly mountPrefix: `/${string}`; |
| 198 | 139 | } |
| @@ -206,6 +147,7 @@ export interface SelfHostAdminUsersApiDeps { · 7 unmodified lines | ||
| 206 | 147 | */ |
| 207 | 148 | export const makeSelfHostAdminUsersApiLayer = ({ |
| 208 | 149 | betterAuth, |
| 150 | + memberDirectory, | |
| 209 | 151 | db, |
| 210 | 152 | mountPrefix, |
| 211 | 153 | }: SelfHostAdminUsersApiDeps) => { |
| @@ -214,6 +156,7 @@ export const makeSelfHostAdminUsersApiLayer = ({ · 2 unmodified lines | ||
| 214 | 156 | ); |
| 215 | 157 | const provider = betterAuthAdminUsersProvider.pipe( |
| 216 | 158 | Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), |
| 159 | + Layer.provide(memberDirectory), | |
| 217 | 160 | Layer.provide(SelfHostDbProvider), |
| 218 | 161 | Layer.provide(SelfHostPluginsProvider), |
| 219 | 162 | Layer.provide(SelfHostHostConfig), |
| @@ -73,7 +73,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 72 unmodified lines | ||
| 73 | 73 | // ---- auth providers --------------------------------------------------- |
| 74 | 74 | // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account |
| 75 | 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 | 79 | // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- |
| 79 | 80 | const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config); |
| @@ -130,7 +131,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 50 unmodified lines | ||
| 130 | 131 | // Tenant-wide admin users API (/api/admin/users*): the owner's view of |
| 131 | 132 | // who uses this instance and what they've connected. Owner/admin-gated, |
| 132 | 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 | 140 | // Public system API: /api/health + /api/setup-status (unauthenticated). |
| 135 | 141 | makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), |
| 136 | 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 | 147 | // The boot-scoped context provideMerge'd under everything: the long-lived DB |
| 142 | 148 | // handle (read by the DbProvider seam, Better Auth, and the MCP store) + the |
| 143 | 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 | 152 | // + the artifact-usage observer (this HTTP plane is the console UI's data |
| 145 | 153 | // layer, so operations it serves file as `via: "ui"`). |
| 146 | 154 | boot: Layer.mergeAll( |
| 147 | 155 | Layer.succeed(SelfHostDb)(dbHandle), |
| 148 | 156 | identityLayer, |
| 157 | + memberDirectoryLayer, | |
| 149 | 158 | Layer.succeed(ArtifactUsageObserver)((action) => |
| 150 | 159 | selfHostAnalytics.record(`artifact_${action}`, { via: "ui" }), |
| 151 | 160 | ), |
| @@ -1,24 +1,28 @@ | ||
| 1 | 1 | import { Layer } from "effect"; |
| 2 | 2 | |
| 3 | −import { IdentityProvider } from "@executor-js/api/server"; | |
| 3 | +import { IdentityProvider, MemberDirectory } from "@executor-js/api/server"; | |
| 4 | 4 | |
| 5 | 5 | import { loadConfig } from "../config"; |
| 6 | 6 | import type { SelfHostDbHandle } from "../db/self-host-db"; |
| 7 | 7 | import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 8 | 8 | import { betterAuthIdentityLayer } from "./identity"; |
| 9 | +import { betterAuthMemberDirectoryLayer } from "./member-directory"; | |
| 9 | 10 | import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; |
| 10 | 11 | import { rewriteInvalidOrigin } from "./invalid-origin-help"; |
| 11 | 12 | |
| 12 | 13 | export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 13 | 14 | export { betterAuthIdentityLayer } from "./identity"; |
| 15 | +export { betterAuthMemberDirectoryLayer } from "./member-directory"; | |
| 14 | 16 | |
| 15 | 17 | // --------------------------------------------------------------------------- |
| 16 | 18 | // Resolve the self-host auth providers. |
| 17 | 19 | // |
| 18 | 20 | // Build the Better Auth instance over the shared libSQL file, expose its |
| 19 | −// `IdentityProvider` (cookie/bearer/api-key) and its web handler (mounted at | |
| 20 | −// /api/auth/*). Returns the live `BetterAuthHandle` so the composition root can | |
| 21 | −// build the account API and the Better Auth MCP OAuth seam. | |
| 21 | +// `IdentityProvider` (cookie/bearer/api-key), its `MemberDirectory` (the | |
| 22 | +// shared membership read seam over the org plugin's tables) and its web | |
| 23 | +// handler (mounted at /api/auth/*). Returns the live `BetterAuthHandle` so the | |
| 24 | +// composition root can build the account API and the Better Auth MCP OAuth | |
| 25 | +// seam. | |
| 22 | 26 | // |
| 23 | 27 | // This is the one and only production auth path. Tests that need a fake identity |
| 24 | 28 | // (single-admin / header-driven) compose `ExecutorApp.make` directly through |
| @@ -29,6 +33,8 @@ export { betterAuthIdentityLayer } from "./identity"; · 4 unmodified lines | ||
| 29 | 33 | export interface ResolvedAuthProviders { |
| 30 | 34 | /** The resolved Better Auth `IdentityProvider` seam (cookie/bearer/api-key). */ |
| 31 | 35 | readonly identityLayer: Layer.Layer<IdentityProvider>; |
| 36 | + /** The resolved Better Auth `MemberDirectory` seam (org members + users). */ | |
| 37 | + readonly memberDirectoryLayer: Layer.Layer<MemberDirectory>; | |
| 32 | 38 | /** Better Auth's web handler (`/api/auth/*`). */ |
| 33 | 39 | readonly authHandler: (request: Request) => Promise<Response>; |
| 34 | 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 | 85 | return { |
| 80 | 86 | identityLayer: betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)), |
| 87 | + memberDirectoryLayer: betterAuthMemberDirectoryLayer.pipe(Layer.provide(betterAuthLayer)), | |
| 81 | 88 | authHandler, |
| 82 | 89 | betterAuth, |
| 83 | 90 | }; |
| @@ -109,10 +109,17 @@ export const OrgApiKeysResponse = Schema.Struct({ · 108 unmodified lines | ||
| 109 | 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 | + */ | |
| 112 | 119 | export const OrgMember = Schema.Struct({ |
| 113 | 120 | id: Schema.String, |
| 114 | 121 | userId: Schema.String, |
| 115 | − email: Schema.String, | |
| 122 | + email: Schema.NullOr(Schema.String), | |
| 116 | 123 | name: Schema.NullOr(Schema.String), |
| 117 | 124 | avatarUrl: Schema.NullOr(Schema.String), |
| 118 | 125 | role: Schema.String, |
| @@ -395,6 +395,7 @@ const A1_EMAIL = "a1@users.test"; · 394 unmodified lines | ||
| 395 | 395 | const stubUserDirectory = (options: { |
| 396 | 396 | readonly seen?: string[][]; |
| 397 | 397 | readonly resolved?: string[]; |
| 398 | + readonly searched?: string[]; | |
| 398 | 399 | }): AdminUserDirectory => ({ |
| 399 | 400 | identities: (externalIds) => { |
| 400 | 401 | options.seen?.push([...externalIds]); |
| @@ -405,6 +406,13 @@ const stubUserDirectory = (options: { · 4 unmodified lines | ||
| 405 | 406 | // Compares a NORMALIZED stored value, the rule both real hosts follow. |
| 406 | 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 | 418 | /** 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 | 1129 | // A resolver OUTAGE must not read as "no such user": that is a wrong answer an |
| 1026 | 1130 | // operator would act on. Contrast with the identity join, which degrades to |
| 1027 | 1131 | // unnamed rows precisely because it is decoration. |
| @@ -1096,8 +1200,8 @@ const A_SUBJECT: AdminSubject = { · 68 unmodified lines | ||
| 1096 | 1200 | /** An `ExecutorAdmin` that answers everything and records the reads it was |
| 1097 | 1201 | * asked for, so a test can assert the call the filter chose. */ |
| 1098 | 1202 | const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ |
| 1099 | − listSubjects: () => { | |
| 1100 | − calls.push("listSubjects"); | |
| 1203 | + listSubjects: (options) => { | |
| 1204 | + calls.push(`listSubjects:${options?.externalIds?.join(",") ?? "*"}`); | |
| 1101 | 1205 | return Effect.succeed([A_SUBJECT]); |
| 1102 | 1206 | }, |
| 1103 | 1207 | getSubject: () => { |
| @@ -1108,8 +1212,8 @@ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ · 4 unmodified lines | ||
| 1108 | 1212 | calls.push("listSubjectConnections"); |
| 1109 | 1213 | return Effect.succeed([]); |
| 1110 | 1214 | }, |
| 1111 | − listSubjectsWithConnections: () => { | |
| 1112 | − calls.push("listSubjectsWithConnections"); | |
| 1215 | + listSubjectsWithConnections: (options) => { | |
| 1216 | + calls.push(`listSubjectsWithConnections:${options?.externalIds?.join(",") ?? "*"}`); | |
| 1113 | 1217 | return Effect.succeed([{ ...A_SUBJECT, connections: [] }]); |
| 1114 | 1218 | }, |
| 1115 | 1219 | getSubjectWithConnections: () => { |
| @@ -1187,7 +1291,56 @@ describe("admin users reads — the ?email= filter is applied before the read", · 71 unmodified lines | ||
| 1187 | 1291 | const calls: string[] = []; |
| 1188 | 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 | + | |
| 1305 | +describe("admin users reads — the ?search= filter pages the directory's matches", () => { | |
| 1306 | + it.effect("hands the matched ids to the paged read, on both views", () => | |
| 1307 | + Effect.gen(function* () { | |
| 1308 | + const calls: string[] = []; | |
| 1309 | + const admin = recordingAdmin(calls); | |
| 1310 | + | |
| 1311 | + yield* listUsers(admin, { search: "a1" }, stubUserDirectory({})); | |
| 1312 | + yield* listUsersWithConnections(admin, { search: "user", limit: 10 }, stubUserDirectory({})); | |
| 1313 | + | |
| 1314 | + expect(calls).toEqual([`listSubjects:${USER_A1}`, `listSubjectsWithConnections:${USER_A1}`]); | |
| 1315 | + }), | |
| 1316 | + ); | |
| 1317 | + | |
| 1318 | + it.effect("issues NO storage read when the directory matches nobody", () => | |
| 1319 | + Effect.gen(function* () { | |
| 1320 | + const calls: string[] = []; | |
| 1321 | + const body = yield* listUsersWithConnections( | |
| 1322 | + recordingAdmin(calls), | |
| 1323 | + { search: "nobody" }, | |
| 1324 | + stubUserDirectory({}), | |
| 1325 | + ); | |
| 1326 | + | |
| 1327 | + expect(calls).toEqual([]); | |
| 1328 | + expect(body.users).toEqual([]); | |
| 1329 | + }), | |
| 1330 | + ); | |
| 1331 | + | |
| 1332 | + it.effect("lets an exact email win over a search term", () => | |
| 1333 | + Effect.gen(function* () { | |
| 1334 | + const calls: string[] = []; | |
| 1335 | + const searched: string[] = []; | |
| 1336 | + yield* listUsers( | |
| 1337 | + recordingAdmin(calls), | |
| 1338 | + { email: A1_EMAIL, search: "anything" }, | |
| 1339 | + stubUserDirectory({ searched }), | |
| 1340 | + ); | |
| 1341 | + | |
| 1342 | + expect(calls, "the keyed read, not a search").toEqual(["getSubject"]); | |
| 1343 | + expect(searched).toEqual([]); | |
| 1191 | 1344 | }), |
| 1192 | 1345 | ); |
| 1193 | 1346 | }); |
| @@ -261,6 +261,15 @@ const AdminUserIdentifierParams = { identifier: Schema.String }; · 260 unmodified lines | ||
| 261 | 261 | // handler seam (`normalizeEmail`), which is also where the single-user path |
| 262 | 262 | // parameter is normalized, so both entry points share ONE rule rather than a |
| 263 | 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. | |
| 264 | 273 | const AdminListQuery = Schema.Struct({ |
| 265 | 274 | limit: Schema.optional( |
| 266 | 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 | 283 | email: Schema.optional(Schema.String), |
| 284 | + search: Schema.optional(Schema.String), | |
| 275 | 285 | }); |
| 276 | 286 | |
| 277 | 287 | // --------------------------------------------------------------------------- |
| @@ -2,6 +2,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line | ||
| 2 | 2 | import { HttpServerRequest } from "effect/unstable/http"; |
| 3 | 3 | import { Effect } from "effect"; |
| 4 | 4 | |
| 5 | +import { normalizeMemberSearch } from "../server/member-directory"; | |
| 5 | 6 | import { AdminUsersHttpApi } from "./api"; |
| 6 | 7 | import { normalizeEmail } from "./reads"; |
| 7 | 8 | import { AdminUsersProvider, type AdminUsersHeaders, type AdminUsersListOptions } from "./service"; |
| @@ -24,15 +25,23 @@ const requestHeaders = Effect.map( · 16 unmodified lines | ||
| 24 | 25 | // than an explicit `undefined` overriding them. |
| 25 | 26 | // `email` is normalized here rather than in the contract schema, so the filter |
| 26 | 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: ""`). | |
| 27 | 31 | const listOptions = (query: { |
| 28 | 32 | readonly limit?: number | undefined; |
| 29 | 33 | readonly offset?: number | undefined; |
| 30 | 34 | readonly email?: string | undefined; |
| 31 | −}): AdminUsersListOptions => ({ | |
| 32 | − ...(query.limit === undefined ? {} : { limit: query.limit }), | |
| 33 | − ...(query.offset === undefined ? {} : { offset: query.offset }), | |
| 34 | − ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), | |
| 35 | −}); | |
| 35 | + readonly search?: string | undefined; | |
| 36 | +}): AdminUsersListOptions => { | |
| 37 | + const search = normalizeMemberSearch(query.search); | |
| 38 | + return { | |
| 39 | + ...(query.limit === undefined ? {} : { limit: query.limit }), | |
| 40 | + ...(query.offset === undefined ? {} : { offset: query.offset }), | |
| 41 | + ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), | |
| 42 | + ...(search === undefined ? {} : { search }), | |
| 43 | + }; | |
| 44 | +}; | |
| 36 | 45 | |
| 37 | 46 | export const AdminUsersHandlers = HttpApiBuilder.group( |
| 38 | 47 | AdminUsersHttpApi, |
| @@ -10,21 +10,26 @@ import { MemberStatus, type MemberDirectoryShape } from "../server/member-direct · 9 unmodified lines | ||
| 10 | 10 | import type { AdminUserDirectory, AdminUserIdentity } from "./reads"; |
| 11 | 11 | |
| 12 | 12 | /** |
| 13 | − * Both directions of the admin plane's directory over one org's | |
| 13 | + * Every direction of the admin plane's directory over one org's | |
| 14 | 14 | * {@link MemberDirectoryShape}. |
| 15 | 15 | * |
| 16 | 16 | * `identities` is one batched `membersById` read for the page of ids (never a |
| 17 | 17 | * lookup per user); a member the org does not hold reports absent identity. |
| 18 | 18 | * `resolveEmail` receives the already-normalized email the contract promises |
| 19 | 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 + pending | |
| 22 | − * default: this plane reports footprint, not current access. A member who was | |
| 23 | − * removed while their connections remain must still be named on the users | |
| 24 | − * page and findable by the address an operator has for them. | |
| 23 | + * Every direction reads ANY membership status — the same reach `membersById` | |
| 24 | + * and `findByEmail` have by contract, and `search` asks for explicitly rather | |
| 25 | + * than taking `members`' active + pending default. This plane reports | |
| 26 | + * footprint, not current access: a member who was deactivated while their | |
| 27 | + * connections remain must still be findable by the address or name an | |
| 28 | + * operator has for them, exactly as `?email=` already finds them. | |
| 25 | 29 | * |
| 26 | − * Both fail with `MemberDirectoryError`, which the shared reads treat as a | |
| 27 | − * decorative-join outage (identities) or surface as a failed read (resolve). | |
| 30 | + * All fail with `MemberDirectoryError`, which the shared reads treat as a | |
| 31 | + * decorative-join outage (identities) or surface as a failed read (resolve, | |
| 32 | + * search). | |
| 28 | 33 | */ |
| 29 | 34 | export const adminUserDirectoryFromMembers = ( |
| 30 | 35 | directory: MemberDirectoryShape, |
| @@ -47,4 +52,8 @@ export const adminUserDirectoryFromMembers = ( · 16 unmodified lines | ||
| 47 | 52 | directory |
| 48 | 53 | .findByEmail(organizationId, email, MemberStatus.literals) |
| 49 | 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 | }); |
| @@ -16,6 +16,7 @@ import { Effect } from "effect"; · 15 unmodified lines | ||
| 16 | 16 | |
| 17 | 17 | import type { |
| 18 | 18 | AdminConnection, |
| 19 | + AdminListSubjectsOptions, | |
| 19 | 20 | AdminSubject, |
| 20 | 21 | AdminSubjectWithConnections, |
| 21 | 22 | Executor, |
| @@ -114,12 +115,27 @@ export type AdminIdentityDirectory = ( · 92 unmodified lines | ||
| 114 | 115 | */ |
| 115 | 116 | export 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 host | |
| 118 | − * with no directory reports unnamed rows and cannot resolve emails), and | |
| 119 | − * optional per direction. */ | |
| 118 | +/** | |
| 119 | + * The directory's SEARCH: a normalized term (trimmed + lower-cased, the same | |
| 120 | + * rule `normalizeEmail` applies) → the host-auth principal ids of every member | |
| 121 | + * whose email or name contains it, in the directory's own order. | |
| 122 | + * | |
| 123 | + * Unlike `resolveEmail` this names a SET, and the reads page that set through | |
| 124 | + * storage rather than in memory: the ids go into the SDK's `externalIds` | |
| 125 | + * filter and the caller's `limit`/`offset` apply there. An empty result means | |
| 126 | + * no member matches, and costs no storage read. Failures are the caller's to | |
| 127 | + * interpret on the same terms as `resolveEmail` — a search that cannot run | |
| 128 | + * must not quietly become "nobody matches". | |
| 129 | + */ | |
| 130 | +export type AdminMemberSearch = (term: string) => Effect.Effect<readonly string[], unknown>; | |
| 131 | + | |
| 132 | +/** Every direction of a host's member directory. Optional as a whole (a host | |
| 133 | + * with no directory reports unnamed rows and cannot resolve emails or search), | |
| 134 | + * and optional per direction. */ | |
| 120 | 135 | export interface AdminUserDirectory { |
| 121 | 136 | readonly identities?: AdminIdentityDirectory; |
| 122 | 137 | readonly resolveEmail?: AdminEmailResolver; |
| 138 | + readonly search?: AdminMemberSearch; | |
| 123 | 139 | } |
| 124 | 140 | |
| 125 | 141 | /** Identity is decoration on an operator view, not part of the answer: a |
| @@ -292,6 +308,72 @@ const selectByEmail = <T>( · 166 unmodified lines | ||
| 292 | 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 | + */ | |
| 327 | +const selectBySearch = <T>( | |
| 328 | + directory: AdminUserDirectory, | |
| 329 | + term: string, | |
| 330 | + read: (externalIds: readonly string[]) => Effect.Effect<readonly T[], AdminUsersError>, | |
| 331 | +): Effect.Effect<readonly T[], AdminUsersError> => | |
| 332 | + Effect.gen(function* () { | |
| 333 | + const search = directory.search; | |
| 334 | + if (!search) return []; | |
| 335 | + const wanted = yield* search(term).pipe( | |
| 336 | + Effect.mapError(() => new AdminUsersError({ message: "Failed to search the directory" })), | |
| 337 | + ); | |
| 338 | + // Nobody matches: an empty page, and no storage read for an `in ()` that | |
| 339 | + // could not match anyway. | |
| 340 | + if (wanted.length === 0) return []; | |
| 341 | + return yield* read(wanted); | |
| 342 | + }); | |
| 343 | + | |
| 344 | +/** | |
| 345 | + * Which filtered read a list request takes. `email` names ONE principal and | |
| 346 | + * wins when both are present: a keyed read is the more specific answer, and | |
| 347 | + * a search term beside an exact address has nothing left to narrow. | |
| 348 | + */ | |
| 349 | +const selectSubjects = <T>( | |
| 350 | + directory: AdminUserDirectory, | |
| 351 | + options: AdminUsersListOptions, | |
| 352 | + reads: { | |
| 353 | + readonly page: ( | |
| 354 | + paging: AdminListSubjectsOptions, | |
| 355 | + ) => Effect.Effect<readonly T[], AdminUsersError>; | |
| 356 | + readonly one: (externalId: string) => Effect.Effect<T | null, AdminUsersError>; | |
| 357 | + }, | |
| 358 | +): Effect.Effect<readonly T[], AdminUsersError> => { | |
| 359 | + if (options.email !== undefined) { | |
| 360 | + return selectByEmail(directory, options.email, options, reads.one); | |
| 361 | + } | |
| 362 | + if (options.search !== undefined) { | |
| 363 | + return selectBySearch(directory, options.search, (externalIds) => | |
| 364 | + reads.page({ ...pagingOf(options), externalIds }), | |
| 365 | + ); | |
| 366 | + } | |
| 367 | + return reads.page(pagingOf(options)); | |
| 368 | +}; | |
| 369 | + | |
| 370 | +/** Only the paging window — never the filters — reaches the SDK: the filters | |
| 371 | + * are resolved here, and the SDK's own `externalIds` is set by this file. */ | |
| 372 | +const pagingOf = (options: AdminUsersListOptions): AdminListSubjectsOptions => ({ | |
| 373 | + ...(options.limit === undefined ? {} : { limit: options.limit }), | |
| 374 | + ...(options.offset === undefined ? {} : { offset: options.offset }), | |
| 375 | +}); | |
| 376 | + | |
| 295 | 377 | export const listUsers = ( |
| 296 | 378 | admin: ExecutorAdmin, |
| 297 | 379 | options: AdminUsersListOptions, |
| @@ -299,12 +381,10 @@ export const listUsers = ( · 1 unmodified line | ||
| 299 | 381 | ): Effect.Effect<typeof AdminUsersResponse.Type, AdminUsersError> => |
| 300 | 382 | Effect.gen(function* () { |
| 301 | 383 | const dir = asDirectory(directory); |
| 302 | − const subjects = | |
| 303 | − options.email === undefined | |
| 304 | − ? yield* admin.listSubjects(options).pipe(Effect.mapError(readFailed("users"))) | |
| 305 | − : yield* selectByEmail(dir, options.email, options, (externalId) => | |
| 306 | − admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))), | |
| 307 | − ); | |
| 384 | + const subjects = yield* selectSubjects(dir, options, { | |
| 385 | + page: (paging) => admin.listSubjects(paging).pipe(Effect.mapError(readFailed("users"))), | |
| 386 | + one: (externalId) => admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))), | |
| 387 | + }); | |
| 308 | 388 | // One directory read for the page that was actually returned, joined in |
| 309 | 389 | // memory — never a lookup per user. |
| 310 | 390 | const identities = yield* resolveIdentities( |
| @@ -321,14 +401,12 @@ export const listUsersWithConnections = ( · 10 unmodified lines | ||
| 321 | 401 | ): Effect.Effect<typeof AdminUsersWithConnectionsResponse.Type, AdminUsersError> => |
| 322 | 402 | Effect.gen(function* () { |
| 323 | 403 | const dir = asDirectory(directory); |
| 324 | − const subjects = | |
| 325 | − options.email === undefined | |
| 326 | − ? yield* admin | |
| 327 | − .listSubjectsWithConnections(options) | |
| 328 | − .pipe(Effect.mapError(readFailed("users"))) | |
| 329 | − : yield* selectByEmail(dir, options.email, options, (externalId) => | |
| 330 | − admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))), | |
| 331 | − ); | |
| 404 | + const subjects = yield* selectSubjects(dir, options, { | |
| 405 | + page: (paging) => | |
| 406 | + admin.listSubjectsWithConnections(paging).pipe(Effect.mapError(readFailed("users"))), | |
| 407 | + one: (externalId) => | |
| 408 | + admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))), | |
| 409 | + }); | |
| 332 | 410 | const identities = yield* resolveIdentities( |
| 333 | 411 | dir.identities, |
| 334 | 412 | subjects.map((subject) => subject.externalId), |
| @@ -31,12 +31,15 @@ import { · 30 unmodified lines | ||
| 31 | 31 | export type AdminUsersHeaders = Record<string, string>; |
| 32 | 32 | |
| 33 | 33 | /** Paging and filtering, mirroring the SDK's `AdminListSubjectsOptions` plus |
| 34 | − * the contract's `?email=`. The email arrives already trimmed and lower-cased | |
| 35 | − * by the contract schema, so a provider never re-normalizes it. */ | |
| 34 | + * the contract's `?email=` and `?search=`. Both filters arrive already | |
| 35 | + * trimmed and lower-cased by the handler seam (a blank search is omitted | |
| 36 | + * entirely), so a provider never re-normalizes them. `email` names ONE | |
| 37 | + * principal and wins when both are present. */ | |
| 36 | 38 | export interface AdminUsersListOptions { |
| 37 | 39 | readonly limit?: number; |
| 38 | 40 | readonly offset?: number; |
| 39 | 41 | readonly email?: string; |
| 42 | + readonly search?: string; | |
| 40 | 43 | } |
| 41 | 44 | |
| 42 | 45 | type User = typeof AdminUserResponse.Type; |
| @@ -40,6 +40,7 @@ export { · 39 unmodified lines | ||
| 40 | 40 | normalizeEmail as normalizeAdminUserEmail, |
| 41 | 41 | type AdminEmailResolver, |
| 42 | 42 | type AdminIdentityDirectory, |
| 43 | + type AdminMemberSearch, | |
| 43 | 44 | type AdminUserDirectory, |
| 44 | 45 | type AdminUserIdentity, |
| 45 | 46 | } from "./admin/reads"; |
| @@ -593,6 +593,14 @@ export interface AdminSubjectWithConnections extends AdminSubject { · 592 unmodified lines | ||
| 593 | 593 | export interface AdminListSubjectsOptions { |
| 594 | 594 | readonly limit?: number; |
| 595 | 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 | 7014 | // them through independently produced a bare OFFSET, which SQLite |
| 7007 | 7015 | // rejects outright. |
| 7008 | 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 | 7021 | return platformCore |
| 7010 | 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 | 7029 | // Oldest first, ties broken on the unique key so the order is |
| 7012 | 7030 | // total and paging can't repeat or skip a row. |
| 7013 | 7031 | orderBy: [ |
| @@ -424,6 +424,61 @@ const expectWriteRefused = <A, E extends { readonly _tag: string }>( · 423 unmodified lines | ||
| 424 | 424 | Effect.orDie, |
| 425 | 425 | ); |
| 426 | 426 | |
| 427 | +describe("platform view — admin.listSubjects externalIds filter", () => { | |
| 428 | + it.effect("keeps only the named ids, still ordered and paged through storage", () => | |
| 429 | + withDb((db) => | |
| 430 | + Effect.gen(function* () { | |
| 431 | + yield* seed(db); | |
| 432 | + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); | |
| 433 | + | |
| 434 | + const only = yield* admin.listSubjects({ externalIds: [SUBJECT_B] }); | |
| 435 | + expect(only.map((entry) => entry.externalId)).toEqual([SUBJECT_B]); | |
| 436 | + | |
| 437 | + // Ids the tenant does not hold are simply absent — including another | |
| 438 | + // tenant's subject, which the policy keeps out regardless of the filter. | |
| 439 | + const mixed = yield* admin.listSubjects({ | |
| 440 | + externalIds: [SUBJECT_B, "user_nobody", "user_elsewhere", SUBJECT_A], | |
| 441 | + }); | |
| 442 | + expect(mixed.map((entry) => entry.externalId).sort()).toEqual([SUBJECT_A, SUBJECT_B]); | |
| 443 | + | |
| 444 | + // "Filter, then page": the window applies to the filtered set. | |
| 445 | + const all = yield* admin.listSubjects(); | |
| 446 | + const second = yield* admin.listSubjects({ | |
| 447 | + externalIds: [SUBJECT_A, SUBJECT_B], | |
| 448 | + limit: 1, | |
| 449 | + offset: 1, | |
| 450 | + }); | |
| 451 | + expect(second.map((entry) => entry.externalId)).toEqual([all[1]?.externalId]); | |
| 452 | + }), | |
| 453 | + ), | |
| 454 | + ); | |
| 455 | + | |
| 456 | + it.effect("an empty id set matches nothing, on both list reads", () => | |
| 457 | + withDb((db) => | |
| 458 | + Effect.gen(function* () { | |
| 459 | + yield* seed(db); | |
| 460 | + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); | |
| 461 | + | |
| 462 | + expect(yield* admin.listSubjects({ externalIds: [] })).toEqual([]); | |
| 463 | + expect(yield* admin.listSubjectsWithConnections({ externalIds: [] })).toEqual([]); | |
| 464 | + }), | |
| 465 | + ), | |
| 466 | + ); | |
| 467 | + | |
| 468 | + it.effect("the joined read filters the same way and still joins connections", () => | |
| 469 | + withDb((db) => | |
| 470 | + Effect.gen(function* () { | |
| 471 | + yield* seed(db); | |
| 472 | + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); | |
| 473 | + | |
| 474 | + const rows = yield* admin.listSubjectsWithConnections({ externalIds: [SUBJECT_A] }); | |
| 475 | + expect(rows.map((entry) => entry.externalId)).toEqual([SUBJECT_A]); | |
| 476 | + expect(rows[0]?.connections.length).toBeGreaterThan(0); | |
| 477 | + }), | |
| 478 | + ), | |
| 479 | + ); | |
| 480 | +}); | |
| 481 | + | |
| 427 | 482 | describe("platform view — read-only across every surface", () => { |
| 428 | 483 | it.effect("refuses org-row writes through policies and oauth", () => |
| 429 | 484 | withDb((db) => |
| @@ -10,10 +10,11 @@ import { ReactivityKey } from "./reactivity-keys"; · 9 unmodified lines | ||
| 10 | 10 | // rejects writes at tenant reach), so there are no mutations here and every |
| 11 | 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 and | |
| 14 | −// stepping back to a visited page is instant. `Atom.family` (not a bare arrow) | |
| 15 | −// because the page component re-derives the key object on every render — a | |
| 16 | −// fresh atom per render would refetch in a loop. | |
| 13 | +// Paging and the search term are part of the atom identity, so each page of | |
| 14 | +// each search is its own cache entry and stepping back to a visited page is | |
| 15 | +// instant. `Atom.family` (not a bare arrow) because the page component | |
| 16 | +// re-derives the key object on every render — a fresh atom per render would | |
| 17 | +// refetch in a loop. | |
| 17 | 18 | // --------------------------------------------------------------------------- |
| 18 | 19 | |
| 19 | 20 | /** 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 | ||
| 24 | 25 | export interface AdminUsersPage { |
| 25 | 26 | readonly limit: number; |
| 26 | 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 | */ |
| 36 | 41 | export const adminUsersWithConnectionsAtom = Atom.family((page: AdminUsersPage) => |
| 37 | 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 | 48 | timeToLive: "30 seconds", |
| 40 | 49 | reactivityKeys: [ReactivityKey.adminUsers], |
| 41 | 50 | }), |
| @@ -1,6 +1,7 @@ | ||
| 1 | −import { useState } from "react"; | |
| 1 | +import { useEffect, useState } from "react"; | |
| 2 | 2 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 3 | 3 | import { useParams } from "@tanstack/react-router"; |
| 4 | +import { SearchIcon, XIcon } from "lucide-react"; | |
| 4 | 5 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | 6 | import * as Cause from "effect/Cause"; |
| 6 | 7 | import * as Option from "effect/Option"; |
| @@ -18,6 +19,7 @@ import { ownerLabel } from "../api/owner-display"; · 11 unmodified lines | ||
| 18 | 19 | import { Button } from "../components/button"; |
| 19 | 20 | import { CopyButton } from "../components/copy-button"; |
| 20 | 21 | import { ErrorState } from "../components/error-state"; |
| 22 | +import { Input } from "../components/input"; | |
| 21 | 23 | import { |
| 22 | 24 | IntegrationFavicon, |
| 23 | 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. */ | |
| 531 | +const SEARCH_DEBOUNCE_MS = 250; | |
| 532 | + | |
| 533 | +/** | |
| 534 | + * The search box: what is typed, and the settled term the list actually asks | |
| 535 | + * for. Two values because the request is debounced, and the input must keep | |
| 536 | + * echoing keystrokes while the term catches up. Clearing bypasses the debounce | |
| 537 | + * — an emptied box should show everyone at once, not after a pause. | |
| 538 | + */ | |
| 539 | +const useDebouncedSearch = (): { | |
| 540 | + readonly typed: string; | |
| 541 | + readonly term: string; | |
| 542 | + readonly setTyped: (value: string) => void; | |
| 543 | + readonly clear: () => void; | |
| 544 | +} => { | |
| 545 | + const [typed, setTypedState] = useState(""); | |
| 546 | + const [term, setTerm] = useState(""); | |
| 547 | + | |
| 548 | + useEffect(() => { | |
| 549 | + if (typed === term) return; | |
| 550 | + const handle = setTimeout(() => setTerm(typed), SEARCH_DEBOUNCE_MS); | |
| 551 | + return () => clearTimeout(handle); | |
| 552 | + }, [typed, term]); | |
| 553 | + | |
| 554 | + return { | |
| 555 | + typed, | |
| 556 | + term, | |
| 557 | + setTyped: setTypedState, | |
| 558 | + clear: () => { | |
| 559 | + setTypedState(""); | |
| 560 | + setTerm(""); | |
| 561 | + }, | |
| 562 | + }; | |
| 563 | +}; | |
| 564 | + | |
| 565 | +function UserSearch(props: { | |
| 566 | + readonly value: string; | |
| 567 | + readonly onChange: (value: string) => void; | |
| 568 | + readonly onClear: () => void; | |
| 569 | +}) { | |
| 570 | + return ( | |
| 571 | + <div className="relative mb-4 min-w-0" data-slot="admin-users-search"> | |
| 572 | + <SearchIcon | |
| 573 | + className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" | |
| 574 | + aria-hidden | |
| 575 | + /> | |
| 576 | + <Input | |
| 577 | + type="search" | |
| 578 | + value={props.value} | |
| 579 | + onChange={(event) => props.onChange((event.target as HTMLInputElement).value)} | |
| 580 | + onKeyDown={(event) => { | |
| 581 | + if (event.key === "Escape" && props.value !== "") props.onClear(); | |
| 582 | + }} | |
| 583 | + placeholder="Search by name or email" | |
| 584 | + aria-label="Search users by name or email" | |
| 585 | + className="h-9 pl-9 pr-9 text-sm [&::-webkit-search-cancel-button]:hidden" | |
| 586 | + /> | |
| 587 | + {props.value !== "" && ( | |
| 588 | + <Button | |
| 589 | + type="button" | |
| 590 | + variant="ghost" | |
| 591 | + size="icon-xs" | |
| 592 | + aria-label="Clear search" | |
| 593 | + onClick={props.onClear} | |
| 594 | + className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground" | |
| 595 | + > | |
| 596 | + <XIcon /> | |
| 597 | + </Button> | |
| 598 | + )} | |
| 599 | + </div> | |
| 600 | + ); | |
| 601 | +} | |
| 602 | + | |
| 524 | 603 | // ── Page ──────────────────────────────────────────────────────────────────── |
| 525 | 604 | |
| 526 | 605 | export function AdminUsersPage() { |
| 527 | 606 | useExecutorDocumentTitle("Users"); |
| 528 | 607 | const [offset, setOffset] = useState(0); |
| 529 | 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 | 614 | const result = useAtomValue(adminUsersWithConnectionsAtom(page)); |
| 533 | 615 | const refresh = useAtomRefresh(adminUsersWithConnectionsAtom(page)); |
| 534 | 616 | const catalog = useCatalogRows(); |
| @@ -555,10 +637,24 @@ export function AdminUsersPage() { · 20 unmodified lines | ||
| 555 | 637 | </div> |
| 556 | 638 | ); |
| 557 | 639 | |
| 640 | + const searching = search.term !== ""; | |
| 641 | + | |
| 558 | 642 | return ( |
| 559 | 643 | <PageContainer> |
| 560 | 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 | 658 | {isAsyncResultLoading(result) |
| 563 | 659 | ? loading |
| 564 | 660 | : AsyncResult.match(result, { |
| @@ -572,6 +668,25 @@ export function AdminUsersPage() { · 7 unmodified lines | ||
| 572 | 668 | onSuccess: ({ value }) => { |
| 573 | 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 “ | |
| 680 | + {search.term}”. 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 | 690 | if (rows.length === 0) { |
| 576 | 691 | return ( |
| 577 | 692 | <div className="rounded-lg border border-dashed border-border bg-card p-8"> |
| @@ -69,7 +69,7 @@ import { isAsyncResultLoading } from "../lib/async-result"; · 68 unmodified lines | ||
| 69 | 69 | |
| 70 | 70 | type MemberData = { |
| 71 | 71 | id: string; |
| 72 | − email: string; | |
| 72 | + email: string | null; | |
| 73 | 73 | name: string | null; |
| 74 | 74 | avatarUrl: string | null; |
| 75 | 75 | role: string; |
| @@ -80,6 +80,23 @@ type MemberData = { · 4 unmodified lines | ||
| 80 | 80 | |
| 81 | 81 | type 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. */ | |
| 86 | +const memberLabel = (member: MemberData): string => member.name ?? member.email ?? member.id; | |
| 87 | + | |
| 88 | +const memberInitials = (member: MemberData): string => { | |
| 89 | + if (member.name) { | |
| 90 | + return member.name | |
| 91 | + .split(" ") | |
| 92 | + .map((n: string) => n[0]) | |
| 93 | + .join("") | |
| 94 | + .slice(0, 2) | |
| 95 | + .toUpperCase(); | |
| 96 | + } | |
| 97 | + return (member.email?.[0] ?? "?").toUpperCase(); | |
| 98 | +}; | |
| 99 | + | |
| 83 | 100 | type InviteState = { |
| 84 | 101 | email: string; |
| 85 | 102 | roleSlug: string; |
| @@ -314,7 +331,7 @@ export function OrgPage(props: { · 228 unmodified lines | ||
| 314 | 331 | const filtered = search |
| 315 | 332 | ? members.filter( |
| 316 | 333 | (m: MemberData) => |
| 317 | − m.email.toLowerCase().includes(search.toLowerCase()) || | |
| 334 | + (m.email?.toLowerCase().includes(search.toLowerCase()) ?? false) || | |
| 318 | 335 | (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false), |
| 319 | 336 | ) |
| 320 | 337 | : members; |
| @@ -338,21 +355,14 @@ export function OrgPage(props: { · 17 unmodified lines | ||
| 338 | 355 | <img src={member.avatarUrl} alt="" className="size-8 rounded-full" /> |
| 339 | 356 | ) : ( |
| 340 | 357 | <div className="flex size-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground"> |
| 341 | − {member.name | |
| 342 | − ? member.name | |
| 343 | − .split(" ") | |
| 344 | − .map((n: string) => n[0]) | |
| 345 | − .join("") | |
| 346 | − .slice(0, 2) | |
| 347 | − .toUpperCase() | |
| 348 | − : member.email[0]!.toUpperCase()} | |
| 358 | + {memberInitials(member)} | |
| 349 | 359 | </div> |
| 350 | 360 | )} |
| 351 | 361 | |
| 352 | 362 | <div className="min-w-0"> |
| 353 | 363 | <div className="flex items-center gap-2"> |
| 354 | 364 | <p className="truncate text-sm font-medium text-foreground leading-none"> |
| 355 | − {member.name ?? member.email} | |
| 365 | + {memberLabel(member)} | |
| 356 | 366 | </p> |
| 357 | 367 | {member.isCurrentUser && ( |
| 358 | 368 | <Badge className="bg-muted text-muted-foreground">You</Badge> |
| @@ -361,7 +371,7 @@ export function OrgPage(props: { · 2 unmodified lines | ||
| 361 | 371 | <Badge className="bg-muted text-muted-foreground">Invited</Badge> |
| 362 | 372 | )} |
| 363 | 373 | </div> |
| 364 | − {member.name && ( | |
| 374 | + {member.name && member.email && ( | |
| 365 | 375 | <p className="mt-0.5 truncate text-xs text-muted-foreground leading-none"> |
| 366 | 376 | {member.email} |
| 367 | 377 | </p> |
| @@ -421,7 +431,7 @@ export function OrgPage(props: { · 53 unmodified lines | ||
| 421 | 431 | onClick={() => |
| 422 | 432 | setRemovingMember({ |
| 423 | 433 | id: member.id, |
| 424 | − name: member.name ?? member.email, | |
| 434 | + name: memberLabel(member), | |
| 425 | 435 | }) |
| 426 | 436 | } |
| 427 | 437 | > |