Org-scoped requests carry the active org in an x-executor-organization header (a slug or org_ id); the server resolves it against live WorkOS membership and scopes to it, falling back to the session's own org when absent. Additive and backward-compatible — existing cookie-only callers keep working via the fallback. The header is a selector, not a trust boundary (membership is always re-checked), mirroring the MCP plane. This is the server half of moving off cookie-based 'active org': once the web client sends its URL's org per request, two browser tabs on different orgs scope independently instead of fighting over one shared cookie. Covers the executor data plane (workos-auth-provider) and the account plane (members/api-keys/me). The domains plane stays session-scoped: its HttpApiMiddleware security handler may carry no residual requirement, so it can't reach the per-request UserStoreService a slug needs — noted for a follow-up that converts it to an HttpRouter middleware.
Unified · Split
| @@ -1,6 +1,6 @@ | |||
| 1 | import { Context, Effect, Layer } from "effect"; | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | 2 | ||
| 3 | import { AccountProvider } from "@executor-js/api/server"; | 3 | import { AccountProvider, type AccountHeaders } from "@executor-js/api/server"; |
| 4 | import { | 4 | import { |
| 5 | AccountError, | 5 | AccountError, |
| 6 | AccountForbidden, | 6 | AccountForbidden, |
| @@ -12,7 +12,7 @@ import { ApiKeyService } from "../auth/api-keys"; · 5 unmodified lines | |||
| 12 | import { UserStoreService } from "../auth/context"; | 12 | import { UserStoreService } from "../auth/context"; |
| 13 | import type { Session } from "../auth/middleware"; | 13 | import type { Session } from "../auth/middleware"; |
| 14 | import { WorkOSClient } from "../auth/workos"; | 14 | import { WorkOSClient } from "../auth/workos"; |
| 15 | import { authorizeOrganization } from "../auth/organization"; | 15 | import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; |
| 16 | import { AutumnService } from "../extensions/billing/service"; | 16 | import { AutumnService } from "../extensions/billing/service"; |
| 17 | import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "../extensions/billing/plans"; | 17 | import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "../extensions/billing/plans"; |
| 18 | 18 | ||
| @@ -87,16 +87,20 @@ export const workosAccountProvider: Layer.Layer< · 68 unmodified lines | |||
| 87 | ? Effect.succeed(caller.session) | 87 | ? Effect.succeed(caller.session) |
| 88 | : Effect.fail<AccountUnauthorized>(new AccountUnauthorized()); | 88 | : Effect.fail<AccountUnauthorized>(new AccountUnauthorized()); |
| 89 | 89 | ||
| 90 | // Like cloud's `requireSessionOrganization`: an authenticated session that | 90 | // The org scope for an org-scoped request: the console URL's org (sent in |
| 91 | // currently holds an active membership in its session org. Yields the | 91 | // the selector header) when present, else the session's own org. Membership |
| 92 | // session + resolved org, or AccountNoOrganization. | 92 | // is re-checked live, so the header is a selector, not a trust boundary — |
| 93 | const requireOrganization = () => | 93 | // and two browser tabs on different orgs each send their own header, so |
| 94 | // they stay independent (see organization.ts). Yields the session + | ||
| 95 | // resolved org, or AccountNoOrganization. | ||
| 96 | const requireOrganization = (headers: AccountHeaders) => | ||
| 94 | Effect.gen(function* () { | 97 | Effect.gen(function* () { |
| 95 | const session = yield* requireSession(); | 98 | const session = yield* requireSession(); |
| 96 | if (!session.organizationId) { | 99 | const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; |
| 100 | if (!selector) { | ||
| 97 | return yield* new AccountNoOrganization(); | 101 | return yield* new AccountNoOrganization(); |
| 98 | } | 102 | } |
| 99 | const org = yield* authorizeOrganization(session.accountId, session.organizationId).pipe( | 103 | const org = yield* authorizeOrganizationSelector(session.accountId, selector).pipe( |
| 100 | Effect.provideContext(ctx), | 104 | Effect.provideContext(ctx), |
| 101 | Effect.mapError(() => new AccountNoOrganization()), | 105 | Effect.mapError(() => new AccountNoOrganization()), |
| 102 | ); | 106 | ); |
| @@ -158,11 +162,15 @@ export const workosAccountProvider: Layer.Layer< · 55 unmodified lines | |||
| 158 | }); | 162 | }); |
| 159 | 163 | ||
| 160 | return AccountProvider.of({ | 164 | return AccountProvider.of({ |
| 161 | me: () => | 165 | me: (headers) => |
| 162 | Effect.gen(function* () { | 166 | Effect.gen(function* () { |
| 163 | const session = yield* requireSession(); | 167 | const session = yield* requireSession(); |
| 164 | const org = session.organizationId | 168 | // Same selector precedence as requireOrganization: the URL's org |
| 165 | ? yield* authorizeOrganization(session.accountId, session.organizationId).pipe( | 169 | // (header) drives /account/me so the shell reflects the org the tab |
| 170 | // is viewing, not a session-global active org. | ||
| 171 | const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; | ||
| 172 | const org = selector | ||
| 173 | ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe( | ||
| 166 | Effect.provideContext(ctx), | 174 | Effect.provideContext(ctx), |
| 167 | Effect.orElseSucceed(() => null), | 175 | Effect.orElseSucceed(() => null), |
| 168 | ) | 176 | ) |
| @@ -178,18 +186,18 @@ export const workosAccountProvider: Layer.Layer< · 9 unmodified lines | |||
| 178 | }; | 186 | }; |
| 179 | }), | 187 | }), |
| 180 | 188 | ||
| 181 | listApiKeys: () => | 189 | listApiKeys: (headers) => |
| 182 | Effect.gen(function* () { | 190 | Effect.gen(function* () { |
| 183 | const { session, org } = yield* requireOrganization(); | 191 | const { session, org } = yield* requireOrganization(headers); |
| 184 | const keys = yield* apiKeys | 192 | const keys = yield* apiKeys |
| 185 | .listUserKeys({ accountId: session.accountId, organizationId: org.id }) | 193 | .listUserKeys({ accountId: session.accountId, organizationId: org.id }) |
| 186 | .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); | 194 | .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); |
| 187 | return { apiKeys: keys }; | 195 | return { apiKeys: keys }; |
| 188 | }), | 196 | }), |
| 189 | 197 | ||
| 190 | createApiKey: (_headers, name) => | 198 | createApiKey: (headers, name) => |
| 191 | Effect.gen(function* () { | 199 | Effect.gen(function* () { |
| 192 | const { session, org } = yield* requireOrganization(); | 200 | const { session, org } = yield* requireOrganization(headers); |
| 193 | const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH); | 201 | const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH); |
| 194 | if (!trimmed) { | 202 | if (!trimmed) { |
| 195 | return yield* new AccountError({ message: "API key name is required" }); | 203 | return yield* new AccountError({ message: "API key name is required" }); |
| @@ -199,9 +207,9 @@ export const workosAccountProvider: Layer.Layer< · 3 unmodified lines | |||
| 199 | .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); | 207 | .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); |
| 200 | }), | 208 | }), |
| 201 | 209 | ||
| 202 | revokeApiKey: (_headers, apiKeyId) => | 210 | revokeApiKey: (headers, apiKeyId) => |
| 203 | Effect.gen(function* () { | 211 | Effect.gen(function* () { |
| 204 | const { session, org } = yield* requireOrganization(); | 212 | const { session, org } = yield* requireOrganization(headers); |
| 205 | const ownedKeys = yield* apiKeys | 213 | const ownedKeys = yield* apiKeys |
| 206 | .listUserKeys({ accountId: session.accountId, organizationId: org.id }) | 214 | .listUserKeys({ accountId: session.accountId, organizationId: org.id }) |
| 207 | .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); | 215 | .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); |
| @@ -214,9 +222,9 @@ export const workosAccountProvider: Layer.Layer< · 6 unmodified lines | |||
| 214 | return { success: true }; | 222 | return { success: true }; |
| 215 | }), | 223 | }), |
| 216 | 224 | ||
| 217 | listMembers: () => | 225 | listMembers: (headers) => |
| 218 | Effect.gen(function* () { | 226 | Effect.gen(function* () { |
| 219 | const { session, org } = yield* requireOrganization(); | 227 | const { session, org } = yield* requireOrganization(headers); |
| 220 | 228 | ||
| 221 | // Seats fall back to safe display defaults on lookup error — never | 229 | // Seats fall back to safe display defaults on lookup error — never |
| 222 | // blank the page over a transient Autumn/WorkOS hiccup. The real cap | 230 | // blank the page over a transient Autumn/WorkOS hiccup. The real cap |
| @@ -252,9 +260,9 @@ export const workosAccountProvider: Layer.Layer< · 29 unmodified lines | |||
| 252 | return { members, seats }; | 260 | return { members, seats }; |
| 253 | }), | 261 | }), |
| 254 | 262 | ||
| 255 | listRoles: () => | 263 | listRoles: (headers) => |
| 256 | Effect.gen(function* () { | 264 | Effect.gen(function* () { |
| 257 | const { org } = yield* requireOrganization(); | 265 | const { org } = yield* requireOrganization(headers); |
| 258 | const result = yield* workos | 266 | const result = yield* workos |
| 259 | .listOrgRoles(org.id) | 267 | .listOrgRoles(org.id) |
| 260 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); | 268 | .pipe(Effect.catchTag("WorkOSError", toAccountError)); |
| @@ -263,9 +271,9 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines | |||
| 263 | }; | 271 | }; |
| 264 | }), | 272 | }), |
| 265 | 273 | ||
| 266 | inviteMember: (_headers, body) => | 274 | inviteMember: (headers, body) => |
| 267 | Effect.gen(function* () { | 275 | Effect.gen(function* () { |
| 268 | const { session, org } = yield* requireOrganization(); | 276 | const { session, org } = yield* requireOrganization(headers); |
| 269 | yield* requireAdmin(session.accountId, org.id); | 277 | yield* requireAdmin(session.accountId, org.id); |
| 270 | yield* reserveMemberSlot(org.id); | 278 | yield* reserveMemberSlot(org.id); |
| 271 | const invitation = yield* workos | 279 | const invitation = yield* workos |
| @@ -278,9 +286,9 @@ export const workosAccountProvider: Layer.Layer< · 6 unmodified lines | |||
| 278 | return { id: invitation.id, email: invitation.email }; | 286 | return { id: invitation.id, email: invitation.email }; |
| 279 | }), | 287 | }), |
| 280 | 288 | ||
| 281 | removeMember: (_headers, membershipId) => | 289 | removeMember: (headers, membershipId) => |
| 282 | Effect.gen(function* () { | 290 | Effect.gen(function* () { |
| 283 | const { session, org } = yield* requireOrganization(); | 291 | const { session, org } = yield* requireOrganization(headers); |
| 284 | yield* requireAdmin(session.accountId, org.id); | 292 | yield* requireAdmin(session.accountId, org.id); |
| 285 | yield* assertMembershipInOrg(org.id, membershipId); | 293 | yield* assertMembershipInOrg(org.id, membershipId); |
| 286 | yield* workos | 294 | yield* workos |
| @@ -289,9 +297,9 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines | |||
| 289 | return { success: true }; | 297 | return { success: true }; |
| 290 | }), | 298 | }), |
| 291 | 299 | ||
| 292 | updateMemberRole: (_headers, membershipId, roleSlug) => | 300 | updateMemberRole: (headers, membershipId, roleSlug) => |
| 293 | Effect.gen(function* () { | 301 | Effect.gen(function* () { |
| 294 | const { session, org } = yield* requireOrganization(); | 302 | const { session, org } = yield* requireOrganization(headers); |
| 295 | yield* requireAdmin(session.accountId, org.id); | 303 | yield* requireAdmin(session.accountId, org.id); |
| 296 | yield* assertMembershipInOrg(org.id, membershipId); | 304 | yield* assertMembershipInOrg(org.id, membershipId); |
| 297 | yield* workos | 305 | yield* workos |
| @@ -300,9 +308,9 @@ export const workosAccountProvider: Layer.Layer< · 2 unmodified lines | |||
| 300 | return { success: true }; | 308 | return { success: true }; |
| 301 | }), | 309 | }), |
| 302 | 310 | ||
| 303 | updateOrgName: (_headers, name) => | 311 | updateOrgName: (headers, name) => |
| 304 | Effect.gen(function* () { | 312 | Effect.gen(function* () { |
| 305 | const { session, org } = yield* requireOrganization(); | 313 | const { session, org } = yield* requireOrganization(headers); |
| 306 | yield* requireAdmin(session.accountId, org.id); | 314 | yield* requireAdmin(session.accountId, org.id); |
| 307 | const updated = yield* workos | 315 | const updated = yield* workos |
| 308 | .updateOrganization(org.id, name) | 316 | .updateOrganization(org.id, name) |
| @@ -67,7 +67,7 @@ export const makeNonProtectedApiLive = (rsLive: Layer.Layer<DbService | UserStor · 66 unmodified lines | |||
| 67 | // `getDomainVerificationLink` handler also gates on billing, so | 67 | // `getDomainVerificationLink` handler also gates on billing, so |
| 68 | // `AutumnService.Default` is provided HERE (not on the neutral boot core). | 68 | // `AutumnService.Default` is provided HERE (not on the neutral boot core). |
| 69 | // Unlike the member endpoints that used to live here, they need no per-request | 69 | // Unlike the member endpoints that used to live here, they need no per-request |
| 70 | // DB scoping. | 70 | // DB scoping (and `OrgAuthLive` stays session-scoped — see its note). |
| 71 | export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe( | 71 | export const OrgApiLive = HttpApiBuilder.layer(OrgHttpApi).pipe( |
| 72 | Layer.provide(OrgHandlers), | 72 | Layer.provide(OrgHandlers), |
| 73 | Layer.provideMerge(OrgAuthLive), | 73 | Layer.provideMerge(OrgAuthLive), |
| @@ -85,6 +85,13 @@ export const OrgAuthLive = Layer.effect( · 84 unmodified lines | |||
| 85 | return yield* Effect.fail(new NoOrganization()); | 85 | return yield* Effect.fail(new NoOrganization()); |
| 86 | } | 86 | } |
| 87 | 87 | ||
| 88 | // NOTE: the domains plane stays scoped to the SESSION org, not the URL | ||
| 89 | // org. Unlike the data + account planes (which resolve the selector | ||
| 90 | // header), this `HttpApiMiddleware` security handler may carry no | ||
| 91 | // residual requirement, so it can't reach the per-request | ||
| 92 | // `UserStoreService` a slug→id resolution needs. The domains surface | ||
| 93 | // (org-settings → domain verification) is niche; URL-scoping it would | ||
| 94 | // mean converting this to an HttpRouter middleware — deferred. | ||
| 88 | const session = sessionFromSealed(result, Redacted.value(credential)); | 95 | const session = sessionFromSealed(result, Redacted.value(credential)); |
| 89 | const auth = { | 96 | const auth = { |
| 90 | accountId: session.accountId, | 97 | accountId: session.accountId, |
| @@ -0,0 +1,129 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | |||
| 4 | import { ApiKeyService } from "./api-keys"; | ||
| 5 | import { UserStoreService } from "./context"; | ||
| 6 | import { resolveSessionPrincipal } from "./workos-auth-provider"; | ||
| 7 | import { WorkOSClient, type WorkOSClientService } from "./workos"; | ||
| 8 | |||
| 9 | // The org a console request resolves to is the URL's org (sent in the | ||
| 10 | // `x-executor-organization` selector header), not the session's stored org — | ||
| 11 | // with the session org as the fallback for non-console callers, and live | ||
| 12 | // membership re-checked either way. This is what makes two browser tabs on | ||
| 13 | // different orgs independent. | ||
| 14 | |||
| 15 | const createdAt = new Date("2026-01-01T00:00:00.000Z"); | ||
| 16 | |||
| 17 | // user_session belongs to BOTH orgs; the URL selects which one a request hits. | ||
| 18 | const MEMBER = "user_session"; | ||
| 19 | const SESSION_ORG = "org_session"; | ||
| 20 | const URL_ORG = "org_url"; | ||
| 21 | const URL_SLUG = "acme"; | ||
| 22 | |||
| 23 | const stubApiKeys = Layer.succeed(ApiKeyService)({ | ||
| 24 | // No Authorization header in these tests → the api-key path returns null and | ||
| 25 | // resolution falls through to the session path. | ||
| 26 | validate: () => Effect.succeed(null), | ||
| 27 | listUserKeys: () => Effect.succeed([]), | ||
| 28 | createUserKey: () => Effect.die("not used"), | ||
| 29 | revokeUserKey: () => Effect.void, | ||
| 30 | }); | ||
| 31 | |||
| 32 | const stubWorkOS = Layer.succeed( | ||
| 33 | WorkOSClient, | ||
| 34 | new Proxy({} as WorkOSClientService, { | ||
| 35 | get: (_t, prop) => { | ||
| 36 | if (prop === "authenticateRequest") { | ||
| 37 | return () => | ||
| 38 | Effect.succeed({ userId: MEMBER, email: "u@e2e.test", organizationId: SESSION_ORG }); | ||
| 39 | } | ||
| 40 | if (prop === "listUserMemberships") { | ||
| 41 | return (userId: string) => | ||
| 42 | Effect.succeed({ | ||
| 43 | data: | ||
| 44 | userId === MEMBER | ||
| 45 | ? [ | ||
| 46 | { userId, organizationId: SESSION_ORG, status: "active" }, | ||
| 47 | { userId, organizationId: URL_ORG, status: "active" }, | ||
| 48 | ] | ||
| 49 | : [], | ||
| 50 | }); | ||
| 51 | } | ||
| 52 | return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | ||
| 53 | }, | ||
| 54 | }), | ||
| 55 | ); | ||
| 56 | |||
| 57 | const stubUsers = Layer.succeed(UserStoreService)({ | ||
| 58 | use: (fn) => | ||
| 59 | Effect.promise(() => | ||
| 60 | fn({ | ||
| 61 | ensureAccount: async (id: string) => ({ id, createdAt }), | ||
| 62 | getAccount: async (id: string) => ({ id, createdAt }), | ||
| 63 | upsertOrganization: async (org: { id: string; name: string }) => ({ | ||
| 64 | ...org, | ||
| 65 | slug: null, | ||
| 66 | createdAt, | ||
| 67 | }), | ||
| 68 | getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }), | ||
| 69 | // The URL slug maps to URL_ORG (the member's other org); any other slug | ||
| 70 | // maps to an org the caller is NOT a member of, so membership rejects it. | ||
| 71 | getOrganizationBySlug: async (slug: string) => ({ | ||
| 72 | id: slug === URL_SLUG ? URL_ORG : "org_outsider", | ||
| 73 | name: `Org ${slug}`, | ||
| 74 | slug, | ||
| 75 | createdAt, | ||
| 76 | }), | ||
| 77 | ensureOrganizationSlug: async (org: { id: string; name: string; slug: string | null }) => ({ | ||
| 78 | ...org, | ||
| 79 | slug: org.slug ?? org.id, | ||
| 80 | createdAt, | ||
| 81 | }), | ||
| 82 | }), | ||
| 83 | ), | ||
| 84 | }); | ||
| 85 | |||
| 86 | const run = (headers: Record<string, string>) => | ||
| 87 | resolveSessionPrincipal(new Request("https://executor.test/api/tools", { headers })).pipe( | ||
| 88 | Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)), | ||
| 89 | ); | ||
| 90 | |||
| 91 | describe("resolveSessionPrincipal · URL org selector", () => { | ||
| 92 | it.effect("falls back to the session org when no selector header is sent", () => | ||
| 93 | Effect.gen(function* () { | ||
| 94 | const principal = yield* run({ cookie: "wos-session=x" }); | ||
| 95 | expect(principal.organizationId, "scopes to the session org").toBe(SESSION_ORG); | ||
| 96 | }), | ||
| 97 | ); | ||
| 98 | |||
| 99 | it.effect("scopes to the URL org (by slug) over the session org", () => | ||
| 100 | Effect.gen(function* () { | ||
| 101 | const principal = yield* run({ | ||
| 102 | cookie: "wos-session=x", | ||
| 103 | "x-executor-organization": URL_SLUG, | ||
| 104 | }); | ||
| 105 | expect(principal.organizationId, "the slug header wins over the session org").toBe(URL_ORG); | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | |||
| 109 | it.effect("accepts a WorkOS org id as the selector too", () => | ||
| 110 | Effect.gen(function* () { | ||
| 111 | const principal = yield* run({ | ||
| 112 | cookie: "wos-session=x", | ||
| 113 | "x-executor-organization": URL_ORG, | ||
| 114 | }); | ||
| 115 | expect(principal.organizationId).toBe(URL_ORG); | ||
| 116 | }), | ||
| 117 | ); | ||
| 118 | |||
| 119 | it.effect("rejects a selector for an org the caller is not a member of", () => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | // The slug resolves to a real org id, but membership is re-checked — a | ||
| 122 | // slug is a selector, not a trust boundary, so a non-member is rejected. | ||
| 123 | const error = yield* Effect.flip( | ||
| 124 | run({ cookie: "wos-session=x", "x-executor-organization": "outsider-slug" }), | ||
| 125 | ); | ||
| 126 | expect(error).toMatchObject({ _tag: "NoOrganization" }); | ||
| 127 | }), | ||
| 128 | ); | ||
| 129 | }); | ||
| @@ -79,3 +79,44 @@ export const authorizeOrganization = (userId: string, organizationId: string) => · 78 unmodified lines | |||
| 79 | 79 | ||
| 80 | return yield* resolveOrganization(organizationId); | 80 | return yield* resolveOrganization(organizationId); |
| 81 | }); | 81 | }); |
| 82 | |||
| 83 | // --------------------------------------------------------------------------- | ||
| 84 | // Org SELECTOR — the URL is the scope authority, not the session. | ||
| 85 | // --------------------------------------------------------------------------- | ||
| 86 | // | ||
| 87 | // Org-scoped requests carry the active org in this header, set by the web | ||
| 88 | // client from the console URL's slug (the MCP plane carries the same idea in | ||
| 89 | // its own `x-executor-mcp-organization`). The selector is a slug (`acme`, the | ||
| 90 | // readable URL form) or a WorkOS id (`org_…`, the legacy/token form). It is a | ||
| 91 | // SELECTOR, not a trust boundary: `authorizeOrganizationSelector` re-checks | ||
| 92 | // live membership, so the worst a forged header does is name an org the caller | ||
| 93 | // already belongs to. | ||
| 94 | // | ||
| 95 | // Why a header and not the session's `org_id`: a browser shares ONE cookie jar | ||
| 96 | // across tabs, so a single session-pinned org makes "active org" a | ||
| 97 | // browser-global — two tabs can't be in two orgs at once, and switching in one | ||
| 98 | // silently re-scopes the other. Scoping per-request from the URL makes each | ||
| 99 | // tab independent. | ||
| 100 | |||
| 101 | export const ORG_SELECTOR_HEADER = "x-executor-organization"; | ||
| 102 | |||
| 103 | /** The URL-pinned org selector for a request, or `null` to fall back to the session. */ | ||
| 104 | export const orgSelectorFromRequest = (request: Request): string | null => | ||
| 105 | request.headers.get(ORG_SELECTOR_HEADER); | ||
| 106 | |||
| 107 | /** | ||
| 108 | * Resolve an org SELECTOR (URL slug or `org_…` id) to the organization the | ||
| 109 | * caller actively belongs to, or `null`. A slug resolves through the local | ||
| 110 | * mirror to its id first; ids pass straight through. Either way membership is | ||
| 111 | * verified live via {@link authorizeOrganization}. | ||
| 112 | */ | ||
| 113 | export const authorizeOrganizationSelector = (userId: string, selector: string) => | ||
| 114 | Effect.gen(function* () { | ||
| 115 | if (selector.startsWith("org_")) { | ||
| 116 | return yield* authorizeOrganization(userId, selector); | ||
| 117 | } | ||
| 118 | const users = yield* UserStoreService; | ||
| 119 | const org = yield* users.use((s) => s.getOrganizationBySlug(selector)); | ||
| 120 | if (!org) return null; | ||
| 121 | return yield* authorizeOrganization(userId, org.id); | ||
| 122 | }); | ||
| @@ -40,7 +40,11 @@ import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@exec · 39 unmodified lines | |||
| 40 | 40 | ||
| 41 | import { ApiKeyService } from "./api-keys"; | 41 | import { ApiKeyService } from "./api-keys"; |
| 42 | import { BEARER_PREFIX } from "./bearer"; | 42 | import { BEARER_PREFIX } from "./bearer"; |
| 43 | import { authorizeOrganization } from "./organization"; | 43 | import { |
| 44 | authorizeOrganization, | ||
| 45 | authorizeOrganizationSelector, | ||
| 46 | orgSelectorFromRequest, | ||
| 47 | } from "./organization"; | ||
| 44 | import { UserStoreService } from "./context"; | 48 | import { UserStoreService } from "./context"; |
| 45 | import { sealedSessionDisplayName } from "./middleware"; | 49 | import { sealedSessionDisplayName } from "./middleware"; |
| 46 | import type { UserStoreError, WorkOSError } from "./errors"; | 50 | import type { UserStoreError, WorkOSError } from "./errors"; |
| @@ -108,10 +112,18 @@ export const resolveSessionPrincipal = (request: Request) => · 61 unmodified lines | |||
| 108 | Effect.gen(function* () { | 112 | Effect.gen(function* () { |
| 109 | const workos = yield* WorkOSClient; | 113 | const workos = yield* WorkOSClient; |
| 110 | const session = yield* workos.authenticateRequest(request); | 114 | const session = yield* workos.authenticateRequest(request); |
| 111 | if (!session || !session.organizationId) { | 115 | if (!session) { |
| 116 | return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | ||
| 117 | } | ||
| 118 | // The console URL's org is the scope authority (sent as a header); the | ||
| 119 | // session's own org is the fallback for non-console callers. Membership is | ||
| 120 | // re-checked live either way — the header is a selector, not a trust | ||
| 121 | // boundary (see organization.ts). | ||
| 122 | const selector = orgSelectorFromRequest(request) ?? session.organizationId; | ||
| 123 | if (!selector) { | ||
| 112 | return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | 124 | return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); |
| 113 | } | 125 | } |
| 114 | const org = yield* authorizeOrganization(session.userId, session.organizationId); | 126 | const org = yield* authorizeOrganizationSelector(session.userId, selector); |
| 115 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); | 127 | if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_SESSION); |
| 116 | return { | 128 | return { |
| 117 | accountId: session.userId, | 129 | accountId: session.userId, |