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 | AccountProvider, | 5 | AccountProvider, |
| 6 | makeAccountApiLayer, | 6 | makeAccountApiLayer, |
| 7 | requestScopedMiddleware, | 7 | requestScopedMiddleware, |
| 8 | type MemberDirectory, | ||
| 8 | } from "@executor-js/api/server"; | 9 | } from "@executor-js/api/server"; |
| 9 | 10 | ||
| 10 | import { ApiKeyService } from "../auth/api-keys"; | 11 | import { ApiKeyService } from "../auth/api-keys"; |
| @@ -46,7 +47,8 @@ import { AccountCaller, workosAccountProvider } from "./workos-account-service"; · 35 unmodified lines | |||
| 46 | // Builds the WorkOS `AccountProvider` per request, providing it to the handler. | 47 | // Builds the WorkOS `AccountProvider` per request, providing it to the handler. |
| 47 | // Long-lived `WorkOSClient | AutumnService` come from the surrounding context | 48 | // Long-lived `WorkOSClient | AutumnService` come from the surrounding context |
| 48 | // (Autumn provided by `makeAccountApiLive` for the seat-gate); the per-request | 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 | // `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`. | 52 | // `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`. |
| 51 | const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()( | 53 | const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()( |
| 52 | Effect.gen(function* () { | 54 | Effect.gen(function* () { |
| @@ -97,11 +99,11 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi · 44 unmodified lines | |||
| 97 | * (the seat-gate) stays a residual requirement, satisfied by the app `boot`. | 99 | * (the seat-gate) stays a residual requirement, satisfied by the app `boot`. |
| 98 | */ | 100 | */ |
| 99 | export const workosAccountMiddleware = ( | 101 | export const workosAccountMiddleware = ( |
| 100 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>, | 102 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, |
| 101 | ) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; | 103 | ) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; |
| 102 | 104 | ||
| 103 | export const makeAccountApiLive = ( | 105 | export const makeAccountApiLive = ( |
| 104 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>, | 106 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, |
| 105 | ) => { | 107 | ) => { |
| 106 | // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it | 108 | // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it |
| 107 | // closes over the per-request postgres socket), so it can't be a self- | 109 | // closes over the per-request postgres socket), so it can't be a self- |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Layer } from "effect"; | 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 | import { AccountError, AccountForbidden } from "@executor-js/api"; | 5 | import { AccountError, AccountForbidden } from "@executor-js/api"; |
| 6 | 6 | ||
| 7 | import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; | 7 | import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; |
| @@ -147,6 +147,14 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ · 139 unmodified lines | |||
| 147 | organizationBackfilledAt: () => Effect.die("revoke does not report seats"), | 147 | organizationBackfilledAt: () => Effect.die("revoke does not report seats"), |
| 148 | }); | 148 | }); |
| 149 | 149 | ||
| 150 | // Revoke lists no members either. | ||
| 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 | const stubAutumn = Layer.succeed(AutumnService)({ | 158 | const stubAutumn = Layer.succeed(AutumnService)({ |
| 151 | use: () => Effect.die("revoke does not touch billing"), | 159 | use: () => Effect.die("revoke does not touch billing"), |
| 152 | ensureCustomer: () => Effect.die("revoke does not touch billing"), | 160 | ensureCustomer: () => Effect.die("revoke does not touch billing"), |
| @@ -185,6 +193,7 @@ const providerWith = (accountId: string) => { · 32 unmodified lines | |||
| 185 | stubWorkOS, | 193 | stubWorkOS, |
| 186 | stubUsers, | 194 | stubUsers, |
| 187 | stubMirror, | 195 | stubMirror, |
| 196 | stubDirectory, | ||
| 188 | stubApiKeys, | 197 | stubApiKeys, |
| 189 | stubAutumn, | 198 | stubAutumn, |
| 190 | Layer.succeed(AccountCaller)({ session: session(accountId) }), | 199 | Layer.succeed(AccountCaller)({ session: session(accountId) }), |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { Context, Effect, Layer } from "effect"; | 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 | import { | 4 | import { |
| 5 | AccountError, | 5 | AccountError, |
| 6 | AccountForbidden, | 6 | AccountForbidden, |
| @@ -12,6 +12,7 @@ import { ApiKeyService } from "../auth/api-keys"; · 5 unmodified lines | |||
| 12 | import { UserStoreService } from "../auth/context"; | 12 | import { UserStoreService } from "../auth/context"; |
| 13 | import type { Session } from "../auth/middleware"; | 13 | import type { Session } from "../auth/middleware"; |
| 14 | import { WorkOSClient } from "../auth/workos"; | 14 | import { WorkOSClient } from "../auth/workos"; |
| 15 | import { ensureOrganizationBackfilled, mirrorInvitedMember } from "../auth/mirror-feeders"; | ||
| 15 | import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror"; | 16 | import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror"; |
| 16 | import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; | 17 | import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; |
| 17 | import { AutumnService } from "../extensions/billing/service"; | 18 | import { AutumnService } from "../extensions/billing/service"; |
| @@ -66,7 +67,13 @@ const toAccountError = () => Effect.fail(new AccountError({ message: "Account re · 48 unmodified lines | |||
| 66 | export const workosAccountProvider: Layer.Layer< | 67 | export const workosAccountProvider: Layer.Layer< |
| 67 | AccountProvider, | 68 | AccountProvider, |
| 68 | never, | 69 | never, |
| 69 | WorkOSClient | UserStoreService | WorkOsMirror | ApiKeyService | AutumnService | AccountCaller | 70 | | WorkOSClient |
| 71 | | UserStoreService | ||
| 72 | | WorkOsMirror | ||
| 73 | | MemberDirectory | ||
| 74 | | ApiKeyService | ||
| 75 | | AutumnService | ||
| 76 | | AccountCaller | ||
| 70 | > = Layer.effect(AccountProvider)( | 77 | > = Layer.effect(AccountProvider)( |
| 71 | Effect.gen(function* () { | 78 | Effect.gen(function* () { |
| 72 | const workos = yield* WorkOSClient; | 79 | const workos = yield* WorkOSClient; |
| @@ -77,6 +84,10 @@ export const workosAccountProvider: Layer.Layer< · 4 unmodified lines | |||
| 77 | // written through to the local mirror so the member list and the seat | 84 | // written through to the local mirror so the member list and the seat |
| 78 | // count read the change without waiting for the Events reconciler. | 85 | // count read the change without waiting for the Events reconciler. |
| 79 | const mirror = yield* WorkOsMirror; | 86 | const mirror = yield* WorkOsMirror; |
| 87 | // Membership READS come from the mirror through the shared directory: the | ||
| 88 | // member list and the seat count are one local query each, never a | ||
| 89 | // WorkOS read per member. | ||
| 90 | const directory = yield* MemberDirectory; | ||
| 80 | 91 | ||
| 81 | // The caller, resolved once per request by the cookie-only session | 92 | // The caller, resolved once per request by the cookie-only session |
| 82 | // middleware (account-api.ts) — the same credential `SessionAuthLive` | 93 | // middleware (account-api.ts) — the same credential `SessionAuthLive` |
| @@ -85,10 +96,12 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines | |||
| 85 | const caller = yield* AccountCaller; | 96 | const caller = yield* AccountCaller; |
| 86 | 97 | ||
| 87 | // Capture the resolved service context once so the method bodies — which | 98 | // Capture the resolved service context once so the method bodies — which |
| 88 | // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) — | 99 | // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`), |
| 89 | // can be erased to `R = never`, as the neutral AccountProvider shape | 100 | // the mirror feeders, and the seat reporter — can be erased to `R = never`, |
| 90 | // requires. Provided per method below. | 101 | // as the neutral AccountProvider shape requires. Provided per method below. |
| 91 | const ctx = yield* Effect.context<WorkOSClient | UserStoreService | AutumnService>(); | 102 | const ctx = yield* Effect.context< |
| 103 | WorkOSClient | UserStoreService | AutumnService | MemberDirectory | WorkOsMirror | ||
| 104 | >(); | ||
| 92 | 105 | ||
| 93 | // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly | 106 | // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly |
| 94 | // as the old inline `requireSession` did. | 107 | // as the old inline `requireSession` did. |
| @@ -145,7 +158,14 @@ export const workosAccountProvider: Layer.Layer< · 50 unmodified lines | |||
| 145 | return membership; | 158 | return membership; |
| 146 | }); | 159 | }); |
| 147 | 160 | ||
| 148 | // Mirror of org/handlers `getMemberSeats` — live seat usage from WorkOS. | 161 | // Seat usage: memberships from the local directory (active + pending, the |
| 162 | // `members` default), pending invitations live from WorkOS — invitations | ||
| 163 | // are not mirrored. The directory is trusted for a COUNT only once this | ||
| 164 | // organization's membership list has been scanned from WorkOS in full: | ||
| 165 | // login and write-through record single memberships, so an organization | ||
| 166 | // the one-off backfill did not cover holds a partial list, and counting | ||
| 167 | // it would admit invitations past the plan limit. The scan runs here, | ||
| 168 | // once, when the organization's mark is missing. | ||
| 149 | const getMemberSeats = (organizationId: string) => | 169 | const getMemberSeats = (organizationId: string) => |
| 150 | Effect.gen(function* () { | 170 | Effect.gen(function* () { |
| 151 | const customer = yield* autumn.use((client) => | 171 | const customer = yield* autumn.use((client) => |
| @@ -154,15 +174,16 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines | |||
| 154 | const planId = selectActiveMemberLimitPlan(customer.subscriptions); | 174 | const planId = selectActiveMemberLimitPlan(customer.subscriptions); |
| 155 | const limit = getMemberLimitForPlan(planId); | 175 | const limit = getMemberLimitForPlan(planId); |
| 156 | 176 | ||
| 157 | // `listOrgMembers` returns active members AND pending memberships (an | 177 | yield* ensureOrganizationBackfilled(organizationId).pipe(Effect.provideContext(ctx)); |
| 158 | // invited user shows up as status "pending"); `listPendingInvitations` | 178 | // The directory reports active members AND pending memberships (an |
| 179 | // invited user is mirrored with status "pending"); `listPendingInvitations` | ||
| 159 | // returns the same invited users again. `countSeatsUsed` dedupes them | 180 | // returns the same invited users again. `countSeatsUsed` dedupes them |
| 160 | // so an outstanding invite is not counted twice. | 181 | // so an outstanding invite is not counted twice. |
| 161 | const memberships = yield* workos.listOrgMembers(organizationId); | 182 | const memberships = yield* directory.members(organizationId); |
| 162 | const invitations = yield* workos.listPendingInvitations(organizationId); | 183 | const invitations = yield* workos.listPendingInvitations(organizationId); |
| 163 | 184 | ||
| 164 | return { | 185 | return { |
| 165 | used: countSeatsUsed(memberships.data, invitations.data.length), | 186 | used: countSeatsUsed(memberships, invitations.data.length), |
| 166 | granted: limit ?? 0, | 187 | granted: limit ?? 0, |
| 167 | unlimited: limit === null, | 188 | unlimited: limit === null, |
| 168 | }; | 189 | }; |
| @@ -328,29 +349,23 @@ export const workosAccountProvider: Layer.Layer< · 159 unmodified lines | |||
| 328 | Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })), | 349 | Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })), |
| 329 | ); | 350 | ); |
| 330 | 351 | ||
| 331 | const memberships = yield* workos | 352 | // One directory read (active + pending, ordered by email) with the |
| 332 | .listOrgMembers(org.id) | 353 | // profile already joined — no per-member WorkOS user fetch. |
| 333 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); | 354 | const directoryMembers = yield* directory |
| 334 | 355 | .members(org.id) | |
| 335 | const members = yield* Effect.all( | 356 | .pipe(Effect.catchTag("MemberDirectoryError", toAccountError)); |
| 336 | memberships.data.map((m) => | 357 | |
| 337 | Effect.gen(function* () { | 358 | const members = directoryMembers.map((m) => ({ |
| 338 | const user = yield* workos.getUser(m.userId); | 359 | id: m.membershipId, |
| 339 | return { | 360 | userId: m.accountId, |
| 340 | id: m.id, | 361 | email: m.email, |
| 341 | userId: m.userId, | 362 | name: m.name, |
| 342 | email: user.email, | 363 | avatarUrl: m.avatarUrl, |
| 343 | name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, | 364 | role: m.role, |
| 344 | avatarUrl: user.profilePictureUrl ?? null, | 365 | status: m.status, |
| 345 | role: m.role?.slug ?? "member", | 366 | lastActiveAt: m.lastActiveAt === null ? null : new Date(m.lastActiveAt).toISOString(), |
| 346 | status: m.status, | 367 | isCurrentUser: m.accountId === session.accountId, |
| 347 | lastActiveAt: user.lastSignInAt ?? null, | 368 | })); |
| 348 | isCurrentUser: m.userId === session.accountId, | ||
| 349 | }; | ||
| 350 | }), | ||
| 351 | ), | ||
| 352 | { concurrency: 5 }, | ||
| 353 | ).pipe(Effect.catchTag("WorkOSError", toAccountError)); | ||
| 354 | 369 | ||
| 355 | return { members, seats }; | 370 | return { members, seats }; |
| 356 | }), | 371 | }), |
| @@ -378,6 +393,23 @@ export const workosAccountProvider: Layer.Layer< · 21 unmodified lines | |||
| 378 | ...(body.roleSlug ? { roleSlug: body.roleSlug } : {}), | 393 | ...(body.roleSlug ? { roleSlug: body.roleSlug } : {}), |
| 379 | }) | 394 | }) |
| 380 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); | 395 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); |
| 396 | // Write-through: WorkOS creates a PENDING membership for the invitee | ||
| 397 | // alongside the invitation, and the member list (the "Invited" row | ||
| 398 | // and its revoke button) reads memberships from the mirror only, so | ||
| 399 | // the row must land now — the Events reconciler is not on this path. | ||
| 400 | const mirrored = yield* mirrorInvitedMember(org.id, invitation.email).pipe( | ||
| 401 | Effect.provideContext(ctx), | ||
| 402 | Effect.catchTags({ | ||
| 403 | WorkOSError: toAccountError, | ||
| 404 | WorkOsMirrorError: toAccountError, | ||
| 405 | }), | ||
| 406 | ); | ||
| 407 | if (!mirrored) { | ||
| 408 | yield* Effect.logWarning("inviteMember: no pending membership for the invitee yet", { | ||
| 409 | organizationId: org.id, | ||
| 410 | invitationId: invitation.id, | ||
| 411 | }); | ||
| 412 | } | ||
| 381 | return { id: invitation.id, email: invitation.email }; | 413 | return { id: invitation.id, email: invitation.email }; |
| 382 | }), | 414 | }), |
| 383 | 415 | ||
| @@ -25,28 +25,24 @@ · 24 unmodified lines | |||
| 25 | // every query by that tenant. | 25 | // every query by that tenant. |
| 26 | // --------------------------------------------------------------------------- | 26 | // --------------------------------------------------------------------------- |
| 27 | 27 | ||
| 28 | import { env } from "cloudflare:workers"; | ||
| 29 | import { HttpRouter } from "effect/unstable/http"; | 28 | import { HttpRouter } from "effect/unstable/http"; |
| 30 | import { Context, Effect, Layer, Option } from "effect"; | 29 | import { Effect, Layer } from "effect"; |
| 31 | 30 | ||
| 32 | import { | 31 | import { |
| 33 | AdminUsersProvider, | 32 | AdminUsersProvider, |
| 34 | DbProvider, | 33 | DbProvider, |
| 35 | HostConfig, | 34 | HostConfig, |
| 35 | MemberDirectory, | ||
| 36 | PluginsProvider, | 36 | PluginsProvider, |
| 37 | adminUserDirectoryFromMembers, | ||
| 37 | getAdminUser, | 38 | getAdminUser, |
| 38 | listAdminUserConnections, | 39 | listAdminUserConnections, |
| 39 | listAdminUsers, | 40 | listAdminUsers, |
| 40 | listAdminUsersWithConnections, | 41 | listAdminUsersWithConnections, |
| 41 | makeAdminUsersApiLayer, | 42 | makeAdminUsersApiLayer, |
| 42 | makePlatformExecutor, | 43 | makePlatformExecutor, |
| 43 | normalizeAdminUserEmail, | ||
| 44 | platformViewOf, | 44 | platformViewOf, |
| 45 | requestScopedMiddleware, | 45 | requestScopedMiddleware, |
| 46 | type AdminEmailResolver, | ||
| 47 | type AdminIdentityDirectory, | ||
| 48 | type AdminUserDirectory, | ||
| 49 | type AdminUserIdentity, | ||
| 50 | type AdminUsersHeaders, | 46 | type AdminUsersHeaders, |
| 51 | } from "@executor-js/api/server"; | 47 | } from "@executor-js/api/server"; |
| 52 | import { | 48 | import { |
| @@ -122,124 +118,6 @@ const authorizeTenant = ( · 69 unmodified lines | |||
| 122 | return org.id; | 118 | return org.id; |
| 123 | }); | 119 | }); |
| 124 | 120 | ||
| 125 | /** | ||
| 126 | * How many user-detail reads run at once. Matches the account plane's own | ||
| 127 | * member listing (`workos-account-service.ts`), which fans out the same way for | ||
| 128 | * the same reason. | ||
| 129 | */ | ||
| 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 | * Authorize, then run `body` against the tenant's platform view. | 122 | * Authorize, then run `body` against the tenant's platform view. |
| 245 | * | 123 | * |
| @@ -264,8 +142,9 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 18 unmodified lines | |||
| 264 | const executor = yield* makePlatformExecutor(organizationId).pipe( | 142 | const executor = yield* makePlatformExecutor(organizationId).pipe( |
| 265 | Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })), | 143 | Effect.mapError(() => new AdminUsersError({ message: "Failed to open the platform view" })), |
| 266 | ); | 144 | ); |
| 267 | // The authorized tenant is handed to the body so an identity join reads the | 145 | // The authorized tenant is handed to the body so the directory reads the |
| 268 | // SAME org the reads are scoped to — never one named by client input. | 146 | // SAME org the storage reads are scoped to — never one named by client |
| 147 | // input. | ||
| 269 | return yield* Effect.ensuring( | 148 | return yield* Effect.ensuring( |
| 270 | body(executor, organizationId), | 149 | body(executor, organizationId), |
| 271 | executor.close().pipe(Effect.ignore), | 150 | executor.close().pipe(Effect.ignore), |
| @@ -275,22 +154,40 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 3 unmodified lines | |||
| 275 | /** | 154 | /** |
| 276 | * Cloud's `AdminUsersProvider`, built per request so the platform executor | 155 | * Cloud's `AdminUsersProvider`, built per request so the platform executor |
| 277 | * closes over the per-request postgres socket. | 156 | * closes over the per-request postgres socket. |
| 157 | * | ||
| 158 | * Identity (email/name per row), the `?email=` resolver and the `?search=` | ||
| 159 | * match all come from the shared `MemberDirectory` — cloud's is the LOCAL | ||
| 160 | * membership mirror (`auth/member-directory.ts`), so an admin page costs one | ||
| 161 | * indexed query per direction and never a WorkOS read per member. The | ||
| 162 | * directory is per-request too (it reads the same postgres socket), which is | ||
| 163 | * why it is captured from the request context rather than at boot. | ||
| 278 | */ | 164 | */ |
| 279 | export const workosAdminUsersProvider: Layer.Layer< | 165 | export const workosAdminUsersProvider: Layer.Layer< |
| 280 | AdminUsersProvider, | 166 | AdminUsersProvider, |
| 281 | never, | 167 | never, |
| 282 | WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig | 168 | | WorkOSClient |
| 169 | | ApiKeyService | ||
| 170 | | UserStoreService | ||
| 171 | | MemberDirectory | ||
| 172 | | DbProvider | ||
| 173 | | PluginsProvider | ||
| 174 | | HostConfig | ||
| 283 | > = Layer.effect(AdminUsersProvider)( | 175 | > = Layer.effect(AdminUsersProvider)( |
| 284 | Effect.gen(function* () { | 176 | Effect.gen(function* () { |
| 285 | const context = yield* Effect.context< | 177 | const context = yield* Effect.context< |
| 286 | WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig | 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 | return AdminUsersProvider.of({ | 185 | return AdminUsersProvider.of({ |
| 289 | listUsers: (headers, options) => | 186 | listUsers: (headers, options) => |
| 290 | withPlatformView(headers, (executor, organizationId) => | 187 | withPlatformView(headers, (executor, organizationId) => |
| 291 | platformViewOf(executor).pipe( | 188 | platformViewOf(executor).pipe( |
| 292 | Effect.flatMap((admin) => | 189 | Effect.flatMap((admin) => |
| 293 | listAdminUsers(admin, options, userDirectory(organizationId, context)), | 190 | listAdminUsers(admin, options, userDirectory(organizationId)), |
| 294 | ), | 191 | ), |
| 295 | ), | 192 | ), |
| 296 | ).pipe(Effect.provideContext(context)), | 193 | ).pipe(Effect.provideContext(context)), |
| @@ -298,7 +195,7 @@ export const workosAdminUsersProvider: Layer.Layer< · 1 unmodified line | |||
| 298 | withPlatformView(headers, (executor, organizationId) => | 195 | withPlatformView(headers, (executor, organizationId) => |
| 299 | platformViewOf(executor).pipe( | 196 | platformViewOf(executor).pipe( |
| 300 | Effect.flatMap((admin) => | 197 | Effect.flatMap((admin) => |
| 301 | listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)), | 198 | listAdminUsersWithConnections(admin, options, userDirectory(organizationId)), |
| 302 | ), | 199 | ), |
| 303 | ), | 200 | ), |
| 304 | ).pipe(Effect.provideContext(context)), | 201 | ).pipe(Effect.provideContext(context)), |
| @@ -312,7 +209,7 @@ export const workosAdminUsersProvider: Layer.Layer< · 7 unmodified lines | |||
| 312 | withPlatformView(headers, (executor, organizationId) => | 209 | withPlatformView(headers, (executor, organizationId) => |
| 313 | platformViewOf(executor).pipe( | 210 | platformViewOf(executor).pipe( |
| 314 | Effect.flatMap((admin) => | 211 | Effect.flatMap((admin) => |
| 315 | getAdminUser(admin, identifier, userDirectory(organizationId, context)), | 212 | getAdminUser(admin, identifier, userDirectory(organizationId)), |
| 316 | ), | 213 | ), |
| 317 | ), | 214 | ), |
| 318 | ).pipe(Effect.provideContext(context)), | 215 | ).pipe(Effect.provideContext(context)), |
| @@ -322,8 +219,9 @@ export const workosAdminUsersProvider: Layer.Layer< · 3 unmodified lines | |||
| 322 | 219 | ||
| 323 | // Builds the provider per request, providing it to the handlers. Long-lived | 220 | // Builds the provider per request, providing it to the handlers. Long-lived |
| 324 | // `WorkOSClient | ApiKeyService` come from the surrounding boot context; the | 221 | // `WorkOSClient | ApiKeyService` come from the surrounding boot context; the |
| 325 | // per-request `DbService`/`UserStoreService` (and the execution seams built | 222 | // per-request `DbService`/`UserStoreService`/`MemberDirectory` (and the |
| 326 | // over them) are supplied by the combined `requestScopedMiddleware`. | 223 | // execution seams built over them) are supplied by the combined |
| 224 | // `requestScopedMiddleware`. | ||
| 327 | const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()( | 225 | const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()( |
| 328 | Effect.gen(function* () { | 226 | Effect.gen(function* () { |
| 329 | const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>(); | 227 | const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>(); |
| @@ -348,7 +246,7 @@ const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUser · 18 unmodified lines | |||
| 348 | * `/api` prefix as the rest of the cloud router. | 246 | * `/api` prefix as the rest of the cloud router. |
| 349 | */ | 247 | */ |
| 350 | export const makeCloudAdminUsersRoutes = ( | 248 | export const makeCloudAdminUsersRoutes = ( |
| 351 | rsLive: Layer.Layer<DbService | UserStoreService>, | 249 | rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory>, |
| 352 | options: Parameters<typeof makeAdminUsersApiLayer>[1] = {}, | 250 | options: Parameters<typeof makeAdminUsersApiLayer>[1] = {}, |
| 353 | ) => | 251 | ) => |
| 354 | makeAdminUsersApiLayer( | 252 | makeAdminUsersApiLayer( |
| @@ -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 | import { HttpServer } from "effect/unstable/http"; | 2 | import { HttpServer } from "effect/unstable/http"; |
| 3 | import { Layer } from "effect"; | 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 | import { SessionAuthLive } from "../auth/middleware-live"; | 11 | import { SessionAuthLive } from "../auth/middleware-live"; |
| 8 | import { UserStoreService } from "../auth/context"; | 12 | import { UserStoreService } from "../auth/context"; |
| 13 | import { cloudMemberDirectoryLayer } from "../auth/member-directory"; | ||
| 9 | import { WorkOsMirror } from "../auth/workos-mirror"; | 14 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| 10 | import { | 15 | import { |
| 11 | CloudAuthPublicHandlers, | 16 | CloudAuthPublicHandlers, |
| @@ -27,12 +32,17 @@ import { CoreSharedServices } from "../auth/workos"; · 15 unmodified lines | |||
| 27 | const DbLive = DbService.Live; | 32 | const DbLive = DbService.Live; |
| 28 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); | 33 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); |
| 29 | const WorkOsMirrorLive = WorkOsMirror.Live.pipe(Layer.provide(DbLive)); | 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 | // Per-request layer. Anything that opens an I/O object (postgres.js socket, | 39 | // Per-request layer. Anything that opens an I/O object (postgres.js socket, |
| 32 | // fetch stream readers, anything backed by a `Writable`) MUST live here — | 40 | // fetch stream readers, anything backed by a `Writable`) MUST live here — |
| 33 | // `provideRequestScoped` rebuilds it per request so Cloudflare Workers' | 41 | // `provideRequestScoped` rebuilds it per request so Cloudflare Workers' |
| 34 | // I/O isolation is satisfied. See `api.request-scope.test.ts`. | 42 | // I/O isolation is satisfied. See `api.request-scope.test.ts`. |
| 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 | // Boot-scoped layer. Built once at worker boot, reused across requests. | 47 | // Boot-scoped layer. Built once at worker boot, reused across requests. |
| 38 | // Safe for config, in-memory caches, the global tracer provider, and | 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 | // handler reads it for the free-organizations-per-user limit gate — one of the | 67 | // handler reads it for the free-organizations-per-user limit gate — one of the |
| 58 | // few app-only billing touchpoints. (It is NOT on the neutral boot core.) | 68 | // few app-only billing touchpoints. (It is NOT on the neutral boot core.) |
| 59 | export const makeNonProtectedApiLive = ( | 69 | export const makeNonProtectedApiLive = ( |
| 60 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>, | 70 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, |
| 61 | ) => | 71 | ) => |
| 62 | HttpApiBuilder.layer(NonProtectedApi).pipe( | 72 | HttpApiBuilder.layer(NonProtectedApi).pipe( |
| 63 | Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), | 73 | Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), |
| @@ -1,7 +1,11 @@ | |||
| 1 | import { Layer } from "effect"; | 1 | import { Layer } from "effect"; |
| 2 | import { HttpRouter } from "effect/unstable/http"; | 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 | import { UserStoreService } from "../auth/context"; | 10 | import { UserStoreService } from "../auth/context"; |
| 7 | import { WorkOsMirror } from "../auth/workos-mirror"; | 11 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| @@ -31,7 +35,7 @@ import { makeProtectedApiLive } from "./protected"; · 23 unmodified lines | |||
| 31 | // assert per-request semantics — see | 35 | // assert per-request semantics — see |
| 32 | // `apps/cloud/src/api.request-scope.node.test.ts`. | 36 | // `apps/cloud/src/api.request-scope.node.test.ts`. |
| 33 | export const makeApiLive = ( | 37 | export const makeApiLive = ( |
| 34 | requestScopedLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>, | 38 | requestScopedLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, |
| 35 | ) => { | 39 | ) => { |
| 36 | const BillingRoutesLive = AutumnRoutesLive.pipe( | 40 | const BillingRoutesLive = AutumnRoutesLive.pipe( |
| 37 | Layer.provide(requestScopedMiddleware(requestScopedLive).layer), | 41 | Layer.provide(requestScopedMiddleware(requestScopedLive).layer), |
| @@ -12,11 +12,20 @@ · 11 unmodified lines | |||
| 12 | // - the callback picks the landing org from that same list: a returnTo | 12 | // - the callback picks the landing org from that same list: a returnTo |
| 13 | // slug or last-org cookie lands only in an ACTIVE membership, an unknown | 13 | // slug or last-org cookie lands only in an ACTIVE membership, an unknown |
| 14 | // or pending one falls through | 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 | // - `removeMember` tombstones the mirror row after the WorkOS delete, | 18 | // - `removeMember` tombstones the mirror row after the WorkOS delete, |
| 16 | // stamped with the membership's last WorkOS state (never a local clock), | 19 | // stamped with the membership's last WorkOS state (never a local clock), |
| 17 | // so a replay of the membership as it was before the delete cannot | 20 | // so a replay of the membership as it was before the delete cannot |
| 18 | // restore it while a replacement WorkOS created meanwhile is accepted | 21 | // restore it while a replacement WorkOS created meanwhile is accepted |
| 19 | // - `updateMemberRole` writes the role WorkOS returned | 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 | // - the backfill mirrors every org's members and counts what it wrote, | 29 | // - the backfill mirrors every org's members and counts what it wrote, |
| 21 | // writes nothing on a dry run, converges on a re-run, tombstones a | 30 | // writes nothing on a dry run, converges on a re-run, tombstones a |
| 22 | // membership WorkOS no longer lists — but never one written after its | 31 | // membership WorkOS no longer lists — but never one written after its |
| @@ -39,10 +48,11 @@ · 16 unmodified lines | |||
| 39 | 48 | ||
| 40 | import { describe, expect, it } from "@effect/vitest"; | 49 | import { describe, expect, it } from "@effect/vitest"; |
| 41 | import { sql } from "drizzle-orm"; | 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 | import { HttpRouter, HttpServer } from "effect/unstable/http"; | 52 | import { HttpRouter, HttpServer } from "effect/unstable/http"; |
| 44 | import { HttpApiBuilder } from "effect/unstable/httpapi"; | 53 | import { HttpApiBuilder } from "effect/unstable/httpapi"; |
| 45 | 54 | ||
| 55 | import { AccountForbidden } from "@executor-js/api"; | ||
| 46 | import { | 56 | import { |
| 47 | AccountProvider, | 57 | AccountProvider, |
| 48 | MemberDirectory, | 58 | MemberDirectory, |
| @@ -53,6 +63,7 @@ import { · 4 unmodified lines | |||
| 53 | import { AccountCaller, workosAccountProvider } from "../account/workos-account-service"; | 63 | import { AccountCaller, workosAccountProvider } from "../account/workos-account-service"; |
| 54 | import { RequestScopedServicesLive } from "../api/layers"; | 64 | import { RequestScopedServicesLive } from "../api/layers"; |
| 55 | import { DbService } from "../db/db"; | 65 | import { DbService } from "../db/db"; |
| 66 | import { forkReportMemberSeats } from "../extensions/billing/member-seats"; | ||
| 56 | import { AutumnService } from "../extensions/billing/service"; | 67 | import { AutumnService } from "../extensions/billing/service"; |
| 57 | import { ApiKeyService } from "./api-keys"; | 68 | import { ApiKeyService } from "./api-keys"; |
| 58 | import { UserStoreService } from "./context"; | 69 | import { UserStoreService } from "./context"; |
| @@ -210,13 +221,21 @@ describe("login callback", () => { · 151 unmodified lines | |||
| 210 | listMetadata: { before: null, after: null }, | 221 | listMetadata: { before: null, after: null }, |
| 211 | }); | 222 | }); |
| 212 | }, | 223 | }, |
| 213 | // The forked seat recount after login. | 224 | // The landing org's seat recount scans the org from WorkOS the first |
| 214 | listOrgMembers: () => | 225 | // time it is counted (its per-org backfill mark is missing); the |
| 215 | Effect.succeed({ | 226 | // scan lists the org's members and fetches each user. |
| 227 | listOrgMembers: (organizationId) => { | ||
| 228 | calls.push(`listOrgMembers:${organizationId}`); | ||
| 229 | return Effect.succeed({ | ||
| 216 | object: "list" as const, | 230 | object: "list" as const, |
| 217 | data: [] as never[], | 231 | data: listed.filter((m) => m.organizationId === organizationId) as never[], |
| 218 | listMetadata: { before: null, after: null }, | 232 | listMetadata: { before: null, after: null }, |
| 219 | }), | 233 | }); |
| 234 | }, | ||
| 235 | getUser: (id) => { | ||
| 236 | calls.push(`getUser:${id}`); | ||
| 237 | return Effect.succeed(workosUser(id) as never); | ||
| 238 | }, | ||
| 220 | refreshSession: (_sealed, organizationId) => { | 239 | refreshSession: (_sealed, organizationId) => { |
| 221 | refreshedInto.push(organizationId); | 240 | refreshedInto.push(organizationId); |
| 222 | return Effect.succeed("sealed-refreshed"); | 241 | return Effect.succeed("sealed-refreshed"); |
| @@ -268,7 +287,17 @@ describe("login callback", () => { · 45 unmodified lines | |||
| 268 | const response = await handler(callbackRequest({})); | 287 | const response = await handler(callbackRequest({})); |
| 269 | 288 | ||
| 270 | expect(response.status).toBe(302); | 289 | expect(response.status).toBe(302); |
| 271 | expect(calls, "one membership list for the whole callback").toEqual([ | 290 | expect( |
| 291 | calls, | ||
| 292 | "one membership list for the callback itself; the landing org, never scanned, is scanned once for its seat count", | ||
| 293 | ).toEqual([ | ||
| 294 | `listUserMemberships:${userId}`, | ||
| 295 | `listOrgMembers:${activeOrg}`, | ||
| 296 | `getUser:${userId}`, | ||
| 297 | ]); | ||
| 298 | calls.length = 0; | ||
| 299 | expect((await handler(callbackRequest({}))).status).toBe(302); | ||
| 300 | expect(calls, "a second sign-in lists memberships only: the org is now marked").toEqual([ | ||
| 272 | `listUserMemberships:${userId}`, | 301 | `listUserMemberships:${userId}`, |
| 273 | ]); | 302 | ]); |
| 274 | 303 | ||
| @@ -542,7 +571,14 @@ describe("account service writes through to the mirror", () => { · 267 unmodified lines | |||
| 542 | * Provided around the WHOLE test body so the postgres socket outlives the | 571 | * Provided around the WHOLE test body so the postgres socket outlives the |
| 543 | * provider call under test. | 572 | * provider call under test. |
| 544 | */ | 573 | */ |
| 545 | const providerLayer = (org: string, deleted: string[]) => { | 574 | const providerLayer = ( |
| 575 | org: string, | ||
| 576 | deleted: string[], | ||
| 577 | options: { | ||
| 578 | readonly workos?: Partial<WorkOSClientService>; | ||
| 579 | readonly autumn?: Layer.Layer<AutumnService>; | ||
| 580 | } = {}, | ||
| 581 | ) => { | ||
| 546 | const list = (data: readonly unknown[]) => | 582 | const list = (data: readonly unknown[]) => |
| 547 | Effect.succeed({ | 583 | Effect.succeed({ |
| 548 | object: "list" as const, | 584 | object: "list" as const, |
| @@ -550,6 +586,7 @@ describe("account service writes through to the mirror", () => { · 1 unmodified line | |||
| 550 | listMetadata: { before: null, after: null }, | 586 | listMetadata: { before: null, after: null }, |
| 551 | }); | 587 | }); |
| 552 | const workos = stubWorkOS({ | 588 | const workos = stubWorkOS({ |
| 589 | ...options.workos, | ||
| 553 | listUserMemberships: (userId) => list([workosMembership(userId, org)]), | 590 | listUserMemberships: (userId) => list([workosMembership(userId, org)]), |
| 554 | getUserOrgMembership: (organizationId, userId) => | 591 | getUserOrgMembership: (organizationId, userId) => |
| 555 | Effect.succeed( | 592 | Effect.succeed( |
| @@ -571,7 +608,6 @@ describe("account service writes through to the mirror", () => { · 15 unmodified lines | |||
| 571 | updatedAt: T2, | 608 | updatedAt: T2, |
| 572 | }) as never, | 609 | }) as never, |
| 573 | ), | 610 | ), |
| 574 | listOrgMembers: () => list([]), | ||
| 575 | }); | 611 | }); |
| 576 | // The test database serves ONE connection at a time, so the seed, the | 612 | // The test database serves ONE connection at a time, so the seed, the |
| 577 | // provider, and the directory read all share this layer's socket. | 613 | // provider, and the directory read all share this layer's socket. |
| @@ -585,7 +621,7 @@ describe("account service writes through to the mirror", () => { · 7 unmodified lines | |||
| 585 | Layer.mergeAll( | 621 | Layer.mergeAll( |
| 586 | workos, | 622 | workos, |
| 587 | stubApiKeys, | 623 | stubApiKeys, |
| 588 | stubAutumn, | 624 | options.autumn ?? stubAutumn, |
| 589 | Layer.succeed(AccountCaller)({ session: session(ADMIN) }), | 625 | Layer.succeed(AccountCaller)({ session: session(ADMIN) }), |
| 590 | ), | 626 | ), |
| 591 | ), | 627 | ), |
| @@ -595,7 +631,13 @@ describe("account service writes through to the mirror", () => { · 3 unmodified lines | |||
| 595 | }; | 631 | }; |
| 596 | 632 | ||
| 597 | // TARGET as an existing member of `org`, seeded through the live mirror. | 633 | // TARGET as an existing member of `org`, seeded through the live mirror. |
| 598 | const seedTarget = (org: string) => | 634 | // The org is marked backfilled (as the one-off backfill leaves every org) |
| 635 | // unless a test wants the unscanned state, so a seat count reads the mirror | ||
| 636 | // rather than scanning WorkOS. | ||
| 637 | const seedTarget = ( | ||
| 638 | org: string, | ||
| 639 | options: { readonly backfilled: boolean } = { backfilled: true }, | ||
| 640 | ) => | ||
| 599 | Effect.gen(function* () { | 641 | Effect.gen(function* () { |
| 600 | const users = yield* UserStoreService; | 642 | const users = yield* UserStoreService; |
| 601 | const mirror = yield* WorkOsMirror; | 643 | const mirror = yield* WorkOsMirror; |
| @@ -614,11 +656,175 @@ describe("account service writes through to the mirror", () => { · 12 unmodified lines | |||
| 614 | status: "active", | 656 | status: "active", |
| 615 | updatedAt: new Date(T1), | 657 | updatedAt: new Date(T1), |
| 616 | }); | 658 | }); |
| 659 | if (options.backfilled) { | ||
| 660 | // An empty listing at T1 (nothing to tombstone: TARGET's row is | ||
| 661 | // stamped T1, not before it) marks the org scanned as of T1. | ||
| 662 | yield* mirror.applyOrganizationScan({ | ||
| 663 | organizationId: org, | ||
| 664 | listedAt: new Date(T1), | ||
| 665 | members: [], | ||
| 666 | }); | ||
| 667 | } | ||
| 617 | }); | 668 | }); |
| 618 | 669 | ||
| 619 | const membersOf = (org: string) => | 670 | const membersOf = (org: string) => |
| 620 | Effect.flatMap(MemberDirectory.asEffect(), (directory) => directory.members(org)); | 671 | Effect.flatMap(MemberDirectory.asEffect(), (directory) => directory.members(org)); |
| 621 | 672 | ||
| 673 | it.effect("inviteMember mirrors the pending membership WorkOS created for the invitee", () => { | ||
| 674 | const org = freshId("org"); | ||
| 675 | // Two people are already invited; the new invitee is a third pending | ||
| 676 | // membership, and only their user carries the invited address — with | ||
| 677 | // different casing than the admin typed, as WorkOS may store it. | ||
| 678 | const earlier = [freshId("user"), freshId("user")]; | ||
| 679 | const invitee = freshId("user"); | ||
| 680 | const invitedEmail = `${invitee}@placeholder.test`; | ||
| 681 | const userCalls: string[] = []; | ||
| 682 | // The plan gate reads the customer's plan before inviting: an unlimited | ||
| 683 | // plan so the seat cap never interferes with what is under test. | ||
| 684 | const teamAutumn = Layer.succeed(AutumnService)({ | ||
| 685 | use: () => | ||
| 686 | Effect.succeed({ | ||
| 687 | subscriptions: [{ planId: "team", status: "active" }], | ||
| 688 | } as never), | ||
| 689 | ensureCustomer: () => Effect.void, | ||
| 690 | checkExecutionBalance: () => Effect.die("invite does not check balances"), | ||
| 691 | trackExecution: () => Effect.void, | ||
| 692 | setMemberSeats: () => Effect.void, | ||
| 693 | }); | ||
| 694 | const layer = providerLayer(org, [], { | ||
| 695 | autumn: teamAutumn, | ||
| 696 | workos: { | ||
| 697 | listPendingInvitations: () => | ||
| 698 | Effect.succeed({ | ||
| 699 | object: "list" as const, | ||
| 700 | data: [] as never[], | ||
| 701 | listMetadata: { before: null, after: null }, | ||
| 702 | }), | ||
| 703 | sendInvitation: ({ email }) => | ||
| 704 | Effect.succeed({ | ||
| 705 | id: `invitation_${invitee}`, | ||
| 706 | email: email.toUpperCase(), | ||
| 707 | } as never), | ||
| 708 | listOrgMembers: (organizationId, statuses) => { | ||
| 709 | expect(organizationId).toBe(org); | ||
| 710 | expect(statuses, "only the pending set is listed").toEqual(["pending"]); | ||
| 711 | return Effect.succeed({ | ||
| 712 | object: "list" as const, | ||
| 713 | data: [...earlier, invitee].map((userId) => | ||
| 714 | workosMembership(userId, org, { status: "pending" }), | ||
| 715 | ) as never[], | ||
| 716 | listMetadata: { before: null, after: null }, | ||
| 717 | }); | ||
| 718 | }, | ||
| 719 | getUser: (userId) => | ||
| 720 | Effect.sync(() => { | ||
| 721 | userCalls.push(userId); | ||
| 722 | return workosUser(userId, { | ||
| 723 | firstName: "Invited", | ||
| 724 | lastName: "Person", | ||
| 725 | }) as never; | ||
| 726 | }), | ||
| 727 | }, | ||
| 728 | }); | ||
| 729 | return Effect.gen(function* () { | ||
| 730 | yield* seedTarget(org); | ||
| 731 | const account = yield* AccountProvider; | ||
| 732 | |||
| 733 | const result = yield* account.inviteMember( | ||
| 734 | { [ORG_SELECTOR_HEADER]: org }, | ||
| 735 | { email: invitedEmail }, | ||
| 736 | ); | ||
| 737 | |||
| 738 | expect(result.id).toBe(`invitation_${invitee}`); | ||
| 739 | const members = yield* membersOf(org); | ||
| 740 | const pending = members.find((m) => m.status === "pending"); | ||
| 741 | expect(pending, "the invitee appears as a pending member").toMatchObject({ | ||
| 742 | accountId: invitee, | ||
| 743 | membershipId: `om_${invitee}_${org}`, | ||
| 744 | email: invitedEmail, | ||
| 745 | name: "Invited Person", | ||
| 746 | role: "member", | ||
| 747 | }); | ||
| 748 | expect( | ||
| 749 | members.filter((m) => m.status === "pending"), | ||
| 750 | "only the invitee's pending membership is mirrored, not the other pending ones", | ||
| 751 | ).toHaveLength(1); | ||
| 752 | expect( | ||
| 753 | userCalls.sort(), | ||
| 754 | "one getUser per pending membership, bounded to the pending set", | ||
| 755 | ).toEqual([...earlier, invitee].sort()); | ||
| 756 | }).pipe(Effect.provide(layer)); | ||
| 757 | }); | ||
| 758 | |||
| 759 | it.effect( | ||
| 760 | "inviteMember scans an organization the backfill never covered before counting its seats, once", | ||
| 761 | () => { | ||
| 762 | const org = freshId("org"); | ||
| 763 | const listed: string[] = []; | ||
| 764 | // A free plan (limit 3). The mirror holds ONE member of the org (TARGET) | ||
| 765 | // and the org is unmarked; WorkOS lists three. Only a count taken after | ||
| 766 | // the scan refuses the invite. | ||
| 767 | const freeAutumn = Layer.succeed(AutumnService)({ | ||
| 768 | use: () => Effect.succeed({ subscriptions: [] } as never), | ||
| 769 | ensureCustomer: () => Effect.void, | ||
| 770 | checkExecutionBalance: () => Effect.die("invite does not check balances"), | ||
| 771 | trackExecution: () => Effect.void, | ||
| 772 | setMemberSeats: () => Effect.void, | ||
| 773 | }); | ||
| 774 | const others = [freshId("user"), freshId("user")]; | ||
| 775 | const layer = providerLayer(org, [], { | ||
| 776 | autumn: freeAutumn, | ||
| 777 | workos: { | ||
| 778 | listPendingInvitations: () => | ||
| 779 | Effect.succeed({ | ||
| 780 | object: "list" as const, | ||
| 781 | data: [] as never[], | ||
| 782 | listMetadata: { before: null, after: null }, | ||
| 783 | }), | ||
| 784 | listOrgMembers: (organizationId, statuses) => { | ||
| 785 | listed.push(organizationId); | ||
| 786 | expect(statuses, "the scan lists every status, inactive included").toEqual([ | ||
| 787 | "active", | ||
| 788 | "pending", | ||
| 789 | "inactive", | ||
| 790 | ]); | ||
| 791 | return Effect.succeed({ | ||
| 792 | object: "list" as const, | ||
| 793 | data: [TARGET, ...others].map((userId) => workosMembership(userId, org)) as never[], | ||
| 794 | listMetadata: { before: null, after: null }, | ||
| 795 | }); | ||
| 796 | }, | ||
| 797 | getUser: (userId) => Effect.succeed(workosUser(userId) as never), | ||
| 798 | sendInvitation: () => | ||
| 799 | Effect.die("the plan gate refuses before WorkOS is asked to invite"), | ||
| 800 | }, | ||
| 801 | }); | ||
| 802 | return Effect.gen(function* () { | ||
| 803 | yield* seedTarget(org, { backfilled: false }); | ||
| 804 | const account = yield* AccountProvider; | ||
| 805 | const invite = () => | ||
| 806 | Effect.flip( | ||
| 807 | account.inviteMember({ [ORG_SELECTOR_HEADER]: org }, { email: "new@placeholder.test" }), | ||
| 808 | ); | ||
| 809 | |||
| 810 | const error = yield* invite(); | ||
| 811 | expect(error).toBeInstanceOf(AccountForbidden); | ||
| 812 | expect(error).toMatchObject({ | ||
| 813 | message: expect.stringContaining("Your plan includes 3 members"), | ||
| 814 | }); | ||
| 815 | expect(listed, "the org was scanned from WorkOS before it was counted").toEqual([org]); | ||
| 816 | expect( | ||
| 817 | (yield* membersOf(org)).map((m) => m.accountId).sort(), | ||
| 818 | "and the scan filled the mirror", | ||
| 819 | ).toEqual([TARGET, ...others].sort()); | ||
| 820 | |||
| 821 | const again = yield* invite(); | ||
| 822 | expect(again).toBeInstanceOf(AccountForbidden); | ||
| 823 | expect(listed, "a marked org is never scanned again").toEqual([org]); | ||
| 824 | }).pipe(Effect.provide(layer)); | ||
| 825 | }, | ||
| 826 | ); | ||
| 827 | |||
| 622 | it.effect("removeMember tombstones the mirror row after the WorkOS delete", () => { | 828 | it.effect("removeMember tombstones the mirror row after the WorkOS delete", () => { |
| 623 | const org = freshId("org"); | 829 | const org = freshId("org"); |
| 624 | const deleted: string[] = []; | 830 | const deleted: string[] = []; |
| @@ -688,6 +894,149 @@ describe("account service writes through to the mirror", () => { · 63 unmodified lines | |||
| 688 | }); | 894 | }); |
| 689 | }); | 895 | }); |
| 690 | 896 | ||
| 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 | describe("backfill", () => { | 1040 | describe("backfill", () => { |
| 692 | /** A fake WorkOS holding `orgs` → members, counting `getUser` calls. */ | 1041 | /** A fake WorkOS holding `orgs` → members, counting `getUser` calls. */ |
| 693 | const source = (orgs: ReadonlyMap<string, readonly FakeMembership[]>, userCalls: string[]) => ({ | 1042 | const source = (orgs: ReadonlyMap<string, readonly FakeMembership[]>, userCalls: string[]) => ({ |
| @@ -5,14 +5,21 @@ · 4 unmodified lines | |||
| 5 | // Each feeder takes the WorkOS payload the caller ALREADY holds (the | 5 | // Each feeder takes the WorkOS payload the caller ALREADY holds (the |
| 6 | // authenticated user, the membership list the callback fetches to pick a | 6 | // authenticated user, the membership list the callback fetches to pick a |
| 7 | // landing org, the membership a write returned) so feeding the mirror never | 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 | 8 | // adds a WorkOS read — except the two writes whose WorkOS response is not the |
| 9 | // membership read path, so a login that could not record its memberships is | 9 | // membership they changed: invitation acceptance (`auth/handlers.ts` reads |
| 10 | // not a login that finished. | 10 | // the activated membership back) and sending an invitation |
| 11 | // (`mirrorInvitedMember` below reads the pending one WorkOS created). Both | ||
| 12 | // are rare, admin-driven paths. Mirror failures fail the request: the mirror | ||
| 13 | // is the membership read path, so a login that could not record its | ||
| 14 | // memberships is not a login that finished. | ||
| 11 | // --------------------------------------------------------------------------- | 15 | // --------------------------------------------------------------------------- |
| 12 | 16 | ||
| 13 | import { Effect } from "effect"; | 17 | import { Effect } from "effect"; |
| 14 | 18 | ||
| 19 | import { normalizeAdminUserEmail } from "@executor-js/api/server"; | ||
| 20 | |||
| 15 | import { UserStoreService } from "./context"; | 21 | import { UserStoreService } from "./context"; |
| 22 | import { WorkOSClient } from "./workos"; | ||
| 16 | import { | 23 | import { |
| 17 | WorkOsMirror, | 24 | WorkOsMirror, |
| 18 | mirrorMembershipFromWorkOs, | 25 | mirrorMembershipFromWorkOs, |
| @@ -20,6 +27,7 @@ import { · 1 unmodified line | |||
| 20 | type WorkOsMembershipPayload, | 27 | type WorkOsMembershipPayload, |
| 21 | type WorkOsUserPayload, | 28 | type WorkOsUserPayload, |
| 22 | } from "./workos-mirror"; | 29 | } from "./workos-mirror"; |
| 30 | import { backfillOrganization } from "./workos-mirror-backfill"; | ||
| 23 | 31 | ||
| 24 | /** | 32 | /** |
| 25 | * A membership as WorkOS lists it for a user: carries the organization's name, | 33 | * A membership as WorkOS lists it for a user: carries the organization's name, |
| @@ -78,3 +86,101 @@ export const mirrorMembership = (membership: WorkOsMembershipPayload) => · 52 unmodified lines | |||
| 78 | Effect.flatMap(WorkOsMirror.asEffect(), (mirror) => | 86 | Effect.flatMap(WorkOsMirror.asEffect(), (mirror) => |
| 79 | mirror.upsertMembership(mirrorMembershipFromWorkOs(membership)), | 87 | mirror.upsertMembership(mirrorMembershipFromWorkOs(membership)), |
| 80 | ); | 88 | ); |
| 89 | |||
| 90 | // Bounded fan-out for the per-invitee `getUser` calls, matching the backfill: | ||
| 91 | // enough to overlap WorkOS round-trips, low enough to stay clear of its rate | ||
| 92 | // limit. | ||
| 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 | import { HttpApiBuilder } from "effect/unstable/httpapi"; | 16 | import { HttpApiBuilder } from "effect/unstable/httpapi"; |
| 17 | import { HttpApi } from "effect/unstable/httpapi"; | 17 | import { HttpApi } from "effect/unstable/httpapi"; |
| 18 | 18 | ||
| 19 | import { MemberDirectory } from "@executor-js/api/server"; | ||
| 20 | |||
| 19 | import { CloudAuthPublicHandlers } from "./handlers"; | 21 | import { CloudAuthPublicHandlers } from "./handlers"; |
| 20 | import { CloudAuthPublicApi } from "./api"; | 22 | import { CloudAuthPublicApi } from "./api"; |
| 21 | import { UserStoreService } from "./context"; | 23 | import { UserStoreService } from "./context"; |
| @@ -50,9 +52,6 @@ const stubWorkOS = Layer.succeed( · 28 unmodified lines | |||
| 50 | if (prop === "listUserMemberships") { | 52 | if (prop === "listUserMemberships") { |
| 51 | return () => Effect.succeed({ data: [] }); | 53 | return () => Effect.succeed({ data: [] }); |
| 52 | } | 54 | } |
| 53 | if (prop === "listOrgMembers") { | ||
| 54 | return () => Effect.succeed({ data: [{ status: "active" }] }); | ||
| 55 | } | ||
| 56 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | 55 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); |
| 57 | }, | 56 | }, |
| 58 | }), | 57 | }), |
| @@ -109,7 +108,9 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 50 unmodified lines | |||
| 109 | }); | 108 | }); |
| 110 | 109 | ||
| 111 | // The callback records the sign-in (user + memberships) in the membership | 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 | const stubMirror = Layer.succeed(WorkOsMirror)({ | 114 | const stubMirror = Layer.succeed(WorkOsMirror)({ |
| 114 | upsertUser: () => Effect.succeed(true), | 115 | upsertUser: () => Effect.succeed(true), |
| 115 | upsertMembership: () => Effect.succeed(true), | 116 | upsertMembership: () => Effect.succeed(true), |
| @@ -124,7 +125,27 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ · 8 unmodified lines | |||
| 124 | markBackfillCompleted: () => Effect.die("the callback does not run the backfill"), | 125 | markBackfillCompleted: () => Effect.die("the callback does not run the backfill"), |
| 125 | drainedAt: () => Effect.die("the callback does not check mirror readiness"), | 126 | drainedAt: () => Effect.die("the callback does not check mirror readiness"), |
| 126 | markDrained: () => Effect.die("the callback does not run the reconciler"), | 127 | markDrained: () => Effect.die("the callback does not run the reconciler"), |
| 127 | organizationBackfilledAt: () => Effect.die("the callback does not report seats"), | 128 | organizationBackfilledAt: () => Effect.succeed(new Date()), |
| 129 | }); | ||
| 130 | |||
| 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 | // Only the public group is under test; the session group (and its SessionAuth | 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 | Layer.provide(stubWorkOS), | 158 | Layer.provide(stubWorkOS), |
| 138 | Layer.provide(stubUsers), | 159 | Layer.provide(stubUsers), |
| 139 | Layer.provide(stubMirror), | 160 | Layer.provide(stubMirror), |
| 161 | Layer.provide(stubDirectory), | ||
| 140 | Layer.provide(AutumnService.Default), | 162 | Layer.provide(AutumnService.Default), |
| 141 | Layer.provide(HttpServer.layerServices), | 163 | Layer.provide(HttpServer.layerServices), |
| 142 | ); | 164 | ); |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | WorkOS, | 9 | WorkOS, |
| 10 | type Event as WorkOSEvent, | 10 | type Event as WorkOSEvent, |
| 11 | type EventName as WorkOSEventName, | 11 | type EventName as WorkOSEventName, |
| 12 | type OrganizationMembershipStatus, | ||
| 12 | } from "@workos-inc/node/worker"; | 13 | } from "@workos-inc/node/worker"; |
| 13 | import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto"; | 14 | import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto"; |
| 14 | import { decodeJwt, jwtVerify } from "jose"; | 15 | import { decodeJwt, jwtVerify } from "jose"; |
| @@ -665,13 +666,21 @@ const make = Effect.gen(function* () { · 650 unmodified lines | |||
| 665 | deleteApiKey: (id: string) => | 666 | deleteApiKey: (id: string) => |
| 666 | use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)), | 667 | use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)), |
| 667 | 668 | ||
| 668 | /** List organization memberships with user details. */ | 669 | /** |
| 669 | listOrgMembers: (organizationId: string) => | 670 | * An organization's memberships, all pages. Defaults to active + pending |
| 671 | * (the seat-occupying set); pass `statuses` to narrow — the invite | ||
| 672 | * write-through lists only `pending` to find the membership WorkOS | ||
| 673 | * created for the invitee. | ||
| 674 | */ | ||
| 675 | listOrgMembers: ( | ||
| 676 | organizationId: string, | ||
| 677 | statuses: readonly OrganizationMembershipStatus[] = ["active", "pending"], | ||
| 678 | ) => | ||
| 670 | use("userManagement.listOrganizationMemberships", async (wos) => | 679 | use("userManagement.listOrganizationMemberships", async (wos) => |
| 671 | collectWorkOSList( | 680 | collectWorkOSList( |
| 672 | await wos.userManagement.listOrganizationMemberships({ | 681 | await wos.userManagement.listOrganizationMemberships({ |
| 673 | organizationId, | 682 | organizationId, |
| 674 | statuses: ["active", "pending"], | 683 | statuses: [...statuses], |
| 675 | }), | 684 | }), |
| 676 | ), | 685 | ), |
| 677 | ), | 686 | ), |
| @@ -691,17 +700,6 @@ const make = Effect.gen(function* () { · 13 unmodified lines | |||
| 691 | getUser: (userId: string) => | 700 | getUser: (userId: string) => |
| 692 | use("userManagement.getUser", (wos) => wos.userManagement.getUser(userId)), | 701 | use("userManagement.getUser", (wos) => wos.userManagement.getUser(userId)), |
| 693 | 702 | ||
| 694 | /** List users matching an email within one organization. */ | ||
| 695 | listUsers: (params: { email: string; organizationId: string }) => | ||
| 696 | use("userManagement.listUsers", async (wos) => | ||
| 697 | collectWorkOSList( | ||
| 698 | await wos.userManagement.listUsers({ | ||
| 699 | email: params.email, | ||
| 700 | organizationId: params.organizationId, | ||
| 701 | }), | ||
| 702 | ), | ||
| 703 | ), | ||
| 704 | |||
| 705 | /** Send an organization invitation. */ | 703 | /** Send an organization invitation. */ |
| 706 | sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => | 704 | sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => |
| 707 | use("userManagement.sendInvitation", (wos) => | 705 | use("userManagement.sendInvitation", (wos) => |
| @@ -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 | import { Effect } from "effect"; | 6 | import { Effect } from "effect"; |
| 6 | import { waitUntil } from "cloudflare:workers"; | 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 | import { AutumnService } from "./service"; | 14 | import { AutumnService } from "./service"; |
| 10 | 15 | ||
| 11 | /** | 16 | /** |
| @@ -16,39 +21,54 @@ import { AutumnService } from "./service"; · 4 unmodified lines | |||
| 16 | * Seats change through paths the app never sees a mutation for (invitation | 21 | * Seats change through paths the app never sees a mutation for (invitation |
| 17 | * acceptance in AuthKit, SSO JIT provisioning, join by domain, WorkOS | 22 | * acceptance in AuthKit, SSO JIT provisioning, join by domain, WorkOS |
| 18 | * dashboard edits), so this reconciles from a full recount rather than | 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 | 24 | * tracking deltas. The count comes from the local membership mirror through |
| 20 | * login callback, so drift from out-of-band changes heals on the next | 25 | * the shared `MemberDirectory`: every in-app membership mutation writes |
| 21 | * sign-in. Fire-and-forget-safe: errors are logged, never surfaced. | 26 | * through to the mirror BEFORE calling this, and out-of-band changes land via |
| 27 | * login and the Events reconciler, so the recount reads the change on the | ||
| 28 | * next sign-in exactly as it did against WorkOS — without a WorkOS read. | ||
| 29 | * | ||
| 30 | * The Autumn call runs off the calling request's critical path: Cloudflare | ||
| 31 | * owns its promise through `waitUntil`, so the recount can finish after the | ||
| 32 | * response, and billing never stalls or fails a user-facing request. Errors | ||
| 33 | * are logged, never surfaced. | ||
| 34 | * | ||
| 35 | * The count is a PARTIAL one until THIS organization's membership list has | ||
| 36 | * been scanned from WorkOS in full (the one-off backfill, or the on-demand | ||
| 37 | * scan below): before that, the mirror holds only the members who signed in | ||
| 38 | * or were changed since the mirror shipped. Because the Autumn write is an | ||
| 39 | * authoritative SET, pushing a partial count would under-bill the | ||
| 40 | * organization, so the recount first makes sure the organization is | ||
| 41 | * backfilled (`ensureOrganizationBackfilled`: a scan runs now when its | ||
| 42 | * per-organization mark is missing) and only then counts. The plan gate | ||
| 43 | * (`reserveMemberSlot`) goes through the same step, so it never admits an | ||
| 44 | * invite past the plan limit on a partial mirror. | ||
| 45 | * | ||
| 46 | * The COUNT is read inline, not in the fork: `MemberDirectory` is per-request | ||
| 47 | * (it holds the request's postgres socket, which Cloudflare Workers' I/O | ||
| 48 | * isolation ties to the request), so a forked fiber reading it could outlive | ||
| 49 | * the socket. One indexed local query is cheap enough to pay inline; only the | ||
| 50 | * Autumn call — over the boot-scoped `AutumnService` — is forked, so the | ||
| 51 | * forked fiber captures nothing request-scoped. | ||
| 22 | */ | 52 | */ |
| 23 | export const reportMemberSeats = ( | 53 | export const forkReportMemberSeats = ( |
| 24 | organizationId: string, | 54 | organizationId: string, |
| 25 | ): Effect.Effect<void, never, WorkOSClient | AutumnService> => | 55 | ): Effect.Effect<void, never, MemberDirectory | WorkOsMirror | WorkOSClient | AutumnService> => |
| 26 | Effect.gen(function* () { | 56 | Effect.gen(function* () { |
| 27 | const workos = yield* WorkOSClient; | 57 | const directory = yield* MemberDirectory; |
| 28 | const autumn = yield* AutumnService; | 58 | const autumn = yield* AutumnService; |
| 29 | const memberships = yield* workos.listOrgMembers(organizationId); | 59 | yield* ensureOrganizationBackfilled(organizationId); |
| 30 | const seats = memberships.data.filter((m) => m.status === "active").length; | 60 | const seats = yield* directory |
| 31 | yield* autumn.setMemberSeats(organizationId, seats); | 61 | .members(organizationId, { statuses: ["active"] }) |
| 62 | .pipe(Effect.map((members) => members.length)); | ||
| 63 | yield* Effect.sync(() => { | ||
| 64 | waitUntil(Effect.runPromise(autumn.setMemberSeats(organizationId, seats))); | ||
| 65 | }); | ||
| 32 | }).pipe( | 66 | }).pipe( |
| 33 | Effect.catch((error) => | 67 | Effect.catch((error) => |
| 34 | Effect.logWarning("reportMemberSeats: seat recount failed", { organizationId, error }), | 68 | Effect.logWarning("reportMemberSeats: seat recount failed", { |
| 69 | organizationId, | ||
| 70 | error, | ||
| 71 | }), | ||
| 35 | ), | 72 | ), |
| 36 | Effect.withSpan("billing.reportMemberSeats"), | 73 | Effect.withSpan("billing.reportMemberSeats"), |
| 37 | ); | 74 | ); |
| 38 | |||
| 39 | /** | ||
| 40 | * Fork `reportMemberSeats` off the calling request, mirroring how execution | ||
| 41 | * tracking is forked: billing must never stall or fail a user-facing | ||
| 42 | * request. Cloudflare owns the promise through waitUntil, so the recount can | ||
| 43 | * finish after the response. Only boot-scoped WorkOS and Autumn services are | ||
| 44 | * captured. | ||
| 45 | */ | ||
| 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 | import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; | 28 | import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; |
| 29 | 29 | ||
| 30 | import { AccountApi, AdminUsersApi } from "@executor-js/api"; | 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 | import { UserStoreService } from "../auth/context"; | 33 | import { UserStoreService } from "../auth/context"; |
| 34 | import { WorkOsMirror } from "../auth/workos-mirror"; | 34 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| @@ -79,7 +79,7 @@ const spec = OpenApi.fromApi(CloudOpenApi); · 44 unmodified lines | |||
| 79 | * core. | 79 | * core. |
| 80 | */ | 80 | */ |
| 81 | export const makeCloudExtensionRoutes = ( | 81 | export const makeCloudExtensionRoutes = ( |
| 82 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror>, | 82 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, |
| 83 | ) => { | 83 | ) => { |
| 84 | // Session routes (login / callback / me / switch-org / …). Handlers yield | 84 | // Session routes (login / callback / me / switch-org / …). Handlers yield |
| 85 | // `UserStoreService` directly; the per-request DB combine keeps the postgres | 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 | const members = result.members.map((member) => ({ | 145 | const members = result.members.map((member) => ({ |
| 146 | id: member.id, | 146 | id: member.id, |
| 147 | userId: member.userId, | 147 | userId: member.userId, |
| 148 | email: member.user?.email ?? "", | 148 | email: member.user?.email ?? null, |
| 149 | name: member.user?.name ?? null, | 149 | name: member.user?.name ?? null, |
| 150 | avatarUrl: member.user?.image ?? null, | 150 | avatarUrl: member.user?.image ?? null, |
| 151 | role: member.role, | 151 | role: member.role, |
| @@ -16,7 +16,11 @@ · 15 unmodified lines | |||
| 16 | // | 16 | // |
| 17 | // The READ half is identical to cloud's: a subject-less, tenant-reach executor | 17 | // The READ half is identical to cloud's: a subject-less, tenant-reach executor |
| 18 | // from `makePlatformExecutor`, projected by the shared `admin/reads`. Self-host | 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 | import { HttpRouter } from "effect/unstable/http"; | 26 | import { HttpRouter } from "effect/unstable/http"; |
| @@ -26,18 +30,17 @@ import { · 3 unmodified lines | |||
| 26 | AdminUsersProvider, | 30 | AdminUsersProvider, |
| 27 | DbProvider, | 31 | DbProvider, |
| 28 | HostConfig, | 32 | HostConfig, |
| 33 | MemberDirectory, | ||
| 29 | PluginsProvider, | 34 | PluginsProvider, |
| 35 | adminUserDirectoryFromMembers, | ||
| 30 | getAdminUser, | 36 | getAdminUser, |
| 31 | listAdminUserConnections, | 37 | listAdminUserConnections, |
| 32 | listAdminUsers, | 38 | listAdminUsers, |
| 33 | listAdminUsersWithConnections, | 39 | listAdminUsersWithConnections, |
| 34 | makeAdminUsersApiLayer, | 40 | makeAdminUsersApiLayer, |
| 35 | makePlatformExecutor, | 41 | makePlatformExecutor, |
| 36 | normalizeAdminUserEmail, | ||
| 37 | platformViewOf, | 42 | platformViewOf, |
| 38 | requestScopedMiddleware, | 43 | requestScopedMiddleware, |
| 39 | type AdminUserDirectory, | ||
| 40 | type AdminUserIdentity, | ||
| 41 | type AdminUsersHeaders, | 44 | type AdminUsersHeaders, |
| 42 | } from "@executor-js/api/server"; | 45 | } from "@executor-js/api/server"; |
| 43 | import { | 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 | const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>( | 73 | const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = AdminUsersError>( |
| 135 | headers: AdminUsersHeaders, | 74 | headers: AdminUsersHeaders, |
| 136 | organizationId: string, | 75 | organizationId: string, |
| @@ -153,24 +92,25 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 16 unmodified lines | |||
| 153 | export const betterAuthAdminUsersProvider: Layer.Layer< | 92 | export const betterAuthAdminUsersProvider: Layer.Layer< |
| 154 | AdminUsersProvider, | 93 | AdminUsersProvider, |
| 155 | never, | 94 | never, |
| 156 | BetterAuth | DbProvider | PluginsProvider | HostConfig | 95 | BetterAuth | MemberDirectory | DbProvider | PluginsProvider | HostConfig |
| 157 | > = Layer.effect(AdminUsersProvider)( | 96 | > = Layer.effect(AdminUsersProvider)( |
| 158 | Effect.gen(function* () { | 97 | Effect.gen(function* () { |
| 159 | const context = yield* Effect.context<BetterAuth | DbProvider | PluginsProvider | HostConfig>(); | 98 | const context = yield* Effect.context<BetterAuth | DbProvider | PluginsProvider | HostConfig>(); |
| 160 | const { auth, organizationId } = yield* BetterAuth; | 99 | const { organizationId } = yield* BetterAuth; |
| 100 | // Scoped to the INSTANCE's org — the same one the platform view is opened | ||
| 101 | // for, never the caller's `activeOrganizationId` (see require-admin.ts). | ||
| 102 | const directory = adminUserDirectoryFromMembers(yield* MemberDirectory, organizationId); | ||
| 161 | return AdminUsersProvider.of({ | 103 | return AdminUsersProvider.of({ |
| 162 | listUsers: (headers, options) => | 104 | listUsers: (headers, options) => |
| 163 | withPlatformView(headers, organizationId, (executor) => | 105 | withPlatformView(headers, organizationId, (executor) => |
| 164 | platformViewOf(executor).pipe( | 106 | platformViewOf(executor).pipe( |
| 165 | Effect.flatMap((admin) => listAdminUsers(admin, options, userDirectory(auth, headers))), | 107 | Effect.flatMap((admin) => listAdminUsers(admin, options, directory)), |
| 166 | ), | 108 | ), |
| 167 | ).pipe(Effect.provideContext(context)), | 109 | ).pipe(Effect.provideContext(context)), |
| 168 | listUsersWithConnections: (headers, options) => | 110 | listUsersWithConnections: (headers, options) => |
| 169 | withPlatformView(headers, organizationId, (executor) => | 111 | withPlatformView(headers, organizationId, (executor) => |
| 170 | platformViewOf(executor).pipe( | 112 | platformViewOf(executor).pipe( |
| 171 | Effect.flatMap((admin) => | 113 | Effect.flatMap((admin) => listAdminUsersWithConnections(admin, options, directory)), |
| 172 | listAdminUsersWithConnections(admin, options, userDirectory(auth, headers)), | ||
| 173 | ), | ||
| 174 | ), | 114 | ), |
| 175 | ).pipe(Effect.provideContext(context)), | 115 | ).pipe(Effect.provideContext(context)), |
| 176 | listUserConnections: (headers, externalId) => | 116 | listUserConnections: (headers, externalId) => |
| @@ -182,9 +122,7 @@ export const betterAuthAdminUsersProvider: Layer.Layer< · 5 unmodified lines | |||
| 182 | getUser: (headers, identifier) => | 122 | getUser: (headers, identifier) => |
| 183 | withPlatformView(headers, organizationId, (executor) => | 123 | withPlatformView(headers, organizationId, (executor) => |
| 184 | platformViewOf(executor).pipe( | 124 | platformViewOf(executor).pipe( |
| 185 | Effect.flatMap((admin) => | 125 | Effect.flatMap((admin) => getAdminUser(admin, identifier, directory)), |
| 186 | getAdminUser(admin, identifier, userDirectory(auth, headers)), | ||
| 187 | ), | ||
| 188 | ), | 126 | ), |
| 189 | ).pipe(Effect.provideContext(context)), | 127 | ).pipe(Effect.provideContext(context)), |
| 190 | }); | 128 | }); |
| @@ -193,6 +131,9 @@ export const betterAuthAdminUsersProvider: Layer.Layer< · 2 unmodified lines | |||
| 193 | 131 | ||
| 194 | export interface SelfHostAdminUsersApiDeps { | 132 | export interface SelfHostAdminUsersApiDeps { |
| 195 | readonly betterAuth: BetterAuthHandle; | 133 | readonly betterAuth: BetterAuthHandle; |
| 134 | /** The boot-built `MemberDirectory` (see `resolveAuthProviders`), so this | ||
| 135 | * plane reads the same directory instance every other plane does. */ | ||
| 136 | readonly memberDirectory: Layer.Layer<MemberDirectory>; | ||
| 196 | readonly db: SelfHostDbHandle; | 137 | readonly db: SelfHostDbHandle; |
| 197 | readonly mountPrefix: `/${string}`; | 138 | readonly mountPrefix: `/${string}`; |
| 198 | } | 139 | } |
| @@ -206,6 +147,7 @@ export interface SelfHostAdminUsersApiDeps { · 7 unmodified lines | |||
| 206 | */ | 147 | */ |
| 207 | export const makeSelfHostAdminUsersApiLayer = ({ | 148 | export const makeSelfHostAdminUsersApiLayer = ({ |
| 208 | betterAuth, | 149 | betterAuth, |
| 150 | memberDirectory, | ||
| 209 | db, | 151 | db, |
| 210 | mountPrefix, | 152 | mountPrefix, |
| 211 | }: SelfHostAdminUsersApiDeps) => { | 153 | }: SelfHostAdminUsersApiDeps) => { |
| @@ -214,6 +156,7 @@ export const makeSelfHostAdminUsersApiLayer = ({ · 2 unmodified lines | |||
| 214 | ); | 156 | ); |
| 215 | const provider = betterAuthAdminUsersProvider.pipe( | 157 | const provider = betterAuthAdminUsersProvider.pipe( |
| 216 | Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), | 158 | Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), |
| 159 | Layer.provide(memberDirectory), | ||
| 217 | Layer.provide(SelfHostDbProvider), | 160 | Layer.provide(SelfHostDbProvider), |
| 218 | Layer.provide(SelfHostPluginsProvider), | 161 | Layer.provide(SelfHostPluginsProvider), |
| 219 | Layer.provide(SelfHostHostConfig), | 162 | Layer.provide(SelfHostHostConfig), |
| @@ -73,7 +73,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 72 unmodified lines | |||
| 73 | // ---- auth providers --------------------------------------------------- | 73 | // ---- auth providers --------------------------------------------------- |
| 74 | // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account | 74 | // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account |
| 75 | // API + MCP OAuth seam, all over the shared libSQL handle. | 75 | // API + MCP OAuth seam, all over the shared libSQL handle. |
| 76 | const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle); | 76 | const { identityLayer, memberDirectoryLayer, authHandler, betterAuth } = |
| 77 | await resolveAuthProviders(dbHandle); | ||
| 77 | 78 | ||
| 78 | // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- | 79 | // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- |
| 79 | const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config); | 80 | const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config); |
| @@ -130,7 +131,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 50 unmodified lines | |||
| 130 | // Tenant-wide admin users API (/api/admin/users*): the owner's view of | 131 | // Tenant-wide admin users API (/api/admin/users*): the owner's view of |
| 131 | // who uses this instance and what they've connected. Owner/admin-gated, | 132 | // who uses this instance and what they've connected. Owner/admin-gated, |
| 132 | // same as the invite routes above. | 133 | // same as the invite routes above. |
| 133 | makeSelfHostAdminUsersApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), | 134 | makeSelfHostAdminUsersApiLayer({ |
| 135 | betterAuth, | ||
| 136 | memberDirectory: memberDirectoryLayer, | ||
| 137 | db: dbHandle, | ||
| 138 | mountPrefix: "/api", | ||
| 139 | }), | ||
| 134 | // Public system API: /api/health + /api/setup-status (unauthenticated). | 140 | // Public system API: /api/health + /api/setup-status (unauthenticated). |
| 135 | makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), | 141 | makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), |
| 136 | // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). | 142 | // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). |
| @@ -141,11 +147,14 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 4 unmodified lines | |||
| 141 | // The boot-scoped context provideMerge'd under everything: the long-lived DB | 147 | // The boot-scoped context provideMerge'd under everything: the long-lived DB |
| 142 | // handle (read by the DbProvider seam, Better Auth, and the MCP store) + the | 148 | // handle (read by the DbProvider seam, Better Auth, and the MCP store) + the |
| 143 | // resolved identity (captured once by the execution middleware + MCP auth) | 149 | // resolved identity (captured once by the execution middleware + MCP auth) |
| 150 | // + the member directory (the shared membership read seam, boot-scoped | ||
| 151 | // beside identity because Better Auth's handle is an app singleton) | ||
| 144 | // + the artifact-usage observer (this HTTP plane is the console UI's data | 152 | // + the artifact-usage observer (this HTTP plane is the console UI's data |
| 145 | // layer, so operations it serves file as `via: "ui"`). | 153 | // layer, so operations it serves file as `via: "ui"`). |
| 146 | boot: Layer.mergeAll( | 154 | boot: Layer.mergeAll( |
| 147 | Layer.succeed(SelfHostDb)(dbHandle), | 155 | Layer.succeed(SelfHostDb)(dbHandle), |
| 148 | identityLayer, | 156 | identityLayer, |
| 157 | memberDirectoryLayer, | ||
| 149 | Layer.succeed(ArtifactUsageObserver)((action) => | 158 | Layer.succeed(ArtifactUsageObserver)((action) => |
| 150 | selfHostAnalytics.record(`artifact_${action}`, { via: "ui" }), | 159 | selfHostAnalytics.record(`artifact_${action}`, { via: "ui" }), |
| 151 | ), | 160 | ), |
| @@ -1,24 +1,28 @@ | |||
| 1 | import { Layer } from "effect"; | 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 | import { loadConfig } from "../config"; | 5 | import { loadConfig } from "../config"; |
| 6 | import type { SelfHostDbHandle } from "../db/self-host-db"; | 6 | import type { SelfHostDbHandle } from "../db/self-host-db"; |
| 7 | import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; | 7 | import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 8 | import { betterAuthIdentityLayer } from "./identity"; | 8 | import { betterAuthIdentityLayer } from "./identity"; |
| 9 | import { betterAuthMemberDirectoryLayer } from "./member-directory"; | ||
| 9 | import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; | 10 | import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; |
| 10 | import { rewriteInvalidOrigin } from "./invalid-origin-help"; | 11 | import { rewriteInvalidOrigin } from "./invalid-origin-help"; |
| 11 | 12 | ||
| 12 | export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; | 13 | export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; |
| 13 | export { betterAuthIdentityLayer } from "./identity"; | 14 | export { betterAuthIdentityLayer } from "./identity"; |
| 15 | export { betterAuthMemberDirectoryLayer } from "./member-directory"; | ||
| 14 | 16 | ||
| 15 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| 16 | // Resolve the self-host auth providers. | 18 | // Resolve the self-host auth providers. |
| 17 | // | 19 | // |
| 18 | // Build the Better Auth instance over the shared libSQL file, expose its | 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 | 21 | // `IdentityProvider` (cookie/bearer/api-key), its `MemberDirectory` (the |
| 20 | // /api/auth/*). Returns the live `BetterAuthHandle` so the composition root can | 22 | // shared membership read seam over the org plugin's tables) and its web |
| 21 | // build the account API and the Better Auth MCP OAuth seam. | 23 | // handler (mounted at /api/auth/*). Returns the live `BetterAuthHandle` so the |
| 24 | // composition root can build the account API and the Better Auth MCP OAuth | ||
| 25 | // seam. | ||
| 22 | // | 26 | // |
| 23 | // This is the one and only production auth path. Tests that need a fake identity | 27 | // This is the one and only production auth path. Tests that need a fake identity |
| 24 | // (single-admin / header-driven) compose `ExecutorApp.make` directly through | 28 | // (single-admin / header-driven) compose `ExecutorApp.make` directly through |
| @@ -29,6 +33,8 @@ export { betterAuthIdentityLayer } from "./identity"; · 4 unmodified lines | |||
| 29 | export interface ResolvedAuthProviders { | 33 | export interface ResolvedAuthProviders { |
| 30 | /** The resolved Better Auth `IdentityProvider` seam (cookie/bearer/api-key). */ | 34 | /** The resolved Better Auth `IdentityProvider` seam (cookie/bearer/api-key). */ |
| 31 | readonly identityLayer: Layer.Layer<IdentityProvider>; | 35 | readonly identityLayer: Layer.Layer<IdentityProvider>; |
| 36 | /** The resolved Better Auth `MemberDirectory` seam (org members + users). */ | ||
| 37 | readonly memberDirectoryLayer: Layer.Layer<MemberDirectory>; | ||
| 32 | /** Better Auth's web handler (`/api/auth/*`). */ | 38 | /** Better Auth's web handler (`/api/auth/*`). */ |
| 33 | readonly authHandler: (request: Request) => Promise<Response>; | 39 | readonly authHandler: (request: Request) => Promise<Response>; |
| 34 | /** The live Better Auth handle (account API + Better Auth MCP OAuth seam). */ | 40 | /** The live Better Auth handle (account API + Better Auth MCP OAuth seam). */ |
| @@ -78,6 +84,7 @@ export const resolveAuthProviders = async ( · 43 unmodified lines | |||
| 78 | 84 | ||
| 79 | return { | 85 | return { |
| 80 | identityLayer: betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)), | 86 | identityLayer: betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)), |
| 87 | memberDirectoryLayer: betterAuthMemberDirectoryLayer.pipe(Layer.provide(betterAuthLayer)), | ||
| 81 | authHandler, | 88 | authHandler, |
| 82 | betterAuth, | 89 | betterAuth, |
| 83 | }; | 90 | }; |
| @@ -109,10 +109,17 @@ export const OrgApiKeysResponse = Schema.Struct({ · 108 unmodified lines | |||
| 109 | apiKeys: Schema.Array(ApiKeySummary), | 109 | apiKeys: Schema.Array(ApiKeySummary), |
| 110 | }); | 110 | }); |
| 111 | 111 | ||
| 112 | /** | ||
| 113 | * One member of the caller's organization, as the host's member directory | ||
| 114 | * reports them. `email` is nullable: a host can hold a membership whose | ||
| 115 | * profile it has not yet learned (cloud mirrors the membership before the | ||
| 116 | * user record lands), and reporting `""` for that would let the UI render an | ||
| 117 | * empty address as if it were one. | ||
| 118 | */ | ||
| 112 | export const OrgMember = Schema.Struct({ | 119 | export const OrgMember = Schema.Struct({ |
| 113 | id: Schema.String, | 120 | id: Schema.String, |
| 114 | userId: Schema.String, | 121 | userId: Schema.String, |
| 115 | email: Schema.String, | 122 | email: Schema.NullOr(Schema.String), |
| 116 | name: Schema.NullOr(Schema.String), | 123 | name: Schema.NullOr(Schema.String), |
| 117 | avatarUrl: Schema.NullOr(Schema.String), | 124 | avatarUrl: Schema.NullOr(Schema.String), |
| 118 | role: Schema.String, | 125 | role: Schema.String, |
| @@ -395,6 +395,7 @@ const A1_EMAIL = "a1@users.test"; · 394 unmodified lines | |||
| 395 | const stubUserDirectory = (options: { | 395 | const stubUserDirectory = (options: { |
| 396 | readonly seen?: string[][]; | 396 | readonly seen?: string[][]; |
| 397 | readonly resolved?: string[]; | 397 | readonly resolved?: string[]; |
| 398 | readonly searched?: string[]; | ||
| 398 | }): AdminUserDirectory => ({ | 399 | }): AdminUserDirectory => ({ |
| 399 | identities: (externalIds) => { | 400 | identities: (externalIds) => { |
| 400 | options.seen?.push([...externalIds]); | 401 | options.seen?.push([...externalIds]); |
| @@ -405,6 +406,13 @@ const stubUserDirectory = (options: { · 4 unmodified lines | |||
| 405 | // Compares a NORMALIZED stored value, the rule both real hosts follow. | 406 | // Compares a NORMALIZED stored value, the rule both real hosts follow. |
| 406 | return Effect.succeed(A1_EMAIL_STORED.toLowerCase() === email ? USER_A1 : null); | 407 | return Effect.succeed(A1_EMAIL_STORED.toLowerCase() === email ? USER_A1 : null); |
| 407 | }, | 408 | }, |
| 409 | search: (term) => { | ||
| 410 | options.searched?.push(term); | ||
| 411 | // The one member the directory knows, matched on the normalized email or | ||
| 412 | // the display name — the substring rule both real hosts apply. | ||
| 413 | const haystack = [A1_EMAIL_STORED.toLowerCase(), "user a1"]; | ||
| 414 | return Effect.succeed(haystack.some((value) => value.includes(term)) ? [USER_A1] : []); | ||
| 415 | }, | ||
| 408 | }); | 416 | }); |
| 409 | 417 | ||
| 410 | /** The failure a host's directory raises — WorkOS or Better Auth being | 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 | // A resolver OUTAGE must not read as "no such user": that is a wrong answer an | 1129 | // A resolver OUTAGE must not read as "no such user": that is a wrong answer an |
| 1026 | // operator would act on. Contrast with the identity join, which degrades to | 1130 | // operator would act on. Contrast with the identity join, which degrades to |
| 1027 | // unnamed rows precisely because it is decoration. | 1131 | // unnamed rows precisely because it is decoration. |
| @@ -1096,8 +1200,8 @@ const A_SUBJECT: AdminSubject = { · 68 unmodified lines | |||
| 1096 | /** An `ExecutorAdmin` that answers everything and records the reads it was | 1200 | /** An `ExecutorAdmin` that answers everything and records the reads it was |
| 1097 | * asked for, so a test can assert the call the filter chose. */ | 1201 | * asked for, so a test can assert the call the filter chose. */ |
| 1098 | const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ | 1202 | const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ |
| 1099 | listSubjects: () => { | 1203 | listSubjects: (options) => { |
| 1100 | calls.push("listSubjects"); | 1204 | calls.push(`listSubjects:${options?.externalIds?.join(",") ?? "*"}`); |
| 1101 | return Effect.succeed([A_SUBJECT]); | 1205 | return Effect.succeed([A_SUBJECT]); |
| 1102 | }, | 1206 | }, |
| 1103 | getSubject: () => { | 1207 | getSubject: () => { |
| @@ -1108,8 +1212,8 @@ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ · 4 unmodified lines | |||
| 1108 | calls.push("listSubjectConnections"); | 1212 | calls.push("listSubjectConnections"); |
| 1109 | return Effect.succeed([]); | 1213 | return Effect.succeed([]); |
| 1110 | }, | 1214 | }, |
| 1111 | listSubjectsWithConnections: () => { | 1215 | listSubjectsWithConnections: (options) => { |
| 1112 | calls.push("listSubjectsWithConnections"); | 1216 | calls.push(`listSubjectsWithConnections:${options?.externalIds?.join(",") ?? "*"}`); |
| 1113 | return Effect.succeed([{ ...A_SUBJECT, connections: [] }]); | 1217 | return Effect.succeed([{ ...A_SUBJECT, connections: [] }]); |
| 1114 | }, | 1218 | }, |
| 1115 | getSubjectWithConnections: () => { | 1219 | getSubjectWithConnections: () => { |
| @@ -1187,7 +1291,56 @@ describe("admin users reads — the ?email= filter is applied before the read", · 71 unmodified lines | |||
| 1187 | const calls: string[] = []; | 1291 | const calls: string[] = []; |
| 1188 | yield* listUsersWithConnections(recordingAdmin(calls), { limit: 50 }, stubUserDirectory({})); | 1292 | yield* listUsersWithConnections(recordingAdmin(calls), { limit: 50 }, stubUserDirectory({})); |
| 1189 | 1293 | ||
| 1190 | expect(calls).toEqual(["listSubjectsWithConnections"]); | 1294 | expect(calls).toEqual(["listSubjectsWithConnections:*"]); |
| 1295 | }), | ||
| 1296 | ); | ||
| 1297 | }); | ||
| 1298 | |||
| 1299 | // --------------------------------------------------------------------------- | ||
| 1300 | // `?search=` is FILTER-THEN-PAGE through storage: the directory names the | ||
| 1301 | // matching principals, and the paged read carries exactly that set as its | ||
| 1302 | // `externalIds` filter — never a page scan that is filtered afterwards. | ||
| 1303 | // --------------------------------------------------------------------------- | ||
| 1304 | |||
| 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 | // handler seam (`normalizeEmail`), which is also where the single-user path | 261 | // handler seam (`normalizeEmail`), which is also where the single-user path |
| 262 | // parameter is normalized, so both entry points share ONE rule rather than a | 262 | // parameter is normalized, so both entry points share ONE rule rather than a |
| 263 | // schema transform on one and hand-rolled code on the other. | 263 | // schema transform on one and hand-rolled code on the other. |
| 264 | // | ||
| 265 | // `search` is the SUBSTRING counterpart: a case-insensitive match over each | ||
| 266 | // member's email and name in the host's directory, for the operator who knows | ||
| 267 | // a person's name or part of an address rather than the exact one. Like | ||
| 268 | // `email` it narrows the fixed list shape and is applied BEFORE paging (the | ||
| 269 | // directory names the matching principals; storage pages that set), so a | ||
| 270 | // window on a searched list is a window on the matches. A blank term is no | ||
| 271 | // filter. When both filters are present `email` wins: it names one principal, | ||
| 272 | // and there is nothing left for a search to narrow. | ||
| 264 | const AdminListQuery = Schema.Struct({ | 273 | const AdminListQuery = Schema.Struct({ |
| 265 | limit: Schema.optional( | 274 | limit: Schema.optional( |
| 266 | Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })), | 275 | Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })), |
| @@ -272,6 +281,7 @@ const AdminListQuery = Schema.Struct({ · 5 unmodified lines | |||
| 272 | ), | 281 | ), |
| 273 | ), | 282 | ), |
| 274 | email: Schema.optional(Schema.String), | 283 | email: Schema.optional(Schema.String), |
| 284 | search: Schema.optional(Schema.String), | ||
| 275 | }); | 285 | }); |
| 276 | 286 | ||
| 277 | // --------------------------------------------------------------------------- | 287 | // --------------------------------------------------------------------------- |
| @@ -2,6 +2,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 1 unmodified line | |||
| 2 | import { HttpServerRequest } from "effect/unstable/http"; | 2 | import { HttpServerRequest } from "effect/unstable/http"; |
| 3 | import { Effect } from "effect"; | 3 | import { Effect } from "effect"; |
| 4 | 4 | ||
| 5 | import { normalizeMemberSearch } from "../server/member-directory"; | ||
| 5 | import { AdminUsersHttpApi } from "./api"; | 6 | import { AdminUsersHttpApi } from "./api"; |
| 6 | import { normalizeEmail } from "./reads"; | 7 | import { normalizeEmail } from "./reads"; |
| 7 | import { AdminUsersProvider, type AdminUsersHeaders, type AdminUsersListOptions } from "./service"; | 8 | import { AdminUsersProvider, type AdminUsersHeaders, type AdminUsersListOptions } from "./service"; |
| @@ -24,15 +25,23 @@ const requestHeaders = Effect.map( · 16 unmodified lines | |||
| 24 | // than an explicit `undefined` overriding them. | 25 | // than an explicit `undefined` overriding them. |
| 25 | // `email` is normalized here rather than in the contract schema, so the filter | 26 | // `email` is normalized here rather than in the contract schema, so the filter |
| 26 | // and the single-user path parameter share ONE rule (`normalizeEmail`). | 27 | // and the single-user path parameter share ONE rule (`normalizeEmail`). |
| 28 | // `search` gets the directory's own rule (`normalizeMemberSearch`: the same | ||
| 29 | // trim + lower-case, and a blank term is no filter at all — dropped here so a | ||
| 30 | // provider never sees `search: ""`). | ||
| 27 | const listOptions = (query: { | 31 | const listOptions = (query: { |
| 28 | readonly limit?: number | undefined; | 32 | readonly limit?: number | undefined; |
| 29 | readonly offset?: number | undefined; | 33 | readonly offset?: number | undefined; |
| 30 | readonly email?: string | undefined; | 34 | readonly email?: string | undefined; |
| 31 | }): AdminUsersListOptions => ({ | 35 | readonly search?: string | undefined; |
| 32 | ...(query.limit === undefined ? {} : { limit: query.limit }), | 36 | }): AdminUsersListOptions => { |
| 33 | ...(query.offset === undefined ? {} : { offset: query.offset }), | 37 | const search = normalizeMemberSearch(query.search); |
| 34 | ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), | 38 | return { |
| 35 | }); | 39 | ...(query.limit === undefined ? {} : { limit: query.limit }), |
| 40 | ...(query.offset === undefined ? {} : { offset: query.offset }), | ||
| 41 | ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), | ||
| 42 | ...(search === undefined ? {} : { search }), | ||
| 43 | }; | ||
| 44 | }; | ||
| 36 | 45 | ||
| 37 | export const AdminUsersHandlers = HttpApiBuilder.group( | 46 | export const AdminUsersHandlers = HttpApiBuilder.group( |
| 38 | AdminUsersHttpApi, | 47 | AdminUsersHttpApi, |
| @@ -10,21 +10,26 @@ import { MemberStatus, type MemberDirectoryShape } from "../server/member-direct · 9 unmodified lines | |||
| 10 | import type { AdminUserDirectory, AdminUserIdentity } from "./reads"; | 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 | * {@link MemberDirectoryShape}. | 14 | * {@link MemberDirectoryShape}. |
| 15 | * | 15 | * |
| 16 | * `identities` is one batched `membersById` read for the page of ids (never a | 16 | * `identities` is one batched `membersById` read for the page of ids (never a |
| 17 | * lookup per user); a member the org does not hold reports absent identity. | 17 | * lookup per user); a member the org does not hold reports absent identity. |
| 18 | * `resolveEmail` receives the already-normalized email the contract promises | 18 | * `resolveEmail` receives the already-normalized email the contract promises |
| 19 | * and answers with the host principal id, or `null` when no member has it. | 19 | * and answers with the host principal id, or `null` when no member has it. |
| 20 | * `search` is one `members` read for the term, answering with the matching | ||
| 21 | * principal ids in directory order. | ||
| 20 | * | 22 | * |
| 21 | * Both read ANY membership status, not the directory's active + pending | 23 | * Every direction reads ANY membership status — the same reach `membersById` |
| 22 | * default: this plane reports footprint, not current access. A member who was | 24 | * and `findByEmail` have by contract, and `search` asks for explicitly rather |
| 23 | * removed while their connections remain must still be named on the users | 25 | * than taking `members`' active + pending default. This plane reports |
| 24 | * page and findable by the address an operator has for them. | 26 | * footprint, not current access: a member who was deactivated while their |
| 27 | * connections remain must still be findable by the address or name an | ||
| 28 | * operator has for them, exactly as `?email=` already finds them. | ||
| 25 | * | 29 | * |
| 26 | * Both fail with `MemberDirectoryError`, which the shared reads treat as a | 30 | * All fail with `MemberDirectoryError`, which the shared reads treat as a |
| 27 | * decorative-join outage (identities) or surface as a failed read (resolve). | 31 | * decorative-join outage (identities) or surface as a failed read (resolve, |
| 32 | * search). | ||
| 28 | */ | 33 | */ |
| 29 | export const adminUserDirectoryFromMembers = ( | 34 | export const adminUserDirectoryFromMembers = ( |
| 30 | directory: MemberDirectoryShape, | 35 | directory: MemberDirectoryShape, |
| @@ -47,4 +52,8 @@ export const adminUserDirectoryFromMembers = ( · 16 unmodified lines | |||
| 47 | directory | 52 | directory |
| 48 | .findByEmail(organizationId, email, MemberStatus.literals) | 53 | .findByEmail(organizationId, email, MemberStatus.literals) |
| 49 | .pipe(Effect.map((member) => (member === null ? null : member.accountId))), | 54 | .pipe(Effect.map((member) => (member === null ? null : member.accountId))), |
| 55 | search: (term) => | ||
| 56 | directory | ||
| 57 | .members(organizationId, { search: term, statuses: MemberStatus.literals }) | ||
| 58 | .pipe(Effect.map((members) => members.map((member) => member.accountId))), | ||
| 50 | }); | 59 | }); |
| @@ -16,6 +16,7 @@ import { Effect } from "effect"; · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | import type { | 17 | import type { |
| 18 | AdminConnection, | 18 | AdminConnection, |
| 19 | AdminListSubjectsOptions, | ||
| 19 | AdminSubject, | 20 | AdminSubject, |
| 20 | AdminSubjectWithConnections, | 21 | AdminSubjectWithConnections, |
| 21 | Executor, | 22 | Executor, |
| @@ -114,12 +115,27 @@ export type AdminIdentityDirectory = ( · 92 unmodified lines | |||
| 114 | */ | 115 | */ |
| 115 | export type AdminEmailResolver = (email: string) => Effect.Effect<string | null, unknown>; | 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 | /** |
| 118 | * with no directory reports unnamed rows and cannot resolve emails), and | 119 | * The directory's SEARCH: a normalized term (trimmed + lower-cased, the same |
| 119 | * optional per direction. */ | 120 | * rule `normalizeEmail` applies) → the host-auth principal ids of every member |
| 121 | * whose email or name contains it, in the directory's own order. | ||
| 122 | * | ||
| 123 | * Unlike `resolveEmail` this names a SET, and the reads page that set through | ||
| 124 | * storage rather than in memory: the ids go into the SDK's `externalIds` | ||
| 125 | * filter and the caller's `limit`/`offset` apply there. An empty result means | ||
| 126 | * no member matches, and costs no storage read. Failures are the caller's to | ||
| 127 | * interpret on the same terms as `resolveEmail` — a search that cannot run | ||
| 128 | * must not quietly become "nobody matches". | ||
| 129 | */ | ||
| 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 | export interface AdminUserDirectory { | 135 | export interface AdminUserDirectory { |
| 121 | readonly identities?: AdminIdentityDirectory; | 136 | readonly identities?: AdminIdentityDirectory; |
| 122 | readonly resolveEmail?: AdminEmailResolver; | 137 | readonly resolveEmail?: AdminEmailResolver; |
| 138 | readonly search?: AdminMemberSearch; | ||
| 123 | } | 139 | } |
| 124 | 140 | ||
| 125 | /** Identity is decoration on an operator view, not part of the answer: a | 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 | return row === null ? [] : pageOf([row], options); | 308 | return row === null ? [] : pageOf([row], options); |
| 293 | }); | 309 | }); |
| 294 | 310 | ||
| 311 | /** | ||
| 312 | * The `?search=` read: FILTER by the directory, then PAGE through storage. | ||
| 313 | * | ||
| 314 | * The term names a SET of principals rather than one, so unlike `?email=` it | ||
| 315 | * cannot become a keyed read — but it still must not become a page-then-filter | ||
| 316 | * scan, which on a large tenant would page past every unmatched subject before | ||
| 317 | * finding the first match. So the directory answers with the matching ids and | ||
| 318 | * storage pages exactly that set (`externalIds` + the caller's window), which | ||
| 319 | * keeps "filter, then page" as the one paging rule every filtered list here | ||
| 320 | * follows. | ||
| 321 | * | ||
| 322 | * A host with no search direction answers nothing, for the same reason an | ||
| 323 | * unanswerable `?email=` does: a filter no host can apply must return an empty | ||
| 324 | * page, never an unfiltered one. A search FAILURE is a 500 on the same terms as | ||
| 325 | * a resolver failure. | ||
| 326 | */ | ||
| 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 | export const listUsers = ( | 377 | export const listUsers = ( |
| 296 | admin: ExecutorAdmin, | 378 | admin: ExecutorAdmin, |
| 297 | options: AdminUsersListOptions, | 379 | options: AdminUsersListOptions, |
| @@ -299,12 +381,10 @@ export const listUsers = ( · 1 unmodified line | |||
| 299 | ): Effect.Effect<typeof AdminUsersResponse.Type, AdminUsersError> => | 381 | ): Effect.Effect<typeof AdminUsersResponse.Type, AdminUsersError> => |
| 300 | Effect.gen(function* () { | 382 | Effect.gen(function* () { |
| 301 | const dir = asDirectory(directory); | 383 | const dir = asDirectory(directory); |
| 302 | const subjects = | 384 | const subjects = yield* selectSubjects(dir, options, { |
| 303 | options.email === undefined | 385 | page: (paging) => admin.listSubjects(paging).pipe(Effect.mapError(readFailed("users"))), |
| 304 | ? yield* admin.listSubjects(options).pipe(Effect.mapError(readFailed("users"))) | 386 | one: (externalId) => admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))), |
| 305 | : yield* selectByEmail(dir, options.email, options, (externalId) => | 387 | }); |
| 306 | admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))), | ||
| 307 | ); | ||
| 308 | // One directory read for the page that was actually returned, joined in | 388 | // One directory read for the page that was actually returned, joined in |
| 309 | // memory — never a lookup per user. | 389 | // memory — never a lookup per user. |
| 310 | const identities = yield* resolveIdentities( | 390 | const identities = yield* resolveIdentities( |
| @@ -321,14 +401,12 @@ export const listUsersWithConnections = ( · 10 unmodified lines | |||
| 321 | ): Effect.Effect<typeof AdminUsersWithConnectionsResponse.Type, AdminUsersError> => | 401 | ): Effect.Effect<typeof AdminUsersWithConnectionsResponse.Type, AdminUsersError> => |
| 322 | Effect.gen(function* () { | 402 | Effect.gen(function* () { |
| 323 | const dir = asDirectory(directory); | 403 | const dir = asDirectory(directory); |
| 324 | const subjects = | 404 | const subjects = yield* selectSubjects(dir, options, { |
| 325 | options.email === undefined | 405 | page: (paging) => |
| 326 | ? yield* admin | 406 | admin.listSubjectsWithConnections(paging).pipe(Effect.mapError(readFailed("users"))), |
| 327 | .listSubjectsWithConnections(options) | 407 | one: (externalId) => |
| 328 | .pipe(Effect.mapError(readFailed("users"))) | 408 | admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))), |
| 329 | : yield* selectByEmail(dir, options.email, options, (externalId) => | 409 | }); |
| 330 | admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))), | ||
| 331 | ); | ||
| 332 | const identities = yield* resolveIdentities( | 410 | const identities = yield* resolveIdentities( |
| 333 | dir.identities, | 411 | dir.identities, |
| 334 | subjects.map((subject) => subject.externalId), | 412 | subjects.map((subject) => subject.externalId), |
| @@ -31,12 +31,15 @@ import { · 30 unmodified lines | |||
| 31 | export type AdminUsersHeaders = Record<string, string>; | 31 | export type AdminUsersHeaders = Record<string, string>; |
| 32 | 32 | ||
| 33 | /** Paging and filtering, mirroring the SDK's `AdminListSubjectsOptions` plus | 33 | /** Paging and filtering, mirroring the SDK's `AdminListSubjectsOptions` plus |
| 34 | * the contract's `?email=`. The email arrives already trimmed and lower-cased | 34 | * the contract's `?email=` and `?search=`. Both filters arrive already |
| 35 | * by the contract schema, so a provider never re-normalizes it. */ | 35 | * trimmed and lower-cased by the handler seam (a blank search is omitted |
| 36 | * entirely), so a provider never re-normalizes them. `email` names ONE | ||
| 37 | * principal and wins when both are present. */ | ||
| 36 | export interface AdminUsersListOptions { | 38 | export interface AdminUsersListOptions { |
| 37 | readonly limit?: number; | 39 | readonly limit?: number; |
| 38 | readonly offset?: number; | 40 | readonly offset?: number; |
| 39 | readonly email?: string; | 41 | readonly email?: string; |
| 42 | readonly search?: string; | ||
| 40 | } | 43 | } |
| 41 | 44 | ||
| 42 | type User = typeof AdminUserResponse.Type; | 45 | type User = typeof AdminUserResponse.Type; |
| @@ -40,6 +40,7 @@ export { · 39 unmodified lines | |||
| 40 | normalizeEmail as normalizeAdminUserEmail, | 40 | normalizeEmail as normalizeAdminUserEmail, |
| 41 | type AdminEmailResolver, | 41 | type AdminEmailResolver, |
| 42 | type AdminIdentityDirectory, | 42 | type AdminIdentityDirectory, |
| 43 | type AdminMemberSearch, | ||
| 43 | type AdminUserDirectory, | 44 | type AdminUserDirectory, |
| 44 | type AdminUserIdentity, | 45 | type AdminUserIdentity, |
| 45 | } from "./admin/reads"; | 46 | } from "./admin/reads"; |
| @@ -593,6 +593,14 @@ export interface AdminSubjectWithConnections extends AdminSubject { · 592 unmodified lines | |||
| 593 | export interface AdminListSubjectsOptions { | 593 | export interface AdminListSubjectsOptions { |
| 594 | readonly limit?: number; | 594 | readonly limit?: number; |
| 595 | readonly offset?: number; | 595 | readonly offset?: number; |
| 596 | /** | ||
| 597 | * Keep only subjects whose `external_id` is in this set — the host's answer | ||
| 598 | * to a directory search (name or email), paged through storage rather than | ||
| 599 | * in memory. An EMPTY set matches nothing; `undefined` is no filter. Paging | ||
| 600 | * applies to the filtered set: "filter, then page", the same order the | ||
| 601 | * `?email=` read follows. | ||
| 602 | */ | ||
| 603 | readonly externalIds?: readonly string[]; | ||
| 596 | } | 604 | } |
| 597 | 605 | ||
| 598 | /** | 606 | /** |
| @@ -7006,8 +7014,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6407 unmodified lines | |||
| 7006 | // them through independently produced a bare OFFSET, which SQLite | 7014 | // them through independently produced a bare OFFSET, which SQLite |
| 7007 | // rejects outright. | 7015 | // rejects outright. |
| 7008 | const { limit, offset } = normalizeAdminPaging(options); | 7016 | const { limit, offset } = normalizeAdminPaging(options); |
| 7017 | const externalIds = options?.externalIds; | ||
| 7018 | // An empty id set is a query that cannot match; answer it without a | ||
| 7019 | // round trip rather than emitting `in ()`, which some drivers reject. | ||
| 7020 | if (externalIds !== undefined && externalIds.length === 0) return Effect.succeed([]); | ||
| 7009 | return platformCore | 7021 | return platformCore |
| 7010 | .findMany("subject", { | 7022 | .findMany("subject", { |
| 7023 | // The id filter is the ONLY predicate here; the tenant clause is | ||
| 7024 | // the policy's, added to every read the same way `getSubject` | ||
| 7025 | // relies on it. | ||
| 7026 | ...(externalIds === undefined | ||
| 7027 | ? {} | ||
| 7028 | : { where: (b: AnyCb) => b("external_id", "in", [...externalIds]) }), | ||
| 7011 | // Oldest first, ties broken on the unique key so the order is | 7029 | // Oldest first, ties broken on the unique key so the order is |
| 7012 | // total and paging can't repeat or skip a row. | 7030 | // total and paging can't repeat or skip a row. |
| 7013 | orderBy: [ | 7031 | orderBy: [ |
| @@ -424,6 +424,61 @@ const expectWriteRefused = <A, E extends { readonly _tag: string }>( · 423 unmodified lines | |||
| 424 | Effect.orDie, | 424 | Effect.orDie, |
| 425 | ); | 425 | ); |
| 426 | 426 | ||
| 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 | describe("platform view — read-only across every surface", () => { | 482 | describe("platform view — read-only across every surface", () => { |
| 428 | it.effect("refuses org-row writes through policies and oauth", () => | 483 | it.effect("refuses org-row writes through policies and oauth", () => |
| 429 | withDb((db) => | 484 | withDb((db) => |
| @@ -10,10 +10,11 @@ import { ReactivityKey } from "./reactivity-keys"; · 9 unmodified lines | |||
| 10 | // rejects writes at tenant reach), so there are no mutations here and every | 10 | // rejects writes at tenant reach), so there are no mutations here and every |
| 11 | // atom carries the same reactivity key. | 11 | // atom carries the same reactivity key. |
| 12 | // | 12 | // |
| 13 | // Paging is part of the atom identity, so each page is its own cache entry and | 13 | // Paging and the search term are part of the atom identity, so each page of |
| 14 | // stepping back to a visited page is instant. `Atom.family` (not a bare arrow) | 14 | // each search is its own cache entry and stepping back to a visited page is |
| 15 | // because the page component re-derives the key object on every render — a | 15 | // instant. `Atom.family` (not a bare arrow) because the page component |
| 16 | // fresh atom per render would refetch in a loop. | 16 | // re-derives the key object on every render — a fresh atom per render would |
| 17 | // refetch in a loop. | ||
| 17 | // --------------------------------------------------------------------------- | 18 | // --------------------------------------------------------------------------- |
| 18 | 19 | ||
| 19 | /** How many users one page of the list shows. Well inside the contract's | 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 | export interface AdminUsersPage { | 25 | export interface AdminUsersPage { |
| 25 | readonly limit: number; | 26 | readonly limit: number; |
| 26 | readonly offset: number; | 27 | readonly offset: number; |
| 28 | /** The `?search=` term (name or email substring), already debounced by the | ||
| 29 | * page. `""` is no filter and is sent as no param at all, so the unfiltered | ||
| 30 | * list keeps one cache identity regardless of how the term was cleared. */ | ||
| 31 | readonly search: string; | ||
| 27 | } | 32 | } |
| 28 | 33 | ||
| 29 | /** | 34 | /** |
| @@ -35,7 +40,11 @@ export interface AdminUsersPage { · 5 unmodified lines | |||
| 35 | */ | 40 | */ |
| 36 | export const adminUsersWithConnectionsAtom = Atom.family((page: AdminUsersPage) => | 41 | export const adminUsersWithConnectionsAtom = Atom.family((page: AdminUsersPage) => |
| 37 | AdminApiClient.query("adminUsers", "listUsersWithConnections", { | 42 | AdminApiClient.query("adminUsers", "listUsersWithConnections", { |
| 38 | query: { limit: page.limit + 1, offset: page.offset }, | 43 | query: { |
| 44 | limit: page.limit + 1, | ||
| 45 | offset: page.offset, | ||
| 46 | ...(page.search === "" ? {} : { search: page.search }), | ||
| 47 | }, | ||
| 39 | timeToLive: "30 seconds", | 48 | timeToLive: "30 seconds", |
| 40 | reactivityKeys: [ReactivityKey.adminUsers], | 49 | reactivityKeys: [ReactivityKey.adminUsers], |
| 41 | }), | 50 | }), |
| @@ -1,6 +1,7 @@ | |||
| 1 | import { useState } from "react"; | 1 | import { useEffect, useState } from "react"; |
| 2 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 3 | import { useParams } from "@tanstack/react-router"; | 3 | import { useParams } from "@tanstack/react-router"; |
| 4 | import { SearchIcon, XIcon } from "lucide-react"; | ||
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 5 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | import * as Cause from "effect/Cause"; | 6 | import * as Cause from "effect/Cause"; |
| 6 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| @@ -18,6 +19,7 @@ import { ownerLabel } from "../api/owner-display"; · 11 unmodified lines | |||
| 18 | import { Button } from "../components/button"; | 19 | import { Button } from "../components/button"; |
| 19 | import { CopyButton } from "../components/copy-button"; | 20 | import { CopyButton } from "../components/copy-button"; |
| 20 | import { ErrorState } from "../components/error-state"; | 21 | import { ErrorState } from "../components/error-state"; |
| 22 | import { Input } from "../components/input"; | ||
| 21 | import { | 23 | import { |
| 22 | IntegrationFavicon, | 24 | IntegrationFavicon, |
| 23 | integrationInferredUrl, | 25 | integrationInferredUrl, |
| @@ -521,14 +523,94 @@ function UserDetail(props: { · 497 unmodified lines | |||
| 521 | }); | 523 | }); |
| 522 | } | 524 | } |
| 523 | 525 | ||
| 526 | // ── Search ────────────────────────────────────────────────────────────────── | ||
| 527 | |||
| 528 | /** How long the typed term settles before it becomes a request. Long enough | ||
| 529 | * that a typed name is one query rather than one per keystroke, short enough | ||
| 530 | * to read as immediate. */ | ||
| 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 | // ── Page ──────────────────────────────────────────────────────────────────── | 603 | // ── Page ──────────────────────────────────────────────────────────────────── |
| 525 | 604 | ||
| 526 | export function AdminUsersPage() { | 605 | export function AdminUsersPage() { |
| 527 | useExecutorDocumentTitle("Users"); | 606 | useExecutorDocumentTitle("Users"); |
| 528 | const [offset, setOffset] = useState(0); | 607 | const [offset, setOffset] = useState(0); |
| 529 | const [selected, setSelected] = useState<AdminUserRow | null>(null); | 608 | const [selected, setSelected] = useState<AdminUserRow | null>(null); |
| 609 | const search = useDebouncedSearch(); | ||
| 530 | 610 | ||
| 531 | const page = { limit: ADMIN_USERS_PAGE_SIZE, offset }; | 611 | // A new term is a new list, so it starts on its first page: an offset kept |
| 612 | // from a broader list would land past the end of a narrower one. | ||
| 613 | const page = { limit: ADMIN_USERS_PAGE_SIZE, offset, search: search.term }; | ||
| 532 | const result = useAtomValue(adminUsersWithConnectionsAtom(page)); | 614 | const result = useAtomValue(adminUsersWithConnectionsAtom(page)); |
| 533 | const refresh = useAtomRefresh(adminUsersWithConnectionsAtom(page)); | 615 | const refresh = useAtomRefresh(adminUsersWithConnectionsAtom(page)); |
| 534 | const catalog = useCatalogRows(); | 616 | const catalog = useCatalogRows(); |
| @@ -555,10 +637,24 @@ export function AdminUsersPage() { · 20 unmodified lines | |||
| 555 | </div> | 637 | </div> |
| 556 | ); | 638 | ); |
| 557 | 639 | ||
| 640 | const searching = search.term !== ""; | ||
| 641 | |||
| 558 | return ( | 642 | return ( |
| 559 | <PageContainer> | 643 | <PageContainer> |
| 560 | {header} | 644 | {header} |
| 561 | 645 | ||
| 646 | <UserSearch | ||
| 647 | value={search.typed} | ||
| 648 | onChange={(value) => { | ||
| 649 | search.setTyped(value); | ||
| 650 | setOffset(0); | ||
| 651 | }} | ||
| 652 | onClear={() => { | ||
| 653 | search.clear(); | ||
| 654 | setOffset(0); | ||
| 655 | }} | ||
| 656 | /> | ||
| 657 | |||
| 562 | {isAsyncResultLoading(result) | 658 | {isAsyncResultLoading(result) |
| 563 | ? loading | 659 | ? loading |
| 564 | : AsyncResult.match(result, { | 660 | : AsyncResult.match(result, { |
| @@ -572,6 +668,25 @@ export function AdminUsersPage() { · 7 unmodified lines | |||
| 572 | onSuccess: ({ value }) => { | 668 | onSuccess: ({ value }) => { |
| 573 | const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE); | 669 | const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE); |
| 574 | 670 | ||
| 671 | if (rows.length === 0 && searching && offset === 0) { | ||
| 672 | return ( | ||
| 673 | <div | ||
| 674 | className="rounded-lg border border-dashed border-border bg-card p-8" | ||
| 675 | data-slot="admin-users-no-match" | ||
| 676 | > | ||
| 677 | <h2 className="text-base font-semibold text-foreground">No users match</h2> | ||
| 678 | <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground"> | ||
| 679 | Nobody in this workspace has a name or email containing “ | ||
| 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 | if (rows.length === 0) { | 690 | if (rows.length === 0) { |
| 576 | return ( | 691 | return ( |
| 577 | <div className="rounded-lg border border-dashed border-border bg-card p-8"> | 692 | <div className="rounded-lg border border-dashed border-border bg-card p-8"> |
| @@ -69,7 +69,7 @@ import { isAsyncResultLoading } from "../lib/async-result"; · 68 unmodified lines | |||
| 69 | 69 | ||
| 70 | type MemberData = { | 70 | type MemberData = { |
| 71 | id: string; | 71 | id: string; |
| 72 | email: string; | 72 | email: string | null; |
| 73 | name: string | null; | 73 | name: string | null; |
| 74 | avatarUrl: string | null; | 74 | avatarUrl: string | null; |
| 75 | role: string; | 75 | role: string; |
| @@ -80,6 +80,23 @@ type MemberData = { · 4 unmodified lines | |||
| 80 | 80 | ||
| 81 | type RoleData = { slug: string; name: string }; | 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 | type InviteState = { | 100 | type InviteState = { |
| 84 | email: string; | 101 | email: string; |
| 85 | roleSlug: string; | 102 | roleSlug: string; |
| @@ -314,7 +331,7 @@ export function OrgPage(props: { · 228 unmodified lines | |||
| 314 | const filtered = search | 331 | const filtered = search |
| 315 | ? members.filter( | 332 | ? members.filter( |
| 316 | (m: MemberData) => | 333 | (m: MemberData) => |
| 317 | m.email.toLowerCase().includes(search.toLowerCase()) || | 334 | (m.email?.toLowerCase().includes(search.toLowerCase()) ?? false) || |
| 318 | (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false), | 335 | (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false), |
| 319 | ) | 336 | ) |
| 320 | : members; | 337 | : members; |
| @@ -338,21 +355,14 @@ export function OrgPage(props: { · 17 unmodified lines | |||
| 338 | <img src={member.avatarUrl} alt="" className="size-8 rounded-full" /> | 355 | <img src={member.avatarUrl} alt="" className="size-8 rounded-full" /> |
| 339 | ) : ( | 356 | ) : ( |
| 340 | <div className="flex size-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground"> | 357 | <div className="flex size-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground"> |
| 341 | {member.name | 358 | {memberInitials(member)} |
| 342 | ? member.name | ||
| 343 | .split(" ") | ||
| 344 | .map((n: string) => n[0]) | ||
| 345 | .join("") | ||
| 346 | .slice(0, 2) | ||
| 347 | .toUpperCase() | ||
| 348 | : member.email[0]!.toUpperCase()} | ||
| 349 | </div> | 359 | </div> |
| 350 | )} | 360 | )} |
| 351 | 361 | ||
| 352 | <div className="min-w-0"> | 362 | <div className="min-w-0"> |
| 353 | <div className="flex items-center gap-2"> | 363 | <div className="flex items-center gap-2"> |
| 354 | <p className="truncate text-sm font-medium text-foreground leading-none"> | 364 | <p className="truncate text-sm font-medium text-foreground leading-none"> |
| 355 | {member.name ?? member.email} | 365 | {memberLabel(member)} |
| 356 | </p> | 366 | </p> |
| 357 | {member.isCurrentUser && ( | 367 | {member.isCurrentUser && ( |
| 358 | <Badge className="bg-muted text-muted-foreground">You</Badge> | 368 | <Badge className="bg-muted text-muted-foreground">You</Badge> |
| @@ -361,7 +371,7 @@ export function OrgPage(props: { · 2 unmodified lines | |||
| 361 | <Badge className="bg-muted text-muted-foreground">Invited</Badge> | 371 | <Badge className="bg-muted text-muted-foreground">Invited</Badge> |
| 362 | )} | 372 | )} |
| 363 | </div> | 373 | </div> |
| 364 | {member.name && ( | 374 | {member.name && member.email && ( |
| 365 | <p className="mt-0.5 truncate text-xs text-muted-foreground leading-none"> | 375 | <p className="mt-0.5 truncate text-xs text-muted-foreground leading-none"> |
| 366 | {member.email} | 376 | {member.email} |
| 367 | </p> | 377 | </p> |
| @@ -421,7 +431,7 @@ export function OrgPage(props: { · 53 unmodified lines | |||
| 421 | onClick={() => | 431 | onClick={() => |
| 422 | setRemovingMember({ | 432 | setRemovingMember({ |
| 423 | id: member.id, | 433 | id: member.id, |
| 424 | name: member.name ?? member.email, | 434 | name: memberLabel(member), |
| 425 | }) | 435 | }) |
| 426 | } | 436 | } |
| 427 | > | 437 | > |