Unified · Split
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/cloud": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | `authorizeOrganization` now reads the local membership mirror unconditionally: the per-request readiness check (`MirrorReadiness`) and its live WorkOS `listUserMemberships` fallback are gone from the request path entirely. The backfill is complete and permanent, and an organization that predates the mirror is still covered by the existing on-demand scan (`ensureOrganizationBackfilled`). A stalled reconciler is now an operational alert instead of a per-request fallback: after each run, the cron checks the mirror's `drained_at` heartbeat and, if it has fallen behind the lag budget, logs a structured error and reports it to Sentry. The deploy gate (`scripts/ensure-workos-mirror-ready.ts`) is unchanged — it still refuses to ship while the mirror is unready — and `drained_at` keeps being written by every reconciler run. | ||
| @@ -57,22 +57,33 @@ join the same traces via traceparent). · 56 unmodified lines | |||
| 57 | `execute`/`execute-action` calls `mcp.execute.code` (the script itself, | 57 | `execute`/`execute-action` calls `mcp.execute.code` (the script itself, |
| 58 | capped at 10k chars — cloud-only content capture; local/self-host | 58 | capped at 10k chars — cloud-only content capture; local/self-host |
| 59 | telemetry never records content). | 59 | telemetry never records content). |
| 60 | - `auth.authorize_organization` — every membership authorization. | 60 | - `auth.authorize_organization` — every membership authorization. Reads the |
| 61 | `mirror.ready` (bool: the local membership mirror answered; `false` = | 61 | local membership mirror unconditionally; there is no per-request readiness |
| 62 | the request fell back to a live WorkOS read) and `mirror.readiness` | 62 | check and no WorkOS fallback, so this span carries no readiness attribute. |
| 63 | (why: `ready`, `backfill pending: …`, `reconciler stale: …`). The | 63 | The mirror's write spans are `workos_mirror.<op>`; the reconciler run is |
| 64 | mirror's write spans are `workos_mirror.<op>`; the reconciler run is | ||
| 65 | `workos_events.sync`. `workos_sync.drained_at` in the prod DB is the | 64 | `workos_events.sync`. `workos_sync.drained_at` in the prod DB is the |
| 66 | reconciler heartbeat. | 65 | reconciler heartbeat, and a stalled reconciler now raises its own error |
| 66 | from the cron (see below) rather than showing up as a fallback here. | ||
| 67 | 67 | ||
| 68 | **Recipe — membership-mirror fallback rate (should be ~0 after cutover):** | 68 | **Recipe — reconciler heartbeat (ticks should land roughly every minute; a |
| 69 | gap wider than the 10-minute lag budget means the cron alert should already | ||
| 70 | have fired — see `workos_events: reconciler stale` below):** | ||
| 69 | 71 | ||
| 70 | ```apl | 72 | ```apl |
| 71 | ['executor-cloud'] | 73 | ['executor-cloud'] |
| 72 | | where _time > ago(1h) and name == "auth.authorize_organization" | 74 | | where _time > ago(1h) and name == "workos_events.sync" |
| 73 | | extend ready = tobool(['attributes.custom']['mirror.ready']) | 75 | | summarize n = count() by bin(_time, 1m) |
| 74 | | extend why = tostring(['attributes.custom']['mirror.readiness']) | 76 | | sort by _time desc |
| 75 | | summarize n = count() by ready, why | 77 | ``` |
| 78 | |||
| 79 | **Recipe — stale-reconciler alerts (should be empty; each row is one paging | ||
| 80 | event):** | ||
| 81 | |||
| 82 | ```apl | ||
| 83 | ['executor-cloud'] | ||
| 84 | | where _time > ago(1d) and ['status.message'] contains "workos_events: reconciler stale" | ||
| 85 | | project _time, trace_id, msg = tostring(['status.message']) | ||
| 86 | | sort by _time desc | ||
| 76 | ``` | 87 | ``` |
| 77 | 88 | ||
| 78 | **Recipe — error signatures by class (the daily-digest query):** | 89 | **Recipe — error signatures by class (the daily-digest query):** |
| @@ -10,7 +10,6 @@ import { · 9 unmodified lines | |||
| 10 | 10 | ||
| 11 | import { ApiKeyService } from "../auth/api-keys"; | 11 | import { ApiKeyService } from "../auth/api-keys"; |
| 12 | import { UserStoreService } from "../auth/context"; | 12 | import { UserStoreService } from "../auth/context"; |
| 13 | import { MirrorReadiness } from "../auth/mirror-readiness"; | ||
| 14 | import { WorkOsMirror } from "../auth/workos-mirror"; | 13 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| 15 | import { sessionFromSealed, type Session } from "../auth/middleware"; | 14 | import { sessionFromSealed, type Session } from "../auth/middleware"; |
| 16 | import { WorkOSClient } from "../auth/workos"; | 15 | import { WorkOSClient } from "../auth/workos"; |
| @@ -51,7 +50,9 @@ import { AccountCaller, workosAccountProvider } from "./workos-account-service"; · 34 unmodified lines | |||
| 51 | // `UserStoreService` / `WorkOsMirror` / `MemberDirectory` are supplied by the | 50 | // `UserStoreService` / `WorkOsMirror` / `MemberDirectory` are supplied by the |
| 52 | // combined `rsLive` layer. | 51 | // combined `rsLive` layer. |
| 53 | // `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`. | 52 | // `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`. |
| 54 | const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()( | 53 | const AccountProviderMiddleware = HttpRouter.middleware<{ |
| 54 | provides: AccountProvider; | ||
| 55 | }>()( | ||
| 55 | Effect.gen(function* () { | 56 | Effect.gen(function* () { |
| 56 | // Long-lived services only (built once at boot). `UserStoreService` and | 57 | // Long-lived services only (built once at boot). `UserStoreService` and |
| 57 | // `DbService` are NOT grabbed here — they come per request from the combined | 58 | // `DbService` are NOT grabbed here — they come per request from the combined |
| @@ -100,15 +101,11 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi · 42 unmodified lines | |||
| 100 | * (the seat-gate) stays a residual requirement, satisfied by the app `boot`. | 101 | * (the seat-gate) stays a residual requirement, satisfied by the app `boot`. |
| 101 | */ | 102 | */ |
| 102 | export const workosAccountMiddleware = ( | 103 | export const workosAccountMiddleware = ( |
| 103 | rsLive: Layer.Layer< | 104 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, |
| 104 | DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness | ||
| 105 | >, | ||
| 106 | ) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; | 105 | ) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; |
| 107 | 106 | ||
| 108 | export const makeAccountApiLive = ( | 107 | export const makeAccountApiLive = ( |
| 109 | rsLive: Layer.Layer< | 108 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, |
| 110 | DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness | ||
| 111 | >, | ||
| 112 | ) => { | 109 | ) => { |
| 113 | // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it | 110 | // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it |
| 114 | // closes over the per-request postgres socket), so it can't be a self- | 111 | // closes over the per-request postgres socket), so it can't be a self- |
| @@ -6,7 +6,6 @@ import { AccountError, AccountForbidden } from "@executor-js/api"; · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; | 7 | import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; |
| 8 | import { UserStoreService } from "../auth/context"; | 8 | import { UserStoreService } from "../auth/context"; |
| 9 | import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness"; | ||
| 10 | import { ORG_SELECTOR_HEADER } from "../auth/organization"; | 9 | import { ORG_SELECTOR_HEADER } from "../auth/organization"; |
| 11 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | 10 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; |
| 12 | import { WorkOsMirror } from "../auth/workos-mirror"; | 11 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| @@ -132,12 +131,6 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ · 119 unmodified lines | |||
| 132 | // The mirror as the directory reads it: both are active members of ORG, and | 131 | // The mirror as the directory reads it: both are active members of ORG, and |
| 133 | // only ADMIN carries the `admin` role. Revoke reads the caller's membership | 132 | // only ADMIN carries the `admin` role. Revoke reads the caller's membership |
| 134 | // (the org check and the admin gate) and nothing else. | 133 | // (the org check and the admin gate) and nothing else. |
| 135 | // The mirror is READY in these tests (backfill complete, reconciler caught | ||
| 136 | // up), so membership is read from the stubbed directory, never from WorkOS. | ||
| 137 | const stubReadiness = Layer.succeed(MirrorReadiness)({ | ||
| 138 | state: () => Effect.succeed(MirrorReadinessState.Ready()), | ||
| 139 | }); | ||
| 140 | |||
| 141 | const stubDirectory = Layer.succeed(MemberDirectory)({ | 134 | const stubDirectory = Layer.succeed(MemberDirectory)({ |
| 142 | membership: (accountId, organizationId) => | 135 | membership: (accountId, organizationId) => |
| 143 | Effect.succeed( | 136 | Effect.succeed( |
| @@ -201,7 +194,6 @@ const providerWith = (accountId: string) => { · 57 unmodified lines | |||
| 201 | stubUsers, | 194 | stubUsers, |
| 202 | stubMirror, | 195 | stubMirror, |
| 203 | stubDirectory, | 196 | stubDirectory, |
| 204 | stubReadiness, | ||
| 205 | stubApiKeys, | 197 | stubApiKeys, |
| 206 | stubAutumn, | 198 | stubAutumn, |
| 207 | Layer.succeed(AccountCaller)({ session: session(accountId) }), | 199 | Layer.succeed(AccountCaller)({ session: session(accountId) }), |
| @@ -11,7 +11,6 @@ import { · 10 unmodified lines | |||
| 11 | import { ApiKeyService } from "../auth/api-keys"; | 11 | import { ApiKeyService } from "../auth/api-keys"; |
| 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 { MirrorReadiness } from "../auth/mirror-readiness"; | ||
| 15 | import { WorkOSClient } from "../auth/workos"; | 14 | import { WorkOSClient } from "../auth/workos"; |
| 16 | import { ensureOrganizationBackfilled, mirrorInvitedMember } from "../auth/mirror-feeders"; | 15 | import { ensureOrganizationBackfilled, mirrorInvitedMember } from "../auth/mirror-feeders"; |
| 17 | import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror"; | 16 | import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror"; |
| @@ -72,7 +71,6 @@ export const workosAccountProvider: Layer.Layer< · 54 unmodified lines | |||
| 72 | | UserStoreService | 71 | | UserStoreService |
| 73 | | WorkOsMirror | 72 | | WorkOsMirror |
| 74 | | MemberDirectory | 73 | | MemberDirectory |
| 75 | | MirrorReadiness | ||
| 76 | | ApiKeyService | 74 | | ApiKeyService |
| 77 | | AutumnService | 75 | | AutumnService |
| 78 | | AccountCaller | 76 | | AccountCaller |
| @@ -103,12 +101,7 @@ export const workosAccountProvider: Layer.Layer< · 24 unmodified lines | |||
| 103 | // erased to `R = never`, as the neutral AccountProvider shape requires. | 101 | // erased to `R = never`, as the neutral AccountProvider shape requires. |
| 104 | // Provided per method below. | 102 | // Provided per method below. |
| 105 | const ctx = yield* Effect.context< | 103 | const ctx = yield* Effect.context< |
| 106 | | WorkOSClient | 104 | WorkOSClient | UserStoreService | AutumnService | MemberDirectory | WorkOsMirror |
| 107 | | UserStoreService | ||
| 108 | | AutumnService | ||
| 109 | | MemberDirectory | ||
| 110 | | MirrorReadiness | ||
| 111 | | WorkOsMirror | ||
| 112 | >(); | 105 | >(); |
| 113 | 106 | ||
| 114 | // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly | 107 | // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly |
| @@ -6,7 +6,6 @@ import { MemberDirectory, type DirectoryMember } from "@executor-js/api/server"; · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import { ApiKeyService } from "../auth/api-keys"; | 7 | import { ApiKeyService } from "../auth/api-keys"; |
| 8 | import { UserStoreService } from "../auth/context"; | 8 | import { UserStoreService } from "../auth/context"; |
| 9 | import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness"; | ||
| 10 | import { ORG_SELECTOR_HEADER } from "../auth/organization"; | 9 | import { ORG_SELECTOR_HEADER } from "../auth/organization"; |
| 11 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | 10 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; |
| 12 | import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; | 11 | import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; |
| @@ -49,12 +48,6 @@ const memberships = new Map<string, DirectoryMember>([ · 36 unmodified lines | |||
| 49 | ["user_invited_admin", mirrored("user_invited_admin", { role: "admin", status: "pending" })], | 48 | ["user_invited_admin", mirrored("user_invited_admin", { role: "admin", status: "pending" })], |
| 50 | ]); | 49 | ]); |
| 51 | 50 | ||
| 52 | // The mirror is READY in these tests (backfill complete, reconciler caught | ||
| 53 | // up), so membership is read from the stubbed directory, never from WorkOS. | ||
| 54 | const stubReadiness = Layer.succeed(MirrorReadiness)({ | ||
| 55 | state: () => Effect.succeed(MirrorReadinessState.Ready()), | ||
| 56 | }); | ||
| 57 | |||
| 58 | const stubDirectory = Layer.succeed(MemberDirectory)({ | 51 | const stubDirectory = Layer.succeed(MemberDirectory)({ |
| 59 | membership: (accountId, organizationId) => | 52 | membership: (accountId, organizationId) => |
| 60 | Effect.succeed(organizationId === ORG ? (memberships.get(accountId) ?? null) : null), | 53 | Effect.succeed(organizationId === ORG ? (memberships.get(accountId) ?? null) : null), |
| @@ -149,7 +142,11 @@ const stubWorkOS = (userId: string) => · 88 unmodified lines | |||
| 149 | get: (_target, prop) => { | 142 | get: (_target, prop) => { |
| 150 | if (prop === "authenticateRequest") { | 143 | if (prop === "authenticateRequest") { |
| 151 | return () => | 144 | return () => |
| 152 | Effect.succeed({ userId, email: `${userId}@placeholder.test`, organizationId: null }); | 145 | Effect.succeed({ |
| 146 | userId, | ||
| 147 | email: `${userId}@placeholder.test`, | ||
| 148 | organizationId: null, | ||
| 149 | }); | ||
| 153 | } | 150 | } |
| 154 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | 151 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); |
| 155 | }, | 152 | }, |
| @@ -163,14 +160,7 @@ const authorizeAs = (userId: string) => · 7 unmodified lines | |||
| 163 | }), | 160 | }), |
| 164 | ).pipe( | 161 | ).pipe( |
| 165 | Effect.provide( | 162 | Effect.provide( |
| 166 | Layer.mergeAll( | 163 | Layer.mergeAll(stubDirectory, stubApiKeys, stubUsers, stubWorkOS(userId), stubMirror), |
| 167 | stubDirectory, | ||
| 168 | stubApiKeys, | ||
| 169 | stubUsers, | ||
| 170 | stubWorkOS(userId), | ||
| 171 | stubMirror, | ||
| 172 | stubReadiness, | ||
| 173 | ), | ||
| 174 | ), | 164 | ), |
| 175 | ); | 165 | ); |
| 176 | 166 | ||
| @@ -56,7 +56,6 @@ import type { Executor } from "@executor-js/sdk"; · 55 unmodified lines | |||
| 56 | 56 | ||
| 57 | import { ApiKeyService } from "../auth/api-keys"; | 57 | import { ApiKeyService } from "../auth/api-keys"; |
| 58 | import { UserStoreService } from "../auth/context"; | 58 | import { UserStoreService } from "../auth/context"; |
| 59 | import { MirrorReadiness } from "../auth/mirror-readiness"; | ||
| 60 | import { WorkOsMirror } from "../auth/workos-mirror"; | 59 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| 61 | import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider"; | 60 | import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider"; |
| 62 | import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization"; | 61 | import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization"; |
| @@ -77,7 +76,7 @@ export const authorizeTenant = ( · 14 unmodified lines | |||
| 77 | ): Effect.Effect< | 76 | ): Effect.Effect< |
| 78 | string, | 77 | string, |
| 79 | AdminUsersUnauthorized | AdminUsersForbidden, | 78 | AdminUsersUnauthorized | AdminUsersForbidden, |
| 80 | WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror | 79 | WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror |
| 81 | > => | 80 | > => |
| 82 | Effect.gen(function* () { | 81 | Effect.gen(function* () { |
| 83 | // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`, | 82 | // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`, |
| @@ -138,7 +137,6 @@ const withPlatformView = <A, E extends AdminUsersError | AdminUserNotFound = Adm · 54 unmodified lines | |||
| 138 | | ApiKeyService | 137 | | ApiKeyService |
| 139 | | UserStoreService | 138 | | UserStoreService |
| 140 | | MemberDirectory | 139 | | MemberDirectory |
| 141 | | MirrorReadiness | ||
| 142 | | WorkOsMirror | 140 | | WorkOsMirror |
| 143 | | DbProvider | 141 | | DbProvider |
| 144 | | PluginsProvider | 142 | | PluginsProvider |
| @@ -178,7 +176,6 @@ export const workosAdminUsersProvider: Layer.Layer< · 33 unmodified lines | |||
| 178 | | ApiKeyService | 176 | | ApiKeyService |
| 179 | | UserStoreService | 177 | | UserStoreService |
| 180 | | MemberDirectory | 178 | | MemberDirectory |
| 181 | | MirrorReadiness | ||
| 182 | | WorkOsMirror | 179 | | WorkOsMirror |
| 183 | | DbProvider | 180 | | DbProvider |
| 184 | | PluginsProvider | 181 | | PluginsProvider |
| @@ -190,7 +187,6 @@ export const workosAdminUsersProvider: Layer.Layer< · 5 unmodified lines | |||
| 190 | | ApiKeyService | 187 | | ApiKeyService |
| 191 | | UserStoreService | 188 | | UserStoreService |
| 192 | | MemberDirectory | 189 | | MemberDirectory |
| 193 | | MirrorReadiness | ||
| 194 | | WorkOsMirror | 190 | | WorkOsMirror |
| 195 | | DbProvider | 191 | | DbProvider |
| 196 | | PluginsProvider | 192 | | PluginsProvider |
| @@ -241,7 +237,9 @@ export const workosAdminUsersProvider: Layer.Layer< · 44 unmodified lines | |||
| 241 | // per-request `DbService`/`UserStoreService`/`MemberDirectory` (and the | 237 | // per-request `DbService`/`UserStoreService`/`MemberDirectory` (and the |
| 242 | // execution seams built over them) are supplied by the combined | 238 | // execution seams built over them) are supplied by the combined |
| 243 | // `requestScopedMiddleware`. | 239 | // `requestScopedMiddleware`. |
| 244 | const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()( | 240 | const AdminUsersProviderMiddleware = HttpRouter.middleware<{ |
| 241 | provides: AdminUsersProvider; | ||
| 242 | }>()( | ||
| 245 | Effect.gen(function* () { | 243 | Effect.gen(function* () { |
| 246 | const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>(); | 244 | const longLived = yield* Effect.context<WorkOSClient | ApiKeyService>(); |
| 247 | return (httpEffect) => | 245 | return (httpEffect) => |
| @@ -265,9 +263,7 @@ const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUser · 17 unmodified lines | |||
| 265 | * `/api` prefix as the rest of the cloud router. | 263 | * `/api` prefix as the rest of the cloud router. |
| 266 | */ | 264 | */ |
| 267 | export const makeCloudAdminUsersRoutes = ( | 265 | export const makeCloudAdminUsersRoutes = ( |
| 268 | rsLive: Layer.Layer< | 266 | rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory | WorkOsMirror>, |
| 269 | DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror | ||
| 270 | >, | ||
| 271 | options: Parameters<typeof makeAdminUsersApiLayer>[1] = {}, | 267 | options: Parameters<typeof makeAdminUsersApiLayer>[1] = {}, |
| 272 | ) => | 268 | ) => |
| 273 | makeAdminUsersApiLayer( | 269 | makeAdminUsersApiLayer( |
| @@ -11,7 +11,6 @@ import { · 10 unmodified lines | |||
| 11 | import { SessionAuthLive } from "../auth/middleware-live"; | 11 | import { SessionAuthLive } from "../auth/middleware-live"; |
| 12 | import { UserStoreService } from "../auth/context"; | 12 | import { UserStoreService } from "../auth/context"; |
| 13 | import { cloudMemberDirectoryLayer } from "../auth/member-directory"; | 13 | import { cloudMemberDirectoryLayer } from "../auth/member-directory"; |
| 14 | import { MirrorReadiness } from "../auth/mirror-readiness"; | ||
| 15 | import { WorkOsMirror } from "../auth/workos-mirror"; | 14 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| 16 | import { | 15 | import { |
| 17 | CloudAuthPublicHandlers, | 16 | CloudAuthPublicHandlers, |
| @@ -36,23 +35,14 @@ const WorkOsMirrorLive = WorkOsMirror.Live.pipe(Layer.provide(DbLive)); · 18 unmodified lines | |||
| 36 | // The shared `MemberDirectory` read seam over the membership mirror — the | 35 | // The shared `MemberDirectory` read seam over the membership mirror — the |
| 37 | // same per-request socket the mirror writes through. | 36 | // same per-request socket the mirror writes through. |
| 38 | const MemberDirectoryLive = cloudMemberDirectoryLayer.pipe(Layer.provide(DbLive)); | 37 | const MemberDirectoryLive = cloudMemberDirectoryLayer.pipe(Layer.provide(DbLive)); |
| 39 | // Whether the mirror may authorize this request at all (backfill complete, | ||
| 40 | // reconciler caught up) — read on the same socket before the membership row. | ||
| 41 | const MirrorReadinessLive = MirrorReadiness.Live.pipe(Layer.provide(DbLive)); | ||
| 42 | 38 | ||
| 43 | // 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, |
| 44 | // fetch stream readers, anything backed by a `Writable`) MUST live here — | 40 | // fetch stream readers, anything backed by a `Writable`) MUST live here — |
| 45 | // `provideRequestScoped` rebuilds it per request so Cloudflare Workers' | 41 | // `provideRequestScoped` rebuilds it per request so Cloudflare Workers' |
| 46 | // I/O isolation is satisfied. See `api.request-scope.test.ts`. | 42 | // I/O isolation is satisfied. See `api.request-scope.test.ts`. |
| 47 | export const RequestScopedServicesLive: Layer.Layer< | 43 | export const RequestScopedServicesLive: Layer.Layer< |
| 48 | DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness | 44 | DbService | UserStoreService | WorkOsMirror | MemberDirectory |
| 49 | > = Layer.mergeAll( | 45 | > = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive, MemberDirectoryLive); |
| 50 | DbLive, | ||
| 51 | UserStoreLive, | ||
| 52 | WorkOsMirrorLive, | ||
| 53 | MemberDirectoryLive, | ||
| 54 | MirrorReadinessLive, | ||
| 55 | ); | ||
| 56 | 46 | ||
| 57 | // Boot-scoped layer. Built once at worker boot, reused across requests. | 47 | // Boot-scoped layer. Built once at worker boot, reused across requests. |
| 58 | // Safe for config, in-memory caches, the global tracer provider, and | 48 | // Safe for config, in-memory caches, the global tracer provider, and |
| @@ -77,9 +67,7 @@ export const BootSharedServices = Layer.mergeAll( · 18 unmodified lines | |||
| 77 | // 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 |
| 78 | // 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.) |
| 79 | export const makeNonProtectedApiLive = ( | 69 | export const makeNonProtectedApiLive = ( |
| 80 | rsLive: Layer.Layer< | 70 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, |
| 81 | DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness | ||
| 82 | >, | ||
| 83 | ) => | 71 | ) => |
| 84 | HttpApiBuilder.layer(NonProtectedApi).pipe( | 72 | HttpApiBuilder.layer(NonProtectedApi).pipe( |
| 85 | Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), | 73 | Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), |
| @@ -95,9 +83,7 @@ export const makeNonProtectedApiLive = ( · 9 unmodified lines | |||
| 95 | // gates on billing, so `AutumnService.Default` is provided here, not on the | 83 | // gates on billing, so `AutumnService.Default` is provided here, not on the |
| 96 | // neutral boot core. | 84 | // neutral boot core. |
| 97 | export const makeOrgApiLive = ( | 85 | export const makeOrgApiLive = ( |
| 98 | rsLive: Layer.Layer< | 86 | rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory | WorkOsMirror>, |
| 99 | DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror | ||
| 100 | >, | ||
| 101 | ) => | 87 | ) => |
| 102 | HttpApiBuilder.layer(OrgHttpApi).pipe( | 88 | HttpApiBuilder.layer(OrgHttpApi).pipe( |
| 103 | Layer.provide(OrgHandlers), | 89 | Layer.provide(OrgHandlers), |
| @@ -143,7 +129,9 @@ export const OrgApiLive = makeOrgApiLive(RequestScopedServicesLive); · 39 unmodified lines | |||
| 143 | // folded into `.layer` here; the rest of the router (`makeApiLive` in | 129 | // folded into `.layer` here; the rest of the router (`makeApiLive` in |
| 144 | // `./router.ts`, `./protected.ts`, the test harness) re-provides the same | 130 | // `./router.ts`, `./protected.ts`, the test harness) re-provides the same |
| 145 | // shared `RouterConfigLive` directly. | 131 | // shared `RouterConfigLive` directly. |
| 146 | const protectedApi = makeProtectedApiLayer(cloudPlugins, { errorCapture: ErrorCaptureLive }); | 132 | const protectedApi = makeProtectedApiLayer(cloudPlugins, { |
| 133 | errorCapture: ErrorCaptureLive, | ||
| 134 | }); | ||
| 147 | 135 | ||
| 148 | export const ProtectedCloudApi = protectedApi.api; | 136 | export const ProtectedCloudApi = protectedApi.api; |
| 149 | export const ProtectedCloudApiHandlers = protectedApi.handlers; | 137 | export const ProtectedCloudApiHandlers = protectedApi.handlers; |
| @@ -5,7 +5,6 @@ import { MemberDirectory } from "@executor-js/api/server"; · 4 unmodified lines | |||
| 5 | 5 | ||
| 6 | import { ApiKeyService } from "../auth/api-keys"; | 6 | import { ApiKeyService } from "../auth/api-keys"; |
| 7 | import { UserStoreService } from "../auth/context"; | 7 | import { UserStoreService } from "../auth/context"; |
| 8 | import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness"; | ||
| 9 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | 8 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; |
| 10 | import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; | 9 | import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; |
| 11 | import { resolveProtectedPrincipal } from "./protected"; | 10 | import { resolveProtectedPrincipal } from "./protected"; |
| @@ -55,13 +54,8 @@ const stubWorkOS = Layer.succeed( · 43 unmodified lines | |||
| 55 | ); | 54 | ); |
| 56 | 55 | ||
| 57 | // The mirror as the directory reads it: user_123 holds an active membership in | 56 | // The mirror as the directory reads it: user_123 holds an active membership in |
| 58 | // org_123 and nothing else. Membership is never read from WorkOS. | 57 | // org_123 and nothing else. Membership is always read from the mirror, never |
| 59 | // The mirror is READY in these tests (backfill complete, reconciler caught | 58 | // from WorkOS. |
| 60 | // up), so membership is read from the stubbed directory, never from WorkOS. | ||
| 61 | const stubReadiness = Layer.succeed(MirrorReadiness)({ | ||
| 62 | state: () => Effect.succeed(MirrorReadinessState.Ready()), | ||
| 63 | }); | ||
| 64 | |||
| 65 | const stubDirectory = Layer.succeed(MemberDirectory)({ | 59 | const stubDirectory = Layer.succeed(MemberDirectory)({ |
| 66 | membership: (accountId, organizationId) => | 60 | membership: (accountId, organizationId) => |
| 67 | Effect.succeed( | 61 | Effect.succeed( |
| @@ -136,9 +130,7 @@ const stubMirror = Layer.succeed( · 68 unmodified lines | |||
| 136 | 130 | ||
| 137 | const run = (request: Request) => | 131 | const run = (request: Request) => |
| 138 | resolveProtectedPrincipal(request).pipe( | 132 | resolveProtectedPrincipal(request).pipe( |
| 139 | Effect.provide( | 133 | Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror)), |
| 140 | Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror, stubReadiness), | ||
| 141 | ), | ||
| 142 | ); | 134 | ); |
| 143 | 135 | ||
| 144 | describe("protected API key auth", () => { | 136 | describe("protected API key auth", () => { |
| @@ -6,7 +6,6 @@ import { MemberDirectory } from "@executor-js/api/server"; · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import { ApiKeyService } from "../auth/api-keys"; | 7 | import { ApiKeyService } from "../auth/api-keys"; |
| 8 | import { UserStoreService } from "../auth/context"; | 8 | import { UserStoreService } from "../auth/context"; |
| 9 | import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness"; | ||
| 10 | import type { JwtBearerConfig } from "../auth/workos-auth-provider"; | 9 | import type { JwtBearerConfig } from "../auth/workos-auth-provider"; |
| 11 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | 10 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; |
| 12 | import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; | 11 | import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; |
| @@ -72,13 +71,8 @@ const stubWorkOS = Layer.succeed( · 59 unmodified lines | |||
| 72 | ); | 71 | ); |
| 73 | 72 | ||
| 74 | // The mirror as the directory reads it: user_123 holds an active membership in | 73 | // The mirror as the directory reads it: user_123 holds an active membership in |
| 75 | // org_123 and nothing else. Membership is never read from WorkOS. | 74 | // org_123 and nothing else. Membership is always read from the mirror, never |
| 76 | // The mirror is READY in these tests (backfill complete, reconciler caught | 75 | // from WorkOS. |
| 77 | // up), so membership is read from the stubbed directory, never from WorkOS. | ||
| 78 | const stubReadiness = Layer.succeed(MirrorReadiness)({ | ||
| 79 | state: () => Effect.succeed(MirrorReadinessState.Ready()), | ||
| 80 | }); | ||
| 81 | |||
| 82 | const stubDirectory = Layer.succeed(MemberDirectory)({ | 76 | const stubDirectory = Layer.succeed(MemberDirectory)({ |
| 83 | membership: (accountId, organizationId) => | 77 | membership: (accountId, organizationId) => |
| 84 | Effect.succeed( | 78 | Effect.succeed( |
| @@ -153,9 +147,7 @@ const stubMirror = Layer.succeed( · 68 unmodified lines | |||
| 153 | 147 | ||
| 154 | const run = (request: Request, jwt: JwtBearerConfig) => | 148 | const run = (request: Request, jwt: JwtBearerConfig) => |
| 155 | resolveProtectedPrincipal(request, jwt).pipe( | 149 | resolveProtectedPrincipal(request, jwt).pipe( |
| 156 | Effect.provide( | 150 | Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror)), |
| 157 | Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror, stubReadiness), | ||
| 158 | ), | ||
| 159 | ); | 151 | ); |
| 160 | 152 | ||
| 161 | const request = (token: string) => | 153 | const request = (token: string) => |
| @@ -16,7 +16,6 @@ import { · 15 unmodified lines | |||
| 16 | import { cloudPlugins, type CloudPlugins } from "../plugins"; | 16 | import { cloudPlugins, type CloudPlugins } from "../plugins"; |
| 17 | import { ApiKeyService } from "../auth/api-keys"; | 17 | import { ApiKeyService } from "../auth/api-keys"; |
| 18 | import { UserStoreService } from "../auth/context"; | 18 | import { UserStoreService } from "../auth/context"; |
| 19 | import { MirrorReadiness } from "../auth/mirror-readiness"; | ||
| 20 | import { WorkOsMirror } from "../auth/workos-mirror"; | 19 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| 21 | import { cloudIdentityFailureStrategy, workosIdentityLayer } from "../auth/workos-auth-provider"; | 20 | import { cloudIdentityFailureStrategy, workosIdentityLayer } from "../auth/workos-auth-provider"; |
| 22 | import { AutumnService } from "../extensions/billing/service"; | 21 | import { AutumnService } from "../extensions/billing/service"; |
| @@ -97,9 +96,7 @@ const ExecutionStackMiddleware = makeExecutionStackMiddleware< · 74 unmodified lines | |||
| 97 | // account seat-gate, and the createOrganization free-limit gate each provide it | 96 | // account seat-gate, and the createOrganization free-limit gate each provide it |
| 98 | // where they run.) | 97 | // where they run.) |
| 99 | export const makeProtectedApiLive = ( | 98 | export const makeProtectedApiLive = ( |
| 100 | rsLive: Layer.Layer< | 99 | rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory | WorkOsMirror>, |
| 101 | DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror | ||
| 102 | >, | ||
| 103 | ) => { | 100 | ) => { |
| 104 | // The neutral `IdentityProvider`, built per request: it reads `UserStoreService` | 101 | // The neutral `IdentityProvider`, built per request: it reads `UserStoreService` |
| 105 | // + `MemberDirectory` from `rsLive` and the WorkOS control plane (`WorkOSClient` + `ApiKeyService`, | 102 | // + `MemberDirectory` from `rsLive` and the WorkOS control plane (`WorkOSClient` + `ApiKeyService`, |
| @@ -8,7 +8,6 @@ import { · 7 unmodified lines | |||
| 8 | } from "@executor-js/api/server"; | 8 | } from "@executor-js/api/server"; |
| 9 | 9 | ||
| 10 | import { UserStoreService } from "../auth/context"; | 10 | import { UserStoreService } from "../auth/context"; |
| 11 | import { MirrorReadiness } from "../auth/mirror-readiness"; | ||
| 12 | import { WorkOsMirror } from "../auth/workos-mirror"; | 11 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| 13 | import { DbService } from "../db/db"; | 12 | import { DbService } from "../db/db"; |
| 14 | import { makeAccountApiLive } from "../account/account-api"; | 13 | import { makeAccountApiLive } from "../account/account-api"; |
| @@ -36,9 +35,7 @@ import { makeProtectedApiLive } from "./protected"; · 21 unmodified lines | |||
| 36 | // assert per-request semantics — see | 35 | // assert per-request semantics — see |
| 37 | // `apps/cloud/src/api.request-scope.node.test.ts`. | 36 | // `apps/cloud/src/api.request-scope.node.test.ts`. |
| 38 | export const makeApiLive = ( | 37 | export const makeApiLive = ( |
| 39 | requestScopedLive: Layer.Layer< | 38 | requestScopedLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, |
| 40 | DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness | ||
| 41 | >, | ||
| 42 | ) => { | 39 | ) => { |
| 43 | const BillingRoutesLive = AutumnRoutesLive.pipe( | 40 | const BillingRoutesLive = AutumnRoutesLive.pipe( |
| 44 | Layer.provide(requestScopedMiddleware(requestScopedLive).layer), | 41 | Layer.provide(requestScopedMiddleware(requestScopedLive).layer), |
| @@ -42,7 +42,6 @@ import { makeUserStoreLayer, UserStoreService } from "./context"; · 41 unmodified lines | |||
| 42 | import { parseCookie } from "./cookies"; | 42 | import { parseCookie } from "./cookies"; |
| 43 | import { LAST_ORG_COOKIE } from "./last-org-cookie"; | 43 | import { LAST_ORG_COOKIE } from "./last-org-cookie"; |
| 44 | import { makeMemberDirectoryLayer } from "./member-directory"; | 44 | import { makeMemberDirectoryLayer } from "./member-directory"; |
| 45 | import { makeMirrorReadinessLayer } from "./mirror-readiness"; | ||
| 46 | import { makeWorkOsMirrorLayer } from "./workos-mirror"; | 45 | import { makeWorkOsMirrorLayer } from "./workos-mirror"; |
| 47 | import { sealedSessionDisplayName } from "./middleware"; | 46 | import { sealedSessionDisplayName } from "./middleware"; |
| 48 | import { authorizeOrganizationSelector } from "./organization"; | 47 | import { authorizeOrganizationSelector } from "./organization"; |
| @@ -184,7 +183,6 @@ const authorizeLastOrgSlug = async ( · 135 unmodified lines | |||
| 184 | Layer.mergeAll( | 183 | Layer.mergeAll( |
| 185 | makeUserStoreLayer(), | 184 | makeUserStoreLayer(), |
| 186 | makeMemberDirectoryLayer(), | 185 | makeMemberDirectoryLayer(), |
| 187 | makeMirrorReadinessLayer(), | ||
| 188 | makeWorkOsMirrorLayer(), | 186 | makeWorkOsMirrorLayer(), |
| 189 | ).pipe(Layer.provide(dbLive)), | 187 | ).pipe(Layer.provide(dbLive)), |
| 190 | ), | 188 | ), |
| @@ -258,7 +256,9 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( · 67 unmodified lines | |||
| 258 | // the client AuthGate makes mid-session, made here before the document | 256 | // the client AuthGate makes mid-session, made here before the document |
| 259 | // exists so the app shell is never painted for an org-less session. | 257 | // exists so the app shell is never painted for an org-less session. |
| 260 | if (!session.organizationId && !ONBOARDING_PATHS.has(pathname)) { | 258 | if (!session.organizationId && !ONBOARDING_PATHS.has(pathname)) { |
| 261 | return redirect("/create-org", { refreshedSession: session.refreshedSession }); | 259 | return redirect("/create-org", { |
| 260 | refreshedSession: session.refreshedSession, | ||
| 261 | }); | ||
| 262 | } | 262 | } |
| 263 | 263 | ||
| 264 | // A BARE console path (no org slug in the URL) canonicalizes onto the org | 264 | // A BARE console path (no org slug in the URL) canonicalizes onto the org |
| @@ -87,7 +87,6 @@ import { encodeLoginState } from "./login-state"; · 86 unmodified lines | |||
| 87 | import { cloudMemberDirectoryLayer } from "./member-directory"; | 87 | import { cloudMemberDirectoryLayer } from "./member-directory"; |
| 88 | import { SessionAuthLive } from "./middleware-live"; | 88 | import { SessionAuthLive } from "./middleware-live"; |
| 89 | import { mirrorSignIn } from "./mirror-feeders"; | 89 | import { mirrorSignIn } from "./mirror-feeders"; |
| 90 | import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness"; | ||
| 91 | import { | 90 | import { |
| 92 | ORG_SELECTOR_HEADER, | 91 | ORG_SELECTOR_HEADER, |
| 93 | authorizeOrganization, | 92 | authorizeOrganization, |
| @@ -172,14 +171,6 @@ const seedOrganization = (id: string) => · 78 unmodified lines | |||
| 172 | ), | 171 | ), |
| 173 | ); | 172 | ); |
| 174 | 173 | ||
| 175 | // The mirror is READY throughout (backfill complete, reconciler caught up): | ||
| 176 | // every membership read below is against the mirror, never WorkOS. The | ||
| 177 | // readiness rule itself is pinned in workos-mirror.node.test.ts and the | ||
| 178 | // fallback in org-selector-auth.node.test.ts. | ||
| 179 | const readyMirror = Layer.succeed(MirrorReadiness)({ | ||
| 180 | state: () => Effect.succeed(MirrorReadinessState.Ready()), | ||
| 181 | }); | ||
| 182 | |||
| 183 | const stubAutumn = Layer.succeed(AutumnService)({ | 174 | const stubAutumn = Layer.succeed(AutumnService)({ |
| 184 | use: () => Effect.die("feeders do not read billing"), | 175 | use: () => Effect.die("feeders do not read billing"), |
| 185 | ensureCustomer: () => Effect.void, | 176 | ensureCustomer: () => Effect.void, |
| @@ -500,7 +491,12 @@ describe("a delayed sign-in feeder", () => { · 314 unmodified lines | |||
| 500 | const rejoinedAt = "2026-01-03T00:00:00.000Z"; | 491 | const rejoinedAt = "2026-01-03T00:00:00.000Z"; |
| 501 | yield* mirrorSignIn( | 492 | yield* mirrorSignIn( |
| 502 | workosUser(userId), | 493 | workosUser(userId), |
| 503 | [workosMembership(userId, org, { id: `om_${userId}_${org}_2`, updatedAt: rejoinedAt })], | 494 | [ |
| 495 | workosMembership(userId, org, { | ||
| 496 | id: `om_${userId}_${org}_2`, | ||
| 497 | updatedAt: rejoinedAt, | ||
| 498 | }), | ||
| 499 | ], | ||
| 504 | new Date(rejoinedAt), | 500 | new Date(rejoinedAt), |
| 505 | ); | 501 | ); |
| 506 | return { membership, rejoined: yield* readMembership(userId, org) }; | 502 | return { membership, rejoined: yield* readMembership(userId, org) }; |
| @@ -581,20 +577,14 @@ describe("session handlers read membership from the mirror", () => { · 74 unmodified lines | |||
| 581 | DbService | UserStoreService | WorkOsMirror | MemberDirectory | 577 | DbService | UserStoreService | WorkOsMirror | MemberDirectory |
| 582 | >; | 578 | >; |
| 583 | readonly autumn?: Layer.Layer<AutumnService>; | 579 | readonly autumn?: Layer.Layer<AutumnService>; |
| 584 | /** The mirror's readiness for this request; ready unless a test says otherwise. */ | ||
| 585 | readonly readiness?: Layer.Layer<MirrorReadiness>; | ||
| 586 | } = {}, | 580 | } = {}, |
| 587 | ) => | 581 | ) => |
| 588 | HttpRouter.toWebHandler( | 582 | HttpRouter.toWebHandler( |
| 589 | HttpApiBuilder.layer(NonProtectedApi).pipe( | 583 | HttpApiBuilder.layer(NonProtectedApi).pipe( |
| 590 | Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), | 584 | Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), |
| 591 | Layer.provide( | 585 | Layer.provide( |
| 592 | requestScopedMiddleware( | 586 | requestScopedMiddleware(Layer.mergeAll(options.services ?? RequestScopedServicesLive)) |
| 593 | Layer.mergeAll( | 587 | .layer, |
| 594 | options.services ?? RequestScopedServicesLive, | ||
| 595 | options.readiness ?? readyMirror, | ||
| 596 | ), | ||
| 597 | ).layer, | ||
| 598 | ), | 588 | ), |
| 599 | Layer.provideMerge(SessionAuthLive), | 589 | Layer.provideMerge(SessionAuthLive), |
| 600 | Layer.provideMerge(options.autumn ?? stubAutumn), | 590 | Layer.provideMerge(options.autumn ?? stubAutumn), |
| @@ -627,9 +617,10 @@ describe("session handlers read membership from the mirror", () => { · 26 unmodified lines | |||
| 627 | ); | 617 | ); |
| 628 | 618 | ||
| 629 | /** | 619 | /** |
| 630 | * `authorizeOrganization` over the live stores and a READY mirror, as every | 620 | * `authorizeOrganization` over the live stores, as every protected request |
| 631 | * protected request runs it; `workos` serves whatever the check may read | 621 | * runs it: membership is read from the mirror unconditionally; `workos` |
| 632 | * from WorkOS (nothing, by default: any read dies). | 622 | * serves whatever the check may read from WorkOS (nothing, by default: any |
| 623 | * read dies). | ||
| 633 | */ | 624 | */ |
| 634 | const authorize = ( | 625 | const authorize = ( |
| 635 | userId: string, | 626 | userId: string, |
| @@ -639,12 +630,9 @@ describe("session handlers read membership from the mirror", () => { · 3 unmodified lines | |||
| 639 | Effect.runPromise( | 630 | Effect.runPromise( |
| 640 | authorizeOrganization(userId, org).pipe( | 631 | authorizeOrganization(userId, org).pipe( |
| 641 | Effect.provide( | 632 | Effect.provide( |
| 642 | Layer.mergeAll( | 633 | Layer.mergeAll(UserStoreService.Live, WorkOsMirror.Live, cloudMemberDirectoryLayer).pipe( |
| 643 | UserStoreService.Live, | 634 | Layer.provideMerge(DbService.Live), |
| 644 | WorkOsMirror.Live, | 635 | ), |
| 645 | cloudMemberDirectoryLayer, | ||
| 646 | readyMirror, | ||
| 647 | ).pipe(Layer.provideMerge(DbService.Live)), | ||
| 648 | ), | 636 | ), |
| 649 | Effect.provide(workos), | 637 | Effect.provide(workos), |
| 650 | Effect.scoped, | 638 | Effect.scoped, |
| @@ -665,7 +653,12 @@ describe("session handlers read membership from the mirror", () => { · 14 unmodified lines | |||
| 665 | purges.push(op); | 653 | purges.push(op); |
| 666 | }).pipe( | 654 | }).pipe( |
| 667 | Effect.flatMap(() => | 655 | Effect.flatMap(() => |
| 668 | Effect.fail(new UserStoreError({ operation: op, reason: "connection_closed" })), | 656 | Effect.fail( |
| 657 | new UserStoreError({ | ||
| 658 | operation: op, | ||
| 659 | reason: "connection_closed", | ||
| 660 | }), | ||
| 661 | ), | ||
| 669 | ), | 662 | ), |
| 670 | ) | 663 | ) |
| 671 | : live.use(op, fn), | 664 | : live.use(op, fn), |
| @@ -914,7 +907,7 @@ describe("session handlers read membership from the mirror", () => { · 242 unmodified lines | |||
| 914 | expect(await authorized(admin, org)).toBe(false); | 907 | expect(await authorized(admin, org)).toBe(false); |
| 915 | }); | 908 | }); |
| 916 | 909 | ||
| 917 | it("finishes on a retry while the mirror is not ready, after WorkOS already deleted the org", async () => { | 910 | it("finishes on a retry after WorkOS already deleted the org", async () => { |
| 918 | const admin = freshId("user"); | 911 | const admin = freshId("user"); |
| 919 | const member = freshId("user"); | 912 | const member = freshId("user"); |
| 920 | const org = freshId("org"); | 913 | const org = freshId("org"); |
| @@ -931,19 +924,17 @@ describe("session handlers read membership from the mirror", () => { · 10 unmodified lines | |||
| 931 | expect(first.status).toBe(500); | 924 | expect(first.status).toBe(500); |
| 932 | expect(purges).toEqual(["deleteOrganizationCascade"]); | 925 | expect(purges).toEqual(["deleteOrganizationCascade"]); |
| 933 | 926 | ||
| 934 | // The reconciler stalls before the retry. WorkOS no longer lists the org | 927 | // WorkOS no longer has the org to delete a second time. The admin's own |
| 935 | // or the admin's membership in it — and the fallback must not ask it: | 928 | // mirror row, which the failed purge left behind, is what admits the |
| 936 | // the stub dies on `listUserMemberships`. The admin's own mirror row, | 929 | // retry — membership is read from the mirror unconditionally. |
| 937 | // which the failed purge left behind, is what admits the retry. | ||
| 938 | const retry = sessionHandler(admin, { | 930 | const retry = sessionHandler(admin, { |
| 939 | autumn: deletingAutumn, | 931 | autumn: deletingAutumn, |
| 940 | readiness: Layer.succeed(MirrorReadiness)({ | 932 | workos: { |
| 941 | state: () => Effect.succeed(MirrorReadinessState.ReconcilerStale({ drainedAt: null })), | 933 | deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })), |
| 942 | }), | 934 | }, |
| 943 | workos: { deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })) }, | ||
| 944 | }); | 935 | }); |
| 945 | const second = await retry(deleteOrganizationRequest(org)); | 936 | const second = await retry(deleteOrganizationRequest(org)); |
| 946 | expect(second.status, "the retry is admitted from the mirror, not WorkOS").toBe(200); | 937 | expect(second.status, "the retry is admitted from the mirror").toBe(200); |
| 947 | expect(await second.json()).toEqual({ success: true }); | 938 | expect(await second.json()).toEqual({ success: true }); |
| 948 | expect(await readMembers(org), "and the purge ran").toEqual([]); | 939 | expect(await readMembers(org), "and the purge ran").toEqual([]); |
| 949 | expect((await readOrganization(org))?.deletedAt).not.toBeNull(); | 940 | expect((await readOrganization(org))?.deletedAt).not.toBeNull(); |
| @@ -961,7 +952,9 @@ describe("session handlers read membership from the mirror", () => { · 11 unmodified lines | |||
| 961 | calls.push(`getUserOrgMembership:${userId}`); | 952 | calls.push(`getUserOrgMembership:${userId}`); |
| 962 | return Effect.succeed( | 953 | return Effect.succeed( |
| 963 | userId === memberId | 954 | userId === memberId |
| 964 | ? (workosMembership(userId, organizationId, { role: { slug: "admin" } }) as never) | 955 | ? (workosMembership(userId, organizationId, { |
| 956 | role: { slug: "admin" }, | ||
| 957 | }) as never) | ||
| 965 | : null, | 958 | : null, |
| 966 | ); | 959 | ); |
| 967 | }, | 960 | }, |
| @@ -1089,7 +1082,9 @@ describe("session handlers read membership from the mirror", () => { · 121 unmodified lines | |||
| 1089 | ); | 1082 | ); |
| 1090 | 1083 | ||
| 1091 | expect(response.status).toBe(200); | 1084 | expect(response.status).toBe(200); |
| 1092 | const body = (await response.json()) as { organizations: { id: string; slug: string }[] }; | 1085 | const body = (await response.json()) as { |
| 1086 | organizations: { id: string; slug: string }[]; | ||
| 1087 | }; | ||
| 1093 | expect(body.organizations.map((o) => [o.id, o.slug])).toEqual([[live, liveSlug]]); | 1088 | expect(body.organizations.map((o) => [o.id, o.slug])).toEqual([[live, liveSlug]]); |
| 1094 | }); | 1089 | }); |
| 1095 | }); | 1090 | }); |
| @@ -1156,7 +1151,6 @@ describe("account service writes through to the mirror", () => { · 60 unmodified lines | |||
| 1156 | UserStoreService.Live, | 1151 | UserStoreService.Live, |
| 1157 | WorkOsMirror.Live, | 1152 | WorkOsMirror.Live, |
| 1158 | cloudMemberDirectoryLayer, | 1153 | cloudMemberDirectoryLayer, |
| 1159 | readyMirror, | ||
| 1160 | ); | 1154 | ); |
| 1161 | return workosAccountProvider.pipe( | 1155 | return workosAccountProvider.pipe( |
| 1162 | Layer.provide( | 1156 | Layer.provide( |
| @@ -1778,7 +1772,17 @@ describe("backfill", () => { · 615 unmodified lines | |||
| 1778 | ); | 1772 | ); |
| 1779 | 1773 | ||
| 1780 | const counts = await runBackfill( | 1774 | const counts = await runBackfill( |
| 1781 | new Map([[org, [workosMembership(paused, org, { status: "inactive", updatedAt: T2 })]]]), | 1775 | new Map([ |
| 1776 | [ | ||
| 1777 | org, | ||
| 1778 | [ | ||
| 1779 | workosMembership(paused, org, { | ||
| 1780 | status: "inactive", | ||
| 1781 | updatedAt: T2, | ||
| 1782 | }), | ||
| 1783 | ], | ||
| 1784 | ], | ||
| 1785 | ]), | ||
| 1782 | false, | 1786 | false, |
| 1783 | ); | 1787 | ); |
| 1784 | expect(counts, "the deactivated membership is written, not tombstoned").toMatchObject({ | 1788 | expect(counts, "the deactivated membership is written, not tombstoned").toMatchObject({ |
| @@ -1904,7 +1908,10 @@ describe("backfill", () => { · 119 unmodified lines | |||
| 1904 | [orgB, [workosMembership(staying, orgB)]], | 1908 | [orgB, [workosMembership(staying, orgB)]], |
| 1905 | ]); | 1909 | ]); |
| 1906 | const retried = await runBackfill(attemptB, false); | 1910 | const retried = await runBackfill(attemptB, false); |
| 1907 | expect(retried).toMatchObject({ organizations: 2, membershipsTombstoned: 1 }); | 1911 | expect(retried).toMatchObject({ |
| 1912 | organizations: 2, | ||
| 1913 | membershipsTombstoned: 1, | ||
| 1914 | }); | ||
| 1908 | expect( | 1915 | expect( |
| 1909 | await syncState(), | 1916 | await syncState(), |
| 1910 | "the retry keeps the first attempt's boundary instead of taking a later one", | 1917 | "the retry keeps the first attempt's boundary instead of taking a later one", |
| @@ -1,33 +1,30 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Mirror READINESS: whether the local membership mirror may be trusted as | 2 | // Mirror READINESS: whether the local membership mirror has ever been fit to |
| 3 | // the membership authority for a request, or WorkOS must still be asked. | 3 | // authorize from, per the ORIGINAL cutover rule. The request path |
| 4 | // (`organization.ts`) no longer consults this — it authorizes from the | ||
| 5 | // mirror unconditionally, because the one-off backfill is complete and | ||
| 6 | // permanent and a pre-mirror organization is covered by the on-demand scan | ||
| 7 | // (`ensureOrganizationBackfilled`). What remains is the deploy gate | ||
| 8 | // (`scripts/ensure-workos-mirror-ready.ts`), which still refuses to ship a | ||
| 9 | // build that trusts the mirror while it is unready, and the reconciler's own | ||
| 10 | // staleness alert (`workos-events-runner.ts`), which reads `drainedAt` after | ||
| 11 | // each run and raises a Sentry error when the drain has fallen behind the lag | ||
| 12 | // budget below — a stalled reconciler is now an operational page, not a | ||
| 13 | // per-request fallback. | ||
| 4 | // | 14 | // |
| 5 | // The mirror is fed by login, write-through, and the Events API reconciler | 15 | // Readiness is BOTH: the backfill's completion mark |
| 6 | // (`workos-mirror.ts`), and is complete only once the one-off backfill has | ||
| 7 | // written every organization and the reconciler has caught up to the | ||
| 8 | // present. Before that, two things go wrong if it is trusted anyway: | ||
| 9 | // - a member who has not signed in since the mirror shipped has no row | ||
| 10 | // yet, and every protected request of theirs is refused — the backfill | ||
| 11 | // is what writes them; | ||
| 12 | // - a member revoked in the WorkOS dashboard while the reconciler was not | ||
| 13 | // running still holds an active row, and keeps their access until the | ||
| 14 | // stream is replayed — the reconciler is what tombstones them. | ||
| 15 | // So readiness is BOTH: the backfill's completion mark | ||
| 16 | // (`workos_sync.backfill_completed_at`, written once by a run that covered | 16 | // (`workos_sync.backfill_completed_at`, written once by a run that covered |
| 17 | // every live organization) AND a recent drain of the events stream | 17 | // every live organization) AND a recent drain of the events stream |
| 18 | // (`workos_sync.drained_at`, moved forward by every reconciler run that read | 18 | // (`workos_sync.drained_at`, moved forward by every reconciler run that read |
| 19 | // the stream to its end). The lag budget bounds how far behind the reconciler | 19 | // the stream to its end). The lag budget bounds how far behind the reconciler |
| 20 | // may be: it runs every minute, so a mark older than the budget means it has | 20 | // may be: it runs every minute, so a mark older than the budget means it has |
| 21 | // stalled (WorkOS unreachable, the cron not deployed, a backlog draining over | 21 | // stalled (WorkOS unreachable, the cron not deployed, a backlog draining over |
| 22 | // many runs) and the mirror may be missing revocations. While either half is | 22 | // many runs) and the mirror may be missing revocations. |
| 23 | // missing the authorization path reads membership from WorkOS instead | ||
| 24 | // (`organization.ts`), exactly as it did before the cutover; nothing is | ||
| 25 | // denied or granted on the mirror's word. | ||
| 26 | // | 23 | // |
| 27 | // The rule and the row read live here, free of `cloudflare:workers`, so the | 24 | // The rule and the row read live here, free of `cloudflare:workers`, so the |
| 28 | // deploy gate (`scripts/ensure-workos-mirror-ready.ts`) applies the SAME rule | 25 | // deploy gate applies the SAME rule over a plain postgres.js connection under |
| 29 | // over a plain postgres.js connection under bun before the build that trusts | 26 | // bun before a build goes live, and the reconciler's alert applies the SAME |
| 30 | // the mirror goes live. The request-scoped service is `mirror-readiness.ts`. | 27 | // `drainedAt` age check the gate does. |
| 31 | // --------------------------------------------------------------------------- | 28 | // --------------------------------------------------------------------------- |
| 32 | 29 | ||
| 33 | import { eq } from "drizzle-orm"; | 30 | import { eq } from "drizzle-orm"; |
| @@ -1,69 +0,0 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // MirrorReadiness — the request-scoped service that answers whether the | ||
| 3 | // membership mirror may authorize this request (see | ||
| 4 | // `mirror-readiness-store.ts` for the rule and why it exists). | ||
| 5 | // | ||
| 6 | // Per-request layer shape, like `UserStoreService` and `WorkOsMirror`: it | ||
| 7 | // reads the request's postgres socket, so it is rebuilt per request | ||
| 8 | // (`RequestScopedServicesLive`) and never shared across Workers requests. One | ||
| 9 | // indexed point read per authorization, on the same socket the membership | ||
| 10 | // read uses next. | ||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | |||
| 13 | import { Clock, Context, Effect, Layer } from "effect"; | ||
| 14 | |||
| 15 | import { DbService, type DrizzleDb } from "../db/db"; | ||
| 16 | import { | ||
| 17 | WorkOsMirrorError, | ||
| 18 | tryPromiseService, | ||
| 19 | userStoreReasonFromCause, | ||
| 20 | withServiceLogging, | ||
| 21 | } from "./errors"; | ||
| 22 | import { readMirrorReadiness, type MirrorReadinessState } from "./mirror-readiness-store"; | ||
| 23 | |||
| 24 | export { | ||
| 25 | MIRROR_RECONCILER_LAG_BUDGET, | ||
| 26 | MirrorReadinessState, | ||
| 27 | describeMirrorReadiness, | ||
| 28 | mirrorReadinessFrom, | ||
| 29 | type MirrorReadinessRow, | ||
| 30 | } from "./mirror-readiness-store"; | ||
| 31 | |||
| 32 | export interface MirrorReadinessShape { | ||
| 33 | /** | ||
| 34 | * The mirror's readiness as of now. Fails with `WorkOsMirrorError` when the | ||
| 35 | * row cannot be read — the caller must not treat that as either ready or | ||
| 36 | * not; it is the same infra failure as any other mirror read. | ||
| 37 | */ | ||
| 38 | readonly state: () => Effect.Effect<MirrorReadinessState, WorkOsMirrorError>; | ||
| 39 | } | ||
| 40 | |||
| 41 | const makeService = (db: DrizzleDb): MirrorReadinessShape => ({ | ||
| 42 | state: () => | ||
| 43 | Effect.flatMap(Clock.currentTimeMillis, (millis) => | ||
| 44 | withServiceLogging( | ||
| 45 | "workos_mirror.readiness", | ||
| 46 | (failure) => | ||
| 47 | new WorkOsMirrorError({ | ||
| 48 | operation: "readiness", | ||
| 49 | reason: userStoreReasonFromCause(failure), | ||
| 50 | }), | ||
| 51 | tryPromiseService(() => readMirrorReadiness(db, new Date(millis))), | ||
| 52 | ), | ||
| 53 | ), | ||
| 54 | }); | ||
| 55 | |||
| 56 | export class MirrorReadiness extends Context.Service<MirrorReadiness, MirrorReadinessShape>()( | ||
| 57 | "@executor-js/cloud/MirrorReadiness", | ||
| 58 | ) { | ||
| 59 | static Live = Layer.effect(this)(Effect.map(DbService.asEffect(), ({ db }) => makeService(db))); | ||
| 60 | } | ||
| 61 | |||
| 62 | /** | ||
| 63 | * A FRESH `MirrorReadiness` layer (new layer value per call), for a service | ||
| 64 | * built once but invoked across many Workers requests — the MCP | ||
| 65 | * org-authorization seam and the document gate — for the same reason | ||
| 66 | * `makeUserStoreLayer` exists. See [[makeDbLayer]]. | ||
| 67 | */ | ||
| 68 | export const makeMirrorReadinessLayer = (): Layer.Layer<MirrorReadiness, never, DbService> => | ||
| 69 | Layer.effect(MirrorReadiness)(Effect.map(DbService.asEffect(), ({ db }) => makeService(db))); | ||
| @@ -5,7 +5,6 @@ import { MemberDirectory, NoOrganization } from "@executor-js/api/server"; · 4 unmodified lines | |||
| 5 | 5 | ||
| 6 | import { ApiKeyService } from "./api-keys"; | 6 | import { ApiKeyService } from "./api-keys"; |
| 7 | import { UserStoreService } from "./context"; | 7 | import { UserStoreService } from "./context"; |
| 8 | import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness"; | ||
| 9 | import { WorkOSClient, type WorkOSClientService } from "./workos"; | 8 | import { WorkOSClient, type WorkOSClientService } from "./workos"; |
| 10 | import { WorkOsMirror, type WorkOsMirrorShape } from "./workos-mirror"; | 9 | import { WorkOsMirror, type WorkOsMirrorShape } from "./workos-mirror"; |
| 11 | import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./workos-auth-provider"; | 10 | import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./workos-auth-provider"; |
| @@ -70,13 +69,8 @@ const stubWorkOS = Layer.succeed( · 58 unmodified lines | |||
| 70 | ); | 69 | ); |
| 71 | 70 | ||
| 72 | // The mirror as the directory reads it: user_123 holds an active membership in | 71 | // The mirror as the directory reads it: user_123 holds an active membership in |
| 73 | // org_123 and nothing else. Membership is never read from WorkOS. | 72 | // org_123 and nothing else. Membership is always read from the mirror, never |
| 74 | // The mirror is READY in these tests (backfill complete, reconciler caught | 73 | // from WorkOS. |
| 75 | // up), so membership is read from the stubbed directory, never from WorkOS. | ||
| 76 | const stubReadiness = Layer.succeed(MirrorReadiness)({ | ||
| 77 | state: () => Effect.succeed(MirrorReadinessState.Ready()), | ||
| 78 | }); | ||
| 79 | |||
| 80 | const stubDirectory = Layer.succeed(MemberDirectory)({ | 74 | const stubDirectory = Layer.succeed(MemberDirectory)({ |
| 81 | membership: (accountId, organizationId) => | 75 | membership: (accountId, organizationId) => |
| 82 | Effect.succeed( | 76 | Effect.succeed( |
| @@ -149,14 +143,7 @@ const stubMirror = Layer.succeed( · 66 unmodified lines | |||
| 149 | }), | 143 | }), |
| 150 | ); | 144 | ); |
| 151 | 145 | ||
| 152 | const layers = Layer.mergeAll( | 146 | const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror); |
| 153 | stubApiKeys, | ||
| 154 | stubWorkOS, | ||
| 155 | stubUsers, | ||
| 156 | stubDirectory, | ||
| 157 | stubMirror, | ||
| 158 | stubReadiness, | ||
| 159 | ); | ||
| 160 | 147 | ||
| 161 | const bearer = (token: string) => | 148 | const bearer = (token: string) => |
| 162 | new Request("https://executor.test/api/tools", { | 149 | new Request("https://executor.test/api/tools", { |
| @@ -224,14 +211,7 @@ describe("org-level API keys", () => { · 61 unmodified lines | |||
| 224 | const exit = yield* Effect.exit( | 211 | const exit = yield* Effect.exit( |
| 225 | resolveBearerAuth(bearer("valid_org_key")).pipe( | 212 | resolveBearerAuth(bearer("valid_org_key")).pipe( |
| 226 | Effect.provide( | 213 | Effect.provide( |
| 227 | Layer.mergeAll( | 214 | Layer.mergeAll(stubApiKeys, stubWorkOS, deletedOrgUsers, stubDirectory, stubMirror), |
| 228 | stubApiKeys, | ||
| 229 | stubWorkOS, | ||
| 230 | deletedOrgUsers, | ||
| 231 | stubDirectory, | ||
| 232 | stubMirror, | ||
| 233 | stubReadiness, | ||
| 234 | ), | ||
| 235 | ), | 215 | ), |
| 236 | ), | 216 | ), |
| 237 | ); | 217 | ); |
| @@ -292,14 +272,7 @@ describe("org-level API keys", () => { · 54 unmodified lines | |||
| 292 | }); | 272 | }); |
| 293 | const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe( | 273 | const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe( |
| 294 | Effect.provide( | 274 | Effect.provide( |
| 295 | Layer.mergeAll( | 275 | Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, noMembershipReads, stubMirror), |
| 296 | stubApiKeys, | ||
| 297 | stubWorkOS, | ||
| 298 | stubUsers, | ||
| 299 | noMembershipReads, | ||
| 300 | stubMirror, | ||
| 301 | stubReadiness, | ||
| 302 | ), | ||
| 303 | ), | 276 | ), |
| 304 | ); | 277 | ); |
| 305 | 278 | ||
| @@ -6,7 +6,6 @@ import { MemberDirectory, type DirectoryMember } from "@executor-js/api/server"; · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | import { ApiKeyService } from "./api-keys"; | 7 | import { ApiKeyService } from "./api-keys"; |
| 8 | import { UserStoreService } from "./context"; | 8 | import { UserStoreService } from "./context"; |
| 9 | import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness"; | ||
| 10 | import { AUTHORIZE_ORGANIZATION_SPAN } from "./organization"; | 9 | import { AUTHORIZE_ORGANIZATION_SPAN } from "./organization"; |
| 11 | import { resolveSessionPrincipal } from "./workos-auth-provider"; | 10 | import { resolveSessionPrincipal } from "./workos-auth-provider"; |
| 12 | import { WorkOSClient, type WorkOSClientService } from "./workos"; | 11 | import { WorkOSClient, type WorkOSClientService } from "./workos"; |
| @@ -68,12 +67,6 @@ const memberships = new Map<string, DirectoryMember>([ · 55 unmodified lines | |||
| 68 | [PENDING_ORG, mirrored(PENDING_ORG, { status: "pending" })], | 67 | [PENDING_ORG, mirrored(PENDING_ORG, { status: "pending" })], |
| 69 | ]); | 68 | ]); |
| 70 | 69 | ||
| 71 | // The mirror is READY in these tests (backfill complete, reconciler caught | ||
| 72 | // up), so membership is read from the stubbed directory, never from WorkOS. | ||
| 73 | const stubReadiness = Layer.succeed(MirrorReadiness)({ | ||
| 74 | state: () => Effect.succeed(MirrorReadinessState.Ready()), | ||
| 75 | }); | ||
| 76 | |||
| 77 | const stubDirectory = Layer.succeed(MemberDirectory)({ | 70 | const stubDirectory = Layer.succeed(MemberDirectory)({ |
| 78 | membership: (accountId, organizationId) => | 71 | membership: (accountId, organizationId) => |
| 79 | Effect.succeed(accountId === MEMBER ? (memberships.get(organizationId) ?? null) : null), | 72 | Effect.succeed(accountId === MEMBER ? (memberships.get(organizationId) ?? null) : null), |
| @@ -167,85 +160,28 @@ const stubMirror = Layer.succeed( · 87 unmodified lines | |||
| 167 | }), | 160 | }), |
| 168 | ); | 161 | ); |
| 169 | 162 | ||
| 170 | const run = ( | 163 | const run = (headers: Record<string, string>) => |
| 171 | headers: Record<string, string>, | ||
| 172 | readiness: Layer.Layer<MirrorReadiness> = stubReadiness, | ||
| 173 | ) => | ||
| 174 | resolveSessionPrincipal(new Request("https://executor.test/api/tools", { headers })).pipe( | 164 | resolveSessionPrincipal(new Request("https://executor.test/api/tools", { headers })).pipe( |
| 175 | Effect.provide( | 165 | Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror)), |
| 176 | Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror, readiness), | ||
| 177 | ), | ||
| 178 | ); | 166 | ); |
| 179 | 167 | ||
| 180 | // The mirror before the cutover has landed: the backfill has not covered | ||
| 181 | // every organization, or the reconciler has not drained recently. | ||
| 182 | const unreadyMirror = (state: MirrorReadinessState) => | ||
| 183 | Layer.succeed(MirrorReadiness)({ state: () => Effect.succeed(state) }); | ||
| 184 | |||
| 185 | /** | 168 | /** |
| 186 | * A WorkOS that answers the pre-cutover membership list for MEMBER — active | 169 | * A tracer that keeps every span's attributes, so the authorization span |
| 187 | * in SESSION_ORG only, as a member — and records the calls, so the fallback | 170 | * itself — {@link AUTHORIZE_ORGANIZATION_SPAN} — is assertable. |
| 188 | * is assertable: with the mirror unready the list is read from WorkOS and | ||
| 189 | * the directory (which says MEMBER is active in URL_ORG too) is never asked. | ||
| 190 | */ | ||
| 191 | const workosMemberships = (calls: string[]) => | ||
| 192 | Layer.succeed( | ||
| 193 | WorkOSClient, | ||
| 194 | new Proxy({} as WorkOSClientService, { | ||
| 195 | get: (_t, prop) => { | ||
| 196 | if (prop === "authenticateRequest") { | ||
| 197 | return () => | ||
| 198 | Effect.succeed({ | ||
| 199 | userId: MEMBER, | ||
| 200 | email: "u@e2e.test", | ||
| 201 | organizationId: SESSION_ORG, | ||
| 202 | }); | ||
| 203 | } | ||
| 204 | if (prop === "listUserMemberships") { | ||
| 205 | return (userId: string) => | ||
| 206 | Effect.sync(() => { | ||
| 207 | calls.push(`listUserMemberships:${userId}`); | ||
| 208 | return { | ||
| 209 | object: "list" as const, | ||
| 210 | data: [ | ||
| 211 | { | ||
| 212 | id: `om_${MEMBER}_${SESSION_ORG}`, | ||
| 213 | userId: MEMBER, | ||
| 214 | organizationId: SESSION_ORG, | ||
| 215 | status: "active", | ||
| 216 | role: { slug: "member" }, | ||
| 217 | }, | ||
| 218 | ] as never[], | ||
| 219 | listMetadata: { before: null, after: null }, | ||
| 220 | }; | ||
| 221 | }); | ||
| 222 | } | ||
| 223 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | ||
| 224 | }, | ||
| 225 | }), | ||
| 226 | ); | ||
| 227 | |||
| 228 | const unreadDirectory = Layer.succeed(MemberDirectory)({ | ||
| 229 | membership: () => Effect.die("an unready mirror must not be asked for membership"), | ||
| 230 | membershipById: () => Effect.die("an unready mirror must not be asked for membership"), | ||
| 231 | membershipsOf: () => Effect.die("an unready mirror must not be asked for membership"), | ||
| 232 | members: () => Effect.die("an unready mirror must not be asked for membership"), | ||
| 233 | membersById: () => Effect.die("an unready mirror must not be asked for membership"), | ||
| 234 | findByEmail: () => Effect.die("an unready mirror must not be asked for membership"), | ||
| 235 | }); | ||
| 236 | |||
| 237 | /** | ||
| 238 | * A tracer that keeps every span's attributes, so the readiness decision the | ||
| 239 | * authorization stamps on its span (`mirror.ready`, `mirror.readiness`) is | ||
| 240 | * assertable: that attribute is what production counts the fallback by. | ||
| 241 | */ | 171 | */ |
| 242 | const makeRecordingTracer = () => { | 172 | const makeRecordingTracer = () => { |
| 243 | const spans: { readonly name: string; readonly attributes: Map<string, unknown> }[] = []; | 173 | const spans: { |
| 174 | readonly name: string; | ||
| 175 | readonly attributes: Map<string, unknown>; | ||
| 176 | }[] = []; | ||
| 244 | const tracer: Tracer.Tracer = { | 177 | const tracer: Tracer.Tracer = { |
| 245 | span: (options) => { | 178 | span: (options) => { |
| 246 | const attributes = new Map<string, unknown>(); | 179 | const attributes = new Map<string, unknown>(); |
| 247 | spans.push({ name: options.name, attributes }); | 180 | spans.push({ name: options.name, attributes }); |
| 248 | let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; | 181 | let status: Tracer.SpanStatus = { |
| 182 | _tag: "Started", | ||
| 183 | startTime: options.startTime, | ||
| 184 | }; | ||
| 249 | return { | 185 | return { |
| 250 | _tag: "Span", | 186 | _tag: "Span", |
| 251 | name: options.name, | 187 | name: options.name, |
| @@ -261,7 +197,12 @@ const makeRecordingTracer = () => { · 9 unmodified lines | |||
| 261 | sampled: options.sampled, | 197 | sampled: options.sampled, |
| 262 | kind: options.kind, | 198 | kind: options.kind, |
| 263 | end: (endTime, exit) => { | 199 | end: (endTime, exit) => { |
| 264 | status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; | 200 | status = { |
| 201 | _tag: "Ended", | ||
| 202 | startTime: options.startTime, | ||
| 203 | endTime, | ||
| 204 | exit, | ||
| 205 | }; | ||
| 265 | }, | 206 | }, |
| 266 | attribute: (key, value) => { | 207 | attribute: (key, value) => { |
| 267 | attributes.set(key, value); | 208 | attributes.set(key, value); |
| @@ -275,24 +216,6 @@ const makeRecordingTracer = () => { · 7 unmodified lines | |||
| 275 | return { tracer, attributesOf }; | 216 | return { tracer, attributesOf }; |
| 276 | }; | 217 | }; |
| 277 | 218 | ||
| 278 | const runAgainstWorkOs = (headers: Record<string, string>, state: MirrorReadinessState) => { | ||
| 279 | const calls: string[] = []; | ||
| 280 | return resolveSessionPrincipal(new Request("https://executor.test/api/tools", { headers })) | ||
| 281 | .pipe( | ||
| 282 | Effect.provide( | ||
| 283 | Layer.mergeAll( | ||
| 284 | stubApiKeys, | ||
| 285 | workosMemberships(calls), | ||
| 286 | stubUsers, | ||
| 287 | unreadDirectory, | ||
| 288 | stubMirror, | ||
| 289 | unreadyMirror(state), | ||
| 290 | ), | ||
| 291 | ), | ||
| 292 | ) | ||
| 293 | .pipe(Effect.map((principal) => ({ principal, calls }))); | ||
| 294 | }; | ||
| 295 | |||
| 296 | describe("resolveSessionPrincipal · URL org selector", () => { | 219 | describe("resolveSessionPrincipal · URL org selector", () => { |
| 297 | it.effect("fails closed when no selector header is sent", () => | 220 | it.effect("fails closed when no selector header is sent", () => |
| 298 | Effect.gen(function* () { | 221 | Effect.gen(function* () { |
| @@ -358,79 +281,21 @@ describe("resolveSessionPrincipal · URL org selector", () => { · 59 unmodified lines | |||
| 358 | ); | 281 | ); |
| 359 | }); | 282 | }); |
| 360 | 283 | ||
| 361 | // The mirror authorizes only while it is READY (`mirror-readiness.ts`): | 284 | // Membership is read from the local mirror unconditionally — there is no |
| 362 | // the backfill has written every organization and the reconciler has drained | 285 | // readiness gate and no per-request WorkOS fallback (`auth/organization.ts`). |
| 363 | // the stream within its lag budget. Until then the membership check reads | 286 | // The span every authorization runs under is still pinned here; a stalled |
| 364 | // WorkOS, as it did before the cutover — so a member the backfill has not | 287 | // reconciler is now an operational alert (`workos-events-runner.ts`), not a |
| 365 | // written yet is not locked out, and a stale mirror row cannot grant access | 288 | // request-path branch, so it has no span attribute left to assert on. |
| 366 | // WorkOS has revoked. | 289 | describe("resolveSessionPrincipal · authorization span", () => { |
| 367 | describe("resolveSessionPrincipal · mirror readiness", () => { | 290 | it.effect("runs the authorization under its span", () => |
| 368 | const unready: readonly [string, MirrorReadinessState][] = [ | ||
| 369 | ["the backfill has not completed", MirrorReadinessState.BackfillPending()], | ||
| 370 | ["the reconciler has never drained", MirrorReadinessState.ReconcilerStale({ drainedAt: null })], | ||
| 371 | [ | ||
| 372 | "the reconciler's last drain is older than the budget", | ||
| 373 | MirrorReadinessState.ReconcilerStale({ drainedAt: createdAt }), | ||
| 374 | ], | ||
| 375 | ]; | ||
| 376 | |||
| 377 | for (const [why, state] of unready) { | ||
| 378 | it.effect(`reads membership from WorkOS, not the mirror, while ${why}`, () => | ||
| 379 | Effect.gen(function* () { | ||
| 380 | // WorkOS says MEMBER is active in SESSION_ORG: authorized there... | ||
| 381 | const granted = yield* runAgainstWorkOs( | ||
| 382 | { cookie: "wos-session=x", "x-executor-organization": SESSION_ORG }, | ||
| 383 | state, | ||
| 384 | ); | ||
| 385 | expect(granted.principal.organizationId).toBe(SESSION_ORG); | ||
| 386 | expect(granted.principal.orgRole, "the role comes from WorkOS's list too").toBe("member"); | ||
| 387 | expect(granted.calls).toEqual([`listUserMemberships:${MEMBER}`]); | ||
| 388 | |||
| 389 | // ...and NOT in URL_ORG, even though the (unready) mirror holds an | ||
| 390 | // active admin membership there: the mirror's word is not taken. | ||
| 391 | const refused = yield* Effect.flip( | ||
| 392 | runAgainstWorkOs({ cookie: "wos-session=x", "x-executor-organization": URL_SLUG }, state), | ||
| 393 | ); | ||
| 394 | expect(refused).toMatchObject({ _tag: "NoOrganization" }); | ||
| 395 | }), | ||
| 396 | ); | ||
| 397 | } | ||
| 398 | |||
| 399 | it.effect("reads the mirror once it is ready, without any WorkOS membership call", () => | ||
| 400 | Effect.gen(function* () { | 291 | Effect.gen(function* () { |
| 401 | // `stubWorkOS` dies on any call past session authentication, so a | 292 | const recorder = makeRecordingTracer(); |
| 402 | // resolved principal here proves the list was never requested. | 293 | const principal = yield* run({ |
| 403 | const principal = yield* run( | 294 | cookie: "wos-session=x", |
| 404 | { cookie: "wos-session=x", "x-executor-organization": URL_SLUG }, | 295 | "x-executor-organization": URL_SLUG, |
| 405 | stubReadiness, | 296 | }).pipe(Effect.withTracer(recorder.tracer)); |
| 406 | ); | ||
| 407 | expect(principal.organizationId).toBe(URL_ORG); | 297 | expect(principal.organizationId).toBe(URL_ORG); |
| 408 | expect(principal.orgRole).toBe("admin"); | 298 | expect(recorder.attributesOf(AUTHORIZE_ORGANIZATION_SPAN)).toBeDefined(); |
| 409 | }), | ||
| 410 | ); | ||
| 411 | |||
| 412 | it.effect("stamps which source answered on the authorization span", () => | ||
| 413 | Effect.gen(function* () { | ||
| 414 | // The fallback is counted in production by this attribute, not by | ||
| 415 | // grepping the warning it logs beside it — so both branches must set it. | ||
| 416 | const ready = makeRecordingTracer(); | ||
| 417 | yield* run({ cookie: "wos-session=x", "x-executor-organization": URL_SLUG }).pipe( | ||
| 418 | Effect.withTracer(ready.tracer), | ||
| 419 | ); | ||
| 420 | const readyAttrs = ready.attributesOf(AUTHORIZE_ORGANIZATION_SPAN); | ||
| 421 | expect(readyAttrs?.get("mirror.ready"), "the mirror answered").toBe(true); | ||
| 422 | expect(readyAttrs?.get("mirror.readiness")).toBe("ready"); | ||
| 423 | |||
| 424 | const stale = makeRecordingTracer(); | ||
| 425 | yield* runAgainstWorkOs( | ||
| 426 | { cookie: "wos-session=x", "x-executor-organization": SESSION_ORG }, | ||
| 427 | MirrorReadinessState.ReconcilerStale({ drainedAt: null }), | ||
| 428 | ).pipe(Effect.withTracer(stale.tracer)); | ||
| 429 | const staleAttrs = stale.attributesOf(AUTHORIZE_ORGANIZATION_SPAN); | ||
| 430 | expect(staleAttrs?.get("mirror.ready"), "WorkOS answered").toBe(false); | ||
| 431 | expect(String(staleAttrs?.get("mirror.readiness")), "and the span says why").toContain( | ||
| 432 | "reconciler", | ||
| 433 | ); | ||
| 434 | }), | 299 | }), |
| 435 | ); | 300 | ); |
| 436 | }); | 301 | }); |
| @@ -18,7 +18,6 @@ import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared"; · 17 unmodified lines | |||
| 18 | 18 | ||
| 19 | import { UserStoreService } from "./context"; | 19 | import { UserStoreService } from "./context"; |
| 20 | import { ensureOrganizationBackfilled } from "./mirror-feeders"; | 20 | import { ensureOrganizationBackfilled } from "./mirror-feeders"; |
| 21 | import { MirrorReadiness, MirrorReadinessState, describeMirrorReadiness } from "./mirror-readiness"; | ||
| 22 | import type { Organization } from "./user-store"; | 21 | import type { Organization } from "./user-store"; |
| 23 | import { WorkOSClient } from "./workos"; | 22 | import { WorkOSClient } from "./workos"; |
| 24 | 23 | ||
| @@ -94,12 +93,11 @@ export const markOrganizationDeleted = (organizationId: string) => · 69 unmodified lines | |||
| 94 | // until their access token naturally expired (~10 min) if the session were | 93 | // until their access token naturally expired (~10 min) if the session were |
| 95 | // trusted on its own. | 94 | // trusted on its own. |
| 96 | // | 95 | // |
| 97 | // To close that gap, membership is verified on every protected request — but | 96 | // To close that gap, membership is verified on every protected request |
| 98 | // against the LOCAL mirror of WorkOS memberships (`memberships` join | 97 | // against the LOCAL mirror of WorkOS memberships (`memberships` join |
| 99 | // `accounts`, read through the shared `MemberDirectory`), never against WorkOS | 98 | // `accounts`, read through the shared `MemberDirectory`) — never against |
| 100 | // itself. This used to be one `listUserMemberships` call per request (2026-07: | 99 | // WorkOS itself, and unconditionally: there is no readiness gate and no |
| 101 | // deliberately NOT cached, because a positive TTL cache is exactly what would | 100 | // per-request WorkOS fallback. The mirror is not a cache with a TTL; it is a |
| 102 | // re-open the revocation gap). The mirror is not a cache with a TTL; it is a | ||
| 103 | // replica whose freshness is defined by its feeders: | 101 | // replica whose freshness is defined by its feeders: |
| 104 | // - login (`auth/handlers.ts` callback): the user and every membership WorkOS | 102 | // - login (`auth/handlers.ts` callback): the user and every membership WorkOS |
| 105 | // lists for them, from the list the callback already fetches; | 103 | // lists for them, from the list the callback already fetches; |
| @@ -117,45 +115,39 @@ export const markOrganizationDeleted = (organizationId: string) => · 11 unmodified lines | |||
| 117 | // not finish refuses every session at once — its membership rows are still | 115 | // not finish refuses every session at once — its membership rows are still |
| 118 | // there, live, until the purge removes them, and must not authorize anyone. | 116 | // there, live, until the purge removes them, and must not authorize anyone. |
| 119 | // | 117 | // |
| 120 | // The mirror is trusted only while it is READY (`mirror-readiness.ts`): the | 118 | // The one-off backfill is complete and permanent, and an organization that |
| 121 | // one-off backfill has written every organization, and the events reconciler | 119 | // predates it is covered on demand (below), so there is nothing left for a |
| 122 | // has drained the stream within its lag budget. Until both hold, membership is | 120 | // per-request readiness check to gate. What can still go wrong is the events |
| 123 | // read from WorkOS (`listUserMemberships`, one call per request) exactly as | 121 | // reconciler falling behind — a member revoked in the WorkOS dashboard would |
| 124 | // before the cutover — a member the backfill has not written yet must not be | 122 | // keep a stale active row until it catches up. That is now an OPERATIONAL |
| 125 | // locked out, and a member revoked in the dashboard while the reconciler was | 123 | // concern, not a request-path fallback: the reconciler itself |
| 126 | // down must not be let in on a stale row. The readiness row is one indexed | 124 | // (`workos-events-runner.ts`) checks its own drain lag after every run and |
| 127 | // point read on the same socket; the deploy gate | 125 | // raises a Sentry error when it has stalled, so it is fixed by paging someone, |
| 128 | // (`scripts/ensure-workos-mirror-ready.ts`) applies the same rule before this | 126 | // not by asking WorkOS on every request. The deploy gate |
| 129 | // build goes live, so in steady state the fallback is never taken. A | 127 | // (`scripts/ensure-workos-mirror-ready.ts`) separately refuses to ship a build |
| 130 | // readiness or mirror read failure fails the request (500), never a silent | 128 | // that trusts the mirror while it is unready, using the same rule |
| 131 | // fallback in either direction. The one org the fallback never asks WorkOS | 129 | // (`mirror-readiness-store.ts`). |
| 132 | // about is one the mirror holds as DELETED: WorkOS no longer has it (or is | ||
| 133 | // about to not), so its answer is "no member" for everyone — including the | ||
| 134 | // admin whose deletion failed part-way and must retry it (below). That | ||
| 135 | // membership is read from the mirror, whose rows are exactly what the purge | ||
| 136 | // has not removed yet, ready or not; a refused caller gets null either way. | ||
| 137 | // | 130 | // |
| 138 | // Readiness is database-wide; completeness is PER ORGANIZATION. An | 131 | // Completeness is PER ORGANIZATION. An organization whose row was minted |
| 139 | // organization whose row was minted after the backfill ran — lazily by a | 132 | // after the backfill ran — lazily by a request (`resolveOrganization`), or by |
| 140 | // request (`resolveOrganization`), or by a first login — carries no | 133 | // a first login — carries no `backfilled_at`, and the mirror holds only the |
| 141 | // `backfilled_at`, and the mirror holds only the memberships login and | 134 | // memberships login and write-through happened to record for it: a member |
| 142 | // write-through happened to record for it: a member who has not signed in | 135 | // who has not signed in since would be refused on a row that was never |
| 143 | // since would be refused on a row that was never written. So the org row is | 136 | // written. So the org row is read FIRST, and an unmarked live organization is |
| 144 | // read FIRST, and an unmarked live organization is scanned from WorkOS | 137 | // scanned from WorkOS (`ensureOrganizationBackfilled`: one membership listing |
| 145 | // (`ensureOrganizationBackfilled`: one membership listing plus one `getUser` | 138 | // plus one `getUser` per member, then the mark) BEFORE its mirror is read — |
| 146 | // per member, then the mark) BEFORE its mirror is read — the same on-demand | 139 | // the same on-demand scan the seat gates run. One-time per organization: the |
| 147 | // scan the seat gates run. One-time per organization: the scan marks the | 140 | // scan marks the row, and this branch is never taken for it again. An |
| 148 | // row, and this branch is never taken for it again. An organization the | 141 | // organization the mirror does not hold at all — one that predates the |
| 149 | // mirror does not hold at all — one that predates the mirror and that nobody | 142 | // mirror and that nobody has signed in to since (a CLI or MCP token names it, |
| 150 | // has signed in to since (a CLI or MCP token names it, and the JWT path has | 143 | // and the JWT path has no login feeder), or one created in the WorkOS |
| 151 | // no login feeder), or one created in the WorkOS dashboard — is reachable by | 144 | // dashboard — is reachable by neither the backfill (which lists the mirror's |
| 152 | // neither the backfill (which lists the mirror's organizations) nor the | 145 | // organizations) nor the reconciler (which starts at the replay boundary), so |
| 153 | // reconciler (which starts at the replay boundary), so it is resolved on | 146 | // it is resolved on demand HERE: WorkOS is asked for the caller's own |
| 154 | // demand HERE: WorkOS is asked for the caller's own membership in it first | 147 | // membership in it first (`getUserOrgMembership`, a read scoped to this |
| 155 | // (`getUserOrgMembership`, a read scoped to this caller — never a listing | 148 | // caller — never a listing of the org), and only a member's answer mints the |
| 156 | // of the org), and only a member's answer mints the row | 149 | // row (`resolveOrganization`) and scans it as above. A non-member mints |
| 157 | // (`resolveOrganization`) and scans it as above. A non-member mints nothing: | 150 | // nothing: a signed-in caller cannot create the row of an arbitrary WorkOS |
| 158 | // a signed-in caller cannot create the row of an arbitrary WorkOS | ||
| 159 | // organization by naming its id. An organization marked deleted is never | 151 | // organization by naming its id. An organization marked deleted is never |
| 160 | // scanned: WorkOS no longer has it, and its rows are the purge's to remove, | 152 | // scanned: WorkOS no longer has it, and its rows are the purge's to remove, |
| 161 | // not a listing's to refresh. | 153 | // not a listing's to refresh. |
| @@ -189,20 +181,6 @@ const activeMembershipFromMirror = (userId: string, organizationId: string) => · 27 unmodified lines | |||
| 189 | return active; | 181 | return active; |
| 190 | }); | 182 | }); |
| 191 | 183 | ||
| 192 | // The pre-cutover read, kept for the window in which the mirror is not yet | ||
| 193 | // ready: WorkOS's own membership list for the user, one call per request. | ||
| 194 | const activeMembershipFromWorkOs = (userId: string, organizationId: string) => | ||
| 195 | Effect.gen(function* () { | ||
| 196 | const workos = yield* WorkOSClient; | ||
| 197 | const memberships = yield* workos.listUserMemberships(userId); | ||
| 198 | const membership = memberships.data.find( | ||
| 199 | (m) => m.organizationId === organizationId && m.status === "active", | ||
| 200 | ); | ||
| 201 | if (!membership) return null; | ||
| 202 | const active: ActiveMembership = { role: membership.role.slug }; | ||
| 203 | return active; | ||
| 204 | }); | ||
| 205 | |||
| 206 | // The authorized organization, or null for one marked deleted (unless the | 184 | // The authorized organization, or null for one marked deleted (unless the |
| 207 | // caller is the deletion flow). The membership already names the caller's | 185 | // caller is the deletion flow). The membership already names the caller's |
| 208 | // role — surfaced normalized so identity resolution can bind the executor's | 186 | // role — surfaced normalized so identity resolution can bind the executor's |
| @@ -236,14 +214,7 @@ const heldOrResolvedForMember = (userId: string, organizationId: string) => · 27 unmodified lines | |||
| 236 | return yield* resolveOrganization(organizationId); | 214 | return yield* resolveOrganization(organizationId); |
| 237 | }); | 215 | }); |
| 238 | 216 | ||
| 239 | /** | 217 | /** The span every membership authorization runs under. */ |
| 240 | * The span every membership authorization runs under, with the readiness | ||
| 241 | * decision stamped on it so the fallback can be counted rather than grepped: | ||
| 242 | * `mirror.ready` (boolean, which source answered) and `mirror.readiness` (the | ||
| 243 | * state's description, why). Query Axiom for `name == "auth.authorize_organization"` | ||
| 244 | * and `mirror.ready == false` to see how many requests are on the WorkOS | ||
| 245 | * fallback and for which reason. | ||
| 246 | */ | ||
| 247 | export const AUTHORIZE_ORGANIZATION_SPAN = "auth.authorize_organization"; | 218 | export const AUTHORIZE_ORGANIZATION_SPAN = "auth.authorize_organization"; |
| 248 | 219 | ||
| 249 | export const authorizeOrganization = ( | 220 | export const authorizeOrganization = ( |
| @@ -252,43 +223,14 @@ export const authorizeOrganization = ( · 2 unmodified lines | |||
| 252 | options: AuthorizeOrganizationOptions = {}, | 223 | options: AuthorizeOrganizationOptions = {}, |
| 253 | ) => | 224 | ) => |
| 254 | Effect.gen(function* () { | 225 | Effect.gen(function* () { |
| 255 | const readiness = yield* MirrorReadiness; | ||
| 256 | const state = yield* readiness.state(); | ||
| 257 | const ready = MirrorReadinessState.$is("Ready")(state); | ||
| 258 | yield* Effect.annotateCurrentSpan({ | ||
| 259 | "mirror.ready": ready, | ||
| 260 | "mirror.readiness": describeMirrorReadiness(state), | ||
| 261 | }); | ||
| 262 | if (!ready) { | ||
| 263 | yield* Effect.logWarning( | ||
| 264 | "authorizeOrganization: membership mirror not ready; membership read from WorkOS", | ||
| 265 | { readiness: describeMirrorReadiness(state) }, | ||
| 266 | ); | ||
| 267 | // A marked organization is the mirror's to answer for (see above): | ||
| 268 | // WorkOS lists no member of it, and the deletion retry must still | ||
| 269 | // get in. | ||
| 270 | const users = yield* UserStoreService; | ||
| 271 | const held = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); | ||
| 272 | if (held?.deletedAt != null) { | ||
| 273 | if (options.deleted !== "allow") return null; | ||
| 274 | const membership = yield* activeMembershipFromMirror(userId, organizationId); | ||
| 275 | if (!membership) return null; | ||
| 276 | return authorized(held, membership, options); | ||
| 277 | } | ||
| 278 | const membership = yield* activeMembershipFromWorkOs(userId, organizationId); | ||
| 279 | if (!membership) return null; | ||
| 280 | // The row read above is reused: `resolveOrganization` would read it a | ||
| 281 | // second time, and a request reads the organization row ONCE (the MCP | ||
| 282 | // session DO relies on that — see e2e `mcp-session-cold-init`). | ||
| 283 | const org = held ?? (yield* resolveOrganization(organizationId)); | ||
| 284 | return authorized(org, membership, options); | ||
| 285 | } | ||
| 286 | |||
| 287 | const org = yield* heldOrResolvedForMember(userId, organizationId); | 226 | const org = yield* heldOrResolvedForMember(userId, organizationId); |
| 288 | if (!org) return null; | 227 | if (!org) return null; |
| 289 | // An unmarked live organization is scanned before its mirror is read | 228 | // An unmarked live organization is scanned before its mirror is read |
| 290 | // (see above). The row returned below still shows the mark as it was | 229 | // (see above). The row returned below still shows the mark as it was |
| 291 | // read; nothing past this point reads it. | 230 | // read; nothing past this point reads it. A marked-deleted organization |
| 231 | // is never scanned, so the deletion retry (`deleted: "allow"`) reaches | ||
| 232 | // `authorized()` below with the membership row the purge has not removed | ||
| 233 | // yet. | ||
| 292 | if (org.deletedAt === null && org.backfilledAt === null) { | 234 | if (org.deletedAt === null && org.backfilledAt === null) { |
| 293 | yield* ensureOrganizationBackfilled(organizationId); | 235 | yield* ensureOrganizationBackfilled(organizationId); |
| 294 | } | 236 | } |
| @@ -20,16 +20,16 @@ · 19 unmodified lines | |||
| 20 | // - session org not authorized -> NoOrganization 403 no_organization | 20 | // - session org not authorized -> NoOrganization 403 no_organization |
| 21 | // - no auth header -> falls through to the sealed-session path | 21 | // - no auth header -> falls through to the sealed-session path |
| 22 | // The org-resolution infra errors (`UserStoreError` / `WorkOSError` / | 22 | // The org-resolution infra errors (`UserStoreError` / `WorkOSError` / |
| 23 | // `MemberDirectoryError` / `WorkOsMirrorError`, the last from the mirror | 23 | // `MemberDirectoryError` / `WorkOsMirrorError`) are `Effect.die`d so they |
| 24 | // readiness read) are `Effect.die`d so they surface as 500 defects — the | 24 | // surface as 500 defects — the same status the old inline resolver produced |
| 25 | // same status the old inline resolver produced when those bubbled up. | 25 | // when those bubbled up. |
| 26 | // | 26 | // |
| 27 | // The per-request `UserStoreService` + `MemberDirectory` + `MirrorReadiness` | 27 | // The per-request `UserStoreService` + `MemberDirectory` + `WorkOsMirror` |
| 28 | // + `WorkOsMirror` (read by the org-resolution path: the org row, whether | 28 | // (read by the org-resolution path: the org row, the caller's mirrored |
| 29 | // the mirror may be trusted, the caller's mirrored membership, and the | 29 | // membership, and the on-demand scan of an organization the backfill never |
| 30 | // on-demand scan of an organization the backfill never covered) stay | 30 | // covered) stay REQUIREMENTS OF THE LAYER, satisfied by the facade's |
| 31 | // REQUIREMENTS OF THE LAYER, satisfied by the facade's per-request DB combine — | 31 | // per-request DB combine — NOT function-level requirements (that is what |
| 32 | // NOT function-level requirements (that is what forced a forked tag before). | 32 | // forced a forked tag before). |
| 33 | // --------------------------------------------------------------------------- | 33 | // --------------------------------------------------------------------------- |
| 34 | 34 | ||
| 35 | import { Effect, Layer } from "effect"; | 35 | import { Effect, Layer } from "effect"; |
| @@ -54,7 +54,6 @@ import type { · 18 unmodified lines | |||
| 54 | 54 | ||
| 55 | import { ApiKeyService } from "./api-keys"; | 55 | import { ApiKeyService } from "./api-keys"; |
| 56 | import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; | 56 | import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; |
| 57 | import { MirrorReadiness } from "./mirror-readiness"; | ||
| 58 | import { WorkOsMirror } from "./workos-mirror"; | 57 | import { WorkOsMirror } from "./workos-mirror"; |
| 59 | import { BEARER_PREFIX } from "./bearer"; | 58 | import { BEARER_PREFIX } from "./bearer"; |
| 60 | import { | 59 | import { |
| @@ -215,7 +214,7 @@ export const resolveBearerAuth = ( · 154 unmodified lines | |||
| 215 | | WorkOSError | 214 | | WorkOSError |
| 216 | | WorkOsMirrorError | 215 | | WorkOsMirrorError |
| 217 | | MemberDirectoryError, | 216 | | MemberDirectoryError, |
| 218 | WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror | 217 | WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror |
| 219 | > => | 218 | > => |
| 220 | Effect.gen(function* () { | 219 | Effect.gen(function* () { |
| 221 | const authHeader = request.headers.get("authorization"); | 220 | const authHeader = request.headers.get("authorization"); |
| @@ -301,7 +300,7 @@ export const resolveApiKeyPrincipal = ( · 79 unmodified lines | |||
| 301 | | WorkOSError | 300 | | WorkOSError |
| 302 | | WorkOsMirrorError | 301 | | WorkOsMirrorError |
| 303 | | MemberDirectoryError, | 302 | | MemberDirectoryError, |
| 304 | WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror | 303 | WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror |
| 305 | > => | 304 | > => |
| 306 | Effect.gen(function* () { | 305 | Effect.gen(function* () { |
| 307 | const auth = yield* resolveBearerAuth(request, jwt); | 306 | const auth = yield* resolveBearerAuth(request, jwt); |
| @@ -383,7 +382,7 @@ export const resolveProtectedPrincipal = ( · 75 unmodified lines | |||
| 383 | | WorkOSError | 382 | | WorkOSError |
| 384 | | WorkOsMirrorError | 383 | | WorkOsMirrorError |
| 385 | | MemberDirectoryError, | 384 | | MemberDirectoryError, |
| 386 | WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror | 385 | WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror |
| 387 | > => | 386 | > => |
| 388 | Effect.gen(function* () { | 387 | Effect.gen(function* () { |
| 389 | const bearerPrincipal = yield* resolveApiKeyPrincipal(request, jwt); | 388 | const bearerPrincipal = yield* resolveApiKeyPrincipal(request, jwt); |
| @@ -404,17 +403,12 @@ export const resolveProtectedPrincipal = ( · 14 unmodified lines | |||
| 404 | export const workosIdentityLayer: Layer.Layer< | 403 | export const workosIdentityLayer: Layer.Layer< |
| 405 | IdentityProvider, | 404 | IdentityProvider, |
| 406 | never, | 405 | never, |
| 407 | WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror | 406 | WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror |
| 408 | > = Layer.effect( | 407 | > = Layer.effect( |
| 409 | IdentityProvider, | 408 | IdentityProvider, |
| 410 | Effect.gen(function* () { | 409 | Effect.gen(function* () { |
| 411 | const context = yield* Effect.context< | 410 | const context = yield* Effect.context< |
| 412 | | WorkOSClient | 411 | WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror |
| 413 | | ApiKeyService | ||
| 414 | | UserStoreService | ||
| 415 | | MemberDirectory | ||
| 416 | | MirrorReadiness | ||
| 417 | | WorkOsMirror | ||
| 418 | >(); | 412 | >(); |
| 419 | return IdentityProvider.of({ | 413 | return IdentityProvider.of({ |
| 420 | authenticate: (request) => | 414 | authenticate: (request) => |
| @@ -430,7 +424,7 @@ export const workosIdentityLayer: Layer.Layer< · 9 unmodified lines | |||
| 430 | WorkOSError: (error) => Effect.die(error), | 424 | WorkOSError: (error) => Effect.die(error), |
| 431 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: membership-mirror read failure -> 500 defect, same class as the store failure above | 425 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: membership-mirror read failure -> 500 defect, same class as the store failure above |
| 432 | MemberDirectoryError: (error) => Effect.die(error), | 426 | MemberDirectoryError: (error) => Effect.die(error), |
| 433 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: mirror-readiness read failure -> 500 defect, same class as the store failure above | 427 | // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: membership-mirror read failure -> 500 defect, same class as the store failure above |
| 434 | WorkOsMirrorError: (error) => Effect.die(error), | 428 | WorkOsMirrorError: (error) => Effect.die(error), |
| 435 | }), | 429 | }), |
| 436 | Effect.provide(context), | 430 | Effect.provide(context), |
| @@ -0,0 +1,81 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // A reconciler run that does not end `"drained"` can still leave the mirror | ||
| 3 | // fresh (another run drained it moments ago) or leave it stale (nothing has | ||
| 4 | // drained inside the lag budget). `alertOnStaleReconciler` is the ONLY place | ||
| 5 | // that distinction is now reported — the request path no longer reads | ||
| 6 | // readiness at all — so this pins both branches directly against it: a | ||
| 7 | // fresh `drainedAt` logs nothing, and a stale or absent `drainedAt` logs a | ||
| 8 | // structured error. `captureCauseEffect` is not swapped out: it calls | ||
| 9 | // `Sentry.captureException` directly, is a no-op in this uninitialized test | ||
| 10 | // environment, and its call path is exercised for real rather than mocked. | ||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | |||
| 13 | import { describe, expect, it } from "@effect/vitest"; | ||
| 14 | import { Cause, Duration, Effect, Layer, Logger } from "effect"; | ||
| 15 | |||
| 16 | import { WorkOsMirror, type WorkOsMirrorShape } from "./workos-mirror"; | ||
| 17 | import { alertOnStaleReconciler } from "./workos-events-runner"; | ||
| 18 | import type { WorkOsEventsSyncReport } from "./workos-events-sync"; | ||
| 19 | |||
| 20 | const capturingLogger = (sink: Array<string>) => | ||
| 21 | Logger.make<unknown, void>((options) => { | ||
| 22 | sink.push(String(options.message)); | ||
| 23 | sink.push(Cause.pretty(options.cause)); | ||
| 24 | }); | ||
| 25 | |||
| 26 | const reportEndingWith = (stopped: WorkOsEventsSyncReport["stopped"]): WorkOsEventsSyncReport => ({ | ||
| 27 | pages: 1, | ||
| 28 | events: 0, | ||
| 29 | applied: 0, | ||
| 30 | stale: 0, | ||
| 31 | absent: 0, | ||
| 32 | stopped, | ||
| 33 | cursor: null, | ||
| 34 | }); | ||
| 35 | |||
| 36 | const stubMirror = (drainedAt: Date | null) => | ||
| 37 | Layer.succeed( | ||
| 38 | WorkOsMirror, | ||
| 39 | new Proxy({} as WorkOsMirrorShape, { | ||
| 40 | get: (_target, prop) => { | ||
| 41 | if (prop === "drainedAt") return () => Effect.succeed(drainedAt); | ||
| 42 | return () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`); | ||
| 43 | }, | ||
| 44 | }), | ||
| 45 | ); | ||
| 46 | |||
| 47 | const run = (report: WorkOsEventsSyncReport, drainedAt: Date | null) => { | ||
| 48 | const logged: string[] = []; | ||
| 49 | return Effect.runPromise( | ||
| 50 | alertOnStaleReconciler(report).pipe( | ||
| 51 | Effect.provide(stubMirror(drainedAt)), | ||
| 52 | Effect.provide(Logger.layer([capturingLogger(logged)])), | ||
| 53 | ), | ||
| 54 | ).then(() => logged); | ||
| 55 | }; | ||
| 56 | |||
| 57 | describe("alertOnStaleReconciler", () => { | ||
| 58 | it("does not alert when the run itself drained the stream", async () => { | ||
| 59 | // A `"drained"` run just called `markDrained`, so it is healthy by | ||
| 60 | // definition and `drainedAt` is never read. | ||
| 61 | const logged = await run(reportEndingWith("drained"), null); | ||
| 62 | expect(logged).toEqual([]); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("does not alert when the mirror drained inside the lag budget", async () => { | ||
| 66 | const fresh = new Date(Date.now() - Duration.toMillis(Duration.minutes(1))); | ||
| 67 | const logged = await run(reportEndingWith("page_budget"), fresh); | ||
| 68 | expect(logged).toEqual([]); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("alerts when the mirror has not drained inside the lag budget", async () => { | ||
| 72 | const stale = new Date(Date.now() - Duration.toMillis(Duration.minutes(11))); | ||
| 73 | const logged = await run(reportEndingWith("page_budget"), stale); | ||
| 74 | expect(logged.some((line) => line.includes("workos_events: reconciler stale"))).toBe(true); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it("alerts when the mirror has never drained", () => | ||
| 78 | run(reportEndingWith("awaiting_backfill"), null).then((logged) => { | ||
| 79 | expect(logged.some((line) => line.includes("workos_events: reconciler stale"))).toBe(true); | ||
| 80 | })); | ||
| 81 | }); | ||
| @@ -13,17 +13,28 @@ · 12 unmodified lines | |||
| 13 | // A failing run is captured (Sentry + structured log) and swallowed here: | 13 | // A failing run is captured (Sentry + structured log) and swallowed here: |
| 14 | // neither entry has a caller to report to, and the run is retried by the | 14 | // neither entry has a caller to report to, and the run is retried by the |
| 15 | // next cron tick from the last committed cursor. | 15 | // next cron tick from the last committed cursor. |
| 16 | // | ||
| 17 | // A run that does not fail outright can still leave the mirror stale: one | ||
| 18 | // that stops short of `"drained"` (page budget, cursor contention) means the | ||
| 19 | // reconciler did not catch up this tick, and one stuck on | ||
| 20 | // `"awaiting_backfill"` past the budget means the boundary row the backfill | ||
| 21 | // was supposed to hand off was lost — both are read from `drainedAt` after | ||
| 22 | // the run and, past `MIRROR_RECONCILER_LAG_BUDGET`, reported the same way a | ||
| 23 | // thrown failure is: a structured error log plus a Sentry capture. This is | ||
| 24 | // the ONLY place a stale reconciler surfaces now; the request path | ||
| 25 | // (`auth/organization.ts`) no longer checks readiness or falls back. | ||
| 16 | // --------------------------------------------------------------------------- | 26 | // --------------------------------------------------------------------------- |
| 17 | 27 | ||
| 18 | import { Effect, Layer } from "effect"; | 28 | import { Clock, Data, Duration, Effect, Layer } from "effect"; |
| 19 | 29 | ||
| 20 | import { captureCauseEffect } from "../observability"; | 30 | import { captureCauseEffect } from "../observability"; |
| 21 | import { WorkerTelemetryLive } from "../observability/telemetry"; | 31 | import { WorkerTelemetryLive } from "../observability/telemetry"; |
| 22 | import { makeDbLayer } from "../db/db"; | 32 | import { makeDbLayer } from "../db/db"; |
| 23 | import { makeUserStoreLayer } from "./context"; | 33 | import { makeUserStoreLayer } from "./context"; |
| 34 | import { MIRROR_RECONCILER_LAG_BUDGET } from "./mirror-readiness-store"; | ||
| 24 | import { CoreSharedServices } from "./workos"; | 35 | import { CoreSharedServices } from "./workos"; |
| 25 | import { syncWorkOsEvents } from "./workos-events-sync"; | 36 | import { syncWorkOsEvents, type WorkOsEventsSyncReport } from "./workos-events-sync"; |
| 26 | import { makeWorkOsMirrorLayer } from "./workos-mirror"; | 37 | import { makeWorkOsMirrorLayer, WorkOsMirror } from "./workos-mirror"; |
| 27 | 38 | ||
| 28 | const makeSyncServices = () => { | 39 | const makeSyncServices = () => { |
| 29 | const dbLive = makeDbLayer(); | 40 | const dbLive = makeDbLayer(); |
| @@ -34,14 +45,59 @@ const makeSyncServices = () => { · 4 unmodified lines | |||
| 34 | ); | 45 | ); |
| 35 | }; | 46 | }; |
| 36 | 47 | ||
| 48 | const LAG_BUDGET_MS = Duration.toMillis(MIRROR_RECONCILER_LAG_BUDGET); | ||
| 49 | |||
| 50 | /** | ||
| 51 | * The mirror has not drained the WorkOS events stream inside its lag budget. | ||
| 52 | * Its own tagged error so Sentry groups every occurrence under one issue, | ||
| 53 | * with the run's outcome and the heartbeat's age as the fields to read. | ||
| 54 | */ | ||
| 55 | export class WorkOsReconcilerStale extends Data.TaggedError("WorkOsReconcilerStale")<{ | ||
| 56 | readonly stopped: WorkOsEventsSyncReport["stopped"]; | ||
| 57 | readonly drainedAt: string | null; | ||
| 58 | readonly ageMs: number | null; | ||
| 59 | }> {} | ||
| 60 | |||
| 61 | /** | ||
| 62 | * After a run that did not end `"drained"`, check whether the mirror has | ||
| 63 | * fallen behind its lag budget and, if so, report it the way a failed run is | ||
| 64 | * reported: a structured error log plus a Sentry capture. A run ending | ||
| 65 | * `"drained"` just wrote `markDrained` and is healthy by definition, so it is | ||
| 66 | * never checked. Cheap by design: one `drainedAt` read per run (about once a | ||
| 67 | * minute). | ||
| 68 | */ | ||
| 69 | export const alertOnStaleReconciler = Effect.fn("workos_events.alert_on_stale_reconciler")( | ||
| 70 | function* (report: WorkOsEventsSyncReport) { | ||
| 71 | if (report.stopped === "drained") return; | ||
| 72 | |||
| 73 | const mirror = yield* WorkOsMirror; | ||
| 74 | const drainedAt = yield* mirror.drainedAt(); | ||
| 75 | const now = yield* Clock.currentTimeMillis; | ||
| 76 | const ageMs = drainedAt === null ? null : now - drainedAt.getTime(); | ||
| 77 | if (ageMs !== null && ageMs <= LAG_BUDGET_MS) return; | ||
| 78 | |||
| 79 | const stale = new WorkOsReconcilerStale({ | ||
| 80 | stopped: report.stopped, | ||
| 81 | drainedAt: drainedAt === null ? null : drainedAt.toISOString(), | ||
| 82 | ageMs, | ||
| 83 | }); | ||
| 84 | yield* Effect.logError("workos_events: reconciler stale", stale); | ||
| 85 | yield* captureCauseEffect(stale); | ||
| 86 | }, | ||
| 87 | ); | ||
| 88 | |||
| 37 | /** | 89 | /** |
| 38 | * One reconciler pass over fresh request-scoped services. Resolves when the | 90 | * One reconciler pass over fresh request-scoped services. Resolves when the |
| 39 | * pass ends, whether it drained the stream, stopped at the page budget, | 91 | * pass ends, whether it drained the stream, stopped at the page budget, |
| 40 | * yielded to another run, or failed (a failure is reported, never thrown). | 92 | * yielded to another run, or failed (a failure is reported, never thrown). |
| 93 | * A run that ends short of `"drained"` and leaves `drainedAt` past the lag | ||
| 94 | * budget is ALSO reported (see {@link alertOnStaleReconciler}) — a stalled | ||
| 95 | * reconciler is now an operational alert, not a per-request fallback. | ||
| 41 | */ | 96 | */ |
| 42 | export const runWorkOsEventsSync = (): Promise<void> => | 97 | export const runWorkOsEventsSync = (): Promise<void> => |
| 43 | Effect.runPromise( | 98 | Effect.runPromise( |
| 44 | syncWorkOsEvents().pipe( | 99 | syncWorkOsEvents().pipe( |
| 100 | Effect.tap((report) => alertOnStaleReconciler(report)), | ||
| 45 | Effect.asVoid, | 101 | Effect.asVoid, |
| 46 | Effect.provide(makeSyncServices()), | 102 | Effect.provide(makeSyncServices()), |
| 47 | Effect.scoped, | 103 | Effect.scoped, |
| @@ -62,7 +62,6 @@ import { UserStoreService } from "./context"; · 61 unmodified lines | |||
| 62 | import { WorkOSError } from "./errors"; | 62 | import { WorkOSError } from "./errors"; |
| 63 | import { cloudMemberDirectoryLayer } from "./member-directory"; | 63 | import { cloudMemberDirectoryLayer } from "./member-directory"; |
| 64 | import { mirrorSignIn } from "./mirror-feeders"; | 64 | import { mirrorSignIn } from "./mirror-feeders"; |
| 65 | import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness"; | ||
| 66 | import { authorizeOrganization } from "./organization"; | 65 | import { authorizeOrganization } from "./organization"; |
| 67 | import { WorkOSClient, type WorkOSClientService, type WorkOSListEventsOptions } from "./workos"; | 66 | import { WorkOSClient, type WorkOSClientService, type WorkOSListEventsOptions } from "./workos"; |
| 68 | import { | 67 | import { |
| @@ -202,26 +201,14 @@ const profiles = (reads: string[] = []): Partial<WorkOSClientService> => ({ · 133 unmodified lines | |||
| 202 | }); | 201 | }); |
| 203 | 202 | ||
| 204 | const DbLive = DbService.Live; | 203 | const DbLive = DbService.Live; |
| 205 | // The mirror is READY here (the authorization checks below read the mirror, | 204 | // The authorization checks below always read the mirror, never WorkOS. |
| 206 | // not WorkOS); the readiness rule is pinned in workos-mirror.node.test.ts. | ||
| 207 | const readyMirror = Layer.succeed(MirrorReadiness)({ | ||
| 208 | state: () => Effect.succeed(MirrorReadinessState.Ready()), | ||
| 209 | }); | ||
| 210 | |||
| 211 | const MirrorServices = Layer.mergeAll( | 205 | const MirrorServices = Layer.mergeAll( |
| 212 | WorkOsMirror.Live, | 206 | WorkOsMirror.Live, |
| 213 | UserStoreService.Live, | 207 | UserStoreService.Live, |
| 214 | cloudMemberDirectoryLayer, | 208 | cloudMemberDirectoryLayer, |
| 215 | readyMirror, | ||
| 216 | ).pipe(Layer.provideMerge(DbLive)); | 209 | ).pipe(Layer.provideMerge(DbLive)); |
| 217 | 210 | ||
| 218 | type Services = | 211 | type Services = WorkOsMirror | UserStoreService | MemberDirectory | DbService | WorkOSClient; |
| 219 | | WorkOsMirror | ||
| 220 | | UserStoreService | ||
| 221 | | MemberDirectory | ||
| 222 | | MirrorReadiness | ||
| 223 | | DbService | ||
| 224 | | WorkOSClient; | ||
| 225 | 212 | ||
| 226 | const run = <A, E>( | 213 | const run = <A, E>( |
| 227 | body: Effect.Effect<A, E, Services>, | 214 | body: Effect.Effect<A, E, Services>, |
| @@ -549,7 +536,10 @@ describe("applyEvent", () => { · 321 unmodified lines | |||
| 549 | const promoted = yield* applyEvent( | 536 | const promoted = yield* applyEvent( |
| 550 | membershipEvent( | 537 | membershipEvent( |
| 551 | "organization_membership.updated", | 538 | "organization_membership.updated", |
| 552 | workosMembership(joiner, org, { role: { slug: "admin" }, updatedAt: T2 }), | 539 | workosMembership(joiner, org, { |
| 540 | role: { slug: "admin" }, | ||
| 541 | updatedAt: T2, | ||
| 542 | }), | ||
| 553 | ), | 543 | ), |
| 554 | ); | 544 | ); |
| 555 | // A member WorkOS no longer has (their `user.deleted` is further down | 545 | // A member WorkOS no longer has (their `user.deleted` is further down |
| @@ -558,7 +548,15 @@ describe("applyEvent", () => { · 2 unmodified lines | |||
| 558 | membershipEvent("organization_membership.created", workosMembership(gone, org)), | 548 | membershipEvent("organization_membership.created", workosMembership(gone, org)), |
| 559 | ); | 549 | ); |
| 560 | const goneRow = yield* readMembership(gone, org); | 550 | const goneRow = yield* readMembership(gone, org); |
| 561 | return { knownJoins, knownRow, joins, joinerRow, promoted, goneJoins, goneRow }; | 551 | return { |
| 552 | knownJoins, | ||
| 553 | knownRow, | ||
| 554 | joins, | ||
| 555 | joinerRow, | ||
| 556 | promoted, | ||
| 557 | goneJoins, | ||
| 558 | goneRow, | ||
| 559 | }; | ||
| 562 | }), | 560 | }), |
| 563 | stubWorkOS({ | 561 | stubWorkOS({ |
| 564 | getUser: (userId) => | 562 | getUser: (userId) => |
| @@ -581,7 +579,10 @@ describe("applyEvent", () => { · 16 unmodified lines | |||
| 581 | expect(result.joinerRow?.email).toBe(`${joiner}@placeholder.test`); | 579 | expect(result.joinerRow?.email).toBe(`${joiner}@placeholder.test`); |
| 582 | expect(result.promoted).toBe("applied"); | 580 | expect(result.promoted).toBe("applied"); |
| 583 | expect(result.goneJoins, "a member WorkOS no longer has is still mirrored").toBe("applied"); | 581 | expect(result.goneJoins, "a member WorkOS no longer has is still mirrored").toBe("applied"); |
| 584 | expect(result.goneRow).toMatchObject({ membershipId: `om_${gone}_${org}`, name: null }); | 582 | expect(result.goneRow).toMatchObject({ |
| 583 | membershipId: `om_${gone}_${org}`, | ||
| 584 | name: null, | ||
| 585 | }); | ||
| 585 | expect(reads, "one read per unprofiled member, none for a profiled one").toEqual([ | 586 | expect(reads, "one read per unprofiled member, none for a profiled one").toEqual([ |
| 586 | joiner, | 587 | joiner, |
| 587 | gone, | 588 | gone, |
| @@ -601,7 +602,9 @@ describe("applyEvent", () => { · 13 unmodified lines | |||
| 601 | Effect.provide( | 602 | Effect.provide( |
| 602 | Layer.mergeAll( | 603 | Layer.mergeAll( |
| 603 | MirrorServices, | 604 | MirrorServices, |
| 604 | stubWorkOS({ getUser: () => Effect.fail(new WorkOSError({ status: 503 })) }), | 605 | stubWorkOS({ |
| 606 | getUser: () => Effect.fail(new WorkOSError({ status: 503 })), | ||
| 607 | }), | ||
| 605 | ), | 608 | ), |
| 606 | ), | 609 | ), |
| 607 | Effect.scoped, | 610 | Effect.scoped, |
| @@ -731,7 +734,11 @@ describe("applyEvent", () => { · 123 unmodified lines | |||
| 731 | // deletion, and stalled past it now writes what it holds. | 734 | // deletion, and stalled past it now writes what it holds. |
| 732 | yield* mirrorSignIn( | 735 | yield* mirrorSignIn( |
| 733 | workosUser(userId), | 736 | workosUser(userId), |
| 734 | [workosMembership(userId, org, { organizationName: "Never Mirrored" })], | 737 | [ |
| 738 | workosMembership(userId, org, { | ||
| 739 | organizationName: "Never Mirrored", | ||
| 740 | }), | ||
| 741 | ], | ||
| 735 | new Date(T1), | 742 | new Date(T1), |
| 736 | ); | 743 | ); |
| 737 | const afterLogin = yield* readOrganization(org); | 744 | const afterLogin = yield* readOrganization(org); |
| @@ -1108,7 +1115,14 @@ describe("syncWorkOsEvents", () => { · 370 unmodified lines | |||
| 1108 | const cursor = yield* mirror.getCursor(); | 1115 | const cursor = yield* mirror.getCursor(); |
| 1109 | const membership = yield* readMembership(userId, org); | 1116 | const membership = yield* readMembership(userId, org); |
| 1110 | const drainedAfter = yield* mirror.drainedAt(); | 1117 | const drainedAfter = yield* mirror.drainedAt(); |
| 1111 | return { report, cursor, intruder, membership, drainedBefore, drainedAfter }; | 1118 | return { |
| 1119 | report, | ||
| 1120 | cursor, | ||
| 1121 | intruder, | ||
| 1122 | membership, | ||
| 1123 | drainedBefore, | ||
| 1124 | drainedAfter, | ||
| 1125 | }; | ||
| 1112 | }), | 1126 | }), |
| 1113 | ); | 1127 | ); |
| 1114 | expect(requests, "the second page is never read").toHaveLength(1); | 1128 | expect(requests, "the second page is never read").toHaveLength(1); |
| @@ -63,11 +63,10 @@ import { · 62 unmodified lines | |||
| 63 | import { makeWorkOsMirrorStore } from "./workos-mirror-store"; | 63 | import { makeWorkOsMirrorStore } from "./workos-mirror-store"; |
| 64 | import { | 64 | import { |
| 65 | MIRROR_RECONCILER_LAG_BUDGET, | 65 | MIRROR_RECONCILER_LAG_BUDGET, |
| 66 | MirrorReadiness, | ||
| 67 | MirrorReadinessState, | 66 | MirrorReadinessState, |
| 68 | makeMirrorReadinessLayer, | ||
| 69 | mirrorReadinessFrom, | 67 | mirrorReadinessFrom, |
| 70 | } from "./mirror-readiness"; | 68 | readMirrorReadiness, |
| 69 | } from "./mirror-readiness-store"; | ||
| 71 | 70 | ||
| 72 | const DbLive = DbService.Live; | 71 | const DbLive = DbService.Live; |
| 73 | const Services = Layer.mergeAll( | 72 | const Services = Layer.mergeAll( |
| @@ -243,7 +242,11 @@ describe("WorkOsMirror upserts", () => { · 169 unmodified lines | |||
| 243 | // the row holds — the payload a timestamp guard would let through. | 242 | // the row holds — the payload a timestamp guard would let through. |
| 244 | // Same id: the membership is deleted, it never returns. | 243 | // Same id: the membership is deleted, it never returns. |
| 245 | const newerSameId = yield* mirror.upsertMembership( | 244 | const newerSameId = yield* mirror.upsertMembership( |
| 246 | membership(org, id, { id: membershipId, role: "admin", updatedAt: T3 }), | 245 | membership(org, id, { |
| 246 | id: membershipId, | ||
| 247 | role: "admin", | ||
| 248 | updatedAt: T3, | ||
| 249 | }), | ||
| 247 | ); | 250 | ); |
| 248 | const afterNewerSameId = yield* directory.membership(id, org, ["inactive"]); | 251 | const afterNewerSameId = yield* directory.membership(id, org, ["inactive"]); |
| 249 | // The member re-added in WorkOS: a payload newer than the deletion, | 252 | // The member re-added in WorkOS: a payload newer than the deletion, |
| @@ -299,7 +302,10 @@ describe("WorkOsMirror upserts", () => { · 49 unmodified lines | |||
| 299 | result.newerSameId, | 302 | result.newerSameId, |
| 300 | "a payload of the deleted id stamped AFTER the removal is refused: identity, not time", | 303 | "a payload of the deleted id stamped AFTER the removal is refused: identity, not time", |
| 301 | ).toBe(false); | 304 | ).toBe(false); |
| 302 | expect(result.afterNewerSameId).toMatchObject({ status: "inactive", role: "member" }); | 305 | expect(result.afterNewerSameId).toMatchObject({ |
| 306 | status: "inactive", | ||
| 307 | role: "member", | ||
| 308 | }); | ||
| 303 | expect(result.readded, "a replacement under a new id reactivates").toBe(true); | 309 | expect(result.readded, "a replacement under a new id reactivates").toBe(true); |
| 304 | expect(result.afterReadd?.status).toBe("active"); | 310 | expect(result.afterReadd?.status).toBe("active"); |
| 305 | expect(result.lateDelete, "a replayed deletion of the OLD id is refused").toBe(false); | 311 | expect(result.lateDelete, "a replayed deletion of the OLD id is refused").toBe(false); |
| @@ -516,7 +522,11 @@ describe("WorkOsMirror upserts", () => { · 210 unmodified lines | |||
| 516 | // with status inactive): the row is inactive but still WorkOS's, with | 522 | // with status inactive): the row is inactive but still WorkOS's, with |
| 517 | // no `deleted_at` to protect it. | 523 | // no `deleted_at` to protect it. |
| 518 | const deactivated = yield* mirror.upsertMembership( | 524 | const deactivated = yield* mirror.upsertMembership( |
| 519 | membership(org, id, { id: membershipId, status: "inactive", updatedAt: T2 }), | 525 | membership(org, id, { |
| 526 | id: membershipId, | ||
| 527 | status: "inactive", | ||
| 528 | updatedAt: T2, | ||
| 529 | }), | ||
| 520 | ); | 530 | ); |
| 521 | const whileInactive = yield* directory.membership(id, org); | 531 | const whileInactive = yield* directory.membership(id, org); |
| 522 | // A payload older than the deactivation cannot undo it, nor one | 532 | // A payload older than the deactivation cannot undo it, nor one |
| @@ -529,7 +539,11 @@ describe("WorkOsMirror upserts", () => { · 6 unmodified lines | |||
| 529 | ); | 539 | ); |
| 530 | // WorkOS reactivates it, same id, newer stamp: live again. | 540 | // WorkOS reactivates it, same id, newer stamp: live again. |
| 531 | const reactivated = yield* mirror.upsertMembership( | 541 | const reactivated = yield* mirror.upsertMembership( |
| 532 | membership(org, id, { id: membershipId, role: "admin", updatedAt: T3 }), | 542 | membership(org, id, { |
| 543 | id: membershipId, | ||
| 544 | role: "admin", | ||
| 545 | updatedAt: T3, | ||
| 546 | }), | ||
| 533 | ); | 547 | ); |
| 534 | const afterReactivate = yield* directory.membership(id, org); | 548 | const afterReactivate = yield* directory.membership(id, org); |
| 535 | return { | 549 | return { |
| @@ -634,7 +648,10 @@ describe("WorkOsMirror upserts", () => { · 98 unmodified lines | |||
| 634 | result.rejoined, | 648 | result.rejoined, |
| 635 | "a membership of a deleted user is refused however it is stamped: identity, not time", | 649 | "a membership of a deleted user is refused however it is stamped: identity, not time", |
| 636 | ).toBe(false); | 650 | ).toBe(false); |
| 637 | expect(result.afterRejoin).toMatchObject({ status: "inactive", name: null }); | 651 | expect(result.afterRejoin).toMatchObject({ |
| 652 | status: "inactive", | ||
| 653 | name: null, | ||
| 654 | }); | ||
| 638 | expect(result.unknown, "deleting an unseen user leaves a tombstone").toBe(true); | 655 | expect(result.unknown, "deleting an unseen user leaves a tombstone").toBe(true); |
| 639 | expect(result.unseenProfile, "which the older profile cannot fill").toBe(false); | 656 | expect(result.unseenProfile, "which the older profile cannot fill").toBe(false); |
| 640 | expect( | 657 | expect( |
| @@ -883,19 +900,20 @@ describe("mirror readiness", () => { · 242 unmodified lines | |||
| 883 | const result = await run( | 900 | const result = await run( |
| 884 | Effect.gen(function* () { | 901 | Effect.gen(function* () { |
| 885 | const mirror = yield* WorkOsMirror; | 902 | const mirror = yield* WorkOsMirror; |
| 886 | const readiness = yield* MirrorReadiness; | 903 | const { db } = yield* DbService; |
| 904 | const readiness = () => Effect.promise(() => readMirrorReadiness(db, new Date())); | ||
| 887 | yield* clearEventsRow; | 905 | yield* clearEventsRow; |
| 888 | const noRow = yield* readiness.state(); | 906 | const noRow = yield* readiness(); |
| 889 | yield* mirror.setReplayBoundary(T1); | 907 | yield* mirror.setReplayBoundary(T1); |
| 890 | yield* mirror.markBackfillCompleted(T1); | 908 | yield* mirror.markBackfillCompleted(T1); |
| 891 | const backfilledOnly = yield* readiness.state(); | 909 | const backfilledOnly = yield* readiness(); |
| 892 | // A drain as of now: what a reconciler run that just read the stream | 910 | // A drain as of now: what a reconciler run that just read the stream |
| 893 | // to its end records. | 911 | // to its end records. |
| 894 | const drainedAt = new Date(); | 912 | const drainedAt = new Date(); |
| 895 | yield* mirror.markDrained(drainedAt); | 913 | yield* mirror.markDrained(drainedAt); |
| 896 | const ready = yield* readiness.state(); | 914 | const ready = yield* readiness(); |
| 897 | return { noRow, backfilledOnly, ready }; | 915 | return { noRow, backfilledOnly, ready }; |
| 898 | }).pipe(Effect.provide(makeMirrorReadinessLayer().pipe(Layer.provide(DbLive)))), | 916 | }), |
| 899 | ); | 917 | ); |
| 900 | expect(result.noRow).toEqual(MirrorReadinessState.BackfillPending()); | 918 | expect(result.noRow).toEqual(MirrorReadinessState.BackfillPending()); |
| 901 | expect(result.backfilledOnly).toEqual( | 919 | expect(result.backfilledOnly).toEqual( |
| @@ -1006,7 +1024,12 @@ describe("WorkOsMirror backfill sync state", () => { · 104 unmodified lines | |||
| 1006 | yield* mirror.applyOrganizationScan({ | 1024 | yield* mirror.applyOrganizationScan({ |
| 1007 | organizationId: org, | 1025 | organizationId: org, |
| 1008 | listedAt: T2, | 1026 | listedAt: T2, |
| 1009 | members: [{ user: user(kept), membership: membership(org, kept, { updatedAt: T1 }) }], | 1027 | members: [ |
| 1028 | { | ||
| 1029 | user: user(kept), | ||
| 1030 | membership: membership(org, kept, { updatedAt: T1 }), | ||
| 1031 | }, | ||
| 1032 | ], | ||
| 1010 | }); | 1033 | }); |
| 1011 | // The stalled login resumes and writes what it holds: refused, the | 1034 | // The stalled login resumes and writes what it holds: refused, the |
| 1012 | // revocation predates the scan and nothing would ever undo the row. | 1035 | // revocation predates the scan and nothing would ever undo the row. |
| @@ -4,7 +4,6 @@ import { Effect, Layer } from "effect"; · 3 unmodified lines | |||
| 4 | import { MemberDirectory } from "@executor-js/api/server"; | 4 | import { MemberDirectory } from "@executor-js/api/server"; |
| 5 | 5 | ||
| 6 | import { UserStoreService } from "../../auth/context"; | 6 | import { UserStoreService } from "../../auth/context"; |
| 7 | import { MirrorReadiness, MirrorReadinessState } from "../../auth/mirror-readiness"; | ||
| 8 | import { WorkOSClient, type WorkOSClientService } from "../../auth/workos"; | 7 | import { WorkOSClient, type WorkOSClientService } from "../../auth/workos"; |
| 9 | import { WorkOsMirror, type WorkOsMirrorShape } from "../../auth/workos-mirror"; | 8 | import { WorkOsMirror, type WorkOsMirrorShape } from "../../auth/workos-mirror"; |
| 10 | import { resolveBillingOrganization } from "./route"; | 9 | import { resolveBillingOrganization } from "./route"; |
| @@ -40,12 +39,6 @@ const stubWorkOS = Layer.succeed( · 29 unmodified lines | |||
| 40 | ); | 39 | ); |
| 41 | 40 | ||
| 42 | // MEMBER is active in both orgs, as the mirror reports it. | 41 | // MEMBER is active in both orgs, as the mirror reports it. |
| 43 | // The mirror is READY in these tests (backfill complete, reconciler caught | ||
| 44 | // up), so membership is read from the stubbed directory, never from WorkOS. | ||
| 45 | const stubReadiness = Layer.succeed(MirrorReadiness)({ | ||
| 46 | state: () => Effect.succeed(MirrorReadinessState.Ready()), | ||
| 47 | }); | ||
| 48 | |||
| 49 | const stubDirectory = Layer.succeed(MemberDirectory)({ | 42 | const stubDirectory = Layer.succeed(MemberDirectory)({ |
| 50 | membership: (accountId, organizationId) => | 43 | membership: (accountId, organizationId) => |
| 51 | Effect.succeed( | 44 | Effect.succeed( |
| @@ -122,9 +115,7 @@ const run = (headers: Record<string, string>) => · 70 unmodified lines | |||
| 122 | resolveBillingOrganization( | 115 | resolveBillingOrganization( |
| 123 | new Request("https://executor.test/api/billing/customer", { headers }), | 116 | new Request("https://executor.test/api/billing/customer", { headers }), |
| 124 | { userId: MEMBER }, | 117 | { userId: MEMBER }, |
| 125 | ).pipe( | 118 | ).pipe(Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers, stubDirectory, stubMirror))); |
| 126 | Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers, stubDirectory, stubMirror, stubReadiness)), | ||
| 127 | ); | ||
| 128 | 119 | ||
| 129 | describe("billing route org selector", () => { | 120 | describe("billing route org selector", () => { |
| 130 | it.effect("fails closed when no selector header is sent", () => | 121 | it.effect("fails closed when no selector header is sent", () => |
| @@ -31,7 +31,6 @@ import { AccountApi, AdminUsersApi } from "@executor-js/api"; · 30 unmodified lines | |||
| 31 | import { requestScopedMiddleware, type MemberDirectory } 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 { MirrorReadiness } from "../auth/mirror-readiness"; | ||
| 35 | import { WorkOsMirror } from "../auth/workos-mirror"; | 34 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| 36 | import { | 35 | import { |
| 37 | CloudAuthPublicHandlers, | 36 | CloudAuthPublicHandlers, |
| @@ -80,9 +79,7 @@ const spec = OpenApi.fromApi(CloudOpenApi); · 42 unmodified lines | |||
| 80 | * core. | 79 | * core. |
| 81 | */ | 80 | */ |
| 82 | export const makeCloudExtensionRoutes = ( | 81 | export const makeCloudExtensionRoutes = ( |
| 83 | rsLive: Layer.Layer< | 82 | rsLive: Layer.Layer<DbService | UserStoreService | WorkOsMirror | MemberDirectory>, |
| 84 | DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness | ||
| 85 | >, | ||
| 86 | ) => { | 83 | ) => { |
| 87 | // Session routes (login / callback / me / switch-org / …). Handlers yield | 84 | // Session routes (login / callback / me / switch-org / …). Handlers yield |
| 88 | // `UserStoreService` directly; the per-request DB combine keeps the postgres | 85 | // `UserStoreService` directly; the per-request DB combine keeps the postgres |
| @@ -119,7 +116,9 @@ export const makeCloudExtensionRoutes = ( · 30 unmodified lines | |||
| 119 | // rather than on the protected API because the protected plane's middleware | 116 | // rather than on the protected API because the protected plane's middleware |
| 120 | // binds a product-view executor to one acting member — this one authorizes an | 117 | // binds a product-view executor to one acting member — this one authorizes an |
| 121 | // org key (or an admin session) and builds a subject-less platform view. | 118 | // org key (or an admin session) and builds a subject-less platform view. |
| 122 | const AdminUsersRoutes = makeCloudAdminUsersRoutes(rsLive, { router: apiPrefixedRouter }); | 119 | const AdminUsersRoutes = makeCloudAdminUsersRoutes(rsLive, { |
| 120 | router: apiPrefixedRouter, | ||
| 121 | }); | ||
| 123 | 122 | ||
| 124 | // The WorkOS webhook needs no per-request DB layer: it verifies the | 123 | // The WorkOS webhook needs no per-request DB layer: it verifies the |
| 125 | // signature with the boot `WorkOSClient` and detaches a reconciler pass | 124 | // signature with the boot `WorkOSClient` and detaches a reconciler pass |
| @@ -2,7 +2,7 @@ · 1 unmodified line | |||
| 2 | // Cloud McpAuthProvider adapter — the cloud analog of selfHostMcpAuthProviderLayer. | 2 | // Cloud McpAuthProvider adapter — the cloud analog of selfHostMcpAuthProviderLayer. |
| 3 | // | 3 | // |
| 4 | // Folds the entire cloud edge auth/authz surface (WorkOS JWT verify + API-key | 4 | // Folds the entire cloud edge auth/authz surface (WorkOS JWT verify + API-key |
| 5 | // bearer + per-request org-liveness check + the two OAuth discovery docs) into | 5 | // bearer + per-request membership check + the two OAuth discovery docs) into |
| 6 | // ONE `McpAuthProvider` Layer behind the shared host-mcp envelope. | 6 | // ONE `McpAuthProvider` Layer behind the shared host-mcp envelope. |
| 7 | // | 7 | // |
| 8 | // `authenticate(request)` runs on EVERY /mcp request and resolves a typed | 8 | // `authenticate(request)` runs on EVERY /mcp request and resolves a typed |
| @@ -10,13 +10,16 @@ · 1 unmodified line | |||
| 10 | // - missing bearer -> Unauthorized (challenge: Bearer resource_metadata=…) | 10 | // - missing bearer -> Unauthorized (challenge: Bearer resource_metadata=…) |
| 11 | // - invalid token/api key -> Unauthorized (challenge: Bearer error="invalid_token" …) | 11 | // - invalid token/api key -> Unauthorized (challenge: Bearer error="invalid_token" …) |
| 12 | // - transient JWKS OR membership-lookup infra -> Unavailable (caught here; | 12 | // - transient JWKS OR membership-lookup infra -> Unavailable (caught here; |
| 13 | // envelope renders a retryable 503 -32001). A WorkOS blip during the live | 13 | // envelope renders a retryable 503 -32001). The membership check reads |
| 14 | // org check is a TRANSIENT failure, not evidence the org is gone, so it | 14 | // the local mirror (`auth/organization.ts`), so the infra that can fail |
| 15 | // must NOT reach the Forbidden/destroy path below. | 15 | // here is the database — or WorkOS, on the one path that still asks it |
| 16 | // (an organization the mirror has never seen). Either is TRANSIENT, not | ||
| 17 | // evidence the org is gone, and must NOT reach the Forbidden/destroy | ||
| 18 | // path below. | ||
| 16 | // - no org / revoked org -> Forbidden ("No organization in session …", -32001). | 19 | // - no org / revoked org -> Forbidden ("No organization in session …", -32001). |
| 17 | // This requires a POSITIVE determination (the lookup SUCCEEDED and the org | 20 | // This requires a POSITIVE determination (the lookup SUCCEEDED and the org |
| 18 | // is absent), never a failed lookup. Because authenticate reads the | 21 | // is absent), never a failed lookup. Because authenticate reads the |
| 19 | // mcp-session-id header to do the live org check, the envelope's | 22 | // mcp-session-id header to do the membership check, the envelope's |
| 20 | // dispose-on-Forbidden-with-sessionId path reproduces the old inline | 23 | // dispose-on-Forbidden-with-sessionId path reproduces the old inline |
| 21 | // clearExistingSession. | 24 | // clearExistingSession. |
| 22 | // - verified + org allowed -> Authenticated(principal) | 25 | // - verified + org allowed -> Authenticated(principal) |
| @@ -70,15 +73,17 @@ const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_ · 47 unmodified lines | |||
| 70 | 73 | ||
| 71 | const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first"; | 74 | const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first"; |
| 72 | 75 | ||
| 73 | // A transient WorkOS failure (429 / 5xx / timeout / network) during the live | 76 | // A transient failure during the membership lookup must NOT masquerade as |
| 74 | // membership lookup must NOT masquerade as "org revoked" — but the failure | 77 | // "org revoked". The lookup reads the local mirror, so in steady state its |
| 75 | // channel alone is not enough to tell them apart: WorkOS also answers with | 78 | // only failure is the database; the one path that still asks WorkOS (an |
| 76 | // DEFINITIVE 4xx denials (401 revoked/invalid API key, 403, 404 deleted org) | 79 | // organization the mirror has never seen, resolved for a caller WorkOS |
| 77 | // that the SDK throws as typed exceptions. So the classification is: | 80 | // confirms as its member) can also fail with a DEFINITIVE 4xx denial (401 |
| 81 | // revoked/invalid API key, 403, 404 deleted org) that the SDK throws as a | ||
| 82 | // typed exception. So the classification is: | ||
| 78 | // - lookup SUCCEEDS with `null` -> genuine absence -> Forbidden | 83 | // - lookup SUCCEEDS with `null` -> genuine absence -> Forbidden |
| 79 | // - lookup FAILS with WorkOS 401/403/404 -> definitive denial -> Forbidden | 84 | // - lookup FAILS with WorkOS 401/403/404 -> definitive denial -> Forbidden |
| 80 | // (fail CLOSED: WorkOS answered and said no; retrying cannot help) | 85 | // (fail CLOSED: WorkOS answered and said no; retrying cannot help) |
| 81 | // - lookup FAILS any other way (429/5xx/timeout/network/no status) | 86 | // - lookup FAILS any other way (database, 429/5xx/timeout/network/no status) |
| 82 | // -> transient -> retryable 503, session preserved | 87 | // -> transient -> retryable 503, session preserved |
| 83 | // The status rides on `WorkOSError.status` (threaded from the SDK exception at | 88 | // The status rides on `WorkOSError.status` (threaded from the SDK exception at |
| 84 | // the service boundary in auth/workos.ts); `isDefinitiveWorkOSDenial` is the | 89 | // the service boundary in auth/workos.ts); `isDefinitiveWorkOSDenial` is the |
| @@ -18,7 +18,6 @@ import { BEARER_PREFIX } from "../auth/bearer"; · 17 unmodified lines | |||
| 18 | import { authorizeOrganization } from "../auth/organization"; | 18 | import { authorizeOrganization } from "../auth/organization"; |
| 19 | import { UserStoreService, makeUserStoreLayer } from "../auth/context"; | 19 | import { UserStoreService, makeUserStoreLayer } from "../auth/context"; |
| 20 | import { makeMemberDirectoryLayer } from "../auth/member-directory"; | 20 | import { makeMemberDirectoryLayer } from "../auth/member-directory"; |
| 21 | import { makeMirrorReadinessLayer } from "../auth/mirror-readiness"; | ||
| 22 | import { makeWorkOsMirrorLayer } from "../auth/workos-mirror"; | 21 | import { makeWorkOsMirrorLayer } from "../auth/workos-mirror"; |
| 23 | import { CoreSharedServices } from "../auth/workos"; | 22 | import { CoreSharedServices } from "../auth/workos"; |
| 24 | import { makeDbLayer } from "../db/db"; | 23 | import { makeDbLayer } from "../db/db"; |
| @@ -211,13 +210,11 @@ const makeMcpOrganizationAuthServices = () => { · 186 unmodified lines | |||
| 211 | const dbLive = makeDbLayer(); | 210 | const dbLive = makeDbLayer(); |
| 212 | const userStoreLive = makeUserStoreLayer().pipe(Layer.provide(dbLive)); | 211 | const userStoreLive = makeUserStoreLayer().pipe(Layer.provide(dbLive)); |
| 213 | const memberDirectoryLive = makeMemberDirectoryLayer().pipe(Layer.provide(dbLive)); | 212 | const memberDirectoryLive = makeMemberDirectoryLayer().pipe(Layer.provide(dbLive)); |
| 214 | const mirrorReadinessLive = makeMirrorReadinessLayer().pipe(Layer.provide(dbLive)); | ||
| 215 | const workOsMirrorLive = makeWorkOsMirrorLayer().pipe(Layer.provide(dbLive)); | 213 | const workOsMirrorLive = makeWorkOsMirrorLayer().pipe(Layer.provide(dbLive)); |
| 216 | return Layer.mergeAll( | 214 | return Layer.mergeAll( |
| 217 | dbLive, | 215 | dbLive, |
| 218 | userStoreLive, | 216 | userStoreLive, |
| 219 | memberDirectoryLive, | 217 | memberDirectoryLive, |
| 220 | mirrorReadinessLive, | ||
| 221 | workOsMirrorLive, | 218 | workOsMirrorLive, |
| 222 | CoreSharedServices, | 219 | CoreSharedServices, |
| 223 | ); | 220 | ); |
| @@ -329,7 +326,9 @@ export const McpAuthLive = Layer.effect( · 105 unmodified lines | |||
| 329 | if (!verified) return mcpUnauthorized("invalid_token", "The access token is invalid"); | 326 | if (!verified) return mcpUnauthorized("invalid_token", "The access token is invalid"); |
| 330 | if (Predicate.isTagged(verified, "Unauthorized")) return verified; | 327 | if (Predicate.isTagged(verified, "Unauthorized")) return verified; |
| 331 | if (!verified.accountId) { | 328 | if (!verified.accountId) { |
| 332 | yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_subject" }); | 329 | yield* Effect.annotateCurrentSpan({ |
| 330 | "mcp.auth.outcome": "missing_subject", | ||
| 331 | }); | ||
| 333 | return mcpUnauthorized("invalid_token", "The access token is invalid"); | 332 | return mcpUnauthorized("invalid_token", "The access token is invalid"); |
| 334 | } | 333 | } |
| 335 | yield* Effect.annotateCurrentSpan({ | 334 | yield* Effect.annotateCurrentSpan({ |
| @@ -344,7 +343,9 @@ export const McpAuthLive = Layer.effect( · 8 unmodified lines | |||
| 344 | verifyBearer: Effect.fn("mcp.auth.verify_bearer")(function* (request) { | 343 | verifyBearer: Effect.fn("mcp.auth.verify_bearer")(function* (request) { |
| 345 | const authHeader = request.headers.get("authorization"); | 344 | const authHeader = request.headers.get("authorization"); |
| 346 | if (!authHeader?.startsWith(BEARER_PREFIX)) { | 345 | if (!authHeader?.startsWith(BEARER_PREFIX)) { |
| 347 | yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_bearer" }); | 346 | yield* Effect.annotateCurrentSpan({ |
| 347 | "mcp.auth.outcome": "missing_bearer", | ||
| 348 | }); | ||
| 348 | return mcpUnauthorized("missing_bearer"); | 349 | return mcpUnauthorized("missing_bearer"); |
| 349 | } | 350 | } |
| 350 | const token = authHeader.slice(BEARER_PREFIX.length).trim(); | 351 | const token = authHeader.slice(BEARER_PREFIX.length).trim(); |
| @@ -9,7 +9,6 @@ import { · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | import { UserStoreService } from "../auth/context"; | 10 | import { UserStoreService } from "../auth/context"; |
| 11 | import { sessionFromSealed } from "../auth/middleware"; | 11 | import { sessionFromSealed } from "../auth/middleware"; |
| 12 | import { MirrorReadiness } from "../auth/mirror-readiness"; | ||
| 13 | import { WorkOsMirror } from "../auth/workos-mirror"; | 12 | import { WorkOsMirror } from "../auth/workos-mirror"; |
| 14 | import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; | 13 | import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; |
| 15 | import { WorkOSClient } from "../auth/workos"; | 14 | import { WorkOSClient } from "../auth/workos"; |
| @@ -35,19 +34,19 @@ const noOrganization = () => · 19 unmodified lines | |||
| 35 | 34 | ||
| 36 | /** | 35 | /** |
| 37 | * The caller's role in the session org, as `authorizeOrganizationSelector` | 36 | * The caller's role in the session org, as `authorizeOrganizationSelector` |
| 38 | * read it for THIS request: from the mirror while the mirror is ready, from | 37 | * read it for THIS request from the membership mirror (`auth/organization.ts`). |
| 39 | * WorkOS otherwise (`auth/organization.ts`). Provided beside `AuthContext` — | 38 | * Provided beside `AuthContext` — the shared seam, which carries no role — so |
| 40 | * the shared seam, which carries no role — so the domain handlers' admin gate | 39 | * the domain handlers' admin gate is this one value, never a second read of |
| 41 | * is this one value, never a second read of the mirror that would skip the | 40 | * the mirror. |
| 42 | * readiness rule and admit a demoted admin on a stale row while the | ||
| 43 | * reconciler is behind. | ||
| 44 | */ | 41 | */ |
| 45 | export class OrgMemberRole extends Context.Service< | 42 | export class OrgMemberRole extends Context.Service< |
| 46 | OrgMemberRole, | 43 | OrgMemberRole, |
| 47 | { readonly memberRole: "admin" | "member" } | 44 | { readonly memberRole: "admin" | "member" } |
| 48 | >()("@executor-js/cloud/OrgMemberRole") {} | 45 | >()("@executor-js/cloud/OrgMemberRole") {} |
| 49 | 46 | ||
| 50 | const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext | OrgMemberRole }>()( | 47 | const OrgAuthMiddleware = HttpRouter.middleware<{ |
| 48 | provides: AuthContext | OrgMemberRole; | ||
| 49 | }>()( | ||
| 51 | Effect.gen(function* () { | 50 | Effect.gen(function* () { |
| 52 | const captured = yield* Effect.context<WorkOSClient>(); | 51 | const captured = yield* Effect.context<WorkOSClient>(); |
| 53 | const workos = yield* WorkOSClient; | 52 | const workos = yield* WorkOSClient; |
| @@ -93,7 +92,5 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext | OrgMem · 39 unmodified lines | |||
| 93 | ); | 92 | ); |
| 94 | 93 | ||
| 95 | export const orgAuthMiddleware = ( | 94 | export const orgAuthMiddleware = ( |
| 96 | rsLive: Layer.Layer< | 95 | rsLive: Layer.Layer<DbService | UserStoreService | MemberDirectory | WorkOsMirror>, |
| 97 | DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror | ||
| 98 | >, | ||
| 99 | ) => OrgAuthMiddleware.combine(requestScopedMiddleware(rsLive)).layer; | 96 | ) => OrgAuthMiddleware.combine(requestScopedMiddleware(rsLive)).layer; |
| @@ -5,7 +5,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; · 4 unmodified lines | |||
| 5 | 5 | ||
| 6 | import { AuthContext, MemberDirectory, type DirectoryMember } from "@executor-js/api/server"; | 6 | import { AuthContext, MemberDirectory, type DirectoryMember } from "@executor-js/api/server"; |
| 7 | import { UserStoreService } from "../auth/context"; | 7 | import { UserStoreService } from "../auth/context"; |
| 8 | import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness"; | ||
| 9 | import { ORG_SELECTOR_HEADER } from "../auth/organization"; | 8 | import { ORG_SELECTOR_HEADER } from "../auth/organization"; |
| 10 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | 9 | import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; |
| 11 | import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; | 10 | import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; |
| @@ -23,8 +22,8 @@ import { OrgHandlers, assertDomainInSessionOrg, requireAdmin } from "./handlers" · 11 unmodified lines | |||
| 23 | // share — the REAL `requireAdmin` and `assertDomainInSessionOrg` exported from | 22 | // share — the REAL `requireAdmin` and `assertDomainInSessionOrg` exported from |
| 24 | // `org/handlers.ts`, so a change to the gate cannot pass on a stale copy — and | 23 | // `org/handlers.ts`, so a change to the gate cannot pass on a stale copy — and |
| 25 | // the admin gate's SOURCE: the role `orgAuthMiddleware` resolved for the | 24 | // the admin gate's SOURCE: the role `orgAuthMiddleware` resolved for the |
| 26 | // request, so a stale mirror row cannot admit a demoted admin while the mirror | 25 | // request, read from the local membership mirror unconditionally |
| 27 | // is not trusted (the readiness rule in `auth/organization.ts`). | 26 | // (`auth/organization.ts`). |
| 28 | // --------------------------------------------------------------------------- | 27 | // --------------------------------------------------------------------------- |
| 29 | 28 | ||
| 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test stub needs wide function types | 29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test stub needs wide function types |
| @@ -99,7 +98,11 @@ describe("Org domain handlers", () => { · 68 unmodified lines | |||
| 99 | Effect.provide( | 98 | Effect.provide( |
| 100 | provide("admin", { | 99 | provide("admin", { |
| 101 | getOrganizationDomain: () => | 100 | getOrganizationDomain: () => |
| 102 | Effect.succeed({ id: "dom_1", organizationId: "org_1", domain: "acme.test" }), | 101 | Effect.succeed({ |
| 102 | id: "dom_1", | ||
| 103 | organizationId: "org_1", | ||
| 104 | domain: "acme.test", | ||
| 105 | }), | ||
| 103 | }), | 106 | }), |
| 104 | ), | 107 | ), |
| 105 | ), | 108 | ), |
| @@ -113,7 +116,11 @@ describe("Org domain handlers", () => { · 7 unmodified lines | |||
| 113 | Effect.provide( | 116 | Effect.provide( |
| 114 | provide("admin", { | 117 | provide("admin", { |
| 115 | getOrganizationDomain: () => | 118 | getOrganizationDomain: () => |
| 116 | Effect.succeed({ id: "dom_other", organizationId: "org_2", domain: "evil.test" }), | 119 | Effect.succeed({ |
| 120 | id: "dom_other", | ||
| 121 | organizationId: "org_2", | ||
| 122 | domain: "evil.test", | ||
| 123 | }), | ||
| 117 | }), | 124 | }), |
| 118 | ), | 125 | ), |
| 119 | ), | 126 | ), |
| @@ -136,11 +143,12 @@ describe("Org domain handlers", () => { · 16 unmodified lines | |||
| 136 | 143 | ||
| 137 | // --------------------------------------------------------------------------- | 144 | // --------------------------------------------------------------------------- |
| 138 | // The admin gate over HTTP, through `orgAuthMiddleware`: the role the gate | 145 | // The admin gate over HTTP, through `orgAuthMiddleware`: the role the gate |
| 139 | // sees is the one the middleware resolved through `authorizeOrganizationSelector` | 146 | // sees is the one the middleware resolved through `authorizeOrganizationSelector`, |
| 140 | // — the mirror while it is ready, WorkOS otherwise. The mirror row below is | 147 | // which reads the local membership mirror unconditionally — there is no |
| 141 | // STALE: it still says `admin` for a caller WorkOS has demoted to `member`. | 148 | // readiness gate and no WorkOS fallback (`auth/organization.ts`). The mirror |
| 142 | // While the mirror is not trusted (the reconciler is behind), WorkOS's answer | 149 | // row below is the sole source of the role, so `stubWorkOS` here serves only |
| 143 | // must decide, and the delete must be refused. | 150 | // session authentication and dies on anything else, proving membership is |
| 151 | // never re-checked against WorkOS. | ||
| 144 | // --------------------------------------------------------------------------- | 152 | // --------------------------------------------------------------------------- |
| 145 | 153 | ||
| 146 | const ORG = "org_1"; | 154 | const ORG = "org_1"; |
| @@ -148,33 +156,30 @@ const CALLER = "user_caller"; · 1 unmodified line | |||
| 148 | const DOMAIN = "dom_1"; | 156 | const DOMAIN = "dom_1"; |
| 149 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); | 157 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); |
| 150 | 158 | ||
| 151 | // The mirror's row for the caller: an active admin — stale once WorkOS has | 159 | // The mirror's row for the caller, at whatever role a test sets. |
| 152 | // demoted them and the reconciler has not landed the change yet. | 160 | const callerRow = (role: "admin" | "member"): DirectoryMember => ({ |
| 153 | const staleAdminRow: DirectoryMember = { | ||
| 154 | accountId: CALLER, | 161 | accountId: CALLER, |
| 155 | membershipId: `om_${CALLER}_${ORG}`, | 162 | membershipId: `om_${CALLER}_${ORG}`, |
| 156 | organizationId: ORG, | 163 | organizationId: ORG, |
| 157 | email: null, | 164 | email: null, |
| 158 | name: null, | 165 | name: null, |
| 159 | avatarUrl: null, | 166 | avatarUrl: null, |
| 160 | role: "admin", | 167 | role, |
| 161 | status: "active", | 168 | status: "active", |
| 162 | lastActiveAt: null, | 169 | lastActiveAt: null, |
| 163 | }; | ||
| 164 | |||
| 165 | const unread = (why: string) => () => Effect.die(why); | ||
| 166 | const stubDirectory = Layer.succeed(MemberDirectory)({ | ||
| 167 | membership: (accountId, organizationId) => | ||
| 168 | Effect.succeed(accountId === CALLER && organizationId === ORG ? staleAdminRow : null), | ||
| 169 | membershipById: unread("the org plane does not look up by membership id"), | ||
| 170 | membershipsOf: unread("the org plane reads one membership, not the list"), | ||
| 171 | members: unread("the org plane does not list members"), | ||
| 172 | membersById: unread("the org plane does not batch members"), | ||
| 173 | findByEmail: unread("the org plane does not resolve emails"), | ||
| 174 | }); | 170 | }); |
| 175 | 171 | ||
| 176 | const readiness = (state: MirrorReadinessState) => | 172 | const unread = (why: string) => () => Effect.die(why); |
| 177 | Layer.succeed(MirrorReadiness)({ state: () => Effect.succeed(state) }); | 173 | const stubDirectory = (role: "admin" | "member") => |
| 174 | Layer.succeed(MemberDirectory)({ | ||
| 175 | membership: (accountId, organizationId) => | ||
| 176 | Effect.succeed(accountId === CALLER && organizationId === ORG ? callerRow(role) : null), | ||
| 177 | membershipById: unread("the org plane does not look up by membership id"), | ||
| 178 | membershipsOf: unread("the org plane reads one membership, not the list"), | ||
| 179 | members: unread("the org plane does not list members"), | ||
| 180 | membersById: unread("the org plane does not batch members"), | ||
| 181 | findByEmail: unread("the org plane does not resolve emails"), | ||
| 182 | }); | ||
| 178 | 183 | ||
| 179 | const organizationRow = (id: string) => ({ | 184 | const organizationRow = (id: string) => ({ |
| 180 | id, | 185 | id, |
| @@ -226,22 +231,16 @@ const stubAutumn = Layer.succeed(AutumnService)({ · 45 unmodified lines | |||
| 226 | setMemberSeats: unread("the delete does not count seats"), | 231 | setMemberSeats: unread("the delete does not count seats"), |
| 227 | }); | 232 | }); |
| 228 | 233 | ||
| 229 | // WorkOS as the org plane sees it: the caller's session, their CURRENT | 234 | // WorkOS as the org plane sees it: session authentication and the domain to |
| 230 | // membership list (demoted to member), and the domain to delete. | 235 | // delete. `stubWorkOS` dies on anything else, so a `listUserMemberships` call |
| 231 | const workosWithCallerAs = (role: "admin" | "member", deleted: string[]) => | 236 | // would fail the test — membership is never re-checked against WorkOS. |
| 237 | const workosForCaller = (deleted: string[]) => | ||
| 232 | stubWorkOS({ | 238 | stubWorkOS({ |
| 233 | authenticateSealedSession: () => | 239 | authenticateSealedSession: () => |
| 234 | Effect.succeed({ userId: CALLER, email: "caller@placeholder.test", organizationId: ORG }), | ||
| 235 | listUserMemberships: () => | ||
| 236 | Effect.succeed({ | 240 | Effect.succeed({ |
| 237 | data: [ | 241 | userId: CALLER, |
| 238 | { | 242 | email: "caller@placeholder.test", |
| 239 | id: staleAdminRow.membershipId, | 243 | organizationId: ORG, |
| 240 | organizationId: ORG, | ||
| 241 | status: "active", | ||
| 242 | role: { slug: role }, | ||
| 243 | }, | ||
| 244 | ], | ||
| 245 | }), | 244 | }), |
| 246 | getOrganizationDomain: () => | 245 | getOrganizationDomain: () => |
| 247 | Effect.succeed({ id: DOMAIN, organizationId: ORG, domain: "acme.test" }), | 246 | Effect.succeed({ id: DOMAIN, organizationId: ORG, domain: "acme.test" }), |
| @@ -251,8 +250,8 @@ const workosWithCallerAs = (role: "admin" | "member", deleted: string[]) => · 3 unmodified lines | |||
| 251 | }), | 250 | }), |
| 252 | }); | 251 | }); |
| 253 | 252 | ||
| 254 | const orgApp = (state: MirrorReadinessState, workos: Layer.Layer<WorkOSClient>) => { | 253 | const orgApp = (role: "admin" | "member", workos: Layer.Layer<WorkOSClient>) => { |
| 255 | const rsLive = Layer.mergeAll(stubDb, stubUsers, stubDirectory, stubMirror, readiness(state)); | 254 | const rsLive = Layer.mergeAll(stubDb, stubUsers, stubDirectory(role), stubMirror); |
| 256 | const App = HttpApiBuilder.layer(OrgHttpApi).pipe( | 255 | const App = HttpApiBuilder.layer(OrgHttpApi).pipe( |
| 257 | Layer.provide(OrgHandlers), | 256 | Layer.provide(OrgHandlers), |
| 258 | Layer.provide(orgAuthMiddleware(rsLive)), | 257 | Layer.provide(orgAuthMiddleware(rsLive)), |
| @@ -268,9 +267,9 @@ afterAll(async () => { · 9 unmodified lines | |||
| 268 | await Promise.all(apps.map((app) => app.dispose())); | 267 | await Promise.all(apps.map((app) => app.dispose())); |
| 269 | }); | 268 | }); |
| 270 | 269 | ||
| 271 | const deleteDomain = async (state: MirrorReadinessState, role: "admin" | "member") => { | 270 | const deleteDomain = async (role: "admin" | "member") => { |
| 272 | const deleted: string[] = []; | 271 | const deleted: string[] = []; |
| 273 | const app = orgApp(state, workosWithCallerAs(role, deleted)); | 272 | const app = orgApp(role, workosForCaller(deleted)); |
| 274 | apps.push(app); | 273 | apps.push(app); |
| 275 | const response = await app.handler( | 274 | const response = await app.handler( |
| 276 | new Request(`https://executor.test/org/domains/${DOMAIN}`, { | 275 | new Request(`https://executor.test/org/domains/${DOMAIN}`, { |
| @@ -285,30 +284,15 @@ const deleteDomain = async (state: MirrorReadinessState, role: "admin" | "member · 8 unmodified lines | |||
| 285 | }; | 284 | }; |
| 286 | 285 | ||
| 287 | describe("Org domain handlers over HTTP: the admin gate is the authorized role", () => { | 286 | describe("Org domain handlers over HTTP: the admin gate is the authorized role", () => { |
| 288 | it("lets a mirrored admin delete a domain while the mirror is ready", async () => { | 287 | it("lets a mirrored admin delete a domain", async () => { |
| 289 | const { status, deleted } = await deleteDomain(MirrorReadinessState.Ready(), "member"); | 288 | const { status, deleted } = await deleteDomain("admin"); |
| 290 | // WorkOS is not consulted for membership while the mirror is ready: the | ||
| 291 | // mirror row (admin) decides, and the demotion lands through the | ||
| 292 | // reconciler within its lag budget. | ||
| 293 | expect(status).toBe(200); | 289 | expect(status).toBe(200); |
| 294 | expect(deleted).toEqual([DOMAIN]); | 290 | expect(deleted).toEqual([DOMAIN]); |
| 295 | }); | 291 | }); |
| 296 | 292 | ||
| 297 | it("refuses a demoted admin while the mirror is not ready, however stale the mirror row is", async () => { | 293 | it("refuses a mirrored plain member, without ever consulting WorkOS", async () => { |
| 298 | const { status, deleted } = await deleteDomain( | 294 | const { status, deleted } = await deleteDomain("member"); |
| 299 | MirrorReadinessState.ReconcilerStale({ drainedAt: null }), | 295 | expect(status).toBe(403); |
| 300 | "member", | ||
| 301 | ); | ||
| 302 | expect(status, "WorkOS says member; the stale admin row does not grant the delete").toBe(403); | ||
| 303 | expect(deleted).toEqual([]); | 296 | expect(deleted).toEqual([]); |
| 304 | }); | 297 | }); |
| 305 | |||
| 306 | it("lets an admin WorkOS confirms delete a domain while the mirror is not ready", async () => { | ||
| 307 | const { status, deleted } = await deleteDomain( | ||
| 308 | MirrorReadinessState.ReconcilerStale({ drainedAt: null }), | ||
| 309 | "admin", | ||
| 310 | ); | ||
| 311 | expect(status).toBe(200); | ||
| 312 | expect(deleted).toEqual([DOMAIN]); | ||
| 313 | }); | ||
| 314 | }); | 298 | }); |
| @@ -1,25 +1,25 @@ | |||
| 1 | // Cloud: how the per-request live-membership check classifies WorkOS failures, | 1 | // Cloud: an MCP session's relationship to WorkOS after the membership mirror. |
| 2 | // pinned in BOTH directions at the real upstream (faults armed on the WorkOS | ||
| 3 | // emulator's membership endpoint — the same emulator the product's real WorkOS | ||
| 4 | // SDK talks to; no product code or stubs touched): | ||
| 5 | // | 2 | // |
| 6 | // 1. A TRANSIENT WorkOS outage (5xx/timeout) must NOT destroy a live MCP | 3 | // Membership is authorized from the local mirror on every /mcp request |
| 7 | // session. This is the churn-risk defect: a WorkOS blip used to collapse to | 4 | // (`auth/organization.ts`); WorkOS is a write target and an event source, not |
| 8 | // Forbidden, and a Forbidden carrying a session id schedules the session | 5 | // a per-request read. Two contracts follow, pinned here at the real upstream |
| 9 | // Durable Object for destruction (in-flight executions, paused approvals, | 6 | // (faults armed on the WorkOS emulator's membership endpoint — the same |
| 10 | // undelivered results — all gone). For a shared-API-key org a single blip | 7 | // emulator the product's real WorkOS SDK talks to; no product code or stubs |
| 11 | // could mass-condemn every session at once. Contract: the blip request fails | 8 | // touched): |
| 12 | // RETRYABLY (503 + Retry-After), and once WorkOS recovers the SAME session | ||
| 13 | // id keeps serving requests. | ||
| 14 | // | 9 | // |
| 15 | // 2. A DEFINITIVE WorkOS denial (401 — the revoked/invalid API key answer) must | 10 | // 1. A WorkOS OUTAGE is INVISIBLE to a live session. Before the mirror, a |
| 16 | // fail CLOSED: Forbidden, session condemned. Retrying cannot help; treating | 11 | // 5xx from the membership lookup had to be classified as transient (a |
| 17 | // it as transient would preserve sessions indefinitely for a revoked | 12 | // retryable 503 that left the session alive) so a blip could not |
| 18 | // customer (the fail-open inversion the adversarial review caught). | 13 | // mass-condemn every session of a shared-API-key org. Now the request never |
| 14 | // asks WorkOS at all: a request issued during the outage is a plain 200, | ||
| 15 | // and the SAME session id keeps serving afterwards. The fault is armed on | ||
| 16 | // the exact endpoint the old check hit, so an unnoticed regression back to | ||
| 17 | // a per-request WorkOS read would fail this as a 503 (or worse, a 403). | ||
| 19 | // | 18 | // |
| 20 | // Red/green for (1): pre-fix, the outage request returns a session-destroying | 19 | // 2. A REVOKED membership still fails CLOSED. The mirror is not a cache with a |
| 21 | // Forbidden and the post-outage request gets 404 "reconnect". With the fix the | 20 | // TTL: a removal made through the product writes the mirror in the same |
| 22 | // outage request is a 503 and the post-outage request is a clean 200. | 21 | // request, so the removed member's next /mcp request is a Forbidden, the |
| 22 | // session is condemned, and the id is dead. Retrying cannot help. | ||
| 23 | import { expect } from "@effect/vitest"; | 23 | import { expect } from "@effect/vitest"; |
| 24 | import { Effect } from "effect"; | 24 | import { Effect } from "effect"; |
| 25 | 25 | ||
| @@ -29,6 +29,7 @@ import { scenario } from "../src/scenario"; · 3 unmodified lines | |||
| 29 | import { Mcp, Target } from "../src/services"; | 29 | import { Mcp, Target } from "../src/services"; |
| 30 | import type { Identity } from "../src/target"; | 30 | import type { Identity } from "../src/target"; |
| 31 | import { WORKOS_EMULATOR_PORT } from "../targets/cloud"; | 31 | import { WORKOS_EMULATOR_PORT } from "../targets/cloud"; |
| 32 | import { cookieOf, joinOrg, orgSelectorOf } from "./support/session"; | ||
| 32 | 33 | ||
| 33 | const JSON_AND_SSE = "application/json, text/event-stream"; | 34 | const JSON_AND_SSE = "application/json, text/event-stream"; |
| 34 | const PROTOCOL_VERSION = "2025-03-26"; | 35 | const PROTOCOL_VERSION = "2025-03-26"; |
| @@ -105,11 +106,11 @@ const openSession = async (mcpUrl: string, bearer: string): Promise<string> => { · 70 unmodified lines | |||
| 105 | return sessionId; | 106 | return sessionId; |
| 106 | }; | 107 | }; |
| 107 | 108 | ||
| 108 | // The live membership check is `GET /user_management/organization_memberships` | 109 | // The endpoint the pre-mirror per-request check hit |
| 109 | // (WorkOS `listOrganizationMemberships`). A bounded count covers the outage | 110 | // (`GET /user_management/organization_memberships`). Armed to prove it is no |
| 110 | // request without leaking into later (post-clear) requests; we also clear | 111 | // longer on the request path: a request that reached it would fail. `times` |
| 111 | // explicitly. `times` is generous so any internal retry inside the one faulted | 112 | // is generous so any retry inside a faulted request still sees the outage; the |
| 112 | // request still sees the outage, but the finalizer removes whatever remains. | 113 | // finalizer removes whatever remains. |
| 113 | const MEMBERSHIP_FAULT = { | 114 | const MEMBERSHIP_FAULT = { |
| 114 | match: { | 115 | match: { |
| 115 | method: "GET", | 116 | method: "GET", |
| @@ -119,22 +120,8 @@ const MEMBERSHIP_FAULT = { · 3 unmodified lines | |||
| 119 | times: 8, | 120 | times: 8, |
| 120 | } as const; | 121 | } as const; |
| 121 | 122 | ||
| 122 | // The definitive-denial counterpart: WorkOS ANSWERS the membership lookup with | ||
| 123 | // 401 — the shape of a revoked/invalid API key. Not a blip; must fail closed. | ||
| 124 | const MEMBERSHIP_DENIAL_FAULT = { | ||
| 125 | match: { | ||
| 126 | method: "GET", | ||
| 127 | pathPattern: "/user_management/organization_memberships*", | ||
| 128 | }, | ||
| 129 | response: { | ||
| 130 | status: 401, | ||
| 131 | body: { message: "Could not authorize the request. Maybe your API key is invalid?" }, | ||
| 132 | }, | ||
| 133 | times: 8, | ||
| 134 | } as const; | ||
| 135 | |||
| 136 | scenario( | 123 | scenario( |
| 137 | "MCP sessions · a transient WorkOS outage 503s retryably and leaves the session alive", | 124 | "MCP sessions · a WorkOS outage is invisible to a live session, which is authorized from the mirror", |
| 138 | {}, | 125 | {}, |
| 139 | Effect.gen(function* () { | 126 | Effect.gen(function* () { |
| 140 | const target = yield* Target; | 127 | const target = yield* Target; |
| @@ -151,106 +138,150 @@ scenario( · 10 unmodified lines | |||
| 151 | const healthy = yield* Effect.promise(() => | 138 | const healthy = yield* Effect.promise(() => |
| 152 | mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(2) }), | 139 | mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(2) }), |
| 153 | ); | 140 | ); |
| 154 | expect(healthy.status, "the session serves requests before the blip").toBe(200); | 141 | expect(healthy.status, "the session serves requests before the outage").toBe(200); |
| 155 | yield* Effect.promise(() => healthy.text()); | 142 | yield* Effect.promise(() => healthy.text()); |
| 156 | 143 | ||
| 157 | yield* Effect.gen(function* () { | 144 | yield* Effect.gen(function* () { |
| 158 | // The blip: WorkOS membership lookups start failing with 503. | 145 | // The outage: WorkOS membership lookups would fail with 503 — if |
| 146 | // anything asked. | ||
| 159 | yield* Effect.promise(() => workos.faults.arm(MEMBERSHIP_FAULT)); | 147 | yield* Effect.promise(() => workos.faults.arm(MEMBERSHIP_FAULT)); |
| 160 | 148 | ||
| 161 | // A request issued DURING the outage. The membership lookup fails | 149 | // A request issued DURING the outage. Membership is read from the |
| 162 | // transiently — this must be a retryable 503, NOT a Forbidden (which | 150 | // mirror, so WorkOS is never consulted and the request is a plain |
| 163 | // would condemn the session). | 151 | // success — not a retryable 503 (the pre-mirror contract) and never a |
| 152 | // Forbidden (which would condemn the session). | ||
| 164 | const duringOutage = yield* Effect.promise(() => | 153 | const duringOutage = yield* Effect.promise(() => |
| 165 | mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(3) }), | 154 | mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(3) }), |
| 166 | ); | 155 | ); |
| 167 | const outageBody = (yield* Effect.promise(() => duringOutage.json())) as JsonRpcError; | ||
| 168 | expect( | 156 | expect( |
| 169 | duringOutage.status, | 157 | duringOutage.status, |
| 170 | "a WorkOS blip is a retryable 503, not a session-destroying error", | 158 | "a WorkOS outage does not touch a request: membership comes from the mirror", |
| 171 | ).toBe(503); | 159 | ).toBe(200); |
| 172 | expect( | 160 | yield* Effect.promise(() => duringOutage.text()); |
| 173 | duringOutage.status, | ||
| 174 | "the blip is NOT surfaced as a 404 reconnect (which would mean the session was destroyed)", | ||
| 175 | ).not.toBe(404); | ||
| 176 | expect( | ||
| 177 | outageBody.error.code, | ||
| 178 | "the 503 is a JSON-RPC error envelope the transport retries", | ||
| 179 | ).toBe(-32001); | ||
| 180 | expect( | ||
| 181 | duringOutage.headers.get("retry-after"), | ||
| 182 | "the 503 advertises a Retry-After so clients back off", | ||
| 183 | ).toEqual(expect.any(String)); | ||
| 184 | }).pipe( | 161 | }).pipe( |
| 185 | // Always lift the outage, even if an assertion above fails, so the | 162 | // Always lift the outage, even if an assertion above fails. |
| 186 | // recovery request runs against a healthy WorkOS. | ||
| 187 | Effect.ensuring(Effect.promise(() => workos.faults.clear())), | 163 | Effect.ensuring(Effect.promise(() => workos.faults.clear())), |
| 188 | ); | 164 | ); |
| 189 | 165 | ||
| 190 | // WorkOS has recovered. The SAME session id must still serve requests: the | 166 | // The SAME session id keeps serving after the outage: nothing condemned it. |
| 191 | // blip left it untouched. On the pre-fix code this is a 404 (the outage | ||
| 192 | // request destroyed the DO); with the fix it is a clean 200. | ||
| 193 | const afterOutage = yield* Effect.promise(() => | 167 | const afterOutage = yield* Effect.promise(() => |
| 194 | mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(4) }), | 168 | mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(4) }), |
| 195 | ); | 169 | ); |
| 196 | expect( | 170 | expect(afterOutage.status, "the session is untouched by the outage").toBe(200); |
| 197 | afterOutage.status, | ||
| 198 | "the session survived the blip and resumes work once WorkOS recovers", | ||
| 199 | ).toBe(200); | ||
| 200 | yield* Effect.promise(() => afterOutage.text()); | 171 | yield* Effect.promise(() => afterOutage.text()); |
| 201 | }), | 172 | }), |
| 202 | ); | 173 | ); |
| 203 | 174 | ||
| 204 | scenario( | 175 | scenario( |
| 205 | "MCP sessions · a definitive WorkOS denial fails closed and condemns the session", | 176 | "MCP sessions · a revoked membership fails closed on the next request and condemns the session", |
| 206 | {}, | 177 | {}, |
| 207 | Effect.gen(function* () { | 178 | Effect.gen(function* () { |
| 208 | const target = yield* Target; | 179 | const target = yield* Target; |
| 209 | const mcp = yield* Mcp; | 180 | const mcp = yield* Mcp; |
| 210 | const identity = yield* target.newIdentity(); | ||
| 211 | const bearer = yield* mcp.mintBearer(emailOf(identity)); | ||
| 212 | 181 | ||
| 213 | const workos = yield* Effect.promise(() => | 182 | // An admin's org with one plain member, joined through the real invite → |
| 214 | connectEmulator({ baseUrl: `http://127.0.0.1:${WORKOS_EMULATOR_PORT}` }), | 183 | // accept flow. The member is the one whose access is revoked. |
| 215 | ); | 184 | const admin = yield* target.newIdentity(); |
| 185 | const invitee = yield* target.newIdentity({ org: false }); | ||
| 186 | const member = yield* joinOrg(target, admin, invitee); | ||
| 187 | const bearer = yield* mcp.mintBearer(emailOf(member)); | ||
| 188 | const orgSelector = orgSelectorOf(member); | ||
| 216 | 189 | ||
| 217 | // A healthy session doing real work before the denial. | 190 | // The member's healthy session doing real work before the revocation. |
| 218 | const sessionId = yield* Effect.promise(() => openSession(target.mcpUrl, bearer)); | 191 | const mcpUrl = `${target.mcpUrl}`; |
| 219 | const healthy = yield* Effect.promise(() => | 192 | const withOrg = (body: unknown, sessionId?: string) => |
| 220 | mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(2) }), | 193 | fetch(mcpUrl, { |
| 221 | ); | 194 | method: "POST", |
| 222 | expect(healthy.status, "the session serves requests before the denial").toBe(200); | 195 | headers: { |
| 196 | accept: JSON_AND_SSE, | ||
| 197 | "content-type": "application/json", | ||
| 198 | authorization: `Bearer ${bearer}`, | ||
| 199 | "x-executor-mcp-organization": orgSelector, | ||
| 200 | ...(sessionId ? { "mcp-session-id": sessionId } : {}), | ||
| 201 | }, | ||
| 202 | body: JSON.stringify(body), | ||
| 203 | }); | ||
| 204 | const initialize = yield* Effect.promise(() => withOrg(INITIALIZE_REQUEST)); | ||
| 205 | const sessionId = initialize.headers.get("mcp-session-id"); | ||
| 206 | yield* Effect.promise(() => initialize.text()); | ||
| 207 | expect(initialize.status, "the member opens a session in the org").toBe(200); | ||
| 208 | if (!sessionId) throw new Error("initialize returned no session id"); | ||
| 209 | const initialized = yield* Effect.promise(() => withOrg(INITIALIZED_NOTIFICATION, sessionId)); | ||
| 210 | yield* Effect.promise(() => initialized.text()); | ||
| 211 | const healthy = yield* Effect.promise(() => withOrg(toolsList(2), sessionId)); | ||
| 212 | expect(healthy.status, "the session serves requests before the revocation").toBe(200); | ||
| 223 | yield* Effect.promise(() => healthy.text()); | 213 | yield* Effect.promise(() => healthy.text()); |
| 224 | 214 | ||
| 225 | yield* Effect.gen(function* () { | 215 | // The admin removes the member through the product. The removal writes |
| 226 | // WorkOS starts ANSWERING the membership lookup with 401 — the | 216 | // the mirror in the same request (a deletion tombstone keyed to the |
| 227 | // revoked/invalid API key shape. Deterministic denial, not a blip. | 217 | // WorkOS membership id), so no reconciler tick is needed for it to land. |
| 228 | yield* Effect.promise(() => workos.faults.arm(MEMBERSHIP_DENIAL_FAULT)); | 218 | const members = yield* Effect.promise(async () => { |
| 219 | const response = await fetch(new URL("/api/account/members", target.baseUrl), { | ||
| 220 | headers: { ...(admin.headers ?? {}) }, | ||
| 221 | }); | ||
| 222 | if (!response.ok) throw new Error(`/api/account/members failed (${response.status})`); | ||
| 223 | return (await response.json()) as { | ||
| 224 | readonly members: ReadonlyArray<{ readonly id: string; readonly isCurrentUser: boolean }>; | ||
| 225 | }; | ||
| 226 | }); | ||
| 227 | const removed = members.members.find((row) => !row.isCurrentUser); | ||
| 228 | if (!removed) throw new Error("the joined member is not listed in the org"); | ||
| 229 | const removal = yield* Effect.promise(() => | ||
| 230 | fetch(new URL(`/api/account/members/${removed.id}`, target.baseUrl), { | ||
| 231 | method: "DELETE", | ||
| 232 | headers: { ...(admin.headers ?? {}), origin: new URL(target.baseUrl).origin }, | ||
| 233 | }), | ||
| 234 | ); | ||
| 235 | expect(removal.status, "the admin removes the member").toBe(200); | ||
| 236 | yield* Effect.promise(() => removal.text()); | ||
| 229 | 237 | ||
| 230 | const denied = yield* Effect.promise(() => | 238 | // The removed member's NEXT request on the live session: a positive |
| 231 | mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(3) }), | 239 | // determination from the mirror that they hold no active membership — a |
| 232 | ); | 240 | // real Forbidden, which condemns the session. |
| 233 | const deniedBody = (yield* Effect.promise(() => denied.json())) as JsonRpcError; | 241 | const denied = yield* Effect.promise(() => withOrg(toolsList(3), sessionId)); |
| 234 | expect( | 242 | const deniedBody = (yield* Effect.promise(() => denied.json())) as JsonRpcError; |
| 235 | denied.status, | 243 | expect(denied.status, "a revoked member fails closed as Forbidden on the next request").toBe( |
| 236 | "a definitive WorkOS denial fails closed as Forbidden, never a retryable 503", | 244 | 403, |
| 237 | ).toBe(403); | 245 | ); |
| 238 | expect(deniedBody.error.code, "the denial is a JSON-RPC error envelope").toBe(-32001); | 246 | expect(deniedBody.error.code, "the denial is a JSON-RPC error envelope").toBe(-32001); |
| 239 | }).pipe(Effect.ensuring(Effect.promise(() => workos.faults.clear()))); | ||
| 240 | 247 | ||
| 241 | // The Forbidden carried the session id, so the session was condemned: the | 248 | // While revoked, every further request is refused at the gate — still a |
| 242 | // id must NOT serve requests once WorkOS recovers. If this returned 200 the | 249 | // Forbidden, never a 200 (a removed member kept a live session) and never |
| 243 | // fail-closed contract is broken (a revoked customer kept a live session). | 250 | // a retryable 503 (nothing about this is transient). |
| 244 | const afterDenial = yield* Effect.promise(() => | 251 | const stillDenied = yield* Effect.promise(() => withOrg(toolsList(4), sessionId)); |
| 245 | mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(4) }), | 252 | expect(stillDenied.status, "a revoked member stays refused, deterministically").toBe(403); |
| 253 | yield* Effect.promise(() => stillDenied.text()); | ||
| 254 | |||
| 255 | // The Forbidden carried the session id, so the session was condemned. A | ||
| 256 | // caller the gate admits proves it: the admin, still a member, presents | ||
| 257 | // the condemned id with their own bearer. Had the id survived, the answer | ||
| 258 | // would be the ownership Forbidden (-32003: the session belongs to someone | ||
| 259 | // else); condemned, it is dead and the client is told to reconnect. | ||
| 260 | const adminBearer = yield* mcp.mintBearer(emailOf(admin)); | ||
| 261 | const condemned = yield* Effect.promise(() => | ||
| 262 | fetch(mcpUrl, { | ||
| 263 | method: "POST", | ||
| 264 | headers: { | ||
| 265 | accept: JSON_AND_SSE, | ||
| 266 | "content-type": "application/json", | ||
| 267 | authorization: `Bearer ${adminBearer}`, | ||
| 268 | "x-executor-mcp-organization": orgSelectorOf(admin), | ||
| 269 | "mcp-session-id": sessionId, | ||
| 270 | }, | ||
| 271 | body: JSON.stringify(toolsList(5)), | ||
| 272 | }), | ||
| 246 | ); | 273 | ); |
| 247 | expect( | 274 | expect(condemned.status, "the condemned session id is dead (reconnect required)").toBe(404); |
| 248 | afterDenial.status, | 275 | const condemnedBody = (yield* Effect.promise(() => condemned.json())) as JsonRpcError; |
| 249 | "the condemned session id is dead after a definitive denial (reconnect required)", | 276 | expect(condemnedBody.error.message, "the client is told to reconnect").toMatch( |
| 250 | ).toBe(404); | ||
| 251 | const afterBody = (yield* Effect.promise(() => afterDenial.json())) as JsonRpcError; | ||
| 252 | expect(afterBody.error.message, "the client is told to reconnect").toMatch( | ||
| 253 | /timed out|reconnect|not found/i, | 277 | /timed out|reconnect|not found/i, |
| 254 | ); | 278 | ); |
| 279 | |||
| 280 | // The admin's own access is unaffected by removing someone else. | ||
| 281 | const adminStillIn = yield* Effect.promise(() => | ||
| 282 | fetch(new URL("/api/account/me", target.baseUrl), { headers: { cookie: cookieOf(admin) } }), | ||
| 283 | ); | ||
| 284 | expect(adminStillIn.status, "the admin keeps their access").toBe(200); | ||
| 285 | yield* Effect.promise(() => adminStillIn.text()); | ||
| 255 | }), | 286 | }), |
| 256 | ); | 287 | ); |