diff --git a/apps/cloud/src/auth/api-keys.node.test.ts b/apps/cloud/src/auth/api-keys.node.test.ts index 31558ebf0..319948871 100644 --- a/apps/cloud/src/auth/api-keys.node.test.ts +++ b/apps/cloud/src/auth/api-keys.node.test.ts @@ -109,6 +109,7 @@ describe("ApiKeyService.WorkOS", () => { stubWorkOS({ listUserApiKeys: () => Effect.succeed({ + object: "list" as const, data: [ { id: "api_key_listed", @@ -124,6 +125,10 @@ describe("ApiKeyService.WorkOS", () => { }, }, ], + listMetadata: { + before: null, + after: null, + }, }), createUserApiKey: () => Effect.succeed({ diff --git a/apps/cloud/src/auth/workos.node.test.ts b/apps/cloud/src/auth/workos.node.test.ts new file mode 100644 index 000000000..82cbc572c --- /dev/null +++ b/apps/cloud/src/auth/workos.node.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { collectRawWorkOSList, collectWorkOSList } from "./workos"; + +describe("collectWorkOSList", () => { + it("collects memberships beyond the first WorkOS page", async () => { + const autoPaginationCalls: string[] = []; + + const response = await collectWorkOSList({ + object: "list", + data: [{ id: "om_first_page" }], + listMetadata: { + before: null, + after: "om_next_page", + }, + autoPagination: async () => { + autoPaginationCalls.push("called"); + return [{ id: "om_first_page" }, { id: "om_second_page" }]; + }, + }); + + expect(response.data).toEqual([{ id: "om_first_page" }, { id: "om_second_page" }]); + expect(response.listMetadata).toEqual({ before: null, after: null }); + expect(autoPaginationCalls).toEqual(["called"]); + }); + + it("keeps the first page when WorkOS reports no next page", async () => { + let autoPaginationCalls = 0; + + const response = await collectWorkOSList({ + object: "list", + data: [{ id: "om_only_page" }], + listMetadata: { + before: null, + after: null, + }, + autoPagination: async () => { + autoPaginationCalls += 1; + return [{ id: "om_unexpected_page" }]; + }, + }); + + expect(response.data).toEqual([{ id: "om_only_page" }]); + expect(response.listMetadata).toEqual({ before: null, after: null }); + expect(autoPaginationCalls).toBe(0); + }); +}); + +describe("collectRawWorkOSList", () => { + it("collects raw WorkOS lists using snake-case cursors", async () => { + const requestedCursors: Array = []; + + const response = await collectRawWorkOSList(async (after) => { + requestedCursors.push(after); + return after + ? { + data: [{ id: "api_key_second_page" }], + list_metadata: { + before: null, + after: null, + }, + } + : { + data: [{ id: "api_key_first_page" }], + list_metadata: { + before: null, + after: "api_key_second_page", + }, + }; + }); + + expect(response.data).toEqual([{ id: "api_key_first_page" }, { id: "api_key_second_page" }]); + expect(response.listMetadata).toEqual({ before: null, after: null }); + expect(requestedCursors).toEqual([undefined, "api_key_second_page"]); + }); + + it("collects raw WorkOS lists using camel-case cursors", async () => { + const response = await collectRawWorkOSList(async (after) => + after + ? { + data: [{ id: "second" }], + listMetadata: { + before: null, + after: null, + }, + } + : { + data: [{ id: "first" }], + listMetadata: { + before: null, + after: "second", + }, + }, + ); + + expect(response.data).toEqual([{ id: "first" }, { id: "second" }]); + }); +}); diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index b6c1bd649..1b539fe49 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -3,7 +3,7 @@ // --------------------------------------------------------------------------- import { env } from "cloudflare:workers"; -import { Context, Data, Effect, Layer } from "effect"; +import { Context, Data, Effect, Layer, Option, Schema } from "effect"; import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker"; import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors"; @@ -24,6 +24,88 @@ type RawWorkOS = WorkOS & { ) => Promise<{ readonly data: unknown }>; }; +type WorkOSListMetadata = { + readonly before?: string | null; + readonly after?: string | null; +}; + +type WorkOSAutoPaginatable = { + readonly object: "list"; + readonly data: Resource[]; + readonly listMetadata: WorkOSListMetadata; + readonly autoPagination: () => Promise; +}; + +export type WorkOSCollectedList = { + readonly object: "list"; + readonly data: Resource[]; + readonly listMetadata: { + readonly before: string | null; + readonly after: string | null; + }; +}; + +const RawWorkOSListMetadata = Schema.Struct({ + before: Schema.optional(Schema.NullOr(Schema.String)), + after: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawWorkOSListResponse = Schema.Struct({ + data: Schema.Array(Schema.Unknown), + listMetadata: Schema.optional(RawWorkOSListMetadata), + list_metadata: Schema.optional(RawWorkOSListMetadata), +}); + +const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse); + +const completedListMetadata = { + before: null, + after: null, +} as const; + +const nextCursorFromRawList = (response: typeof RawWorkOSListResponse.Type): string | null => + response.listMetadata?.after ?? response.list_metadata?.after ?? null; + +export const collectWorkOSList = async ( + response: WorkOSAutoPaginatable, +): Promise> => { + const data = response.listMetadata.after ? await response.autoPagination() : response.data; + return { + object: "list", + data, + listMetadata: completedListMetadata, + }; +}; + +export const collectRawWorkOSList = async ( + loadPage: (after?: string) => Promise, +): Promise> => { + const first = Option.getOrNull(decodeRawWorkOSListResponse(await loadPage())); + if (!first) { + return { + object: "list", + data: [], + listMetadata: completedListMetadata, + }; + } + + const data = [...first.data]; + let after = nextCursorFromRawList(first); + + while (after) { + const next = Option.getOrNull(decodeRawWorkOSListResponse(await loadPage(after))); + if (!next) break; + data.push(...next.data); + after = nextCursorFromRawList(next); + } + + return { + object: "list", + data, + listMetadata: completedListMetadata, + }; +}; + class WorkOSAuthConfigurationError extends Data.TaggedError("WorkOSAuthConfigurationError")<{ readonly message: string; }> {} @@ -132,11 +214,13 @@ const make = Effect.gen(function* () { /** List organization memberships for a user. */ listUserMemberships: (userId: string) => - use((wos) => - wos.userManagement.listOrganizationMemberships({ - userId, - statuses: ["active", "pending"], - }), + use(async (wos) => + collectWorkOSList( + await wos.userManagement.listOrganizationMemberships({ + userId, + statuses: ["active", "pending"], + }), + ), ), /** @@ -183,10 +267,16 @@ const make = Effect.gen(function* () { listUserApiKeys: (userId: string, organizationId: string) => use(async (wos) => { const raw = wos as RawWorkOS; - const response = await raw.get(`/user_management/users/${userId}/api_keys`, { - query: { organization_id: organizationId }, + return collectRawWorkOSList(async (after) => { + const response = await raw.get(`/user_management/users/${userId}/api_keys`, { + query: { + organization_id: organizationId, + limit: 100, + ...(after ? { after } : {}), + }, + }); + return response.data; }); - return response.data; }), createUserApiKey: (params: { userId: string; organizationId: string; name: string }) => @@ -203,12 +293,25 @@ const make = Effect.gen(function* () { /** List organization memberships with user details. */ listOrgMembers: (organizationId: string) => - use((wos) => - wos.userManagement.listOrganizationMemberships({ + use(async (wos) => + collectWorkOSList( + await wos.userManagement.listOrganizationMemberships({ + organizationId, + statuses: ["active", "pending"], + }), + ), + ), + + /** Get a user's membership in an organization. */ + getUserOrgMembership: (organizationId: string, userId: string) => + use(async (wos) => { + const response = await wos.userManagement.listOrganizationMemberships({ organizationId, + userId, statuses: ["active", "pending"], - }), - ), + }); + return response.data[0] ?? null; + }), /** Get a user by ID. */ getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)), @@ -229,7 +332,13 @@ const make = Effect.gen(function* () { * API level, so we filter after. */ listPendingInvitations: (organizationId: string) => - use((wos) => wos.userManagement.listInvitations({ organizationId })).pipe( + use(async (wos) => + collectWorkOSList( + await wos.userManagement.listInvitations({ + organizationId, + }), + ), + ).pipe( Effect.map((response) => ({ ...response, data: response.data.filter((i) => i.state === "pending"), @@ -238,7 +347,13 @@ const make = Effect.gen(function* () { /** List invitations for an email address (across all orgs). */ listInvitationsByEmail: (email: string) => - use((wos) => wos.userManagement.listInvitations({ email })), + use(async (wos) => + collectWorkOSList( + await wos.userManagement.listInvitations({ + email, + }), + ), + ), /** Accept an invitation; returns the (now accepted) invitation. */ acceptInvitation: (invitationId: string) => diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index f73a9d451..9040f7bff 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -14,6 +14,7 @@ type StubFn = (...args: never[]) => Effect.Effect; type StubOverrides = { listOrgMembers?: StubFn; + getUserOrgMembership?: StubFn; getUser?: StubFn; sendInvitation?: StubFn; deleteOrgMembership?: StubFn; @@ -122,8 +123,7 @@ const fakeRoles: FakeRole[] = [ const requireAdmin = Effect.gen(function* () { const auth = yield* AuthContext; const workos = yield* WorkOSAuth; - const memberships = yield* workos.listOrgMembers(auth.organizationId); - const current = memberships.data.find((m: FakeMembership) => m.userId === auth.accountId); + const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); if (!current || current.role?.slug !== "admin") { return yield* new Forbidden(); } @@ -136,6 +136,11 @@ const withMembers: StubOverrides = { listOrgMembers: () => Effect.succeed({ data: fakeMemberships }), }; +const withCurrentMembership: StubOverrides = { + getUserOrgMembership: (_organizationId: string, userId: string) => + Effect.succeed(fakeMemberships.find((m) => m.userId === userId) ?? null), +}; + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -205,14 +210,14 @@ describe("Org handlers", () => { describe("requireAdmin", () => { it.effect("passes for admin user", () => - requireAdmin.pipe(Effect.provide(provide(adminAuth, withMembers))), + requireAdmin.pipe(Effect.provide(provide(adminAuth, withCurrentMembership))), ); it.effect("rejects non-admin with Forbidden", () => Effect.gen(function* () { const error = yield* Effect.flip(requireAdmin); expect(error).toBeInstanceOf(Forbidden); - }).pipe(Effect.provide(provide(memberAuth, withMembers))), + }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), ); }); @@ -231,7 +236,7 @@ describe("Org handlers", () => { }).pipe( Effect.provide( provide(adminAuth, { - ...withMembers, + ...withCurrentMembership, sendInvitation: (p: { email: string }) => Effect.succeed({ id: "inv_1", email: p.email }), }), @@ -252,7 +257,7 @@ describe("Org handlers", () => { }), ); expect(error).toBeInstanceOf(Forbidden); - }).pipe(Effect.provide(provide(memberAuth, withMembers))), + }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), ); }); @@ -265,7 +270,7 @@ describe("Org handlers", () => { }).pipe( Effect.provide( provide(adminAuth, { - ...withMembers, + ...withCurrentMembership, deleteOrgMembership: () => Effect.void, }), ), @@ -282,7 +287,7 @@ describe("Org handlers", () => { }), ); expect(error).toBeInstanceOf(Forbidden); - }).pipe(Effect.provide(provide(memberAuth, withMembers))), + }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), ); }); @@ -295,7 +300,7 @@ describe("Org handlers", () => { }).pipe( Effect.provide( provide(adminAuth, { - ...withMembers, + ...withCurrentMembership, updateOrgMembershipRole: () => Effect.void, }), ), @@ -312,7 +317,7 @@ describe("Org handlers", () => { }), ); expect(error).toBeInstanceOf(Forbidden); - }).pipe(Effect.provide(provide(memberAuth, withMembers))), + }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), ); }); }); diff --git a/apps/cloud/src/org/handlers.ts b/apps/cloud/src/org/handlers.ts index 90d4941d4..4309b238f 100644 --- a/apps/cloud/src/org/handlers.ts +++ b/apps/cloud/src/org/handlers.ts @@ -13,8 +13,7 @@ import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-lim const requireAdmin = Effect.gen(function* () { const auth = yield* AuthContext; const workos = yield* WorkOSAuth; - const memberships = yield* workos.listOrgMembers(auth.organizationId); - const currentMembership = memberships.data.find((m) => m.userId === auth.accountId); + const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); if (!currentMembership || currentMembership.role?.slug !== "admin") { return yield* new Forbidden(); }