Unified · Split
| @@ -109,6 +109,7 @@ describe("ApiKeyService.WorkOS", () => { · 108 unmodified lines | ||
| 109 | 109 | stubWorkOS({ |
| 110 | 110 | listUserApiKeys: () => |
| 111 | 111 | Effect.succeed({ |
| 112 | + object: "list" as const, | |
| 112 | 113 | data: [ |
| 113 | 114 | { |
| 114 | 115 | id: "api_key_listed", |
| @@ -124,6 +125,10 @@ describe("ApiKeyService.WorkOS", () => { · 9 unmodified lines | ||
| 124 | 125 | }, |
| 125 | 126 | }, |
| 126 | 127 | ], |
| 128 | + listMetadata: { | |
| 129 | + before: null, | |
| 130 | + after: null, | |
| 131 | + }, | |
| 127 | 132 | }), |
| 128 | 133 | createUserApiKey: () => |
| 129 | 134 | Effect.succeed({ |
| @@ -0,0 +1,98 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { collectRawWorkOSList, collectWorkOSList } from "./workos"; | |
| 4 | + | |
| 5 | +describe("collectWorkOSList", () => { | |
| 6 | + it("collects memberships beyond the first WorkOS page", async () => { | |
| 7 | + const autoPaginationCalls: string[] = []; | |
| 8 | + | |
| 9 | + const response = await collectWorkOSList({ | |
| 10 | + object: "list", | |
| 11 | + data: [{ id: "om_first_page" }], | |
| 12 | + listMetadata: { | |
| 13 | + before: null, | |
| 14 | + after: "om_next_page", | |
| 15 | + }, | |
| 16 | + autoPagination: async () => { | |
| 17 | + autoPaginationCalls.push("called"); | |
| 18 | + return [{ id: "om_first_page" }, { id: "om_second_page" }]; | |
| 19 | + }, | |
| 20 | + }); | |
| 21 | + | |
| 22 | + expect(response.data).toEqual([{ id: "om_first_page" }, { id: "om_second_page" }]); | |
| 23 | + expect(response.listMetadata).toEqual({ before: null, after: null }); | |
| 24 | + expect(autoPaginationCalls).toEqual(["called"]); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + it("keeps the first page when WorkOS reports no next page", async () => { | |
| 28 | + let autoPaginationCalls = 0; | |
| 29 | + | |
| 30 | + const response = await collectWorkOSList({ | |
| 31 | + object: "list", | |
| 32 | + data: [{ id: "om_only_page" }], | |
| 33 | + listMetadata: { | |
| 34 | + before: null, | |
| 35 | + after: null, | |
| 36 | + }, | |
| 37 | + autoPagination: async () => { | |
| 38 | + autoPaginationCalls += 1; | |
| 39 | + return [{ id: "om_unexpected_page" }]; | |
| 40 | + }, | |
| 41 | + }); | |
| 42 | + | |
| 43 | + expect(response.data).toEqual([{ id: "om_only_page" }]); | |
| 44 | + expect(response.listMetadata).toEqual({ before: null, after: null }); | |
| 45 | + expect(autoPaginationCalls).toBe(0); | |
| 46 | + }); | |
| 47 | +}); | |
| 48 | + | |
| 49 | +describe("collectRawWorkOSList", () => { | |
| 50 | + it("collects raw WorkOS lists using snake-case cursors", async () => { | |
| 51 | + const requestedCursors: Array<string | undefined> = []; | |
| 52 | + | |
| 53 | + const response = await collectRawWorkOSList(async (after) => { | |
| 54 | + requestedCursors.push(after); | |
| 55 | + return after | |
| 56 | + ? { | |
| 57 | + data: [{ id: "api_key_second_page" }], | |
| 58 | + list_metadata: { | |
| 59 | + before: null, | |
| 60 | + after: null, | |
| 61 | + }, | |
| 62 | + } | |
| 63 | + : { | |
| 64 | + data: [{ id: "api_key_first_page" }], | |
| 65 | + list_metadata: { | |
| 66 | + before: null, | |
| 67 | + after: "api_key_second_page", | |
| 68 | + }, | |
| 69 | + }; | |
| 70 | + }); | |
| 71 | + | |
| 72 | + expect(response.data).toEqual([{ id: "api_key_first_page" }, { id: "api_key_second_page" }]); | |
| 73 | + expect(response.listMetadata).toEqual({ before: null, after: null }); | |
| 74 | + expect(requestedCursors).toEqual([undefined, "api_key_second_page"]); | |
| 75 | + }); | |
| 76 | + | |
| 77 | + it("collects raw WorkOS lists using camel-case cursors", async () => { | |
| 78 | + const response = await collectRawWorkOSList(async (after) => | |
| 79 | + after | |
| 80 | + ? { | |
| 81 | + data: [{ id: "second" }], | |
| 82 | + listMetadata: { | |
| 83 | + before: null, | |
| 84 | + after: null, | |
| 85 | + }, | |
| 86 | + } | |
| 87 | + : { | |
| 88 | + data: [{ id: "first" }], | |
| 89 | + listMetadata: { | |
| 90 | + before: null, | |
| 91 | + after: "second", | |
| 92 | + }, | |
| 93 | + }, | |
| 94 | + ); | |
| 95 | + | |
| 96 | + expect(response.data).toEqual([{ id: "first" }, { id: "second" }]); | |
| 97 | + }); | |
| 98 | +}); | |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | 5 | import { env } from "cloudflare:workers"; |
| 6 | −import { Context, Data, Effect, Layer } from "effect"; | |
| 6 | +import { Context, Data, Effect, Layer, Option, Schema } from "effect"; | |
| 7 | 7 | import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker"; |
| 8 | 8 | import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors"; |
| 9 | 9 | |
| @@ -24,6 +24,88 @@ type RawWorkOS = WorkOS & { · 14 unmodified lines | ||
| 24 | 24 | ) => Promise<{ readonly data: unknown }>; |
| 25 | 25 | }; |
| 26 | 26 | |
| 27 | +type WorkOSListMetadata = { | |
| 28 | + readonly before?: string | null; | |
| 29 | + readonly after?: string | null; | |
| 30 | +}; | |
| 31 | + | |
| 32 | +type WorkOSAutoPaginatable<Resource> = { | |
| 33 | + readonly object: "list"; | |
| 34 | + readonly data: Resource[]; | |
| 35 | + readonly listMetadata: WorkOSListMetadata; | |
| 36 | + readonly autoPagination: () => Promise<Resource[]>; | |
| 37 | +}; | |
| 38 | + | |
| 39 | +export type WorkOSCollectedList<Resource> = { | |
| 40 | + readonly object: "list"; | |
| 41 | + readonly data: Resource[]; | |
| 42 | + readonly listMetadata: { | |
| 43 | + readonly before: string | null; | |
| 44 | + readonly after: string | null; | |
| 45 | + }; | |
| 46 | +}; | |
| 47 | + | |
| 48 | +const RawWorkOSListMetadata = Schema.Struct({ | |
| 49 | + before: Schema.optional(Schema.NullOr(Schema.String)), | |
| 50 | + after: Schema.optional(Schema.NullOr(Schema.String)), | |
| 51 | +}); | |
| 52 | + | |
| 53 | +const RawWorkOSListResponse = Schema.Struct({ | |
| 54 | + data: Schema.Array(Schema.Unknown), | |
| 55 | + listMetadata: Schema.optional(RawWorkOSListMetadata), | |
| 56 | + list_metadata: Schema.optional(RawWorkOSListMetadata), | |
| 57 | +}); | |
| 58 | + | |
| 59 | +const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse); | |
| 60 | + | |
| 61 | +const completedListMetadata = { | |
| 62 | + before: null, | |
| 63 | + after: null, | |
| 64 | +} as const; | |
| 65 | + | |
| 66 | +const nextCursorFromRawList = (response: typeof RawWorkOSListResponse.Type): string | null => | |
| 67 | + response.listMetadata?.after ?? response.list_metadata?.after ?? null; | |
| 68 | + | |
| 69 | +export const collectWorkOSList = async <Resource>( | |
| 70 | + response: WorkOSAutoPaginatable<Resource>, | |
| 71 | +): Promise<WorkOSCollectedList<Resource>> => { | |
| 72 | + const data = response.listMetadata.after ? await response.autoPagination() : response.data; | |
| 73 | + return { | |
| 74 | + object: "list", | |
| 75 | + data, | |
| 76 | + listMetadata: completedListMetadata, | |
| 77 | + }; | |
| 78 | +}; | |
| 79 | + | |
| 80 | +export const collectRawWorkOSList = async ( | |
| 81 | + loadPage: (after?: string) => Promise<unknown>, | |
| 82 | +): Promise<WorkOSCollectedList<unknown>> => { | |
| 83 | + const first = Option.getOrNull(decodeRawWorkOSListResponse(await loadPage())); | |
| 84 | + if (!first) { | |
| 85 | + return { | |
| 86 | + object: "list", | |
| 87 | + data: [], | |
| 88 | + listMetadata: completedListMetadata, | |
| 89 | + }; | |
| 90 | + } | |
| 91 | + | |
| 92 | + const data = [...first.data]; | |
| 93 | + let after = nextCursorFromRawList(first); | |
| 94 | + | |
| 95 | + while (after) { | |
| 96 | + const next = Option.getOrNull(decodeRawWorkOSListResponse(await loadPage(after))); | |
| 97 | + if (!next) break; | |
| 98 | + data.push(...next.data); | |
| 99 | + after = nextCursorFromRawList(next); | |
| 100 | + } | |
| 101 | + | |
| 102 | + return { | |
| 103 | + object: "list", | |
| 104 | + data, | |
| 105 | + listMetadata: completedListMetadata, | |
| 106 | + }; | |
| 107 | +}; | |
| 108 | + | |
| 27 | 109 | class WorkOSAuthConfigurationError extends Data.TaggedError("WorkOSAuthConfigurationError")<{ |
| 28 | 110 | readonly message: string; |
| 29 | 111 | }> {} |
| @@ -132,11 +214,13 @@ const make = Effect.gen(function* () { · 102 unmodified lines | ||
| 132 | 214 | |
| 133 | 215 | /** List organization memberships for a user. */ |
| 134 | 216 | listUserMemberships: (userId: string) => |
| 135 | − use((wos) => | |
| 136 | − wos.userManagement.listOrganizationMemberships({ | |
| 137 | − userId, | |
| 138 | − statuses: ["active", "pending"], | |
| 139 | − }), | |
| 217 | + use(async (wos) => | |
| 218 | + collectWorkOSList( | |
| 219 | + await wos.userManagement.listOrganizationMemberships({ | |
| 220 | + userId, | |
| 221 | + statuses: ["active", "pending"], | |
| 222 | + }), | |
| 223 | + ), | |
| 140 | 224 | ), |
| 141 | 225 | |
| 142 | 226 | /** |
| @@ -183,10 +267,16 @@ const make = Effect.gen(function* () { · 40 unmodified lines | ||
| 183 | 267 | listUserApiKeys: (userId: string, organizationId: string) => |
| 184 | 268 | use(async (wos) => { |
| 185 | 269 | const raw = wos as RawWorkOS; |
| 186 | − const response = await raw.get(`/user_management/users/${userId}/api_keys`, { | |
| 187 | − query: { organization_id: organizationId }, | |
| 270 | + return collectRawWorkOSList(async (after) => { | |
| 271 | + const response = await raw.get(`/user_management/users/${userId}/api_keys`, { | |
| 272 | + query: { | |
| 273 | + organization_id: organizationId, | |
| 274 | + limit: 100, | |
| 275 | + ...(after ? { after } : {}), | |
| 276 | + }, | |
| 277 | + }); | |
| 278 | + return response.data; | |
| 188 | 279 | }); |
| 189 | − return response.data; | |
| 190 | 280 | }), |
| 191 | 281 | |
| 192 | 282 | createUserApiKey: (params: { userId: string; organizationId: string; name: string }) => |
| @@ -203,12 +293,25 @@ const make = Effect.gen(function* () { · 10 unmodified lines | ||
| 203 | 293 | |
| 204 | 294 | /** List organization memberships with user details. */ |
| 205 | 295 | listOrgMembers: (organizationId: string) => |
| 206 | − use((wos) => | |
| 207 | − wos.userManagement.listOrganizationMemberships({ | |
| 296 | + use(async (wos) => | |
| 297 | + collectWorkOSList( | |
| 298 | + await wos.userManagement.listOrganizationMemberships({ | |
| 299 | + organizationId, | |
| 300 | + statuses: ["active", "pending"], | |
| 301 | + }), | |
| 302 | + ), | |
| 303 | + ), | |
| 304 | + | |
| 305 | + /** Get a user's membership in an organization. */ | |
| 306 | + getUserOrgMembership: (organizationId: string, userId: string) => | |
| 307 | + use(async (wos) => { | |
| 308 | + const response = await wos.userManagement.listOrganizationMemberships({ | |
| 208 | 309 | organizationId, |
| 310 | + userId, | |
| 209 | 311 | statuses: ["active", "pending"], |
| 210 | − }), | |
| 211 | − ), | |
| 312 | + }); | |
| 313 | + return response.data[0] ?? null; | |
| 314 | + }), | |
| 212 | 315 | |
| 213 | 316 | /** Get a user by ID. */ |
| 214 | 317 | getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)), |
| @@ -229,7 +332,13 @@ const make = Effect.gen(function* () { · 14 unmodified lines | ||
| 229 | 332 | * API level, so we filter after. |
| 230 | 333 | */ |
| 231 | 334 | listPendingInvitations: (organizationId: string) => |
| 232 | − use((wos) => wos.userManagement.listInvitations({ organizationId })).pipe( | |
| 335 | + use(async (wos) => | |
| 336 | + collectWorkOSList( | |
| 337 | + await wos.userManagement.listInvitations({ | |
| 338 | + organizationId, | |
| 339 | + }), | |
| 340 | + ), | |
| 341 | + ).pipe( | |
| 233 | 342 | Effect.map((response) => ({ |
| 234 | 343 | ...response, |
| 235 | 344 | data: response.data.filter((i) => i.state === "pending"), |
| @@ -238,7 +347,13 @@ const make = Effect.gen(function* () { · 2 unmodified lines | ||
| 238 | 347 | |
| 239 | 348 | /** List invitations for an email address (across all orgs). */ |
| 240 | 349 | listInvitationsByEmail: (email: string) => |
| 241 | − use((wos) => wos.userManagement.listInvitations({ email })), | |
| 350 | + use(async (wos) => | |
| 351 | + collectWorkOSList( | |
| 352 | + await wos.userManagement.listInvitations({ | |
| 353 | + email, | |
| 354 | + }), | |
| 355 | + ), | |
| 356 | + ), | |
| 242 | 357 | |
| 243 | 358 | /** Accept an invitation; returns the (now accepted) invitation. */ |
| 244 | 359 | acceptInvitation: (invitationId: string) => |
| @@ -14,6 +14,7 @@ type StubFn = (...args: never[]) => Effect.Effect<any>; · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | type StubOverrides = { |
| 16 | 16 | listOrgMembers?: StubFn; |
| 17 | + getUserOrgMembership?: StubFn; | |
| 17 | 18 | getUser?: StubFn; |
| 18 | 19 | sendInvitation?: StubFn; |
| 19 | 20 | deleteOrgMembership?: StubFn; |
| @@ -122,8 +123,7 @@ const fakeRoles: FakeRole[] = [ · 102 unmodified lines | ||
| 122 | 123 | const requireAdmin = Effect.gen(function* () { |
| 123 | 124 | const auth = yield* AuthContext; |
| 124 | 125 | const workos = yield* WorkOSAuth; |
| 125 | − const memberships = yield* workos.listOrgMembers(auth.organizationId); | |
| 126 | − const current = memberships.data.find((m: FakeMembership) => m.userId === auth.accountId); | |
| 126 | + const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); | |
| 127 | 127 | if (!current || current.role?.slug !== "admin") { |
| 128 | 128 | return yield* new Forbidden(); |
| 129 | 129 | } |
| @@ -136,6 +136,11 @@ const withMembers: StubOverrides = { · 6 unmodified lines | ||
| 136 | 136 | listOrgMembers: () => Effect.succeed({ data: fakeMemberships }), |
| 137 | 137 | }; |
| 138 | 138 | |
| 139 | +const withCurrentMembership: StubOverrides = { | |
| 140 | + getUserOrgMembership: (_organizationId: string, userId: string) => | |
| 141 | + Effect.succeed(fakeMemberships.find((m) => m.userId === userId) ?? null), | |
| 142 | +}; | |
| 143 | + | |
| 139 | 144 | // --------------------------------------------------------------------------- |
| 140 | 145 | // Tests |
| 141 | 146 | // --------------------------------------------------------------------------- |
| @@ -205,14 +210,14 @@ describe("Org handlers", () => { · 63 unmodified lines | ||
| 205 | 210 | |
| 206 | 211 | describe("requireAdmin", () => { |
| 207 | 212 | it.effect("passes for admin user", () => |
| 208 | − requireAdmin.pipe(Effect.provide(provide(adminAuth, withMembers))), | |
| 213 | + requireAdmin.pipe(Effect.provide(provide(adminAuth, withCurrentMembership))), | |
| 209 | 214 | ); |
| 210 | 215 | |
| 211 | 216 | it.effect("rejects non-admin with Forbidden", () => |
| 212 | 217 | Effect.gen(function* () { |
| 213 | 218 | const error = yield* Effect.flip(requireAdmin); |
| 214 | 219 | expect(error).toBeInstanceOf(Forbidden); |
| 215 | − }).pipe(Effect.provide(provide(memberAuth, withMembers))), | |
| 220 | + }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), | |
| 216 | 221 | ); |
| 217 | 222 | }); |
| 218 | 223 | |
| @@ -231,7 +236,7 @@ describe("Org handlers", () => { · 12 unmodified lines | ||
| 231 | 236 | }).pipe( |
| 232 | 237 | Effect.provide( |
| 233 | 238 | provide(adminAuth, { |
| 234 | − ...withMembers, | |
| 239 | + ...withCurrentMembership, | |
| 235 | 240 | sendInvitation: (p: { email: string }) => |
| 236 | 241 | Effect.succeed({ id: "inv_1", email: p.email }), |
| 237 | 242 | }), |
| @@ -252,7 +257,7 @@ describe("Org handlers", () => { · 14 unmodified lines | ||
| 252 | 257 | }), |
| 253 | 258 | ); |
| 254 | 259 | expect(error).toBeInstanceOf(Forbidden); |
| 255 | − }).pipe(Effect.provide(provide(memberAuth, withMembers))), | |
| 260 | + }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), | |
| 256 | 261 | ); |
| 257 | 262 | }); |
| 258 | 263 | |
| @@ -265,7 +270,7 @@ describe("Org handlers", () => { · 6 unmodified lines | ||
| 265 | 270 | }).pipe( |
| 266 | 271 | Effect.provide( |
| 267 | 272 | provide(adminAuth, { |
| 268 | − ...withMembers, | |
| 273 | + ...withCurrentMembership, | |
| 269 | 274 | deleteOrgMembership: () => Effect.void, |
| 270 | 275 | }), |
| 271 | 276 | ), |
| @@ -282,7 +287,7 @@ describe("Org handlers", () => { · 10 unmodified lines | ||
| 282 | 287 | }), |
| 283 | 288 | ); |
| 284 | 289 | expect(error).toBeInstanceOf(Forbidden); |
| 285 | − }).pipe(Effect.provide(provide(memberAuth, withMembers))), | |
| 290 | + }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), | |
| 286 | 291 | ); |
| 287 | 292 | }); |
| 288 | 293 | |
| @@ -295,7 +300,7 @@ describe("Org handlers", () => { · 6 unmodified lines | ||
| 295 | 300 | }).pipe( |
| 296 | 301 | Effect.provide( |
| 297 | 302 | provide(adminAuth, { |
| 298 | − ...withMembers, | |
| 303 | + ...withCurrentMembership, | |
| 299 | 304 | updateOrgMembershipRole: () => Effect.void, |
| 300 | 305 | }), |
| 301 | 306 | ), |
| @@ -312,7 +317,7 @@ describe("Org handlers", () => { · 10 unmodified lines | ||
| 312 | 317 | }), |
| 313 | 318 | ); |
| 314 | 319 | expect(error).toBeInstanceOf(Forbidden); |
| 315 | − }).pipe(Effect.provide(provide(memberAuth, withMembers))), | |
| 320 | + }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), | |
| 316 | 321 | ); |
| 317 | 322 | }); |
| 318 | 323 | }); |
| @@ -13,8 +13,7 @@ import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-lim · 12 unmodified lines | ||
| 13 | 13 | const requireAdmin = Effect.gen(function* () { |
| 14 | 14 | const auth = yield* AuthContext; |
| 15 | 15 | const workos = yield* WorkOSAuth; |
| 16 | − const memberships = yield* workos.listOrgMembers(auth.organizationId); | |
| 17 | − const currentMembership = memberships.data.find((m) => m.userId === auth.accountId); | |
| 16 | + const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); | |
| 18 | 17 | if (!currentMembership || currentMembership.role?.slug !== "admin") { |
| 19 | 18 | return yield* new Forbidden(); |
| 20 | 19 | } |