diff --git a/src/agents/agents.spec.ts b/src/agents/agents.spec.ts index 204f83433..3f727f61d 100644 --- a/src/agents/agents.spec.ts +++ b/src/agents/agents.spec.ts @@ -2,6 +2,7 @@ import * as jose from 'jose'; import fetch from 'jest-fetch-mock'; import { fetchBody, fetchOnce, fetchURL } from '../common/utils/test-utils'; import { WorkOS } from '../workos'; +import createClaimAttemptFixture from './fixtures/create-claim-attempt.json'; import getAgentRegistrationFixture from './fixtures/get-agent-registration.json'; import validateAgentCredentialFixture from './fixtures/validate-agent-credential.json'; @@ -48,6 +49,79 @@ describe('Agents', () => { jest.mocked(jose.jwtVerify).mockReset(); }); + describe('linkClaimAttemptToExternalUser', () => { + it('sends the request and deserializes the response', async () => { + fetchOnce(createClaimAttemptFixture); + + const result = await workos.agents.linkClaimAttemptToExternalUser({ + claimAttemptToken: 'cla_tkn_01EHWNCE74X7JSDV0X3SZ3KJNY', + user: { + email: 'alice@example.com', + externalId: 'user_abc123', + }, + }); + + expect(fetchURL()).toContain('/agents/claims/attempts'); + expect(fetchBody()).toEqual({ + type: 'link_external_user', + claim_attempt_token: 'cla_tkn_01EHWNCE74X7JSDV0X3SZ3KJNY', + user: { + email: 'alice@example.com', + external_id: 'user_abc123', + }, + }); + expect(result).toEqual({ + id: 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT', + status: 'unverified', + userCode: 'BCDF-GHJK', + organizations: [ + { + id: 'org_01EHZNVPK3SFK441A1RGBFSHRT', + name: 'Acme Corp', + }, + ], + }); + }); + + it('includes organizationId when provided', async () => { + fetchOnce(createClaimAttemptFixture); + + await workos.agents.linkClaimAttemptToExternalUser({ + claimAttemptToken: 'cla_tkn_01EHWNCE74X7JSDV0X3SZ3KJNY', + user: { + email: 'alice@example.com', + externalId: 'user_abc123', + }, + organizationId: 'org_01EHZNVPK3SFK441A1RGBFSHRT', + }); + + expect(fetchBody()).toEqual({ + type: 'link_external_user', + claim_attempt_token: 'cla_tkn_01EHWNCE74X7JSDV0X3SZ3KJNY', + user: { + email: 'alice@example.com', + external_id: 'user_abc123', + }, + organization_id: 'org_01EHZNVPK3SFK441A1RGBFSHRT', + }); + }); + + it('omits organizationId from the payload when not provided', async () => { + fetchOnce(createClaimAttemptFixture); + + await workos.agents.linkClaimAttemptToExternalUser({ + claimAttemptToken: 'cla_tkn_01EHWNCE74X7JSDV0X3SZ3KJNY', + user: { + email: 'alice@example.com', + externalId: 'user_abc123', + }, + }); + + const body = fetchBody(); + expect(body).not.toHaveProperty('organization_id'); + }); + }); + describe('getRegistration', () => { it('retrieves an agent registration', async () => { fetchOnce(getAgentRegistrationFixture); diff --git a/src/agents/agents.ts b/src/agents/agents.ts index c2362612a..8510591bc 100644 --- a/src/agents/agents.ts +++ b/src/agents/agents.ts @@ -3,7 +3,10 @@ import { WorkOS } from '../workos'; import { AgentCredentialValidation, AgentRegistration, + ClaimAttemptResponse, + LinkClaimAttemptToExternalUserOptions, SerializedAgentAccessTokenClaims, + SerializedClaimAttemptResponse, SerializedAgentCredentialValidation, SerializedAgentRegistration, ValidateAgentAccessTokenOptions, @@ -13,6 +16,8 @@ import { deserializeAgentAccessTokenClaims, deserializeAgentCredentialValidation, deserializeAgentRegistration, + deserializeClaimAttemptResponse, + serializeLinkClaimAttemptToExternalUserOptions, serializeValidateAgentCredentialOptions, } from './serializers'; @@ -41,6 +46,32 @@ export class Agents { constructor(private readonly workos: WorkOS) {} + /** + * Link a claim attempt to an external user + * + * Link an external user to a claim attempt and retrieve the code needed + * for the agent to complete the claim. The user is looked up by external + * ID; if no user exists, one is created. When the user belongs to multiple + * organizations, an explicit organization must be provided. + * + * @param options - Object containing the claim attempt token, user details, and optional organization ID. + * @returns {Promise} + * @throws {BadRequestException} 400 - Invalid request, email mismatch, or wrong account. + * @throws {ForbiddenException} 403 - Claim denied or auth method disabled. + * @throws {ConflictException} 409 - Organization selection required, external ID conflict, or already claimed. + * @throws {GoneException} 410 - Claim or user code expired. + */ + async linkClaimAttemptToExternalUser( + options: LinkClaimAttemptToExternalUserOptions, + ): Promise { + const { data } = await this.workos.post( + '/agents/claims/attempts', + serializeLinkClaimAttemptToExternalUserOptions(options), + ); + + return deserializeClaimAttemptResponse(data); + } + /** * Get an agent registration * diff --git a/src/agents/fixtures/create-claim-attempt.json b/src/agents/fixtures/create-claim-attempt.json new file mode 100644 index 000000000..101de6820 --- /dev/null +++ b/src/agents/fixtures/create-claim-attempt.json @@ -0,0 +1,11 @@ +{ + "id": "agent_reg_01EHZNVPK3SFK441A1RGBFSHRT", + "status": "unverified", + "user_code": "BCDF-GHJK", + "organizations": [ + { + "id": "org_01EHZNVPK3SFK441A1RGBFSHRT", + "name": "Acme Corp" + } + ] +} diff --git a/src/agents/interfaces/claim-attempt.interface.ts b/src/agents/interfaces/claim-attempt.interface.ts new file mode 100644 index 000000000..bbcf3eeb6 --- /dev/null +++ b/src/agents/interfaces/claim-attempt.interface.ts @@ -0,0 +1,53 @@ +import { AgentRegistrationStatus } from './agent-registration.interface'; + +/** Options for linking an external user to a claim attempt via the admin API. */ +export interface LinkClaimAttemptToExternalUserOptions { + /** The claim attempt token identifying the pending claim. */ + claimAttemptToken: string; + /** The user to attach to the claim attempt. */ + user: { + /** The email address of the user. */ + email: string; + /** The external ID of the user. */ + externalId: string; + }; + /** The organization to place the agent in. Required when the user belongs to multiple organizations. */ + organizationId?: string; +} + +export interface SerializedLinkClaimAttemptToExternalUserOptions { + type: 'link_external_user'; + claim_attempt_token: string; + user: { + email: string; + external_id: string; + }; + organization_id?: string; +} + +/** An organization the confirming user belongs to, offered as a placement choice. */ +export interface ClaimAttemptOrganization { + /** The organization ID. */ + id: string; + /** The organization name. */ + name: string; +} + +/** The result of linking an external user to a claim attempt. */ +export interface ClaimAttemptResponse { + /** The agent registration ID. */ + id: string; + /** Current status of the agent registration. */ + status: AgentRegistrationStatus; + /** The user code the agent needs to complete the claim. */ + userCode: string; + /** Organizations the user belongs to, offered as placement choices. */ + organizations: ClaimAttemptOrganization[]; +} + +export interface SerializedClaimAttemptResponse { + id: string; + status: AgentRegistrationStatus; + user_code: string; + organizations: ClaimAttemptOrganization[]; +} diff --git a/src/agents/interfaces/index.ts b/src/agents/interfaces/index.ts index 87ee656f1..eaa6bf490 100644 --- a/src/agents/interfaces/index.ts +++ b/src/agents/interfaces/index.ts @@ -1,2 +1,3 @@ export * from './agent-registration.interface'; +export * from './claim-attempt.interface'; export * from './validate-agent-credential.interface'; diff --git a/src/agents/serializers/claim-attempt.serializer.ts b/src/agents/serializers/claim-attempt.serializer.ts new file mode 100644 index 000000000..56d548306 --- /dev/null +++ b/src/agents/serializers/claim-attempt.serializer.ts @@ -0,0 +1,33 @@ +import { + ClaimAttemptResponse, + LinkClaimAttemptToExternalUserOptions, + SerializedClaimAttemptResponse, + SerializedLinkClaimAttemptToExternalUserOptions, +} from '../interfaces/claim-attempt.interface'; + +export function serializeLinkClaimAttemptToExternalUserOptions( + options: LinkClaimAttemptToExternalUserOptions, +): SerializedLinkClaimAttemptToExternalUserOptions { + return { + type: 'link_external_user', + claim_attempt_token: options.claimAttemptToken, + user: { + email: options.user.email, + external_id: options.user.externalId, + }, + ...(options.organizationId !== undefined && { + organization_id: options.organizationId, + }), + }; +} + +export function deserializeClaimAttemptResponse( + response: SerializedClaimAttemptResponse, +): ClaimAttemptResponse { + return { + id: response.id, + status: response.status, + userCode: response.user_code, + organizations: response.organizations, + }; +} diff --git a/src/agents/serializers/index.ts b/src/agents/serializers/index.ts index 0c2a6f604..ccc8d7b73 100644 --- a/src/agents/serializers/index.ts +++ b/src/agents/serializers/index.ts @@ -1,2 +1,3 @@ export * from './agent-registration.serializer'; +export * from './claim-attempt.serializer'; export * from './validate-agent-credential.serializer';