Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/cloud/src/auth/api-keys.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ describe("ApiKeyService.WorkOS", () => {
stubWorkOS({
listUserApiKeys: () =>
Effect.succeed({
object: "list" as const,
data: [
{
id: "api_key_listed",
Expand All @@ -124,6 +125,10 @@ describe("ApiKeyService.WorkOS", () => {
},
},
],
listMetadata: {
before: null,
after: null,
},
}),
createUserApiKey: () =>
Effect.succeed({
Expand Down
98 changes: 98 additions & 0 deletions apps/cloud/src/auth/workos.node.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> = [];

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" }]);
});
});
145 changes: 130 additions & 15 deletions apps/cloud/src/auth/workos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -24,6 +24,88 @@ type RawWorkOS = WorkOS & {
) => Promise<{ readonly data: unknown }>;
};

type WorkOSListMetadata = {
readonly before?: string | null;
readonly after?: string | null;
};

type WorkOSAutoPaginatable<Resource> = {
readonly object: "list";
readonly data: Resource[];
readonly listMetadata: WorkOSListMetadata;
readonly autoPagination: () => Promise<Resource[]>;
};

export type WorkOSCollectedList<Resource> = {
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 <Resource>(
response: WorkOSAutoPaginatable<Resource>,
): Promise<WorkOSCollectedList<Resource>> => {
const data = response.listMetadata.after ? await response.autoPagination() : response.data;
return {
object: "list",
data,
listMetadata: completedListMetadata,
};
};

export const collectRawWorkOSList = async (
loadPage: (after?: string) => Promise<unknown>,
): Promise<WorkOSCollectedList<unknown>> => {
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;
}> {}
Expand Down Expand Up @@ -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"],
}),
),
),

/**
Expand Down Expand Up @@ -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 }) =>
Expand All @@ -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)),
Expand All @@ -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"),
Expand All @@ -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) =>
Expand Down
Loading
Loading