From f55169a8ca3bbc0099ba99ca3295474967934aea Mon Sep 17 00:00:00 2001 From: "workos-sdk-automation[bot]" <255426317+workos-sdk-automation[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:03:50 +0000 Subject: [PATCH 01/38] Update OpenAPI spec from workos/workos@92db0495807c86fbbc4d45bd266a6c1f5bcbb59c --- .last-synced-sha | 2 +- spec/open-api-spec.yaml | 3745 ++++++++++++++++++++++++++++++--------- 2 files changed, 2896 insertions(+), 851 deletions(-) diff --git a/.last-synced-sha b/.last-synced-sha index 1d29f7c..f72eb0e 100644 --- a/.last-synced-sha +++ b/.last-synced-sha @@ -1 +1 @@ -ab91ff74c791246890e2dcf9d297a758168ec447 +92db0495807c86fbbc4d45bd266a6c1f5bcbb59c diff --git a/spec/open-api-spec.yaml b/spec/open-api-spec.yaml index 734fcea..13d91c5 100644 --- a/spec/open-api-spec.yaml +++ b/spec/open-api-spec.yaml @@ -1148,6 +1148,15 @@ paths: - message tags: - authorization + x-mutually-exclusive-body-groups: &ref_3 + resource_target: + optional: false + variants: + by_id: + - resource_id + by_external_id: + - resource_external_id + - resource_type_slug /authorization/organization_memberships/{organization_membership_id}/resources: get: operationId: AuthorizationController_listResourcesForMembership @@ -1237,7 +1246,8 @@ paths: description: >- The WorkOS ID of the parent resource. Provide this or both `parent_resource_external_id` and `parent_resource_type_slug`, but - not both. + not both. Mutually exclusive with `parent_resource_type_slug` and + `parent_resource_external_id`. schema: type: string example: authz_resource_01XYZ789 @@ -1246,7 +1256,9 @@ paths: in: query description: >- The slug of the parent resource type. Must be provided together with - `parent_resource_external_id`. + `parent_resource_external_id`. Required with + `parent_resource_external_id`. Mutually exclusive with + `parent_resource_id`. schema: type: string example: project @@ -1255,7 +1267,9 @@ paths: in: query description: >- The application-specific external identifier of the parent resource. - Must be provided together with `parent_resource_type_slug`. + Must be provided together with `parent_resource_type_slug`. Required + with `parent_resource_type_slug`. Mutually exclusive with + `parent_resource_id`. schema: type: string example: external_project_123 @@ -1326,6 +1340,270 @@ paths: - message tags: - authorization + x-mutually-exclusive-parameter-groups: + parent_resource: + optional: false + variants: + by_id: + - parent_resource_id + by_external_id: + - parent_resource_type_slug + - parent_resource_external_id + /authorization/organization_memberships/{organization_membership_id}/resources/{resource_id}/permissions: + get: + operationId: AuthorizationController_listEffectivePermissions + summary: List effective permissions for an organization membership on a resource + description: >- + Returns all permissions the organization membership effectively has on a + resource, including permissions inherited through roles assigned to + ancestor resources. + parameters: + - name: organization_membership_id + required: true + in: path + description: The ID of the organization membership. + schema: + type: string + example: om_01HXYZ123456789ABCDEFGHIJ + - name: resource_id + required: true + in: path + description: The ID of the authorization resource. + schema: + type: string + example: authz_resource_01HXYZ123456789ABCDEFGHIJ + - name: before + required: false + in: query + description: >- + An object ID that defines your place in the list. When the ID is not + present, you are at the end of the list. For example, if you make a + list request and receive 100 objects, ending with `"obj_123"`, your + subsequent call can include `before="obj_123"` to fetch a new batch + of objects before `"obj_123"`. + schema: + example: xxx_01HXYZ123456789ABCDEFGHIJ + type: string + - name: after + required: false + in: query + description: >- + An object ID that defines your place in the list. When the ID is not + present, you are at the end of the list. For example, if you make a + list request and receive 100 objects, ending with `"obj_123"`, your + subsequent call can include `after="obj_123"` to fetch a new batch + of objects after `"obj_123"`. + schema: + example: xxx_01HXYZ987654321KJIHGFEDCBA + type: string + - name: limit + required: false + in: query + description: >- + Upper limit on the number of objects to return, between `1` and + `100`. + schema: + minimum: 1 + maximum: 100 + default: 10 + example: 10 + type: integer + - name: order + required: false + in: query + description: >- + Order the results by the creation time. Supported values are `"asc"` + (ascending), `"desc"` (descending), and `"normal"` (descending with + reversed cursor semantics where `before` fetches older records and + `after` fetches newer records). Defaults to descending. + schema: + default: desc + example: desc + enum: + - normal + - desc + - asc + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AuthorizationPermissionList' + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '422': + description: Unprocessable Entity + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + tags: + - authorization + /authorization/organization_memberships/{organization_membership_id}/resources/{resource_type_slug}/{external_id}/permissions: + get: + operationId: AuthorizationController_listEffectivePermissionsByExternalId + summary: >- + List effective permissions for an organization membership on a resource + by external ID + description: >- + Returns all permissions the organization membership effectively has on a + resource identified by its external ID, including permissions inherited + through roles assigned to ancestor resources. + parameters: + - name: organization_membership_id + required: true + in: path + description: The ID of the organization membership. + schema: + type: string + example: om_01HXYZ123456789ABCDEFGHIJ + - name: resource_type_slug + required: true + in: path + description: The slug of the resource type. + schema: + type: string + example: document + - name: external_id + required: true + in: path + description: An identifier you provide to reference the resource in your system. + schema: + type: string + example: doc-456 + - name: before + required: false + in: query + description: >- + An object ID that defines your place in the list. When the ID is not + present, you are at the end of the list. For example, if you make a + list request and receive 100 objects, ending with `"obj_123"`, your + subsequent call can include `before="obj_123"` to fetch a new batch + of objects before `"obj_123"`. + schema: + example: xxx_01HXYZ123456789ABCDEFGHIJ + type: string + - name: after + required: false + in: query + description: >- + An object ID that defines your place in the list. When the ID is not + present, you are at the end of the list. For example, if you make a + list request and receive 100 objects, ending with `"obj_123"`, your + subsequent call can include `after="obj_123"` to fetch a new batch + of objects after `"obj_123"`. + schema: + example: xxx_01HXYZ987654321KJIHGFEDCBA + type: string + - name: limit + required: false + in: query + description: >- + Upper limit on the number of objects to return, between `1` and + `100`. + schema: + minimum: 1 + maximum: 100 + default: 10 + example: 10 + type: integer + - name: order + required: false + in: query + description: >- + Order the results by the creation time. Supported values are `"asc"` + (ascending), `"desc"` (descending), and `"normal"` (descending with + reversed cursor semantics where `before` fetches older records and + `after` fetches newer records). Defaults to descending. + schema: + default: desc + example: desc + enum: + - normal + - desc + - asc + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AuthorizationPermissionList' + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '422': + description: Unprocessable Entity + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + tags: + - authorization /authorization/organization_memberships/{organization_membership_id}/role_assignments: get: operationId: AuthorizationRoleAssignmentsController_listRoleAssignments @@ -1506,6 +1784,15 @@ paths: - message tags: - authorization + x-mutually-exclusive-body-groups: &ref_4 + resource_target: + optional: false + variants: + by_id: + - resource_id + by_external_id: + - resource_external_id + - resource_type_slug delete: operationId: AuthorizationRoleAssignmentsController_removeRoleByCriteria summary: Remove a role assignment @@ -1568,6 +1855,15 @@ paths: - message tags: - authorization + x-mutually-exclusive-body-groups: &ref_5 + resource_target: + optional: false + variants: + by_id: + - resource_id + by_external_id: + - resource_external_id + - resource_type_slug /authorization/organization_memberships/{organization_membership_id}/role_assignments/{role_assignment_id}: delete: operationId: AuthorizationRoleAssignmentsController_removeRoleById @@ -1622,10 +1918,8 @@ paths: /authorization/organizations/{organizationId}/roles: post: operationId: AuthorizationOrganizationRolesController_create - summary: Create a custom organization role - description: >- - Create a new custom organization role. When slug is omitted, it is - auto-generated from the role name. + summary: Create a custom role + description: Create a new custom role for this organization. parameters: - name: organizationId required: true @@ -1678,7 +1972,7 @@ paths: - OrganizationRole description: >- Whether the role is scoped to the environment or an - organization. + organization (custom role). example: OrganizationRole resource_type_slug: type: string @@ -1795,11 +2089,10 @@ paths: - authorization get: operationId: AuthorizationOrganizationRolesController_list - summary: List organization roles + summary: List custom roles description: >- Get a list of all roles that apply to an organization. This includes - both environment roles and organization-specific roles, returned in - priority order. + both environment roles and custom roles, returned in priority order. parameters: - name: organizationId required: true @@ -1846,10 +2139,10 @@ paths: /authorization/organizations/{organizationId}/roles/{slug}: get: operationId: AuthorizationOrganizationRolesController_get - summary: Get an organization role + summary: Get a custom role description: >- Retrieve a role that applies to an organization by its slug. This can - return either an environment role or an organization-specific role. + return either an environment role or a custom role. parameters: - name: organizationId required: true @@ -1903,7 +2196,7 @@ paths: - OrganizationRole description: >- Whether the role is scoped to the environment or an - organization. + organization (custom role). example: OrganizationRole resource_type_slug: type: string @@ -1969,10 +2262,10 @@ paths: - authorization patch: operationId: AuthorizationOrganizationRolesController_update - summary: Update an organization role + summary: Update a custom role description: >- - Update an existing custom organization role. Only the fields provided in - the request body will be updated. + Update an existing custom role. Only the fields provided in the request + body will be updated. parameters: - name: organizationId required: true @@ -2032,7 +2325,7 @@ paths: - OrganizationRole description: >- Whether the role is scoped to the environment or an - organization. + organization (custom role). example: OrganizationRole resource_type_slug: type: string @@ -2130,8 +2423,8 @@ paths: - authorization delete: operationId: AuthorizationOrganizationRolesController_delete - summary: Delete a custom organization role - description: Delete an existing custom organization role. + summary: Delete a custom role + description: Delete an existing custom role. parameters: - name: organizationId required: true @@ -2234,8 +2527,8 @@ paths: /authorization/organizations/{organizationId}/roles/{slug}/permissions: put: operationId: AuthorizationOrganizationRolePermissionsController_setPermissions - summary: Set permissions for a role - description: Replace all permissions on a role with the provided list. + summary: Set permissions for a custom role + description: Replace all permissions on a custom role with the provided list. parameters: - name: organizationId required: true @@ -2295,7 +2588,7 @@ paths: - OrganizationRole description: >- Whether the role is scoped to the environment or an - organization. + organization (custom role). example: OrganizationRole resource_type_slug: type: string @@ -2376,10 +2669,10 @@ paths: - authorization post: operationId: AuthorizationOrganizationRolePermissionsController_addPermission - summary: Add a permission to an organization role + summary: Add a permission to a custom role description: >- - Add a single permission to an organization role. If the permission is - already assigned to the role, this operation has no effect. + Add a single permission to a custom role. If the permission is already + assigned to the role, this operation has no effect. parameters: - name: organizationId required: true @@ -2439,7 +2732,7 @@ paths: - OrganizationRole description: >- Whether the role is scoped to the environment or an - organization. + organization (custom role). example: OrganizationRole resource_type_slug: type: string @@ -2537,8 +2830,8 @@ paths: /authorization/organizations/{organizationId}/roles/{slug}/permissions/{permissionSlug}: delete: operationId: AuthorizationOrganizationRolePermissionsController_removePermission - summary: Remove a permission from an organization role - description: Remove a single permission from an organization role by its slug. + summary: Remove a permission from a custom role + description: Remove a single permission from a custom role by its slug. parameters: - name: organizationId required: true @@ -2841,6 +3134,15 @@ paths: - message tags: - authorization + x-mutually-exclusive-body-groups: &ref_0 + parent_resource: + optional: true + variants: + by_id: + - parent_resource_id + by_external_id: + - parent_resource_external_id + - parent_resource_type_slug delete: operationId: AuthorizationResourcesByExternalIdController_deleteByExternalId summary: Delete an authorization resource by external ID @@ -3222,7 +3524,7 @@ paths: summary: Create a permission description: >- Create a new permission in your WorkOS environment. The permission can - then be assigned to environment roles and organization roles. + then be assigned to environment roles and custom roles. parameters: [] requestBody: required: true @@ -3589,24 +3891,38 @@ paths: schema: type: string example: project + - name: resource_external_id + required: false + in: query + description: Filter resources by external ID. + schema: + type: string + example: my-project-123 - name: parent_resource_id required: false in: query - description: Filter resources by parent resource ID. + description: >- + Filter resources by parent resource ID. Mutually exclusive with + `parent_resource_type_slug` and `parent_external_id`. schema: type: string example: authz_resource_01HXYZ123456789ABCDEFGHIJ - name: parent_resource_type_slug required: false in: query - description: Filter resources by parent resource type slug. + description: >- + Filter resources by parent resource type slug. Required with + `parent_external_id`. Mutually exclusive with `parent_resource_id`. schema: type: string example: workspace - name: parent_external_id required: false in: query - description: Filter resources by parent external ID. + description: >- + Filter resources by parent external ID. Required with + `parent_resource_type_slug`. Mutually exclusive with + `parent_resource_id`. schema: type: string example: ext-workspace-123 @@ -3652,6 +3968,15 @@ paths: - message tags: - authorization + x-mutually-exclusive-parameter-groups: + parent: + optional: true + variants: + by_id: + - parent_resource_id + by_external_id: + - parent_resource_type_slug + - parent_external_id post: operationId: AuthorizationResourcesController_create summary: Create an authorization resource @@ -3840,6 +4165,15 @@ paths: - message tags: - authorization + x-mutually-exclusive-body-groups: &ref_6 + parent_resource: + optional: true + variants: + by_id: + - parent_resource_id + by_external_id: + - parent_resource_external_id + - parent_resource_type_slug /authorization/resources/{resource_id}: get: operationId: AuthorizationResourcesController_findById @@ -4066,6 +4400,7 @@ paths: - message tags: - authorization + x-mutually-exclusive-body-groups: *ref_0 delete: operationId: AuthorizationResourcesController_delete summary: Delete an authorization resource @@ -4380,7 +4715,7 @@ paths: - OrganizationRole description: >- Whether the role is scoped to the environment or an - organization. + organization (custom role). example: EnvironmentRole resource_type_slug: type: string @@ -4626,7 +4961,7 @@ paths: - OrganizationRole description: >- Whether the role is scoped to the environment or an - organization. + organization (custom role). example: EnvironmentRole resource_type_slug: type: string @@ -4779,7 +5114,7 @@ paths: - OrganizationRole description: >- Whether the role is scoped to the environment or an - organization. + organization (custom role). example: EnvironmentRole resource_type_slug: type: string @@ -4935,7 +5270,7 @@ paths: - OrganizationRole description: >- Whether the role is scoped to the environment or an - organization. + organization (custom role). example: EnvironmentRole resource_type_slug: type: string @@ -8124,25 +8459,32 @@ paths: - message tags: - organizations.feature-flags - /portal/generate_link: + /organizations/{organizationId}/groups: post: - operationId: PortalSessionsController_create - summary: Generate a Portal Link - description: Generate a Portal Link scoped to an Organization. - parameters: [] + operationId: GroupsController_create + summary: Create a group + description: Create a new group within an organization. + parameters: + - name: organizationId + required: true + in: path + description: The ID of the organization. + schema: + type: string + example: org_01EHWNCE74X7JSDV0X3SZ3KJNY requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/GenerateLinkDto' + $ref: '#/components/schemas/CreateGroupDto' responses: '201': description: Created content: application/json: schema: - $ref: '#/components/schemas/PortalLinkResponse' + $ref: '#/components/schemas/Group' '400': description: Bad Request content: @@ -8150,11 +8492,17 @@ paths: schema: type: object properties: + code: + type: string + description: The error code identifying the type of error. + example: bad_request + const: bad_request message: type: string description: A human-readable description of the error. - example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + example: Request could not be processed. required: + - code - message '403': description: Forbidden @@ -8196,82 +8544,94 @@ paths: required: - message tags: - - admin-portal - /radar/attempts: - post: - operationId: RadarStandaloneController_assess - summary: Create an attempt - description: Assess a request for risk using the Radar engine and receive a verdict. - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - ip_address: - type: string - description: The IP address of the request to assess. - example: 49.78.240.97 - user_agent: - type: string - description: The user agent string of the request to assess. - example: Mozilla/5.0 - email: - type: string - format: email - description: The email address of the user making the request. - example: user@example.com - auth_method: - type: string - enum: - - Password - - Passkey - - Authenticator - - SMS_OTP - - Email_OTP - - Social - - SSO - - Other - description: The authentication method being used. - example: Password - action: - type: string - enum: - - login - - signup - - sign-up - - sign-in - - sign_up - - sign_in - - sign in - - sign up - description: The action being performed. - example: login - device_fingerprint: - type: string - description: An optional device fingerprint for the request. - example: fp_abc123 - bot_score: - type: string - description: An optional bot detection score for the request. - example: '0.1' - required: - - ip_address - - user_agent - - email - - auth_method - - action + - groups + x-feature-flag: user-groups-enabled + get: + operationId: GroupsController_list + summary: List groups + description: Get a paginated list of groups within an organization. + parameters: + - name: organizationId + required: true + in: path + description: The ID of the organization. + schema: + type: string + example: org_01EHWNCE74X7JSDV0X3SZ3KJNY + - name: before + required: false + in: query + description: >- + An object ID that defines your place in the list. When the ID is not + present, you are at the end of the list. For example, if you make a + list request and receive 100 objects, ending with `"obj_123"`, your + subsequent call can include `before="obj_123"` to fetch a new batch + of objects before `"obj_123"`. + schema: + example: xxx_01HXYZ123456789ABCDEFGHIJ + type: string + - name: after + required: false + in: query + description: >- + An object ID that defines your place in the list. When the ID is not + present, you are at the end of the list. For example, if you make a + list request and receive 100 objects, ending with `"obj_123"`, your + subsequent call can include `after="obj_123"` to fetch a new batch + of objects after `"obj_123"`. + schema: + example: xxx_01HXYZ987654321KJIHGFEDCBA + type: string + - name: limit + required: false + in: query + description: >- + Upper limit on the number of objects to return, between `1` and + `100`. + schema: + minimum: 1 + maximum: 100 + default: 10 + example: 10 + type: integer + - name: order + required: false + in: query + description: >- + Order the results by the creation time. Supported values are `"asc"` + (ascending), `"desc"` (descending), and `"normal"` (descending with + reversed cursor semantics where `before` fetches older records and + `after` fetches newer records). Defaults to descending. + schema: + default: desc + example: desc + enum: + - normal + - desc + - asc + type: string responses: '200': description: OK content: application/json: schema: - $ref: '#/components/schemas/RadarStandaloneResponse' - '400': - description: Standalone radar is not enabled. + $ref: '#/components/schemas/GroupList' + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '404': + description: Not Found content: application/json: schema: @@ -8284,47 +8644,37 @@ paths: required: - message tags: - - radar - /radar/attempts/{id}: - put: - operationId: RadarStandaloneController_updateRadarAttempt - summary: Update a Radar attempt - description: >- - You may optionally inform Radar that an authentication attempt or - challenge was successful using this endpoint. Some Radar controls depend - on tracking recent successful attempts, such as impossible travel. + - groups + x-feature-flag: user-groups-enabled + /organizations/{organizationId}/groups/{groupId}: + get: + operationId: GroupsController_get + summary: Get a group + description: Retrieve a group by its ID within an organization. parameters: - - name: id + - name: organizationId required: true in: path - description: The unique identifier of the Radar attempt to update. + description: The ID of the organization. schema: - example: radar_att_01HZBC6N1EB1ZY7KG32X type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - challenge_status: - type: string - description: Set to `"success"` to mark the challenge as completed. - example: success - const: success - attempt_status: - type: string - description: >- - Set to `"success"` to mark the authentication attempt as - successful. - example: success - const: success + example: org_01EHWNCE74X7JSDV0X3SZ3KJNY + - name: groupId + required: true + in: path + description: The ID of the group. + schema: + type: string + example: group_01HXYZ123456789ABCDEFGHIJ responses: - '204': - description: Radar attempt updated. - '400': - description: Bad Request + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Group' + '403': + description: Forbidden content: application/json: schema: @@ -8350,69 +8700,89 @@ paths: required: - message tags: - - radar - /radar/lists/{type}/{action}: - post: - operationId: RadarStandaloneController_updateRadarList - summary: Add an entry to a Radar list - description: Add an entry to a Radar list. + - groups + x-feature-flag: user-groups-enabled + patch: + operationId: GroupsController_update + summary: Update a group + description: >- + Update an existing group. Only the fields provided in the request body + will be updated. parameters: - - name: type + - name: organizationId required: true in: path - description: The type of the Radar list (e.g. ip_address, domain, email). + description: The ID of the organization. schema: type: string - enum: - - ip_address - - domain - - email - - device - - user_agent - - device_fingerprint - - country - example: ip_address - - name: action + example: org_01EHWNCE74X7JSDV0X3SZ3KJNY + - name: groupId required: true in: path - description: >- - The list action indicating whether to add the entry to the allow or - block list. + description: The ID of the group. schema: type: string - enum: - - block - - allow - example: block + example: group_01HXYZ123456789ABCDEFGHIJ requestBody: required: true content: application/json: schema: - type: object - properties: - entry: - type: string - description: >- - The value to add to the list. Must match the format of the - list type (e.g. a valid IP address for `ip_address`, a valid - email for `email`). - example: 198.51.100.42 - required: - - entry + $ref: '#/components/schemas/UpdateGroupDto' responses: '200': - description: Entry already present in the list. + description: OK content: application/json: schema: - $ref: '#/components/schemas/RadarListEntryAlreadyPresentResponse' - '201': - description: Created - '204': - description: Entry successfully added to the list. + $ref: '#/components/schemas/Group' '400': description: Bad Request + content: + application/json: + schema: + type: object + properties: + code: + type: string + description: The error code identifying the type of error. + example: bad_request + const: bad_request + message: + type: string + description: A human-readable description of the error. + example: Request could not be processed. + required: + - code + - message + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '422': + description: Unprocessable Entity content: application/json: schema: @@ -8425,59 +8795,94 @@ paths: required: - message tags: - - radar + - groups + x-feature-flag: user-groups-enabled delete: - operationId: RadarStandaloneController_deleteRadarListEntry - summary: Remove an entry from a Radar list - description: Remove an entry from a Radar list. + operationId: GroupsController_delete + summary: Delete a group + description: Delete a group from an organization. parameters: - - name: type + - name: organizationId required: true in: path - description: The type of the Radar list (e.g. ip_address, domain, email). + description: The ID of the organization. schema: type: string - enum: - - ip_address - - domain - - email - - device - - user_agent - - device_fingerprint - - country - example: ip_address - - name: action + example: org_01EHWNCE74X7JSDV0X3SZ3KJNY + - name: groupId required: true in: path - description: >- - The list action indicating whether to remove the entry from the - allow or block list. + description: The ID of the group. schema: type: string - enum: - - block - - allow - example: block + example: group_01HXYZ123456789ABCDEFGHIJ + responses: + '204': + description: No Content + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + tags: + - groups + x-feature-flag: user-groups-enabled + /organizations/{organizationId}/groups/{groupId}/organization-memberships: + post: + operationId: GroupMembershipsController_addMember + summary: Add a member to a Group + description: Add an organization membership to a group. + parameters: + - name: organizationId + required: true + in: path + description: Unique identifier of the Organization. + schema: + type: string + example: org_01EHWNCE74X7JSDV0X3SZ3KJNY + - name: groupId + required: true + in: path + description: Unique identifier of the Group. + schema: + type: string + example: group_01HXYZ123456789ABCDEFGHIJ requestBody: required: true content: application/json: schema: - type: object - properties: - entry: - type: string - description: >- - The value to remove from the list. Must match an existing - entry. - example: 198.51.100.42 - required: - - entry + $ref: '#/components/schemas/CreateGroupMembershipDto' responses: - '204': - description: Radar list updated. - '400': - description: Bad Request + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Group' + '403': + description: Forbidden content: application/json: schema: @@ -8502,76 +8907,644 @@ paths: example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' required: - message + '422': + description: Unprocessable Entity + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message tags: - - radar - /sso/authorize: + - groups + x-feature-flag: user-groups-enabled get: - operationId: SsoController_authorize - summary: Initiate SSO - description: Initiates the single sign-on flow. - security: [] + operationId: GroupMembershipsController_listMembers + summary: List Group members + description: Get a list of organization memberships in a group. parameters: - - name: provider_scopes + - name: organizationId + required: true + in: path + description: Unique identifier of the Organization. + schema: + type: string + example: org_01EHWNCE74X7JSDV0X3SZ3KJNY + - name: groupId + required: true + in: path + description: Unique identifier of the Group. + schema: + type: string + example: group_01HXYZ123456789ABCDEFGHIJ + - name: before required: false in: query description: >- - Additional OAuth scopes to request from the identity provider. Only - applicable when using OAuth connections. - style: form - explode: false + An object ID that defines your place in the list. When the ID is not + present, you are at the end of the list. For example, if you make a + list request and receive 100 objects, ending with `"obj_123"`, your + subsequent call can include `before="obj_123"` to fetch a new batch + of objects before `"obj_123"`. schema: - type: array - items: - type: string - example: - - openid - - profile - - email - - name: provider_query_params + example: xxx_01HXYZ123456789ABCDEFGHIJ + type: string + - name: after required: false in: query description: >- - Key/value pairs of query parameters to pass to the OAuth provider. - Only applicable when using OAuth connections. - schema: - additionalProperties: - type: string - example: - hd: example.com - access_type: offline - type: object - - name: client_id - required: true - in: query + An object ID that defines your place in the list. When the ID is not + present, you are at the end of the list. For example, if you make a + list request and receive 100 objects, ending with `"obj_123"`, your + subsequent call can include `after="obj_123"` to fetch a new batch + of objects after `"obj_123"`. schema: + example: xxx_01HXYZ987654321KJIHGFEDCBA type: string - example: client_01HZBC6N1EB1ZY7KG32X - description: The unique identifier of the WorkOS environment client. - - name: domain + - name: limit required: false in: query - deprecated: true - schema: - type: string - example: example.com description: >- - Deprecated. Use `connection` or `organization` instead. Used to - initiate SSO for a connection by domain. The domain must be - associated with a connection in your WorkOS environment. - - name: provider + Upper limit on the number of objects to return, between `1` and + `100`. + schema: + minimum: 1 + maximum: 100 + default: 10 + example: 10 + type: integer + - name: order required: false in: query + description: >- + Order the results by the creation time. Supported values are `"asc"` + (ascending), `"desc"` (descending), and `"normal"` (descending with + reversed cursor semantics where `before` fetches older records and + `after` fetches newer records). Defaults to descending. schema: - type: string + default: desc + example: desc enum: - - AppleOAuth - - GitHubOAuth - - GoogleOAuth + - normal + - desc + - asc + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: >- + #/components/schemas/UserlandUserOrganizationMembershipBaseList + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + tags: + - groups + x-feature-flag: user-groups-enabled + /organizations/{organizationId}/groups/{groupId}/organization-memberships/{omId}: + delete: + operationId: GroupMembershipsController_removeMember + summary: Remove a member from a Group + description: Remove an organization membership from a group. + parameters: + - name: organizationId + required: true + in: path + description: Unique identifier of the Organization. + schema: + type: string + example: org_01EHWNCE74X7JSDV0X3SZ3KJNY + - name: groupId + required: true + in: path + description: Unique identifier of the Group. + schema: + type: string + example: group_01HXYZ123456789ABCDEFGHIJ + - name: omId + required: true + in: path + description: Unique identifier of the Organization Membership. + schema: + type: string + example: om_01HXYZ123456789ABCDEFGHIJ + responses: + '204': + description: No Content + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + tags: + - groups + x-feature-flag: user-groups-enabled + /portal/generate_link: + post: + operationId: PortalSessionsController_create + summary: Generate a Portal Link + description: Generate a Portal Link scoped to an Organization. + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenerateLinkDto' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/PortalLinkResponse' + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '403': + description: Forbidden + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '422': + description: Unprocessable Entity + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + tags: + - admin-portal + /radar/attempts: + post: + operationId: RadarStandaloneController_assess + summary: Create an attempt + description: Assess a request for risk using the Radar engine and receive a verdict. + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + ip_address: + type: string + description: The IP address of the request to assess. + example: 49.78.240.97 + user_agent: + type: string + description: The user agent string of the request to assess. + example: Mozilla/5.0 + email: + type: string + format: email + description: The email address of the user making the request. + example: user@example.com + auth_method: + type: string + enum: + - Password + - Passkey + - Authenticator + - SMS_OTP + - Email_OTP + - Social + - SSO + - Other + description: The authentication method being used. + example: Password + action: + type: string + enum: + - login + - signup + - sign-up + - sign-in + - sign_up + - sign_in + - sign in + - sign up + description: The action being performed. + example: login + device_fingerprint: + type: string + description: An optional device fingerprint for the request. + example: fp_abc123 + bot_score: + type: string + description: An optional bot detection score for the request. + example: '0.1' + required: + - ip_address + - user_agent + - email + - auth_method + - action + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/RadarStandaloneResponse' + '400': + description: Standalone radar is not enabled. + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + tags: + - radar + /radar/attempts/{id}: + put: + operationId: RadarStandaloneController_updateRadarAttempt + summary: Update a Radar attempt + description: >- + You may optionally inform Radar that an authentication attempt or + challenge was successful using this endpoint. Some Radar controls depend + on tracking recent successful attempts, such as impossible travel. + parameters: + - name: id + required: true + in: path + description: The unique identifier of the Radar attempt to update. + schema: + example: radar_att_01HZBC6N1EB1ZY7KG32X + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + challenge_status: + type: string + description: Set to `"success"` to mark the challenge as completed. + example: success + const: success + attempt_status: + type: string + description: >- + Set to `"success"` to mark the authentication attempt as + successful. + example: success + const: success + responses: + '204': + description: Radar attempt updated. + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + tags: + - radar + /radar/lists/{type}/{action}: + post: + operationId: RadarStandaloneController_updateRadarList + summary: Add an entry to a Radar list + description: Add an entry to a Radar list. + parameters: + - name: type + required: true + in: path + description: The type of the Radar list (e.g. ip_address, domain, email). + schema: + type: string + enum: + - ip_address + - domain + - email + - device + - user_agent + - device_fingerprint + - country + example: ip_address + - name: action + required: true + in: path + description: >- + The list action indicating whether to add the entry to the allow or + block list. + schema: + type: string + enum: + - block + - allow + example: block + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + entry: + type: string + description: >- + The value to add to the list. Must match the format of the + list type (e.g. a valid IP address for `ip_address`, a valid + email for `email`). + example: 198.51.100.42 + required: + - entry + responses: + '200': + description: Entry already present in the list. + content: + application/json: + schema: + $ref: '#/components/schemas/RadarListEntryAlreadyPresentResponse' + '201': + description: Created + '204': + description: Entry successfully added to the list. + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + tags: + - radar + delete: + operationId: RadarStandaloneController_deleteRadarListEntry + summary: Remove an entry from a Radar list + description: Remove an entry from a Radar list. + parameters: + - name: type + required: true + in: path + description: The type of the Radar list (e.g. ip_address, domain, email). + schema: + type: string + enum: + - ip_address + - domain + - email + - device + - user_agent + - device_fingerprint + - country + example: ip_address + - name: action + required: true + in: path + description: >- + The list action indicating whether to remove the entry from the + allow or block list. + schema: + type: string + enum: + - block + - allow + example: block + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + entry: + type: string + description: >- + The value to remove from the list. Must match an existing + entry. + example: 198.51.100.42 + required: + - entry + responses: + '204': + description: Radar list updated. + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + tags: + - radar + /sso/authorize: + get: + operationId: SsoController_authorize + summary: Initiate SSO + description: Initiates the single sign-on flow. + security: [] + parameters: + - name: provider_scopes + required: false + in: query + description: >- + Additional scopes to request from the identity provider. Applicable + when using OAuth or OpenID Connect connections. + style: form + explode: false + schema: + type: array + items: + type: string + example: + - openid + - profile + - email + - name: provider_query_params + required: false + in: query + description: >- + Key/value pairs of query parameters to pass to the OAuth provider. + Only applicable when using OAuth connections. + schema: + additionalProperties: + type: string + example: + hd: example.com + access_type: offline + type: object + - name: client_id + required: true + in: query + schema: + type: string + example: client_01HZBC6N1EB1ZY7KG32X + description: The unique identifier of the WorkOS environment client. + - name: domain + required: false + in: query + deprecated: true + schema: + type: string + example: example.com + description: >- + Deprecated. Use `connection` or `organization` instead. Used to + initiate SSO for a connection by domain. The domain must be + associated with a connection in your WorkOS environment. + - name: provider + required: false + in: query + schema: + type: string + enum: + - AppleOAuth + - BitbucketOAuth + - GitHubOAuth + - GitLabOAuth + - GoogleOAuth + - IntuitOAuth + - LinkedInOAuth - MicrosoftOAuth + - SalesforceOAuth + - SlackOAuth + - VercelMarketplaceOAuth + - VercelOAuth + - XeroOAuth example: GoogleOAuth - description: >- - Used to initiate OAuth authentication with Google, Microsoft, - GitHub, or Apple. + description: Used to initiate OAuth authentication with various providers. - name: redirect_uri required: true in: query @@ -9075,6 +10048,16 @@ paths: type: string description: The authorization code received from the redirect. example: vBqZKaPpsnJlPfXiDqN7b6VTz + code_verifier: + type: string + description: >- + The PKCE code verifier used to derive the code challenge + passed to the authorization URL. + example: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk + invitation_token: + type: string + description: An invitation token to accept during authentication. + example: inv_tok_01HXYZ123456789ABCDEFGHIJ ip_address: type: string description: The IP address of the user's request. @@ -10177,9 +11160,18 @@ paths: enum: - authkit - AppleOAuth + - BitbucketOAuth - GitHubOAuth + - GitLabOAuth - GoogleOAuth + - IntuitOAuth + - LinkedInOAuth - MicrosoftOAuth + - SalesforceOAuth + - SlackOAuth + - VercelMarketplaceOAuth + - VercelOAuth + - XeroOAuth example: GoogleOAuth description: >- The OAuth provider to authenticate with (e.g., GoogleOAuth, @@ -10875,6 +11867,16 @@ paths: The ID of the user who accepted the invitation, once accepted. example: user_01E4ZCR3C56J083X43JQXF3JK5 + role_slug: + type: + - string + - 'null' + description: >- + Slug of the role the invitee will be assigned on + acceptance. Reflects the current role on the invitee's + organization membership. null when the invitation has no + associated organization. + example: admin created_at: format: date-time type: string @@ -10905,6 +11907,7 @@ paths: - organization_id - inviter_user_id - accepted_user_id + - role_slug - created_at - updated_at - token @@ -11137,6 +12140,16 @@ paths: The ID of the user who accepted the invitation, once accepted. example: null + role_slug: + type: + - string + - 'null' + description: >- + Slug of the role the invitee will be assigned on + acceptance. Reflects the current role on the invitee's + organization membership. null when the invitation has no + associated organization. + example: admin created_at: format: date-time type: string @@ -11167,6 +12180,7 @@ paths: - organization_id - inviter_user_id - accepted_user_id + - role_slug - created_at - updated_at - token @@ -11791,6 +12805,14 @@ paths: - role_slug tags: - user-management.organization-membership + x-mutually-exclusive-body-groups: &ref_9 + role: + optional: true + variants: + single: + - role_slug + multiple: + - role_slugs /user_management/organization_memberships/{id}: get: operationId: UserlandUserOrganizationMembershipsController_get @@ -11967,6 +12989,14 @@ paths: - role_slug tags: - user-management.organization-membership + x-mutually-exclusive-body-groups: &ref_10 + role: + optional: true + variants: + single: + - role_slug + multiple: + - role_slugs /user_management/organization_memberships/{id}/deactivate: put: operationId: UserlandUserOrganizationMembershipsController_deactivate @@ -12225,6 +13255,94 @@ paths: - message tags: - user-management.organization-membership + /user_management/organization_memberships/{omId}/groups: + get: + operationId: OrganizationMembershipGroupsController_listGroups + summary: List groups + description: Get a list of groups that an organization membership belongs to. + parameters: + - name: omId + required: true + in: path + description: Unique identifier of the Organization Membership. + schema: + type: string + example: om_01HXYZ123456789ABCDEFGHIJ + - name: before + required: false + in: query + description: >- + An object ID that defines your place in the list. When the ID is not + present, you are at the end of the list. For example, if you make a + list request and receive 100 objects, ending with `"obj_123"`, your + subsequent call can include `before="obj_123"` to fetch a new batch + of objects before `"obj_123"`. + schema: + example: xxx_01HXYZ123456789ABCDEFGHIJ + type: string + - name: after + required: false + in: query + description: >- + An object ID that defines your place in the list. When the ID is not + present, you are at the end of the list. For example, if you make a + list request and receive 100 objects, ending with `"obj_123"`, your + subsequent call can include `after="obj_123"` to fetch a new batch + of objects after `"obj_123"`. + schema: + example: xxx_01HXYZ987654321KJIHGFEDCBA + type: string + - name: limit + required: false + in: query + description: >- + Upper limit on the number of objects to return, between `1` and + `100`. + schema: + minimum: 1 + maximum: 100 + default: 10 + example: 10 + type: integer + - name: order + required: false + in: query + description: >- + Order the results by the creation time. Supported values are `"asc"` + (ascending), `"desc"` (descending), and `"normal"` (descending with + reversed cursor semantics where `before` fetches older records and + `after` fetches newer records). Defaults to descending. + schema: + default: desc + example: desc + enum: + - normal + - desc + - asc + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/GroupList' + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: A human-readable description of the error. + example: 'Organization not found: ''org_01EHQMYV6MBK39QC5PZXHY59C3''.' + required: + - message + tags: + - user-management.organization-membership.groups + x-feature-flag: user-groups-enabled /user_management/password_reset: post: operationId: UserlandUsersController_createPasswordResetToken @@ -12927,6 +14045,15 @@ paths: - message tags: - user-management.users + x-mutually-exclusive-body-groups: &ref_11 + password: + optional: true + variants: + plaintext: + - password + hashed: + - password_hash + - password_hash_type /user_management/users/external_id/{external_id}: get: operationId: UserlandUsersController_getByExternalId @@ -13193,6 +14320,15 @@ paths: - message tags: - user-management.users + x-mutually-exclusive-body-groups: &ref_14 + password: + optional: true + variants: + plaintext: + - password + hashed: + - password_hash + - password_hash_type get: operationId: UserlandUsersController_get summary: Get a user @@ -15171,6 +16307,8 @@ tags: description: Manage feature flags. - name: feature-flags.targets description: Manage feature flag targets. + - name: groups + description: Organize and manage user groups within organizations. - name: multi-factor-auth description: Multi-factor authentication factor management. - name: multi-factor-auth.challenges @@ -15207,6 +16345,8 @@ tags: description: Multi-factor authentication endpoints. - name: user-management.organization-membership description: Manage user organization memberships. + - name: user-management.organization-membership.groups + description: Manage groups for a user organization membership. - name: user-management.redirect-uris description: Manage redirect URIs. - name: user-management.session-tokens @@ -15383,7 +16523,7 @@ components: example: An application for managing user access scopes: description: The OAuth scopes granted to the application. - example: &ref_0 + example: &ref_1 - openid - profile - email @@ -15445,7 +16585,7 @@ components: example: An application for managing user access scopes: description: The OAuth scopes granted to the application. - example: *ref_0 + example: *ref_1 type: - array - 'null' @@ -15518,7 +16658,7 @@ components: owner: user_01GBTCQ2 maxProperties: 50 additionalProperties: false - patternProperties: &ref_1 + patternProperties: &ref_2 ^[a-zA-Z0-9_-]{0,40}$: anyOf: - type: string @@ -15550,7 +16690,7 @@ components: owner: user_01GBTCQ2 maxProperties: 50 additionalProperties: false - patternProperties: *ref_1 + patternProperties: *ref_2 required: - id - type @@ -15597,7 +16737,7 @@ components: owner: user_01GBTCQ2 maxProperties: 50 additionalProperties: false - patternProperties: *ref_1 + patternProperties: *ref_2 version: type: integer description: What schema version the event is associated with. @@ -15735,85 +16875,195 @@ components: type: object properties: transactionId: - type: string - required: - - targets - ChallengeAuthenticationFactorDto: - type: object - properties: - sms_template: - type: string - description: >- - A custom template for the SMS message. Use the {{code}} placeholder - to include the verification code. - example: Your verification code is {{code}}. - CheckAuthorizationDto: - type: object - properties: - permission_slug: - type: string - description: The slug of the permission to check. - example: posts:create - resource_id: - type: string - description: The ID of the resource. - example: resource_01HXYZ123456789ABCDEFGHIJ - resource_external_id: - type: string - description: The external ID of the resource. - example: my-custom-id - resource_type_slug: - type: string - description: The slug of the resource type. - example: document - required: - - permission_slug - AssignRoleDto: - type: object - properties: - role_slug: - type: string - description: The slug of the role to assign. - example: editor - resource_id: - type: string - description: >- - The ID of the resource. Use either this or `resource_external_id` - and `resource_type_slug`. - example: authz_resource_01HXYZ123456789ABCDEFGH - resource_external_id: - type: string - description: The external ID of the resource. Requires `resource_type_slug`. - example: project-ext-456 - resource_type_slug: - type: string - description: The resource type slug. Required with `resource_external_id`. - example: project + type: string required: - - role_slug - RemoveRoleDto: + - targets + ChallengeAuthenticationFactorDto: type: object properties: - role_slug: - type: string - description: The slug of the role to remove. - example: editor - resource_id: + sms_template: type: string description: >- - The ID of the resource. Use either this or `resource_external_id` - and `resource_type_slug`. - example: authz_resource_01HXYZ123456789ABCDEFGH - resource_external_id: - type: string - description: The external ID of the resource. Requires `resource_type_slug`. - example: external_01HXYZ123456789ABCDEFGH - resource_type_slug: - type: string - description: The resource type slug. Required with `resource_external_id`. - example: project - required: - - role_slug + A custom template for the SMS message. Use the {{code}} placeholder + to include the verification code. + example: Your verification code is {{code}}. + CheckAuthorizationDto: + allOf: + - type: object + properties: + permission_slug: + type: string + description: The slug of the permission to check. + example: posts:create + required: + - permission_slug + - oneOf: + - type: object + properties: + resource_id: + type: string + description: >- + The ID of the resource. Mutually exclusive with + `resource_external_id` and `resource_type_slug`. + example: resource_01HXYZ123456789ABCDEFGHIJ + required: + - resource_id + not: + anyOf: + - properties: + resource_external_id: + x-exclude-from-lint: true + required: + - resource_external_id + - properties: + resource_type_slug: + x-exclude-from-lint: true + required: + - resource_type_slug + - type: object + properties: + resource_external_id: + type: string + description: >- + The external ID of the resource. Required with + `resource_type_slug`. Mutually exclusive with `resource_id`. + example: my-custom-id + resource_type_slug: + type: string + description: >- + The slug of the resource type. Required with + `resource_external_id`. Mutually exclusive with + `resource_id`. + example: document + required: + - resource_external_id + - resource_type_slug + not: + anyOf: + - properties: + resource_id: + x-exclude-from-lint: true + required: + - resource_id + x-mutually-exclusive-body-groups: *ref_3 + AssignRoleDto: + allOf: + - type: object + properties: + role_slug: + type: string + description: The slug of the role to assign. + example: editor + required: + - role_slug + - oneOf: + - type: object + properties: + resource_id: + type: string + description: >- + The ID of the resource. Mutually exclusive with + `resource_external_id` and `resource_type_slug`. + example: authz_resource_01HXYZ123456789ABCDEFGH + required: + - resource_id + not: + anyOf: + - properties: + resource_external_id: + x-exclude-from-lint: true + required: + - resource_external_id + - properties: + resource_type_slug: + x-exclude-from-lint: true + required: + - resource_type_slug + - type: object + properties: + resource_external_id: + type: string + description: >- + The external ID of the resource. Required with + `resource_type_slug`. Mutually exclusive with `resource_id`. + example: project-ext-456 + resource_type_slug: + type: string + description: >- + The resource type slug. Required with + `resource_external_id`. Mutually exclusive with + `resource_id`. + example: project + required: + - resource_external_id + - resource_type_slug + not: + anyOf: + - properties: + resource_id: + x-exclude-from-lint: true + required: + - resource_id + x-mutually-exclusive-body-groups: *ref_4 + RemoveRoleDto: + allOf: + - type: object + properties: + role_slug: + type: string + description: The slug of the role to remove. + example: editor + required: + - role_slug + - oneOf: + - type: object + properties: + resource_id: + type: string + description: >- + The ID of the resource. Mutually exclusive with + `resource_external_id` and `resource_type_slug`. + example: authz_resource_01HXYZ123456789ABCDEFGH + required: + - resource_id + not: + anyOf: + - properties: + resource_external_id: + x-exclude-from-lint: true + required: + - resource_external_id + - properties: + resource_type_slug: + x-exclude-from-lint: true + required: + - resource_type_slug + - type: object + properties: + resource_external_id: + type: string + description: >- + The external ID of the resource. Required with + `resource_type_slug`. Mutually exclusive with `resource_id`. + example: external_01HXYZ123456789ABCDEFGH + resource_type_slug: + type: string + description: >- + The resource type slug. Required with + `resource_external_id`. Mutually exclusive with + `resource_id`. + example: project + required: + - resource_external_id + - resource_type_slug + not: + anyOf: + - properties: + resource_id: + x-exclude-from-lint: true + required: + - resource_id + x-mutually-exclusive-body-groups: *ref_5 SetRolePermissionsDto: type: object properties: @@ -15887,175 +17137,332 @@ components: CreateAuthorizationPermissionDto: type: object properties: - slug: - type: string - maxLength: 48 - description: >- - A unique key to reference the permission. Must be lowercase and - contain only letters, numbers, hyphens, underscores, colons, - periods, and asterisks. - example: documents:read - name: - type: string - maxLength: 48 - description: A descriptive name for the Permission. - example: View Documents - description: - type: - - string - - 'null' - maxLength: 150 - description: An optional description of the Permission. - example: Allows viewing document contents - resource_type_slug: + slug: + type: string + maxLength: 48 + description: >- + A unique key to reference the permission. Must be lowercase and + contain only letters, numbers, hyphens, underscores, colons, + periods, and asterisks. + example: documents:read + name: + type: string + maxLength: 48 + description: A descriptive name for the Permission. + example: View Documents + description: + type: + - string + - 'null' + maxLength: 150 + description: An optional description of the Permission. + example: Allows viewing document contents + resource_type_slug: + type: string + maxLength: 48 + description: The slug of the resource type this permission is scoped to. + example: document + required: + - slug + - name + UpdateAuthorizationPermissionDto: + type: object + properties: + name: + type: string + maxLength: 48 + description: A descriptive name for the Permission. + example: View Documents + description: + type: + - string + - 'null' + maxLength: 150 + description: An optional description of the Permission. + example: Allows viewing document contents + CreateRoleDto: + type: object + properties: + slug: + type: string + maxLength: 48 + description: A unique slug for the role. + example: editor + name: + type: string + maxLength: 48 + description: A descriptive name for the role. + example: Editor + description: + type: + - string + - 'null' + maxLength: 150 + description: An optional description of the role. + example: Can edit resources + resource_type_slug: + type: string + maxLength: 48 + description: The slug of the resource type the role is scoped to. + example: organization + required: + - slug + - name + UpdateRoleDto: + type: object + properties: + name: + type: string + maxLength: 48 + description: A descriptive name for the role. + example: Super Administrator + description: + type: + - string + - 'null' + maxLength: 150 + description: An optional description of the role. + example: Full administrative access to all resources + UpdateAuthorizationResourceDto: + allOf: + - type: object + properties: + name: + type: string + maxLength: 48 + description: A display name for the resource. + example: Updated Name + description: + type: + - string + - 'null' + maxLength: 150 + description: An optional description of the resource. + example: Updated description + - oneOf: + - type: object + not: + anyOf: + - properties: + parent_resource_id: + x-exclude-from-lint: true + required: + - parent_resource_id + - properties: + parent_resource_external_id: + x-exclude-from-lint: true + required: + - parent_resource_external_id + - properties: + parent_resource_type_slug: + x-exclude-from-lint: true + required: + - parent_resource_type_slug + - type: object + properties: + parent_resource_id: + type: string + description: >- + The ID of the parent resource. Mutually exclusive with + `parent_resource_external_id` and + `parent_resource_type_slug`. + example: authz_resource_01HXYZ123456789ABCDEFGHIJ + required: + - parent_resource_id + not: + anyOf: + - properties: + parent_resource_external_id: + x-exclude-from-lint: true + required: + - parent_resource_external_id + - properties: + parent_resource_type_slug: + x-exclude-from-lint: true + required: + - parent_resource_type_slug + - type: object + properties: + parent_resource_external_id: + type: string + description: >- + The external ID of the parent resource. Required with + `parent_resource_type_slug`. Mutually exclusive with + `parent_resource_id`. + example: parent-workspace-01 + parent_resource_type_slug: + type: string + description: >- + The resource type slug of the parent resource. Required with + `parent_resource_external_id`. Mutually exclusive with + `parent_resource_id`. + example: workspace + required: + - parent_resource_external_id + - parent_resource_type_slug + not: + anyOf: + - properties: + parent_resource_id: + x-exclude-from-lint: true + required: + - parent_resource_id + x-mutually-exclusive-body-groups: *ref_0 + CreateAuthorizationResourceDto: + allOf: + - type: object + properties: + external_id: + type: string + maxLength: 128 + description: An external identifier for the resource. + example: my-workspace-01 + name: + type: string + maxLength: 48 + description: A display name for the resource. + example: Acme Workspace + description: + type: + - string + - 'null' + maxLength: 150 + description: An optional description of the resource. + example: Primary workspace for the Acme team + resource_type_slug: + type: string + description: The slug of the resource type. + example: workspace + organization_id: + type: string + description: The ID of the organization this resource belongs to. + example: org_01EHQMYV6MBK39QC5PZXHY59C3 + required: + - external_id + - name + - resource_type_slug + - organization_id + - oneOf: + - type: object + not: + anyOf: + - properties: + parent_resource_id: + x-exclude-from-lint: true + required: + - parent_resource_id + - properties: + parent_resource_external_id: + x-exclude-from-lint: true + required: + - parent_resource_external_id + - properties: + parent_resource_type_slug: + x-exclude-from-lint: true + required: + - parent_resource_type_slug + - type: object + properties: + parent_resource_id: + type: + - string + - 'null' + description: >- + The ID of the parent resource. Mutually exclusive with + `parent_resource_external_id` and + `parent_resource_type_slug`. + example: authz_resource_01HXYZ123456789ABCDEFGHIJ + required: + - parent_resource_id + not: + anyOf: + - properties: + parent_resource_external_id: + x-exclude-from-lint: true + required: + - parent_resource_external_id + - properties: + parent_resource_type_slug: + x-exclude-from-lint: true + required: + - parent_resource_type_slug + - type: object + properties: + parent_resource_external_id: + type: string + description: >- + The external ID of the parent resource. Required with + `parent_resource_type_slug`. Mutually exclusive with + `parent_resource_id`. + example: parent-workspace-01 + parent_resource_type_slug: + type: string + description: >- + The resource type slug of the parent resource. Required with + `parent_resource_external_id`. Mutually exclusive with + `parent_resource_id`. + example: workspace + required: + - parent_resource_external_id + - parent_resource_type_slug + not: + anyOf: + - properties: + parent_resource_id: + x-exclude-from-lint: true + required: + - parent_resource_id + x-mutually-exclusive-body-groups: *ref_6 + CreateCorsOriginDto: + type: object + properties: + origin: type: string - maxLength: 48 - description: The slug of the resource type this permission is scoped to. - example: document + description: The origin URL to allow for CORS requests. + example: https://example.com required: - - slug - - name - UpdateAuthorizationPermissionDto: + - origin + CreateGroupMembershipDto: type: object properties: - name: + organization_membership_id: type: string - maxLength: 48 - description: A descriptive name for the Permission. - example: View Documents - description: - type: - - string - - 'null' - maxLength: 150 - description: An optional description of the Permission. - example: Allows viewing document contents - CreateRoleDto: + description: The ID of the Organization Membership to add to the group. + example: om_01HXYZ123456789ABCDEFGHIJ + required: + - organization_membership_id + CreateGroupDto: type: object properties: - slug: - type: string - maxLength: 48 - description: A unique slug for the role. - example: editor name: type: string - maxLength: 48 - description: A descriptive name for the role. - example: Editor + maxLength: 256 + description: The name of the Group. + example: Engineering description: type: - string - 'null' maxLength: 150 - description: An optional description of the role. - example: Can edit resources - resource_type_slug: - type: string - maxLength: 48 - description: The slug of the resource type the role is scoped to. - example: organization + description: An optional description of the Group. + example: The engineering team required: - - slug - name - UpdateRoleDto: - type: object - properties: - name: - type: string - maxLength: 48 - description: A descriptive name for the role. - example: Super Administrator - description: - type: - - string - - 'null' - maxLength: 150 - description: An optional description of the role. - example: Full administrative access to all resources - UpdateAuthorizationResourceDto: - type: object - properties: - name: - type: string - maxLength: 48 - description: A display name for the resource. - example: Updated Name - description: - type: - - string - - 'null' - maxLength: 150 - description: An optional description of the resource. - example: Updated description - parent_resource_id: - type: string - description: The ID of the parent resource. - example: authz_resource_01HXYZ123456789ABCDEFGHIJ - parent_resource_external_id: - type: string - description: The external ID of the parent resource. - example: parent-workspace-01 - parent_resource_type_slug: - type: string - description: The resource type slug of the parent resource. - example: workspace - CreateAuthorizationResourceDto: + UpdateGroupDto: type: object properties: - external_id: - type: string - maxLength: 128 - description: An external identifier for the resource. - example: my-workspace-01 name: type: string - maxLength: 48 - description: A display name for the resource. - example: Acme Workspace + maxLength: 256 + description: The name of the Group. + example: Engineering description: type: - string - 'null' maxLength: 150 - description: An optional description of the resource. - example: Primary workspace for the Acme team - resource_type_slug: - type: string - description: The slug of the resource type. - example: workspace - organization_id: - type: string - description: The ID of the organization this resource belongs to. - example: org_01EHQMYV6MBK39QC5PZXHY59C3 - parent_resource_id: - type: - - string - - 'null' - description: The ID of the parent resource. - example: authz_resource_01HXYZ123456789ABCDEFGHIJ - parent_resource_external_id: - type: string - description: The external ID of the parent resource. - example: parent-workspace-01 - parent_resource_type_slug: - type: string - description: The resource type slug of the parent resource. - example: workspace - required: - - external_id - - name - - resource_type_slug - - organization_id - CreateCorsOriginDto: - type: object - properties: - origin: - type: string - description: The origin URL to allow for CORS requests. - example: https://example.com - required: - - origin + description: An optional description of the Group. + example: The engineering team UpdateJwtTemplateDto: type: object properties: @@ -16146,11 +17553,11 @@ components: type: - object - 'null' - additionalProperties: &ref_2 + additionalProperties: &ref_7 type: string maxLength: 600 maxProperties: 50 - example: &ref_3 + example: &ref_8 tier: diamond description: >- Object containing [metadata](/authkit/metadata) key/value pairs @@ -16204,9 +17611,9 @@ components: type: - object - 'null' - additionalProperties: *ref_2 + additionalProperties: *ref_7 maxProperties: 50 - example: *ref_3 + example: *ref_8 description: >- Object containing [metadata](/authkit/metadata) key/value pairs associated with the Organization. @@ -16231,14 +17638,25 @@ components: description: The SSO provider type to configure. example: GoogleSAML const: GoogleSAML + DomainVerificationIntentOptions: + type: object + properties: + domain_name: + type: string + description: >- + The domain name to verify. When provided, the domain verification + flow will skip the domain entry form and go directly to the + verification step. + example: example.com IntentOptions: type: object properties: sso: description: SSO-specific options for the Admin Portal. $ref: '#/components/schemas/SsoIntentOptions' - required: - - sso + domain_verification: + description: Domain verification-specific options for the Admin Portal. + $ref: '#/components/schemas/DomainVerificationIntentOptions' GenerateLinkDto: type: object properties: @@ -16286,12 +17704,12 @@ components: intent_options: description: Options to configure the Admin Portal based on the intent. $ref: '#/components/schemas/IntentOptions' - admin_emails: + it_contact_emails: description: >- - The email addresses of the IT admins to grant access to the Admin + The email addresses of the IT contacts to grant access to the Admin Portal for the given organization. Accepts up to 20 emails. example: - - admin@example.com + - it-contact@example.com maxItems: 20 items: type: string @@ -16580,200 +17998,365 @@ components: [supported locales](/authkit/hosted-ui/localization). example: en CreateUserlandUserOrganizationMembershipDto: - type: object - properties: - user_id: - type: string - description: The ID of the [user](/reference/authkit/user). - example: user_01E4ZCR3C5A4QZ2Z2JQXGKZJ9E - organization_id: - type: string - description: >- - The ID of the [organization](/reference/organization) which the user - belongs to. - example: org_01E4ZCR3C56J083X43JQXF3JK5 - role_slug: - type: string - description: >- - A single role identifier. Defaults to `member` or the explicit - default role. Mutually exclusive with `role_slugs`. - example: admin - x-mutually-exclusive-with: - - role_slugs - role_slugs: - description: >- - An array of role identifiers. Limited to one role when Multiple - Roles is disabled. Mutually exclusive with `role_slug`. - example: - - admin - x-mutually-exclusive-with: - - role_slug - type: array - items: - type: string - required: - - user_id - - organization_id - UpdateUserlandUserOrganizationMembershipDto: - type: object - properties: - role_slug: - type: string - description: >- - A single role identifier. Defaults to `member` or the explicit - default role. Mutually exclusive with `role_slugs`. - example: admin - x-mutually-exclusive-with: - - role_slugs - role_slugs: - description: >- - An array of role identifiers. Limited to one role when Multiple - Roles is disabled. Mutually exclusive with `role_slug`. - example: - - admin - x-mutually-exclusive-with: - - role_slug - type: array - items: - type: string - CreateUserlandUserDto: - type: object - properties: - email: - type: string - description: The email address of the user. - example: marcelina.davis@example.com - password: - type: - - string - - 'null' - description: >- - The password to set for the user. Mutually exclusive with - `password_hash` and `password_hash_type`. - example: strong_password_123! - x-mutually-exclusive-with: - - password_hash - - password_hash_type - password_hash: - type: string - description: >- - The hashed password to set for the user. Mutually exclusive with - `password`. - example: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy - x-mutually-exclusive-with: - - password - password_hash_type: - type: string - enum: &ref_4 - - bcrypt - - firebase-scrypt - - ssha - - scrypt - - pbkdf2 - - argon2 - description: >- - The algorithm originally used to hash the password, used when - providing a `password_hash`. - example: bcrypt - first_name: - type: - - string - - 'null' - description: The first name of the user. - example: Marcelina - last_name: - type: - - string - - 'null' - description: The last name of the user. - example: Davis - email_verified: - type: - - boolean - - 'null' - description: Whether the user's email has been verified. - example: true - metadata: - type: - - object - - 'null' - additionalProperties: *ref_2 - maxProperties: 50 - example: &ref_5 - timezone: America/New_York - description: Object containing metadata key/value pairs associated with the user. - propertyNames: - maxLength: 40 - external_id: - type: - - string - - 'null' - maxLength: 128 - description: The external ID of the user. - example: f1ffa2b2-c20b-4d39-be5c-212726e11222 - required: - - email + allOf: + - type: object + properties: + user_id: + type: string + description: The ID of the [user](/reference/authkit/user). + example: user_01E4ZCR3C5A4QZ2Z2JQXGKZJ9E + organization_id: + type: string + description: >- + The ID of the [organization](/reference/organization) which the + user belongs to. + example: org_01E4ZCR3C56J083X43JQXF3JK5 + required: + - user_id + - organization_id + - oneOf: + - type: object + not: + anyOf: + - properties: + role_slug: + x-exclude-from-lint: true + required: + - role_slug + - properties: + role_slugs: + x-exclude-from-lint: true + required: + - role_slugs + - type: object + properties: + role_slug: + type: string + description: >- + A single role identifier. Defaults to `member` or the + explicit default role. Mutually exclusive with `role_slugs`. + example: admin + required: + - role_slug + not: + anyOf: + - properties: + role_slugs: + x-exclude-from-lint: true + required: + - role_slugs + - type: object + properties: + role_slugs: + description: >- + An array of role identifiers. Limited to one role when + Multiple Roles is disabled. Mutually exclusive with + `role_slug`. + example: + - admin + type: array + items: + type: string + required: + - role_slugs + not: + anyOf: + - properties: + role_slug: + x-exclude-from-lint: true + required: + - role_slug + x-mutually-exclusive-body-groups: *ref_9 + UpdateUserlandUserOrganizationMembershipDto: + x-mutually-exclusive-body-groups: *ref_10 + oneOf: + - type: object + not: + anyOf: + - properties: + role_slug: + x-exclude-from-lint: true + required: + - role_slug + - properties: + role_slugs: + x-exclude-from-lint: true + required: + - role_slugs + - type: object + properties: + role_slug: + type: string + description: >- + A single role identifier. Defaults to `member` or the explicit + default role. Mutually exclusive with `role_slugs`. + example: admin + required: + - role_slug + not: + anyOf: + - properties: + role_slugs: + x-exclude-from-lint: true + required: + - role_slugs + - type: object + properties: + role_slugs: + description: >- + An array of role identifiers. Limited to one role when Multiple + Roles is disabled. Mutually exclusive with `role_slug`. + example: + - admin + type: array + items: + type: string + required: + - role_slugs + not: + anyOf: + - properties: + role_slug: + x-exclude-from-lint: true + required: + - role_slug + CreateUserlandUserDto: + allOf: + - type: object + properties: + email: + type: string + description: The email address of the user. + example: marcelina.davis@example.com + first_name: + type: + - string + - 'null' + description: The first name of the user. + example: Marcelina + last_name: + type: + - string + - 'null' + description: The last name of the user. + example: Davis + email_verified: + type: + - boolean + - 'null' + description: Whether the user's email has been verified. + example: true + metadata: + type: + - object + - 'null' + additionalProperties: *ref_7 + maxProperties: 50 + example: &ref_12 + timezone: America/New_York + description: >- + Object containing metadata key/value pairs associated with the + user. + propertyNames: + maxLength: 40 + external_id: + type: + - string + - 'null' + maxLength: 128 + description: The external ID of the user. + example: f1ffa2b2-c20b-4d39-be5c-212726e11222 + required: + - email + - oneOf: + - type: object + not: + anyOf: + - properties: + password: + x-exclude-from-lint: true + required: + - password + - properties: + password_hash: + x-exclude-from-lint: true + required: + - password_hash + - properties: + password_hash_type: + x-exclude-from-lint: true + required: + - password_hash_type + - type: object + properties: + password: + type: + - string + - 'null' + description: >- + The password to set for the user. Mutually exclusive with + `password_hash` and `password_hash_type`. + example: strong_password_123! + required: + - password + not: + anyOf: + - properties: + password_hash: + x-exclude-from-lint: true + required: + - password_hash + - properties: + password_hash_type: + x-exclude-from-lint: true + required: + - password_hash_type + - type: object + properties: + password_hash: + type: string + description: >- + The hashed password to set for the user. Required with + `password_hash_type`. Mutually exclusive with `password`. + example: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy + password_hash_type: + type: string + enum: &ref_13 + - bcrypt + - firebase-scrypt + - ssha + - scrypt + - pbkdf2 + - argon2 + description: >- + The algorithm originally used to hash the password, used + when providing a `password_hash`. Required with + `password_hash`. Mutually exclusive with `password`. + example: bcrypt + required: + - password_hash + - password_hash_type + not: + anyOf: + - properties: + password: + x-exclude-from-lint: true + required: + - password + x-mutually-exclusive-body-groups: *ref_11 UpdateUserlandUserDto: - type: object - properties: - email: - type: string - description: The email address of the user. - example: marcelina.davis@example.com - first_name: - type: string - description: The first name of the user. - example: Marcelina - last_name: - type: string - description: The last name of the user. - example: Davis - email_verified: - type: boolean - description: Whether the user's email has been verified. - example: true - password: - type: string - description: The password to set for the user. - example: strong_password_123! - password_hash: - type: string - description: >- - The hashed password to set for the user. Mutually exclusive with - `password`. - example: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy - x-mutually-exclusive-with: - - password - password_hash_type: - type: string - enum: *ref_4 - description: >- - The algorithm originally used to hash the password, used when - providing a `password_hash`. - example: bcrypt - metadata: - type: - - object - - 'null' - additionalProperties: *ref_2 - maxProperties: 50 - example: *ref_5 - description: Object containing metadata key/value pairs associated with the user. - propertyNames: - maxLength: 40 - external_id: - type: - - string - - 'null' - maxLength: 128 - description: The external ID of the user. - example: f1ffa2b2-c20b-4d39-be5c-212726e11222 - locale: - type: - - string - - 'null' - description: The user's preferred locale. - example: en-US + allOf: + - type: object + properties: + email: + type: string + description: The email address of the user. + example: marcelina.davis@example.com + first_name: + type: string + description: The first name of the user. + example: Marcelina + last_name: + type: string + description: The last name of the user. + example: Davis + email_verified: + type: boolean + description: Whether the user's email has been verified. + example: true + metadata: + type: + - object + - 'null' + additionalProperties: *ref_7 + maxProperties: 50 + example: *ref_12 + description: >- + Object containing metadata key/value pairs associated with the + user. + propertyNames: + maxLength: 40 + external_id: + type: + - string + - 'null' + maxLength: 128 + description: The external ID of the user. + example: f1ffa2b2-c20b-4d39-be5c-212726e11222 + locale: + type: + - string + - 'null' + description: The user's preferred locale. + example: en-US + - oneOf: + - type: object + not: + anyOf: + - properties: + password: + x-exclude-from-lint: true + required: + - password + - properties: + password_hash: + x-exclude-from-lint: true + required: + - password_hash + - properties: + password_hash_type: + x-exclude-from-lint: true + required: + - password_hash_type + - type: object + properties: + password: + type: string + description: >- + The password to set for the user. Mutually exclusive with + `password_hash` and `password_hash_type`. + example: strong_password_123! + required: + - password + not: + anyOf: + - properties: + password_hash: + x-exclude-from-lint: true + required: + - password_hash + - properties: + password_hash_type: + x-exclude-from-lint: true + required: + - password_hash_type + - type: object + properties: + password_hash: + type: string + description: >- + The hashed password to set for the user. Required with + `password_hash_type`. Mutually exclusive with `password`. + example: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy + password_hash_type: + type: string + enum: *ref_13 + description: >- + The algorithm originally used to hash the password, used + when providing a `password_hash`. Required with + `password_hash`. Mutually exclusive with `password`. + example: bcrypt + required: + - password_hash + - password_hash_type + not: + anyOf: + - properties: + password: + x-exclude-from-lint: true + required: + - password + x-mutually-exclusive-body-groups: *ref_14 VerifyEmailAddressDto: type: object properties: @@ -16851,7 +18434,7 @@ components: description: The events that the Webhook Endpoint is subscribed to. items: type: string - enum: &ref_6 + enum: &ref_15 - authentication.email_verification_succeeded - authentication.magic_auth_failed - authentication.magic_auth_succeeded @@ -16885,6 +18468,11 @@ components: - dsync.user.deleted - dsync.user.updated - email_verification.created + - group.created + - group.deleted + - group.member_added + - group.member_removed + - group.updated - flag.created - flag.deleted - flag.updated @@ -16921,6 +18509,9 @@ components: - permission.updated - session.created - session.revoked + - waitlist_user.approved + - waitlist_user.created + - waitlist_user.denied example: - user.created - dsync.user.created @@ -16946,7 +18537,7 @@ components: description: The events that the Webhook Endpoint is subscribed to. items: type: string - enum: *ref_6 + enum: *ref_15 example: - user.created - dsync.user.created @@ -17316,6 +18907,12 @@ components: - openid - profile - email + oauth_resource: + type: string + description: >- + The OAuth resource associated with the authorized connect + application, if one was requested. + example: https://api.example.com/resource application: $ref: '#/components/schemas/ConnectApplication' required: @@ -17813,26 +19410,124 @@ components: example: Company website redesign project organization_id: type: string - description: The ID of the organization that owns the resource. - example: org_01EHZNVPK3SFK441A1RGBFSHRT - parent_resource_id: + description: The ID of the organization that owns the resource. + example: org_01EHZNVPK3SFK441A1RGBFSHRT + parent_resource_id: + type: + - string + - 'null' + description: The ID of the parent resource, if this resource is nested. + example: authz_resource_01HXYZ123456789ABCDEFGHIJ + id: + type: string + description: The unique ID of the Resource. + example: authz_resource_01HXYZ123456789ABCDEFGH + external_id: + type: string + description: An identifier you provide to reference the resource in your system. + example: proj-456 + resource_type_slug: + type: string + description: The slug of the resource type this resource belongs to. + example: project + created_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + updated_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + required: + - object + - name + - description + - organization_id + - parent_resource_id + - id + - external_id + - resource_type_slug + - created_at + - updated_at + AuthorizationResourceList: + type: object + properties: + object: + type: string + description: Indicates this is a list response. + const: list + data: + type: array + items: + $ref: '#/components/schemas/AuthorizationResource' + description: The list of records for the current page. + list_metadata: + type: object + properties: + before: + type: + - string + - 'null' + description: >- + An object ID that defines your place in the list. When the ID is + not present, you are at the start of the list. + example: authz_resource_01HXYZ123456789ABCDEFGHIJ + after: + type: + - string + - 'null' + description: >- + An object ID that defines your place in the list. When the ID is + not present, you are at the end of the list. + example: authz_resource_01HXYZ987654321KJIHGFEDCBA + required: + - before + - after + description: Pagination cursors for navigating between pages of results. + required: + - object + - data + - list_metadata + AuthorizationPermission: + type: object + properties: + object: + type: string + description: Distinguishes the Permission object. + const: permission + id: + type: string + description: Unique identifier of the Permission. + example: perm_01HXYZ123456789ABCDEFGHIJ + slug: + type: string + description: >- + A unique key to reference the permission. Must be lowercase and + contain only letters, numbers, hyphens, underscores, colons, + periods, and asterisks. + example: documents:read + name: + type: string + description: A descriptive name for the Permission. + example: View Documents + description: type: - string - 'null' - description: The ID of the parent resource, if this resource is nested. - example: authz_resource_01HXYZ123456789ABCDEFGHIJ - id: - type: string - description: The unique ID of the Resource. - example: authz_resource_01HXYZ123456789ABCDEFGH - external_id: - type: string - description: An identifier you provide to reference the resource in your system. - example: proj-456 + description: An optional description of the Permission. + example: Allows viewing document contents + system: + type: boolean + description: >- + Whether the permission is a system permission. System permissions + are managed by WorkOS and cannot be deleted. + example: false resource_type_slug: type: string - description: The slug of the resource type this resource belongs to. - example: project + description: The slug of the resource type associated with the permission. + example: workspace created_at: format: date-time type: string @@ -17845,16 +19540,15 @@ components: example: '2026-01-15T12:00:00.000Z' required: - object + - id + - slug - name - description - - organization_id - - parent_resource_id - - id - - external_id + - system - resource_type_slug - created_at - updated_at - AuthorizationResourceList: + AuthorizationPermissionList: type: object properties: object: @@ -17864,7 +19558,7 @@ components: data: type: array items: - $ref: '#/components/schemas/AuthorizationResource' + $ref: '#/components/schemas/AuthorizationPermission' description: The list of records for the current page. list_metadata: type: object @@ -17876,7 +19570,7 @@ components: description: >- An object ID that defines your place in the list. When the ID is not present, you are at the start of the list. - example: authz_resource_01HXYZ123456789ABCDEFGHIJ + example: perm_01HXYZ123456789ABCDEFGHIJ after: type: - string @@ -17884,7 +19578,7 @@ components: description: >- An object ID that defines your place in the list. When the ID is not present, you are at the end of the list. - example: authz_resource_01HXYZ987654321KJIHGFEDCBA + example: perm_01HXYZ987654321KJIHGFEDCBA required: - before - after @@ -18026,7 +19720,9 @@ components: enum: - EnvironmentRole - OrganizationRole - description: Whether the role is scoped to the environment or an organization. + description: >- + Whether the role is scoped to the environment or an organization + (custom role). example: EnvironmentRole resource_type_slug: type: string @@ -18076,103 +19772,6 @@ components: required: - object - data - AuthorizationPermission: - type: object - properties: - object: - type: string - description: Distinguishes the Permission object. - const: permission - id: - type: string - description: Unique identifier of the Permission. - example: perm_01HXYZ123456789ABCDEFGHIJ - slug: - type: string - description: >- - A unique key to reference the permission. Must be lowercase and - contain only letters, numbers, hyphens, underscores, colons, - periods, and asterisks. - example: documents:read - name: - type: string - description: A descriptive name for the Permission. - example: View Documents - description: - type: - - string - - 'null' - description: An optional description of the Permission. - example: Allows viewing document contents - system: - type: boolean - description: >- - Whether the permission is a system permission. System permissions - are managed by WorkOS and cannot be deleted. - example: false - resource_type_slug: - type: string - description: The slug of the resource type associated with the permission. - example: workspace - created_at: - format: date-time - type: string - description: An ISO 8601 timestamp. - example: '2026-01-15T12:00:00.000Z' - updated_at: - format: date-time - type: string - description: An ISO 8601 timestamp. - example: '2026-01-15T12:00:00.000Z' - required: - - object - - id - - slug - - name - - description - - system - - resource_type_slug - - created_at - - updated_at - AuthorizationPermissionList: - type: object - properties: - object: - type: string - description: Indicates this is a list response. - const: list - data: - type: array - items: - $ref: '#/components/schemas/AuthorizationPermission' - description: The list of records for the current page. - list_metadata: - type: object - properties: - before: - type: - - string - - 'null' - description: >- - An object ID that defines your place in the list. When the ID is - not present, you are at the start of the list. - example: perm_01HXYZ123456789ABCDEFGHIJ - after: - type: - - string - - 'null' - description: >- - An object ID that defines your place in the list. When the ID is - not present, you are at the end of the list. - example: perm_01HXYZ987654321KJIHGFEDCBA - required: - - before - - after - description: Pagination cursors for navigating between pages of results. - required: - - object - - data - - list_metadata UserlandUserOrganizationMembershipBaseList: type: object properties: @@ -18876,7 +20475,10 @@ components: type: array items: $ref: '#/components/schemas/DirectoryGroup' - description: The directory groups the user belongs to. + description: >- + The directory groups the user belongs to. Use the List Directory + Groups endpoint with a user filter instead. + deprecated: true required: - object - id @@ -18930,6 +20532,89 @@ components: - data - list_metadata - list_metadata + Group: + type: object + properties: + object: + type: string + description: The Group object. + example: group + const: group + id: + type: string + description: The unique ID of the Group. + example: group_01HXYZ123456789ABCDEFGHIJ + organization_id: + type: string + description: The ID of the Organization the Group belongs to. + example: org_01EHWNCE74X7JSDV0X3SZ3KJNY + name: + type: string + description: The name of the Group. + example: Engineering + description: + type: + - string + - 'null' + description: An optional description of the Group. + example: The engineering team + created_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + updated_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + required: + - object + - id + - organization_id + - name + - description + - created_at + - updated_at + GroupList: + type: object + properties: + object: + type: string + description: Indicates this is a list response. + const: list + data: + type: array + items: + $ref: '#/components/schemas/Group' + description: The list of records for the current page. + list_metadata: + type: object + properties: + before: + type: + - string + - 'null' + description: >- + An object ID that defines your place in the list. When the ID is + not present, you are at the start of the list. + example: group_01HXYZ123456789ABCDEFGHIJ + after: + type: + - string + - 'null' + description: >- + An object ID that defines your place in the list. When the ID is + not present, you are at the end of the list. + example: group_01HXYZ987654321KJIHGFEDCBA + required: + - before + - after + description: Pagination cursors for navigating between pages of results. + required: + - object + - data + - list_metadata EventContextActorDto: type: object properties: @@ -19094,7 +20779,7 @@ components: description: >- An object containing the custom attribute mapping for the Directory Provider. - example: &ref_9 + example: &ref_18 department: Engineering job_title: Software Engineer role: @@ -19215,7 +20900,57 @@ components: - last_sign_in_at - created_at - updated_at - description: The user object. + description: The user object. + WaitlistUser: + type: object + properties: + object: + type: string + description: Distinguishes the Waitlist User object. + const: waitlist_user + id: + type: string + description: The unique ID of the Waitlist User. + example: wl_user_01E4ZCR3C56J083X43JQXF3JK5 + email: + type: string + description: The email address of the Waitlist User. + example: marcelina.davis@example.com + state: + type: string + enum: + - pending + - approved + - denied + description: The state of the Waitlist User. + example: pending + approved_at: + format: date-time + type: + - string + - 'null' + description: >- + The timestamp when the Waitlist User was approved, or null if not + yet approved. + example: null + created_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + updated_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + required: + - object + - id + - email + - state + - approved_at + - created_at + - updated_at EventSchema: allOf: - type: object @@ -19236,7 +20971,7 @@ components: type: object additionalProperties: {} description: The event payload. - example: &ref_7 + example: &ref_16 id: directory_user_01E1JG7J09H96KYP8HM9B0G5SJ directory_id: directory_01ECAZ4NV9QMV47GW873HDCX74 organization_id: org_01EZTR6WYX1A0DSE2CYMGXQ24Y @@ -19271,11 +21006,11 @@ components: - data - created_at description: An event emitted by WorkOS. - example: &ref_14 + example: &ref_23 object: event id: event_01EHZNVPK3SFK441A1RGBFSHRT event: dsync.user.created - data: *ref_7 + data: *ref_16 created_at: '2021-06-25T19:07:33.155Z' context: {} - oneOf: @@ -19492,7 +21227,7 @@ components: items: type: string description: The permissions granted to the API key. - example: &ref_8 + example: &ref_17 - users:read - users:write created_at: @@ -19585,7 +21320,7 @@ components: items: type: string description: The permissions granted to the API key. - example: *ref_8 + example: *ref_17 created_at: type: string description: The timestamp when the API key was created. @@ -22363,7 +24098,7 @@ components: description: >- An object containing the custom attribute mapping for the Directory Provider. - example: *ref_9 + example: *ref_18 role: $ref: '#/components/schemas/SlimRole' roles: @@ -22553,7 +24288,7 @@ components: description: >- Labels assigned to the Feature Flag for categorizing and filtering. - example: &ref_10 + example: &ref_19 - reports enabled: type: boolean @@ -22713,7 +24448,7 @@ components: description: >- Labels assigned to the Feature Flag for categorizing and filtering. - example: *ref_10 + example: *ref_19 enabled: type: boolean description: >- @@ -22872,7 +24607,7 @@ components: description: >- Labels assigned to the Feature Flag for categorizing and filtering. - example: *ref_10 + example: *ref_19 enabled: type: boolean description: >- @@ -23151,7 +24886,7 @@ components: description: >- Labels assigned to the Feature Flag for categorizing and filtering. - example: *ref_10 + example: *ref_19 enabled: type: boolean description: >- @@ -23274,6 +25009,175 @@ components: - created_at - context - object + - type: object + properties: + id: + type: string + description: Unique identifier for the event. + example: event_01EHZNVPK3SFK441A1RGBFSHRT + event: + type: string + const: group.created + data: + $ref: '#/components/schemas/Group' + description: The event payload. + created_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + context: + $ref: '#/components/schemas/EventContextDto' + object: + type: string + description: Distinguishes the Event object. + const: event + required: + - id + - event + - data + - created_at + - object + - type: object + properties: + id: + type: string + description: Unique identifier for the event. + example: event_01EHZNVPK3SFK441A1RGBFSHRT + event: + type: string + const: group.deleted + data: + $ref: '#/components/schemas/Group' + description: The event payload. + created_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + context: + $ref: '#/components/schemas/EventContextDto' + object: + type: string + description: Distinguishes the Event object. + const: event + required: + - id + - event + - data + - created_at + - object + - type: object + properties: + id: + type: string + description: Unique identifier for the event. + example: event_01EHZNVPK3SFK441A1RGBFSHRT + event: + type: string + const: group.member_added + data: + type: object + properties: + group_id: + type: string + description: The ID of the Group. + example: group_01HXYZ123456789ABCDEFGHIJ + organization_membership_id: + type: string + description: The ID of the OrganizationMembership. + example: om_01EHWNCE74X7JSDV0X3SZ3KJNY + required: + - group_id + - organization_membership_id + description: The event payload. + created_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + context: + $ref: '#/components/schemas/EventContextDto' + object: + type: string + description: Distinguishes the Event object. + const: event + required: + - id + - event + - data + - created_at + - object + - type: object + properties: + id: + type: string + description: Unique identifier for the event. + example: event_01EHZNVPK3SFK441A1RGBFSHRT + event: + type: string + const: group.member_removed + data: + type: object + properties: + group_id: + type: string + description: The ID of the Group. + example: group_01HXYZ123456789ABCDEFGHIJ + organization_membership_id: + type: string + description: The ID of the OrganizationMembership. + example: om_01EHWNCE74X7JSDV0X3SZ3KJNY + required: + - group_id + - organization_membership_id + description: The event payload. + created_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + context: + $ref: '#/components/schemas/EventContextDto' + object: + type: string + description: Distinguishes the Event object. + const: event + required: + - id + - event + - data + - created_at + - object + - type: object + properties: + id: + type: string + description: Unique identifier for the event. + example: event_01EHZNVPK3SFK441A1RGBFSHRT + event: + type: string + const: group.updated + data: + $ref: '#/components/schemas/Group' + description: The event payload. + created_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + context: + $ref: '#/components/schemas/EventContextDto' + object: + type: string + description: Distinguishes the Event object. + const: event + required: + - id + - event + - data + - created_at + - object - type: object properties: id: @@ -23354,6 +25258,16 @@ components: The ID of the user who accepted the invitation, once accepted. example: null + role_slug: + type: + - string + - 'null' + description: >- + Slug of the role the invitee will be assigned on + acceptance. Reflects the current role on the invitee's + organization membership. null when the invitation has no + associated organization. + example: admin created_at: format: date-time type: string @@ -23375,6 +25289,7 @@ components: - organization_id - inviter_user_id - accepted_user_id + - role_slug - created_at - updated_at description: The event payload. @@ -23475,6 +25390,16 @@ components: The ID of the user who accepted the invitation, once accepted. example: null + role_slug: + type: + - string + - 'null' + description: >- + Slug of the role the invitee will be assigned on + acceptance. Reflects the current role on the invitee's + organization membership. null when the invitation has no + associated organization. + example: admin created_at: format: date-time type: string @@ -23496,6 +25421,7 @@ components: - organization_id - inviter_user_id - accepted_user_id + - role_slug - created_at - updated_at description: The event payload. @@ -23596,6 +25522,16 @@ components: The ID of the user who accepted the invitation, once accepted. example: null + role_slug: + type: + - string + - 'null' + description: >- + Slug of the role the invitee will be assigned on + acceptance. Reflects the current role on the invitee's + organization membership. null when the invitation has no + associated organization. + example: admin created_at: format: date-time type: string @@ -23617,6 +25553,7 @@ components: - organization_id - inviter_user_id - accepted_user_id + - role_slug - created_at - updated_at description: The event payload. @@ -23717,6 +25654,16 @@ components: The ID of the user who accepted the invitation, once accepted. example: null + role_slug: + type: + - string + - 'null' + description: >- + Slug of the role the invitee will be assigned on + acceptance. Reflects the current role on the invitee's + organization membership. null when the invitation has no + associated organization. + example: admin created_at: format: date-time type: string @@ -23738,6 +25685,7 @@ components: - organization_id - inviter_user_id - accepted_user_id + - role_slug - created_at - updated_at description: The event payload. @@ -23925,7 +25873,7 @@ components: description: >- Object containing [metadata](/authkit/metadata) key/value pairs associated with the Organization. - example: &ref_11 + example: &ref_20 tier: diamond propertyNames: maxLength: 40 @@ -24075,7 +26023,7 @@ components: description: >- Object containing [metadata](/authkit/metadata) key/value pairs associated with the Organization. - example: *ref_11 + example: *ref_20 propertyNames: maxLength: 40 maxProperties: 50 @@ -24902,7 +26850,7 @@ components: items: type: string description: The permissions granted by the role. - example: &ref_12 + example: &ref_21 - users:read - users:write created_at: @@ -24986,7 +26934,7 @@ components: items: type: string description: The permissions granted by the role. - example: *ref_12 + example: *ref_21 created_at: format: date-time type: string @@ -25068,7 +27016,7 @@ components: items: type: string description: The permissions granted by the role. - example: *ref_12 + example: *ref_21 created_at: format: date-time type: string @@ -25205,7 +27153,7 @@ components: description: >- Object containing [metadata](/authkit/metadata) key/value pairs associated with the Organization. - example: *ref_11 + example: *ref_20 propertyNames: maxLength: 40 maxProperties: 50 @@ -25636,7 +27584,7 @@ components: items: type: string description: The permissions granted by the role. - example: &ref_13 + example: &ref_22 - users:read - users:write created_at: @@ -25702,7 +27650,7 @@ components: items: type: string description: The permissions granted by the role. - example: *ref_13 + example: *ref_22 created_at: format: date-time type: string @@ -25766,7 +27714,7 @@ components: items: type: string description: The permissions granted by the role. - example: *ref_13 + example: *ref_22 created_at: format: date-time type: string @@ -26729,7 +28677,94 @@ components: - data - created_at - object - example: *ref_14 + - type: object + properties: + id: + type: string + description: Unique identifier for the event. + example: event_01EHZNVPK3SFK441A1RGBFSHRT + event: + type: string + const: waitlist_user.approved + data: + $ref: '#/components/schemas/WaitlistUser' + description: The event payload. + created_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + context: + $ref: '#/components/schemas/EventContextDto' + object: + type: string + description: Distinguishes the Event object. + const: event + required: + - id + - event + - data + - created_at + - object + - type: object + properties: + id: + type: string + description: Unique identifier for the event. + example: event_01EHZNVPK3SFK441A1RGBFSHRT + event: + type: string + const: waitlist_user.created + data: + $ref: '#/components/schemas/WaitlistUser' + description: The event payload. + created_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + context: + $ref: '#/components/schemas/EventContextDto' + object: + type: string + description: Distinguishes the Event object. + const: event + required: + - id + - event + - data + - created_at + - object + - type: object + properties: + id: + type: string + description: Unique identifier for the event. + example: event_01EHZNVPK3SFK441A1RGBFSHRT + event: + type: string + const: waitlist_user.denied + data: + $ref: '#/components/schemas/WaitlistUser' + description: The event payload. + created_at: + format: date-time + type: string + description: An ISO 8601 timestamp. + example: '2026-01-15T12:00:00.000Z' + context: + $ref: '#/components/schemas/EventContextDto' + object: + type: string + description: Distinguishes the Event object. + const: event + required: + - id + - event + - data + - created_at + - object + example: *ref_23 description: An event emitted by WorkOS. EventList: type: object @@ -26764,7 +28799,7 @@ components: example: object: list data: - - *ref_14 + - *ref_23 list_metadata: after: event_01EHZNVPK3SFK441A1RGBFSHRT JwtTemplateResponse: @@ -27996,6 +30031,15 @@ components: - 'null' description: The ID of the user who accepted the invitation, once accepted. example: null + role_slug: + type: + - string + - 'null' + description: >- + Slug of the role the invitee will be assigned on acceptance. + Reflects the current role on the invitee's organization membership. + null when the invitation has no associated organization. + example: admin created_at: format: date-time type: string @@ -28026,6 +30070,7 @@ components: - organization_id - inviter_user_id - accepted_user_id + - role_slug - created_at - updated_at - token From 7fdd4854df1107ed69fd78ae9c42cec4bf85326f Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 24 Apr 2026 18:09:31 -0400 Subject: [PATCH 02/38] fix: Pass --sdk-path instead of --sdk-root to oagen compat-extract The oagen CLI expects --sdk-path but the script was passing --sdk-root, causing every validate-sdks job to fail with "required option '--sdk-path' not specified". --- scripts/sdk-compat-extract.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdk-compat-extract.sh b/scripts/sdk-compat-extract.sh index ccc20dd..04fd809 100755 --- a/scripts/sdk-compat-extract.sh +++ b/scripts/sdk-compat-extract.sh @@ -28,4 +28,4 @@ if [[ -z "$OUTPUT" ]]; then OUTPUT=".oagen/${LANG}" fi -exec npx oagen compat-extract --language "$LANG" --sdk-root "$SDK_ROOT" --output "$OUTPUT" --spec "$SPEC" +exec npx oagen compat-extract --language "$LANG" --sdk-path "$SDK_ROOT" --output "$OUTPUT" --spec "$SPEC" From f9179ba3a88d570f5eb210fbc35ebcc1d4b19947 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 24 Apr 2026 18:17:13 -0400 Subject: [PATCH 03/38] fix: Use --lang instead of --language for oagen compat-extract oagen expects --lang but the script was passing --language, causing "required option '--lang' not specified". --- scripts/sdk-compat-extract.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdk-compat-extract.sh b/scripts/sdk-compat-extract.sh index 04fd809..7b0b02d 100755 --- a/scripts/sdk-compat-extract.sh +++ b/scripts/sdk-compat-extract.sh @@ -28,4 +28,4 @@ if [[ -z "$OUTPUT" ]]; then OUTPUT=".oagen/${LANG}" fi -exec npx oagen compat-extract --language "$LANG" --sdk-path "$SDK_ROOT" --output "$OUTPUT" --spec "$SPEC" +exec npx oagen compat-extract --lang "$LANG" --sdk-path "$SDK_ROOT" --output "$OUTPUT" --spec "$SPEC" From 629de865b990d72aca2858acd7b6276e7ab8bc28 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 24 Apr 2026 18:20:48 -0400 Subject: [PATCH 04/38] fix: Create output directory before running compat-extract oagen compat-extract fails with ENOENT when the output directory doesn't exist yet. --- scripts/sdk-compat-extract.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/sdk-compat-extract.sh b/scripts/sdk-compat-extract.sh index 7b0b02d..93fbb17 100755 --- a/scripts/sdk-compat-extract.sh +++ b/scripts/sdk-compat-extract.sh @@ -28,4 +28,5 @@ if [[ -z "$OUTPUT" ]]; then OUTPUT=".oagen/${LANG}" fi +mkdir -p "$OUTPUT" exec npx oagen compat-extract --lang "$LANG" --sdk-path "$SDK_ROOT" --output "$OUTPUT" --spec "$SPEC" From b1d415fd20a04d83b6b5a561836248471f30224a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 24 Apr 2026 18:26:56 -0400 Subject: [PATCH 05/38] fix(ci): Allow compat-diff to report without failing the job Add continue-on-error so breaking changes are surfaced in the PR comment instead of aborting the job before artifacts upload. --- .github/workflows/validate-sdks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index a0c794c..6093428 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -88,6 +88,7 @@ jobs: - name: Compat diff id: compat-diff + continue-on-error: true working-directory: openapi-spec run: npm run sdk:compat-diff -- --lang ${{ matrix.language }} From 496f85e38ef7f98e8564b59999944b75db37c16b Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 24 Apr 2026 19:15:17 -0400 Subject: [PATCH 06/38] chore: update dep for manifest patch fix --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 04b3dd4..80cb70f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "workos-openapi-spec", "version": "0.0.1", "dependencies": { - "@workos/oagen": "^0.9.0", + "@workos/oagen": "^0.9.1", "@workos/oagen-emitters": "^0.6.0", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" @@ -518,9 +518,9 @@ } }, "node_modules/@workos/oagen": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.9.0.tgz", - "integrity": "sha512-U+NGQChtG5nVr2uddELvnXs3cWqFbBFwmtMiZCA0eoiNnwABGHVT/kn8hCcXWae+Dy0wDj/6nPAbcN/aGVFx8g==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.9.1.tgz", + "integrity": "sha512-EZlgwB9p4AB5G66D04hb+63yO4K+jItMm6ua9C9JSPSz6FJ/AC/aQD//UQbwEIeRPQuxpDAbw98WSsY28RqJCw==", "license": "MIT", "dependencies": { "@redocly/openapi-core": "^2.25.1", diff --git a/package.json b/package.json index 87cf272..2121e26 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "prepare": "husky" }, "dependencies": { - "@workos/oagen": "^0.9.0", + "@workos/oagen": "^0.9.1", "@workos/oagen-emitters": "^0.6.0", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" From 03f71af3408f15981f694e1a549c26cb7521a6cf Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 24 Apr 2026 19:33:26 -0400 Subject: [PATCH 07/38] fix: Collapse cross-language symbols in compat PR comment Normalize conceptualChangeId by stripping underscores and lowercasing so the same spec entity (e.g. admin_emails vs AdminEmails) merges into a single row across languages. --- scripts/sdk-compat-pr-comment.mjs | 40 +++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index f9176f0..27fecc3 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -111,6 +111,41 @@ function pickSymbolMeta(change, baselineIndex, candidateIndex) { }; } +/** + * Normalize a conceptualChangeId so that the same underlying spec entity + * collapses into a single row regardless of language-specific naming. + * + * conceptualChangeId has the shape: + * chg__ + * + * Language SDKs may render the symbol differently: + * - dotnet PascalCase: AdminPortalGenerateLinkOptions.AdminEmails + * - go snake_case: AdminPortalGenerateLinkParams.admin_emails + * - ruby snake_case: admin_portal_generate_link_params.admin_emails + * + * The IDs derived from these look like: + * chg_symbol_added_adminportalgeneratelinkoptions.adminemails + * chg_symbol_added_adminportalgeneratelinkparams.admin_emails + * + * Strategy: split on '_', rejoin without separators, then lowercase. + * This makes both map to the same normalized key. We preserve the + * category prefix so that genuinely different change categories still + * produce distinct rows. + */ +function normalizeConceptualId(id) { + // conceptualChangeId format: "chg__" + // e.g. "chg_symbol_added_directoryuser.job_title_" + // "chg_parameter_removed_client.session_manager_encryptor" + // + // Different languages produce different symbol names for the same spec + // entity (PascalCase, snake_case, etc.), which yields different IDs. + // Normalizing by lowercasing and stripping all underscores collapses + // these into the same key. The category prefix (e.g. "symbol_added") + // also loses its underscores, but no two distinct categories collide + // after this transformation. + return id.toLowerCase().replace(/_/g, ''); +} + function highestSeverity(left, right) { const rank = { breaking: 3, 'soft-risk': 2, additive: 1 }; return rank[right] > rank[left] ? right : left; @@ -242,7 +277,8 @@ function buildRollup(languageData) { const routeKey = meta.route ? `${String(meta.route.method).toUpperCase()} ${meta.route.path}` : ''; const manifestEntries = routeKey ? (entry.operationsMap.get(routeKey) ?? []) : []; const manifestEntry = manifestEntries[0]; - const row = rows.get(change.conceptualChangeId) ?? { + const normalizedId = normalizeConceptualId(change.conceptualChangeId); + const row = rows.get(normalizedId) ?? { id: change.conceptualChangeId, severity: change.severity, category: change.category, @@ -280,7 +316,7 @@ function buildRollup(languageData) { }; } - rows.set(change.conceptualChangeId, row); + rows.set(normalizedId, row); } } From 1dd2c1f39e73211852e9f4f89d31090a567e5414 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 24 Apr 2026 19:38:20 -0400 Subject: [PATCH 08/38] chore: update dep for python init fix --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 80cb70f..7afb053 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "workos-openapi-spec", "version": "0.0.1", "dependencies": { - "@workos/oagen": "^0.9.1", + "@workos/oagen": "^0.9.2", "@workos/oagen-emitters": "^0.6.0", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" @@ -518,9 +518,9 @@ } }, "node_modules/@workos/oagen": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.9.1.tgz", - "integrity": "sha512-EZlgwB9p4AB5G66D04hb+63yO4K+jItMm6ua9C9JSPSz6FJ/AC/aQD//UQbwEIeRPQuxpDAbw98WSsY28RqJCw==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.9.2.tgz", + "integrity": "sha512-AfavAV3L3HeI4ZgAmPUYLOtBaZsIZ36AaVOmZwegbtKu9JEu+rZbrivThsJR849ddqvhQs9ijw51BNQuFSDAwQ==", "license": "MIT", "dependencies": { "@redocly/openapi-core": "^2.25.1", diff --git a/package.json b/package.json index 2121e26..d86c959 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "prepare": "husky" }, "dependencies": { - "@workos/oagen": "^0.9.1", + "@workos/oagen": "^0.9.2", "@workos/oagen-emitters": "^0.6.0", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" From b510f4b4de894f7b57e0d0797a36283987370676 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 11:54:31 -0400 Subject: [PATCH 09/38] chore: update dep for symbol unity fix --- package-lock.json | 248 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- 2 files changed, 245 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7afb053..6d52e6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "workos-openapi-spec", "version": "0.0.1", "dependencies": { - "@workos/oagen": "^0.9.2", + "@workos/oagen": "^0.10.0", "@workos/oagen-emitters": "^0.6.0", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" @@ -518,9 +518,9 @@ } }, "node_modules/@workos/oagen": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.9.2.tgz", - "integrity": "sha512-AfavAV3L3HeI4ZgAmPUYLOtBaZsIZ36AaVOmZwegbtKu9JEu+rZbrivThsJR849ddqvhQs9ijw51BNQuFSDAwQ==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.10.0.tgz", + "integrity": "sha512-I+UqxZbIIbqxKEHyCQWSERwWD4yFgr7NTe99gN5tFBwDxcoWmPgBIIXj6HjO0eK/vo+4mL7vr1GlMawloPAOhw==", "license": "MIT", "dependencies": { "@redocly/openapi-core": "^2.25.1", @@ -558,6 +558,246 @@ "node": ">=24.10.0" } }, + "node_modules/@workos/oagen-emitters/node_modules/@workos/oagen": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.9.2.tgz", + "integrity": "sha512-AfavAV3L3HeI4ZgAmPUYLOtBaZsIZ36AaVOmZwegbtKu9JEu+rZbrivThsJR849ddqvhQs9ijw51BNQuFSDAwQ==", + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^2.25.1", + "commander": "^13.1.0", + "dotenv": "^17.3.1", + "tree-sitter": "^0.21.1", + "tree-sitter-c-sharp": "^0.23.1", + "tree-sitter-elixir": "^0.3.5", + "tree-sitter-go": "^0.23.4", + "tree-sitter-kotlin": "^0.3.8", + "tree-sitter-php": "^0.23.12", + "tree-sitter-python": "^0.21.0", + "tree-sitter-ruby": "^0.21.0", + "tree-sitter-rust": "^0.21.0", + "tree-sitter-typescript": "^0.23.2", + "tsx": "^4.19.0", + "typescript": "^6.0.0" + }, + "bin": { + "oagen": "dist/cli/index.mjs" + }, + "engines": { + "node": ">=24.10.0" + } + }, + "node_modules/@workos/oagen-emitters/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", + "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + } + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-elixir": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/tree-sitter-elixir/-/tree-sitter-elixir-0.3.5.tgz", + "integrity": "sha512-xozQMvYK0aSolcQZAx2d84Xe/YMWFuRPYFlLVxO01bM2GITh5jyiIp0TqPCQa8754UzRAI7A83hZmfiYub5TZQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + } + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-elixir/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-go": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/tree-sitter-go/-/tree-sitter-go-0.23.4.tgz", + "integrity": "sha512-iQaHEs4yMa/hMo/ZCGqLfG61F0miinULU1fFh+GZreCRtKylFLtvn798ocCZjO2r/ungNZgAY1s1hPFyAwkc7w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.1", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-javascript": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/tree-sitter-javascript/-/tree-sitter-javascript-0.23.1.tgz", + "integrity": "sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-kotlin": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/tree-sitter-kotlin/-/tree-sitter-kotlin-0.3.8.tgz", + "integrity": "sha512-A4obq6bjzmYrA+F0JLLoheFPcofFkctNaZSpnDd+GPn1SfVZLY4/GG4C0cYVBTOShuPBGGAOPLM1JWLZQV4m1g==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-kotlin/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-php": { + "version": "0.23.12", + "resolved": "https://registry.npmjs.org/tree-sitter-php/-/tree-sitter-php-0.23.12.tgz", + "integrity": "sha512-VwkBVOahhC2NYXK/Fuqq30NxuL/6c2hmbxEF4jrB7AyR5rLc7nT27mzF3qoi+pqx9Gy2AbXnGezF7h4MeM6YRA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-python": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.21.0.tgz", + "integrity": "sha512-IUKx7JcTVbByUx1iHGFS/QsIjx7pqwTMHL9bl/NGyhyyydbfNrpruo2C7W6V4KZrbkkCOlX8QVrCoGOFW5qecg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-python/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-ruby": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-ruby/-/tree-sitter-ruby-0.21.0.tgz", + "integrity": "sha512-UrMpF9CZxKbZ5UFuPdXDuraaaYSMMlAiuzTpQXwNm7y0D48ibc9stWU5D6vDyJD0qf5/R+3yKTYHdHkqibmLSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.1" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-rust": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.21.0.tgz", + "integrity": "sha512-unVr73YLn3VC4Qa/GF0Nk+Wom6UtI526p5kz9Rn2iZSqwIFedyCZ3e0fKCEmUJLIPGrTb/cIEdu3ZUNGzfZx7A==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-rust/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/@workos/oagen-emitters/node_modules/tree-sitter-typescript": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/tree-sitter-typescript/-/tree-sitter-typescript-0.23.2.tgz", + "integrity": "sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2", + "tree-sitter-javascript": "^0.23.1" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, "node_modules/@workos/oagen/node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", diff --git a/package.json b/package.json index d86c959..8b61afb 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "prepare": "husky" }, "dependencies": { - "@workos/oagen": "^0.9.2", + "@workos/oagen": "^0.10.0", "@workos/oagen-emitters": "^0.6.0", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" From d0e533c74787ae283852cde0575be4f86e29802e Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 13:32:53 -0400 Subject: [PATCH 10/38] fix: Remove normalizeConceptualId stopgap from compat PR comment oagen v0.10.0 ships spec-level identity (schemaName) for cross-language symbol collapsing, making the string-normalization workaround unnecessary. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/sdk-compat-pr-comment.mjs | 40 ++----------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index 27fecc3..f9176f0 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -111,41 +111,6 @@ function pickSymbolMeta(change, baselineIndex, candidateIndex) { }; } -/** - * Normalize a conceptualChangeId so that the same underlying spec entity - * collapses into a single row regardless of language-specific naming. - * - * conceptualChangeId has the shape: - * chg__ - * - * Language SDKs may render the symbol differently: - * - dotnet PascalCase: AdminPortalGenerateLinkOptions.AdminEmails - * - go snake_case: AdminPortalGenerateLinkParams.admin_emails - * - ruby snake_case: admin_portal_generate_link_params.admin_emails - * - * The IDs derived from these look like: - * chg_symbol_added_adminportalgeneratelinkoptions.adminemails - * chg_symbol_added_adminportalgeneratelinkparams.admin_emails - * - * Strategy: split on '_', rejoin without separators, then lowercase. - * This makes both map to the same normalized key. We preserve the - * category prefix so that genuinely different change categories still - * produce distinct rows. - */ -function normalizeConceptualId(id) { - // conceptualChangeId format: "chg__" - // e.g. "chg_symbol_added_directoryuser.job_title_" - // "chg_parameter_removed_client.session_manager_encryptor" - // - // Different languages produce different symbol names for the same spec - // entity (PascalCase, snake_case, etc.), which yields different IDs. - // Normalizing by lowercasing and stripping all underscores collapses - // these into the same key. The category prefix (e.g. "symbol_added") - // also loses its underscores, but no two distinct categories collide - // after this transformation. - return id.toLowerCase().replace(/_/g, ''); -} - function highestSeverity(left, right) { const rank = { breaking: 3, 'soft-risk': 2, additive: 1 }; return rank[right] > rank[left] ? right : left; @@ -277,8 +242,7 @@ function buildRollup(languageData) { const routeKey = meta.route ? `${String(meta.route.method).toUpperCase()} ${meta.route.path}` : ''; const manifestEntries = routeKey ? (entry.operationsMap.get(routeKey) ?? []) : []; const manifestEntry = manifestEntries[0]; - const normalizedId = normalizeConceptualId(change.conceptualChangeId); - const row = rows.get(normalizedId) ?? { + const row = rows.get(change.conceptualChangeId) ?? { id: change.conceptualChangeId, severity: change.severity, category: change.category, @@ -316,7 +280,7 @@ function buildRollup(languageData) { }; } - rows.set(normalizedId, row); + rows.set(change.conceptualChangeId, row); } } From 36c6ee4f8ccf2d5ef811ee40486c9bc93e26e8bd Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 14:09:25 -0400 Subject: [PATCH 11/38] chore: update dep for schema fix --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6d52e6c..c3e5f79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "workos-openapi-spec", "version": "0.0.1", "dependencies": { - "@workos/oagen": "^0.10.0", + "@workos/oagen": "^0.10.1", "@workos/oagen-emitters": "^0.6.0", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" @@ -518,9 +518,9 @@ } }, "node_modules/@workos/oagen": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.10.0.tgz", - "integrity": "sha512-I+UqxZbIIbqxKEHyCQWSERwWD4yFgr7NTe99gN5tFBwDxcoWmPgBIIXj6HjO0eK/vo+4mL7vr1GlMawloPAOhw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.10.1.tgz", + "integrity": "sha512-mKgroPbg3eqiHBeM2M8pY7basTuxgtw0IQoDabgCgMxiDZ4Pfs265qAZTfvmgg251dJEfwss36hYDRO1cShRVg==", "license": "MIT", "dependencies": { "@redocly/openapi-core": "^2.25.1", diff --git a/package.json b/package.json index 8b61afb..658e439 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "prepare": "husky" }, "dependencies": { - "@workos/oagen": "^0.10.0", + "@workos/oagen": "^0.10.1", "@workos/oagen-emitters": "^0.6.0", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" From 9a2e14404a8efe820607aed0c17ad7e0e49896e8 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 14:17:56 -0400 Subject: [PATCH 12/38] fix: Merge cross-language duplicate rows in compat PR comment Different languages produce different conceptualChangeIds for the same underlying spec change (e.g. AdminPortalGenerateLinkOptions .AdminEmails in dotnet vs GenerateLink.admin_emails in ruby). Add a post-grouping merge pass that collapses rows with the same change category and matching normalized local symbol names, as long as their language sets don't overlap. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/sdk-compat-pr-comment.mjs | 78 ++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index f9176f0..bbef807 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -226,6 +226,80 @@ function buildLanguageData(dirPath) { }; } +/** + * Extract the local symbol name (part after the last dot) and normalize it + * for cross-language comparison. AdminEmails, admin_emails, adminEmails + * all become "adminemails". + */ +function normalizeLocalName(symbol) { + const local = symbol.includes('.') ? symbol.split('.').pop() : symbol; + return local.replace(/_/g, '').toLowerCase(); +} + +/** + * Merge rows that represent the same spec-level change across languages. + * + * Different languages produce different conceptualChangeIds for the same + * underlying change (e.g. AdminPortalGenerateLinkOptions.AdminEmails in + * dotnet vs GenerateLink.admin_emails in Ruby). This pass collapses them + * into a single row when: + * - they share the same change category + * - their normalized local symbol names match + * - their language sets don't overlap (no two entries for the same lang) + */ +function mergeRelatedRows(rows) { + const groups = new Map(); + for (const row of rows) { + const key = `${row.category}:${normalizeLocalName(row.symbol)}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(row); + } + + const result = []; + for (const group of groups.values()) { + if (group.length === 1) { + result.push(group[0]); + continue; + } + + // Check for overlapping languages — if the same language appears in + // multiple rows, these are genuinely distinct changes, not duplicates. + const allLanguages = new Set(); + let overlap = false; + for (const row of group) { + for (const lang of Object.keys(row.perLanguage)) { + if (allLanguages.has(lang)) { + overlap = true; + break; + } + allLanguages.add(lang); + } + if (overlap) break; + } + + if (overlap) { + result.push(...group); + continue; + } + + // Merge all rows into the first one + const merged = group[0]; + for (let i = 1; i < group.length; i++) { + const donor = group[i]; + for (const [lang, entry] of Object.entries(donor.perLanguage)) { + merged.perLanguage[lang] = entry; + } + merged.severity = highestSeverity(merged.severity, donor.severity); + if (!merged.routeKey && donor.routeKey) merged.routeKey = donor.routeKey; + if (!merged.operationId && donor.operationId) merged.operationId = donor.operationId; + if (!merged.detail && donor.detail) merged.detail = donor.detail; + } + result.push(merged); + } + + return result; +} + function buildRollup(languageData) { const languages = languageData.map((entry) => entry.language).sort(); const rows = new Map(); @@ -284,10 +358,12 @@ function buildRollup(languageData) { } } + const mergedRows = mergeRelatedRows([...rows.values()]); + return { languages, missingLanguages, - rows: [...rows.values()].sort((left, right) => { + rows: mergedRows.sort((left, right) => { const severityOrder = { breaking: 0, 'soft-risk': 1, additive: 2 }; const severityDiff = severityOrder[left.severity] - severityOrder[right.severity]; if (severityDiff !== 0) return severityDiff; From 9905d698234ff316edc953d6df6e50f757f4a3df Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 14:23:29 -0400 Subject: [PATCH 13/38] fix: Greedy cross-language row merge + collapsible category sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The all-or-nothing overlap check aborted merging the entire group when any two rows shared a language (e.g. admin_emails on two different models in the same language). Switch to greedy bucket merge: each row tries to join an existing bucket with non-overlapping languages, and only starts a new bucket when none fits. Also wrap each change category in
tags — breaking changes are expanded by default, everything else collapsed — to reduce scroll length on large reports. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/sdk-compat-pr-comment.mjs | 57 ++++++++++++++++--------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index bbef807..1e617c2 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -262,39 +262,36 @@ function mergeRelatedRows(rows) { continue; } - // Check for overlapping languages — if the same language appears in - // multiple rows, these are genuinely distinct changes, not duplicates. - const allLanguages = new Set(); - let overlap = false; + // Greedy merge: try to fold each row into an existing bucket whose + // language set doesn't overlap. Rows that can't merge into any + // bucket start a new one (they represent genuinely distinct changes + // that happen to share a local name, e.g. admin_emails on two + // different models). + const buckets = []; for (const row of group) { - for (const lang of Object.keys(row.perLanguage)) { - if (allLanguages.has(lang)) { - overlap = true; + const rowLangs = new Set(Object.keys(row.perLanguage)); + let target = null; + for (const bucket of buckets) { + const hasOverlap = [...rowLangs].some((lang) => bucket.perLanguage[lang]); + if (!hasOverlap) { + target = bucket; break; } - allLanguages.add(lang); } - if (overlap) break; - } - - if (overlap) { - result.push(...group); - continue; - } - // Merge all rows into the first one - const merged = group[0]; - for (let i = 1; i < group.length; i++) { - const donor = group[i]; - for (const [lang, entry] of Object.entries(donor.perLanguage)) { - merged.perLanguage[lang] = entry; + if (target) { + for (const [lang, entry] of Object.entries(row.perLanguage)) { + target.perLanguage[lang] = entry; + } + target.severity = highestSeverity(target.severity, row.severity); + if (!target.routeKey && row.routeKey) target.routeKey = row.routeKey; + if (!target.operationId && row.operationId) target.operationId = row.operationId; + if (!target.detail && row.detail) target.detail = row.detail; + } else { + buckets.push(row); } - merged.severity = highestSeverity(merged.severity, donor.severity); - if (!merged.routeKey && donor.routeKey) merged.routeKey = donor.routeKey; - if (!merged.operationId && donor.operationId) merged.operationId = donor.operationId; - if (!merged.detail && donor.detail) merged.detail = donor.detail; } - result.push(merged); + result.push(...buckets); } return result; @@ -422,7 +419,10 @@ function renderMarkdown(languageData, buildResult) { lines.push(''); for (const [category, rows] of groupedRows) { - lines.push(`### ${titleCaseCategory(category)}`); + const title = titleCaseCategory(category); + const isBreaking = category === 'symbol_removed' || category.includes('breaking'); + lines.push(``); + lines.push(`

${title} (${rows.length})

`); lines.push(''); for (const row of rows) { @@ -448,6 +448,9 @@ function renderMarkdown(languageData, buildResult) { } lines.push(''); } + + lines.push('
'); + lines.push(''); } return lines.join('\n') + '\n'; From ae6dad8b908a53f0636eb7a80ee7cc41d4a8d438 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 14:28:50 -0400 Subject: [PATCH 14/38] fix: Redesign compat PR comment layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Group by severity (breaking/soft-risk/additive) instead of category - Breaking and soft-risk sections group changes by route, with languages as columns and one row per change - Only show languages that are actually affected (no "—" clutter) - Additive section uses a compact table with just Change + Languages - Each cell shows the language-specific symbol compactly Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/sdk-compat-pr-comment.mjs | 181 ++++++++++++++++++++++-------- 1 file changed, 135 insertions(+), 46 deletions(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index 1e617c2..2eed66f 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -116,13 +116,6 @@ function highestSeverity(left, right) { return rank[right] > rank[left] ? right : left; } -function titleCaseCategory(category) { - return String(category) - .split('_') - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); -} - function formatDetail(change) { if (change.message) return change.message; @@ -369,6 +362,130 @@ function buildRollup(languageData) { }; } +// --------------------------------------------------------------------------- +// Rendering helpers +// --------------------------------------------------------------------------- + +const CATEGORY_VERBS = { + symbol_removed: 'removed', + symbol_added: 'added', + symbol_renamed: 'renamed', + parameter_removed: 'param removed', + parameter_added_optional_terminal: 'optional param added', + parameter_added_non_terminal_optional: 'optional param added', + parameter_renamed: 'param renamed', + parameter_type_narrowed: 'param type changed', + parameter_requiredness_increased: 'param now required', + parameter_position_changed_order_sensitive: 'param reordered', + constructor_position_changed_order_sensitive: 'ctor param reordered', + constructor_reordered_named_friendly: 'ctor reordered (named-friendly)', + field_type_changed: 'type changed', + return_type_changed: 'return type changed', + enum_member_value_changed: 'enum value changed', +}; + +function categoryVerb(category) { + return CATEGORY_VERBS[category] ?? category.replace(/_/g, ' '); +} + +/** Pull the first backtick-wrapped reference out of a formatted cell. */ +function extractRef(formatted) { + if (!formatted || formatted === '—') return '—'; + const match = formatted.match(/`([^`]+)`/); + if (!match) return formatted.split('
')[0] || '—'; + const inner = match[1]; + if (inner === '(removed)' || inner === '(absent)') return '—'; + return `\`${inner}\``; +} + +/** One compact cell per language in the detailed table. */ +function compactCell(entry) { + if (!entry) return '—'; + const prev = extractRef(entry.previous); + const now = extractRef(entry.now); + if (prev === '—' && now === '—') return '—'; + if (prev === '—') return now; + if (now === '—') return prev; + return `${prev} → ${now}`; +} + +/** Render a table of changes with languages as columns. */ +function renderChangeTable(lines, rows, languages) { + const activeLangs = languages.filter((lang) => rows.some((row) => row.perLanguage[lang])); + if (activeLangs.length === 0) return; + + lines.push(`| Change | ${activeLangs.join(' | ')} |`); + lines.push(`| --- | ${activeLangs.map(() => '---').join(' | ')} |`); + + for (const row of rows) { + const local = row.symbol.includes('.') ? row.symbol.split('.').pop() : row.symbol; + const desc = escapeCell(`\`${local}\` ${categoryVerb(row.category)}`); + const cells = activeLangs.map((lang) => escapeCell(compactCell(row.perLanguage[lang]))); + lines.push(`| ${desc} | ${cells.join(' | ')} |`); + } + lines.push(''); +} + +/** Render breaking / soft-risk section: grouped by route, languages as columns. */ +function renderDetailedSection(lines, title, rows, languages, open) { + lines.push(``); + lines.push(`

${title} (${rows.length})

`); + lines.push(''); + + const byRoute = new Map(); + const noRoute = []; + for (const row of rows) { + if (row.routeKey) { + if (!byRoute.has(row.routeKey)) byRoute.set(row.routeKey, []); + byRoute.get(row.routeKey).push(row); + } else { + noRoute.push(row); + } + } + + for (const [route, routeRows] of byRoute) { + lines.push(`#### \`${route}\``); + lines.push(''); + renderChangeTable(lines, routeRows, languages); + } + + if (noRoute.length > 0) { + if (byRoute.size > 0) { + lines.push('#### Non-operation changes'); + lines.push(''); + } + renderChangeTable(lines, noRoute, languages); + } + + lines.push(''); + lines.push(''); +} + +/** Render additive section: compact list with language coverage. */ +function renderCompactSection(lines, title, rows, languages) { + lines.push('
'); + lines.push(`

${title} (${rows.length})

`); + lines.push(''); + + lines.push('| Change | Languages |'); + lines.push('| --- | --- |'); + + for (const row of rows) { + const affected = languages.filter((lang) => row.perLanguage[lang]); + const langStr = affected.length === languages.length ? 'all' : affected.join(', '); + const local = row.symbol.includes('.') ? row.symbol.split('.').pop() : row.symbol; + lines.push(`| ${escapeCell(`\`${local}\` ${categoryVerb(row.category)}`)} | ${langStr} |`); + } + + lines.push(''); + lines.push('
'); + lines.push(''); +} + +// --------------------------------------------------------------------------- +// Main renderer +// --------------------------------------------------------------------------- + function renderMarkdown(languageData, buildResult) { const rollup = buildRollup(languageData); const lines = []; @@ -409,48 +526,20 @@ function renderMarkdown(languageData, buildResult) { return lines.join('\n') + '\n'; } - const groupedRows = new Map(); - for (const row of rollup.rows) { - const entries = groupedRows.get(row.category) ?? []; - entries.push(row); - groupedRows.set(row.category, entries); - } + const breaking = rollup.rows.filter((r) => r.severity === 'breaking'); + const softRisk = rollup.rows.filter((r) => r.severity === 'soft-risk'); + const additive = rollup.rows.filter((r) => r.severity === 'additive'); lines.push(''); - for (const [category, rows] of groupedRows) { - const title = titleCaseCategory(category); - const isBreaking = category === 'symbol_removed' || category.includes('breaking'); - lines.push(``); - lines.push(`

${title} (${rows.length})

`); - lines.push(''); - - for (const row of rows) { - if (row.routeKey) { - lines.push(`#### \`${row.routeKey}\``); - } else { - lines.push(`#### \`${row.symbol}\``); - } - lines.push(''); - lines.push(row.detail || row.symbol); - lines.push(''); - - if (row.operationId) { - lines.push(`operationId: \`${row.operationId}\``); - lines.push(''); - } - - lines.push('| Language | Previous | Now |'); - lines.push('| --- | --- | --- |'); - for (const language of rollup.languages) { - const entry = row.perLanguage[language]; - lines.push(`| ${language} | ${escapeCell(entry?.previous ?? '—')} | ${escapeCell(entry?.now ?? '—')} |`); - } - lines.push(''); - } - - lines.push(''); - lines.push(''); + if (breaking.length > 0) { + renderDetailedSection(lines, 'Breaking', breaking, rollup.languages, true); + } + if (softRisk.length > 0) { + renderDetailedSection(lines, 'Soft-risk', softRisk, rollup.languages, false); + } + if (additive.length > 0) { + renderCompactSection(lines, 'Additive', additive, rollup.languages); } return lines.join('\n') + '\n'; From 1d827f5a82ed579ea8bb52a2d59141a84aaf47a8 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 14:40:43 -0400 Subject: [PATCH 15/38] fix: Show method signatures with parameter names in compat report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For callable and constructor symbols, render the actual parameter list instead of generic (...). Required params show as-is, optional params get a trailing ?. Truncates to 4 params with ellipsis when there are more than 5. e.g. `AdminPortal.generate_link(organization, return_url?, success_url?, intent?, …)` Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/sdk-compat-pr-comment.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index 2eed66f..0abe63d 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -135,9 +135,19 @@ function escapeCell(value) { return String(value).replace(/\|/g, '\\|').replace(/\n/g, '
'); } +function formatParamList(parameters) { + if (!parameters?.length) return ''; + const params = parameters.map((p) => (p.required ? p.publicName : `${p.publicName}?`)); + if (params.length <= 5) return params.join(', '); + return params.slice(0, 4).join(', ') + ', \u2026'; +} + function formatSymbolReference(symbol) { if (!symbol?.fqName) return ''; - if (symbol.kind === 'callable' || symbol.kind === 'constructor') return `\`${symbol.fqName}(...)\``; + if (symbol.kind === 'callable' || symbol.kind === 'constructor') { + const params = formatParamList(symbol.parameters); + return `\`${symbol.fqName}(${params})\``; + } return `\`${symbol.fqName}\``; } @@ -170,7 +180,8 @@ function formatNowState(change, candidateSymbol, manifestEntry) { const lines = []; if (manifestEntry) { - lines.push(`\`${manifestEntry.service}.${manifestEntry.sdkMethod}(...)\``); + const params = formatParamList(candidateSymbol?.parameters); + lines.push(`\`${manifestEntry.service}.${manifestEntry.sdkMethod}(${params})\``); } else { const symbolRef = formatSymbolReference(candidateSymbol); if (symbolRef) { From d9497104195372d6e259c014a2015271aabbf80d Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 14:51:50 -0400 Subject: [PATCH 16/38] feat: Run SDK CI scripts after generation in validate-sdks workflow Set up language-specific runtimes (Ruby, Python, Go, .NET, PHP) in the build matrix, overlay generated SDK files onto the live SDK checkout, then run script/ci or scripts/ci if present. A non-zero exit fails the job. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/sdk-matrix.json | 20 +++++++++--- .github/workflows/validate-sdks.yml | 47 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/.github/sdk-matrix.json b/.github/sdk-matrix.json index a1a29fc..13008e9 100644 --- a/.github/sdk-matrix.json +++ b/.github/sdk-matrix.json @@ -2,26 +2,36 @@ { "language": "dotnet", "sdk_repo": "workos/workos-dotnet", - "sdk_checkout_path": "backend/workos-dotnet" + "sdk_checkout_path": "backend/workos-dotnet", + "runtime": "dotnet", + "runtime_version": "9.0.x" }, { "language": "go", "sdk_repo": "workos/workos-go", - "sdk_checkout_path": "backend/workos-go" + "sdk_checkout_path": "backend/workos-go", + "runtime": "go", + "runtime_version": "1.24" }, { "language": "php", "sdk_repo": "workos/workos-php", - "sdk_checkout_path": "backend/workos-php" + "sdk_checkout_path": "backend/workos-php", + "runtime": "php", + "runtime_version": "8.3" }, { "language": "python", "sdk_repo": "workos/workos-python", - "sdk_checkout_path": "backend/workos-python" + "sdk_checkout_path": "backend/workos-python", + "runtime": "python", + "runtime_version": "3.13" }, { "language": "ruby", "sdk_repo": "workos/workos-ruby", - "sdk_checkout_path": "backend/workos-ruby" + "sdk_checkout_path": "backend/workos-ruby", + "runtime": "ruby", + "runtime_version": "3.4" } ] diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index 6093428..a909146 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -78,6 +78,53 @@ jobs: working-directory: openapi-spec run: npm run sdk:generate -- --lang ${{ matrix.language }} --output ".oagen/${{ matrix.language }}/sdk" + - name: Setup Ruby + if: matrix.runtime == 'ruby' + uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 # v1 + with: + ruby-version: ${{ matrix.runtime_version }} + bundler-cache: true + working-directory: ${{ matrix.sdk_checkout_path }} + + - name: Setup Python + if: matrix.runtime == 'python' + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.runtime_version }} + + - name: Setup Go + if: matrix.runtime == 'go' + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version: ${{ matrix.runtime_version }} + + - name: Setup .NET + if: matrix.runtime == 'dotnet' + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: ${{ matrix.runtime_version }} + + - name: Setup PHP + if: matrix.runtime == 'php' + uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2 + with: + php-version: ${{ matrix.runtime_version }} + + - name: Run SDK CI + working-directory: ${{ matrix.sdk_checkout_path }} + run: | + # Overlay generated files onto the live SDK checkout + rsync -a "$GITHUB_WORKSPACE/openapi-spec/.oagen/${{ matrix.language }}/sdk/" . + + # Run the SDK's CI script if it exists + if [ -x script/ci ]; then + script/ci + elif [ -x scripts/ci ]; then + scripts/ci + else + echo "No script/ci or scripts/ci found, skipping" + fi + - name: Extract candidate snapshot working-directory: openapi-spec run: | From 1c67f89c8fc7a69becbfd69e9e04ae43e69b3240 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 14:53:36 -0400 Subject: [PATCH 17/38] fix: Read runtime versions from SDK repos instead of hardcoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop redundant runtime/runtime_version from the matrix — language already identifies the runtime. Version is now read from each SDK's canonical source (.ruby-version, go.mod, composer.json, etc.) at build time. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/sdk-matrix.json | 20 +++++------------- .github/workflows/validate-sdks.yml | 32 ++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.github/sdk-matrix.json b/.github/sdk-matrix.json index 13008e9..a1a29fc 100644 --- a/.github/sdk-matrix.json +++ b/.github/sdk-matrix.json @@ -2,36 +2,26 @@ { "language": "dotnet", "sdk_repo": "workos/workos-dotnet", - "sdk_checkout_path": "backend/workos-dotnet", - "runtime": "dotnet", - "runtime_version": "9.0.x" + "sdk_checkout_path": "backend/workos-dotnet" }, { "language": "go", "sdk_repo": "workos/workos-go", - "sdk_checkout_path": "backend/workos-go", - "runtime": "go", - "runtime_version": "1.24" + "sdk_checkout_path": "backend/workos-go" }, { "language": "php", "sdk_repo": "workos/workos-php", - "sdk_checkout_path": "backend/workos-php", - "runtime": "php", - "runtime_version": "8.3" + "sdk_checkout_path": "backend/workos-php" }, { "language": "python", "sdk_repo": "workos/workos-python", - "sdk_checkout_path": "backend/workos-python", - "runtime": "python", - "runtime_version": "3.13" + "sdk_checkout_path": "backend/workos-python" }, { "language": "ruby", "sdk_repo": "workos/workos-ruby", - "sdk_checkout_path": "backend/workos-ruby", - "runtime": "ruby", - "runtime_version": "3.4" + "sdk_checkout_path": "backend/workos-ruby" } ] diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index a909146..5f85823 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -78,37 +78,49 @@ jobs: working-directory: openapi-spec run: npm run sdk:generate -- --lang ${{ matrix.language }} --output ".oagen/${{ matrix.language }}/sdk" + - name: Detect SDK runtime version + id: sdk-version + working-directory: ${{ matrix.sdk_checkout_path }} + run: | + case "${{ matrix.language }}" in + ruby) echo "version=$(cat .ruby-version)" >> "$GITHUB_OUTPUT" ;; + python) echo "version=$(cat .python-version 2>/dev/null || grep -Po '(?<=requires-python.*>=)\d+\.\d+' pyproject.toml)" >> "$GITHUB_OUTPUT" ;; + go) echo "version=$(grep '^go ' go.mod | awk '{print $2}')" >> "$GITHUB_OUTPUT" ;; + dotnet) echo "version=$(grep -Po '(?<=net)\d+\.\d+' $(find . -name '*.csproj' -path '*/src/*' | head -1))" >> "$GITHUB_OUTPUT" ;; + php) echo "version=$(grep -Po '\"php\":\s*\"\^?\K[\d.]+' composer.json)" >> "$GITHUB_OUTPUT" ;; + esac + - name: Setup Ruby - if: matrix.runtime == 'ruby' + if: matrix.language == 'ruby' uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 # v1 with: - ruby-version: ${{ matrix.runtime_version }} + ruby-version: ${{ steps.sdk-version.outputs.version }} bundler-cache: true working-directory: ${{ matrix.sdk_checkout_path }} - name: Setup Python - if: matrix.runtime == 'python' + if: matrix.language == 'python' uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - python-version: ${{ matrix.runtime_version }} + python-version: ${{ steps.sdk-version.outputs.version }} - name: Setup Go - if: matrix.runtime == 'go' + if: matrix.language == 'go' uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 with: - go-version: ${{ matrix.runtime_version }} + go-version: ${{ steps.sdk-version.outputs.version }} - name: Setup .NET - if: matrix.runtime == 'dotnet' + if: matrix.language == 'dotnet' uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: - dotnet-version: ${{ matrix.runtime_version }} + dotnet-version: ${{ steps.sdk-version.outputs.version }} - name: Setup PHP - if: matrix.runtime == 'php' + if: matrix.language == 'php' uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2 with: - php-version: ${{ matrix.runtime_version }} + php-version: ${{ steps.sdk-version.outputs.version }} - name: Run SDK CI working-directory: ${{ matrix.sdk_checkout_path }} From c11bc3bd6de2c8c3ef47510aba8ff79db3e13b9c Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 14:55:19 -0400 Subject: [PATCH 18/38] chore: add sdk:languages script to list matrix languages Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 658e439..e965b13 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "sdk:compat-extract": "bash scripts/sdk-compat-extract.sh", "sdk:compat-diff": "bash scripts/sdk-compat-diff.sh", "sdk:compat-summary": "bash scripts/sdk-compat-summary.sh", + "sdk:languages": "node -e \"console.log(JSON.parse(require('fs').readFileSync('.github/sdk-matrix.json','utf8')).map(e=>e.language).join('\\n'))\"", "sdk:check": "npx oagen resolve --spec spec/open-api-spec.yaml --format json > /dev/null && echo 'Config loaded successfully'", "dev:link": "npm link @workos/oagen @workos/oagen-emitters", "dev:unlink": "npm install", From d24369a179befd6417c1ca333f4bfdeffa0236f0 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 16:15:14 -0400 Subject: [PATCH 19/38] feat: Add sdk:generate-all script and show code samples in compat report Add a generate-all script that runs generation + CI across all SDK languages, and improve the compat PR comment to show full symbol context (e.g. `DirectoryUser.slug` instead of `slug`) with before/after method signatures per language. Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 + scripts/sdk-compat-pr-comment.mjs | 38 +++++++------ scripts/sdk-generate-all.sh | 92 +++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 17 deletions(-) create mode 100755 scripts/sdk-generate-all.sh diff --git a/package.json b/package.json index e965b13..c8807ac 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "sdk:compat-diff": "bash scripts/sdk-compat-diff.sh", "sdk:compat-summary": "bash scripts/sdk-compat-summary.sh", "sdk:languages": "node -e \"console.log(JSON.parse(require('fs').readFileSync('.github/sdk-matrix.json','utf8')).map(e=>e.language).join('\\n'))\"", + "sdk:generate-all": "bash scripts/sdk-generate-all.sh --parent-dir", "sdk:check": "npx oagen resolve --spec spec/open-api-spec.yaml --format json > /dev/null && echo 'Config loaded successfully'", "dev:link": "npm link @workos/oagen @workos/oagen-emitters", "dev:unlink": "npm install", diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index 0abe63d..48c3387 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -420,21 +420,26 @@ function compactCell(entry) { return `${prev} → ${now}`; } -/** Render a table of changes with languages as columns. */ -function renderChangeTable(lines, rows, languages) { - const activeLangs = languages.filter((lang) => rows.some((row) => row.perLanguage[lang])); - if (activeLangs.length === 0) return; - - lines.push(`| Change | ${activeLangs.join(' | ')} |`); - lines.push(`| --- | ${activeLangs.map(() => '---').join(' | ')} |`); - +/** Render changes as per-change blocks with before/after code samples. */ +function renderChangeBlocks(lines, rows, languages) { for (const row of rows) { - const local = row.symbol.includes('.') ? row.symbol.split('.').pop() : row.symbol; - const desc = escapeCell(`\`${local}\` ${categoryVerb(row.category)}`); - const cells = activeLangs.map((lang) => escapeCell(compactCell(row.perLanguage[lang]))); - lines.push(`| ${desc} | ${cells.join(' | ')} |`); + const activeLangs = languages.filter((lang) => row.perLanguage[lang]); + if (activeLangs.length === 0) continue; + + const desc = `\`${row.symbol}\` ${categoryVerb(row.category)}`; + lines.push(`**${desc}**`); + lines.push(''); + lines.push('| Language | Before | After |'); + lines.push('| --- | --- | --- |'); + + for (const lang of activeLangs) { + const entry = row.perLanguage[lang]; + const before = entry.previous || '—'; + const after = entry.now || '—'; + lines.push(`| ${lang} | ${escapeCell(before)} | ${escapeCell(after)} |`); + } + lines.push(''); } - lines.push(''); } /** Render breaking / soft-risk section: grouped by route, languages as columns. */ @@ -457,7 +462,7 @@ function renderDetailedSection(lines, title, rows, languages, open) { for (const [route, routeRows] of byRoute) { lines.push(`#### \`${route}\``); lines.push(''); - renderChangeTable(lines, routeRows, languages); + renderChangeBlocks(lines, routeRows, languages); } if (noRoute.length > 0) { @@ -465,7 +470,7 @@ function renderDetailedSection(lines, title, rows, languages, open) { lines.push('#### Non-operation changes'); lines.push(''); } - renderChangeTable(lines, noRoute, languages); + renderChangeBlocks(lines, noRoute, languages); } lines.push(''); @@ -484,8 +489,7 @@ function renderCompactSection(lines, title, rows, languages) { for (const row of rows) { const affected = languages.filter((lang) => row.perLanguage[lang]); const langStr = affected.length === languages.length ? 'all' : affected.join(', '); - const local = row.symbol.includes('.') ? row.symbol.split('.').pop() : row.symbol; - lines.push(`| ${escapeCell(`\`${local}\` ${categoryVerb(row.category)}`)} | ${langStr} |`); + lines.push(`| ${escapeCell(`\`${row.symbol}\` ${categoryVerb(row.category)}`)} | ${langStr} |`); } lines.push(''); diff --git a/scripts/sdk-generate-all.sh b/scripts/sdk-generate-all.sh new file mode 100755 index 0000000..45bda04 --- /dev/null +++ b/scripts/sdk-generate-all.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PARENT_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --parent-dir) PARENT_DIR="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$PARENT_DIR" ]]; then + echo "Usage: sdk-generate-all.sh --parent-dir " >&2 + echo " is the directory containing workos-{lang} SDK repos" >&2 + exit 1 +fi + +PARENT_DIR="$(cd "$PARENT_DIR" && pwd)" + +PASSED=() +FAILED=() + +LANGUAGES=$(npm --prefix "$REPO_ROOT" run sdk:languages --silent 2>/dev/null) + +for lang in $LANGUAGES; do + OUTPUT="$PARENT_DIR/workos-$lang" + echo "" + echo "========================================" + echo "Generating SDK: $lang -> $OUTPUT" + echo "========================================" + + if ! npm --prefix "$REPO_ROOT" run sdk:generate -- --lang "$lang" --output "$OUTPUT"; then + echo "[FAIL] Generation failed for: $lang" + FAILED+=("$lang (generation failed)") + continue + fi + + CI_SCRIPT="" + if [[ -f "$OUTPUT/scripts/ci" ]]; then + CI_SCRIPT="$OUTPUT/scripts/ci" + elif [[ -f "$OUTPUT/script/ci" ]]; then + CI_SCRIPT="$OUTPUT/script/ci" + fi + + if [[ -z "$CI_SCRIPT" ]]; then + echo "[WARN] No CI script found for: $lang (checked scripts/ci and script/ci)" + PASSED+=("$lang (no CI script)") + continue + fi + + echo "Running CI: $CI_SCRIPT" + if ! (cd "$OUTPUT" && bash "$CI_SCRIPT"); then + echo "[FAIL] CI failed for: $lang" + FAILED+=("$lang (CI failed)") + else + echo "[PASS] $lang" + PASSED+=("$lang") + fi +done + +echo "" +echo "========================================" +echo "Results" +echo "========================================" + +echo "" +echo "PASSED (${#PASSED[@]}):" +if [[ ${#PASSED[@]} -eq 0 ]]; then + echo " (none)" +else + for s in "${PASSED[@]}"; do + echo " ✓ $s" + done +fi + +echo "" +echo "FAILED (${#FAILED[@]}):" +if [[ ${#FAILED[@]} -eq 0 ]]; then + echo " (none)" +else + for s in "${FAILED[@]}"; do + echo " ✗ $s" + done +fi + +echo "" +if [[ ${#FAILED[@]} -gt 0 ]]; then + exit 1 +fi From d048de42640a5b3dcadcfbdc7b86854416d75418 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 16:27:05 -0400 Subject: [PATCH 20/38] new workflows --- .github/workflows/validate-sdks.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index 5f85823..2bf4cd4 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -92,7 +92,7 @@ jobs: - name: Setup Ruby if: matrix.language == 'ruby' - uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 # v1 + uses: ruby/setup-ruby@0cb964fd540e0a24c900370abf38a33466142735 # v1.305.0 with: ruby-version: ${{ steps.sdk-version.outputs.version }} bundler-cache: true @@ -100,25 +100,25 @@ jobs: - name: Setup Python if: matrix.language == 'python' - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ steps.sdk-version.outputs.version }} - name: Setup Go if: matrix.language == 'go' - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version: ${{ steps.sdk-version.outputs.version }} - name: Setup .NET if: matrix.language == 'dotnet' - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 with: dotnet-version: ${{ steps.sdk-version.outputs.version }} - name: Setup PHP if: matrix.language == 'php' - uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: ${{ steps.sdk-version.outputs.version }} From de077acaf7281b43ad1092884d83520583a2718c Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 25 Apr 2026 18:08:12 -0400 Subject: [PATCH 21/38] fix: Install uv for Python CI and group compat report by service Add astral-sh/setup-uv to the validate-sdks workflow so Python's CI script can find uv. Also group compat report changes by service mount point (AdminPortal, Authorization, etc.) instead of raw HTTP routes, with the route shown inline per change. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate-sdks.yml | 7 ++++++ scripts/sdk-compat-pr-comment.mjs | 36 ++++++++++++++++------------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index 2bf4cd4..382faf9 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -104,6 +104,13 @@ jobs: with: python-version: ${{ steps.sdk-version.outputs.version }} + - name: Setup uv + if: matrix.language == 'python' + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + enable-cache: true + cache-dependency-glob: '${{ matrix.sdk_checkout_path }}/**/uv.lock' + - name: Setup Go if: matrix.language == 'go' uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index 48c3387..300f045 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -290,6 +290,7 @@ function mergeRelatedRows(rows) { target.severity = highestSeverity(target.severity, row.severity); if (!target.routeKey && row.routeKey) target.routeKey = row.routeKey; if (!target.operationId && row.operationId) target.operationId = row.operationId; + if (!target.service && row.service) target.service = row.service; if (!target.detail && row.detail) target.detail = row.detail; } else { buckets.push(row); @@ -324,6 +325,7 @@ function buildRollup(languageData) { detail: formatDetail(change), routeKey, operationId: meta.operationId ?? '', + service: manifestEntry?.service ?? '', symbol: change.symbol, perLanguage: {}, }; @@ -331,6 +333,7 @@ function buildRollup(languageData) { row.severity = highestSeverity(row.severity, change.severity); if (!row.routeKey && routeKey) row.routeKey = routeKey; if (!row.operationId && meta.operationId) row.operationId = meta.operationId; + if (!row.service && manifestEntry?.service) row.service = manifestEntry.service; if (!row.detail && change.message) row.detail = change.message; if (manifestEntries.length > 1) { @@ -368,7 +371,7 @@ function buildRollup(languageData) { const severityOrder = { breaking: 0, 'soft-risk': 1, additive: 2 }; const severityDiff = severityOrder[left.severity] - severityOrder[right.severity]; if (severityDiff !== 0) return severityDiff; - return `${left.routeKey} ${left.symbol}`.localeCompare(`${right.routeKey} ${right.symbol}`); + return `${left.service} ${left.routeKey} ${left.symbol}`.localeCompare(`${right.service} ${right.routeKey} ${right.symbol}`); }), }; } @@ -427,7 +430,8 @@ function renderChangeBlocks(lines, rows, languages) { if (activeLangs.length === 0) continue; const desc = `\`${row.symbol}\` ${categoryVerb(row.category)}`; - lines.push(`**${desc}**`); + const route = row.routeKey ? ` — \`${row.routeKey}\`` : ''; + lines.push(`**${desc}**${route}`); lines.push(''); lines.push('| Language | Before | After |'); lines.push('| --- | --- | --- |'); @@ -442,35 +446,35 @@ function renderChangeBlocks(lines, rows, languages) { } } -/** Render breaking / soft-risk section: grouped by route, languages as columns. */ +/** Render breaking / soft-risk section: grouped by service, with route shown inline. */ function renderDetailedSection(lines, title, rows, languages, open) { lines.push(``); lines.push(`

${title} (${rows.length})

`); lines.push(''); - const byRoute = new Map(); - const noRoute = []; + const byService = new Map(); + const noService = []; for (const row of rows) { - if (row.routeKey) { - if (!byRoute.has(row.routeKey)) byRoute.set(row.routeKey, []); - byRoute.get(row.routeKey).push(row); + if (row.service) { + if (!byService.has(row.service)) byService.set(row.service, []); + byService.get(row.service).push(row); } else { - noRoute.push(row); + noService.push(row); } } - for (const [route, routeRows] of byRoute) { - lines.push(`#### \`${route}\``); + for (const [service, serviceRows] of byService) { + lines.push(`#### ${service}`); lines.push(''); - renderChangeBlocks(lines, routeRows, languages); + renderChangeBlocks(lines, serviceRows, languages); } - if (noRoute.length > 0) { - if (byRoute.size > 0) { - lines.push('#### Non-operation changes'); + if (noService.length > 0) { + if (byService.size > 0) { + lines.push('#### Other changes'); lines.push(''); } - renderChangeBlocks(lines, noRoute, languages); + renderChangeBlocks(lines, noService, languages); } lines.push(''); From 70421afba5e4435153331257f30d780278478c7b Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 10:54:26 -0400 Subject: [PATCH 22/38] chore: update deps --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index c3e5f79..9a94e21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,8 @@ "name": "workos-openapi-spec", "version": "0.0.1", "dependencies": { - "@workos/oagen": "^0.10.1", - "@workos/oagen-emitters": "^0.6.0", + "@workos/oagen": "^0.10.2", + "@workos/oagen-emitters": "^0.6.1", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" }, @@ -518,9 +518,9 @@ } }, "node_modules/@workos/oagen": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.10.1.tgz", - "integrity": "sha512-mKgroPbg3eqiHBeM2M8pY7basTuxgtw0IQoDabgCgMxiDZ4Pfs265qAZTfvmgg251dJEfwss36hYDRO1cShRVg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.10.2.tgz", + "integrity": "sha512-h61yIxsagzeV5EKfnKwaQ4Yi1WaxBwPgABI4I+AV7f4uE3BH2+IIrV4i1La3u0ZdOFqzSBn6+g1KwQosTC4lyA==", "license": "MIT", "dependencies": { "@redocly/openapi-core": "^2.25.1", @@ -547,9 +547,9 @@ } }, "node_modules/@workos/oagen-emitters": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@workos/oagen-emitters/-/oagen-emitters-0.6.0.tgz", - "integrity": "sha512-GRNeR7hDRuvGwE+d92486RBnuQ8yerP2r0Pj4UUAEmPporTRA0rtkWw3ZgnO2/iagbbSNbGyafMkPxmCN4n+1A==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@workos/oagen-emitters/-/oagen-emitters-0.6.1.tgz", + "integrity": "sha512-1rNeA6O0rnLwheRT7W6mF/kqi4R/H5VuqhO2haT0VlhIOAepIhCkwP6cvYVuWyBsaBkLlkqpacXKhenmMzmgSw==", "license": "MIT", "dependencies": { "@workos/oagen": "^0.9.0" diff --git a/package.json b/package.json index c8807ac..5f1e9bd 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "prepare": "husky" }, "dependencies": { - "@workos/oagen": "^0.10.1", - "@workos/oagen-emitters": "^0.6.0", + "@workos/oagen": "^0.10.2", + "@workos/oagen-emitters": "^0.6.1", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" }, From e9ecfecf1300e1df5921fad8504b63986887a350 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 16:21:07 -0400 Subject: [PATCH 23/38] fix: Generate SDKs into checkout dir so formatters find project files The oagen formatter needs each SDK's tooling (Gemfile, go.mod, pyproject.toml, etc.) to be present in the output directory. Generating into .oagen/{lang}/sdk/ left those files missing, causing formatter failures and unformatted output that failed CI linting. Now generates directly into the SDK checkout, matching local behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate-sdks.yml | 36 ++++++++++++++++------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index 382faf9..7f0dedc 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -67,17 +67,6 @@ jobs: working-directory: openapi-spec run: npm ci - - name: Extract baseline snapshot - working-directory: openapi-spec - run: | - npm run sdk:compat-extract -- \ - --lang ${{ matrix.language }} \ - --sdk-root "$GITHUB_WORKSPACE/${{ matrix.sdk_checkout_path }}" - - - name: Generate SDK - working-directory: openapi-spec - run: npm run sdk:generate -- --lang ${{ matrix.language }} --output ".oagen/${{ matrix.language }}/sdk" - - name: Detect SDK runtime version id: sdk-version working-directory: ${{ matrix.sdk_checkout_path }} @@ -129,13 +118,20 @@ jobs: with: php-version: ${{ steps.sdk-version.outputs.version }} + - name: Extract baseline snapshot + working-directory: openapi-spec + run: | + npm run sdk:compat-extract -- \ + --lang ${{ matrix.language }} \ + --sdk-root "$GITHUB_WORKSPACE/${{ matrix.sdk_checkout_path }}" + + - name: Generate SDK + working-directory: openapi-spec + run: npm run sdk:generate -- --lang ${{ matrix.language }} --output "$GITHUB_WORKSPACE/${{ matrix.sdk_checkout_path }}" + - name: Run SDK CI working-directory: ${{ matrix.sdk_checkout_path }} run: | - # Overlay generated files onto the live SDK checkout - rsync -a "$GITHUB_WORKSPACE/openapi-spec/.oagen/${{ matrix.language }}/sdk/" . - - # Run the SDK's CI script if it exists if [ -x script/ci ]; then script/ci elif [ -x scripts/ci ]; then @@ -149,9 +145,17 @@ jobs: run: | npm run sdk:compat-extract -- \ --lang ${{ matrix.language }} \ - --sdk-root .oagen/${{ matrix.language }}/sdk \ + --sdk-root "$GITHUB_WORKSPACE/${{ matrix.sdk_checkout_path }}" \ --output .oagen/${{ matrix.language }}/sdk + - name: Copy manifest for diagnostics + if: always() + working-directory: openapi-spec + run: | + mkdir -p .oagen/${{ matrix.language }}/sdk + cp "$GITHUB_WORKSPACE/${{ matrix.sdk_checkout_path }}/.oagen-manifest.json" \ + .oagen/${{ matrix.language }}/sdk/ 2>/dev/null || true + - name: Compat diff id: compat-diff continue-on-error: true From da9a21f7da341efade817795d3f88919d6f9b499 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 16:25:18 -0400 Subject: [PATCH 24/38] fix: Install ruff and php-cs-fixer before SDK generation oagen's formatter needs ruff in PATH for Python and php-cs-fixer for PHP. In CI these tools are only available inside virtualenvs/vendor, not globally. Install them before generation so the formatter produces correctly-styled output. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate-sdks.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index 7f0dedc..8750db0 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -118,6 +118,14 @@ jobs: with: php-version: ${{ steps.sdk-version.outputs.version }} + - name: Install SDK formatter tools + working-directory: ${{ matrix.sdk_checkout_path }} + run: | + case "${{ matrix.language }}" in + python) uv tool install ruff ;; + php) composer install --prefer-dist --no-progress --no-interaction ;; + esac + - name: Extract baseline snapshot working-directory: openapi-spec run: | From a14bed877639fac2ce020b58f01bee46c35e66c4 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 16:28:09 -0400 Subject: [PATCH 25/38] fix: Run SDK setup scripts before generation instead of hardcoding Replace the per-language case statement with a generic hook that runs script/setup or scripts/setup from the SDK repo if it exists. Each SDK controls its own pre-generation dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate-sdks.yml | 13 ++++++++----- scripts/sdk-generate-all.sh | 9 +++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index 8750db0..b090572 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -118,13 +118,16 @@ jobs: with: php-version: ${{ steps.sdk-version.outputs.version }} - - name: Install SDK formatter tools + - name: Setup SDK working-directory: ${{ matrix.sdk_checkout_path }} run: | - case "${{ matrix.language }}" in - python) uv tool install ruff ;; - php) composer install --prefer-dist --no-progress --no-interaction ;; - esac + for f in script/setup scripts/setup script/setup.sh scripts/setup.sh; do + if [ -x "$f" ]; then + echo "Running $f" + ./"$f" + break + fi + done - name: Extract baseline snapshot working-directory: openapi-spec diff --git a/scripts/sdk-generate-all.sh b/scripts/sdk-generate-all.sh index 45bda04..06404a2 100755 --- a/scripts/sdk-generate-all.sh +++ b/scripts/sdk-generate-all.sh @@ -32,6 +32,15 @@ for lang in $LANGUAGES; do echo "Generating SDK: $lang -> $OUTPUT" echo "========================================" + # Run the SDK's setup script if it exists (installs formatters, deps, etc.) + for f in script/setup scripts/setup script/setup.sh scripts/setup.sh; do + if [[ -x "$OUTPUT/$f" ]]; then + echo "Running setup: $OUTPUT/$f" + (cd "$OUTPUT" && ./"$f") + break + fi + done + if ! npm --prefix "$REPO_ROOT" run sdk:generate -- --lang "$lang" --output "$OUTPUT"; then echo "[FAIL] Generation failed for: $lang" FAILED+=("$lang (generation failed)") From 7bd7a61b4a603066733000134619bb99cbbf3f8d Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 16:56:31 -0400 Subject: [PATCH 26/38] Revert "fix: Run SDK setup scripts before generation instead of hardcoding" This reverts commit a14bed877639fac2ce020b58f01bee46c35e66c4. --- .github/workflows/validate-sdks.yml | 13 +++++-------- scripts/sdk-generate-all.sh | 9 --------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index b090572..8750db0 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -118,16 +118,13 @@ jobs: with: php-version: ${{ steps.sdk-version.outputs.version }} - - name: Setup SDK + - name: Install SDK formatter tools working-directory: ${{ matrix.sdk_checkout_path }} run: | - for f in script/setup scripts/setup script/setup.sh scripts/setup.sh; do - if [ -x "$f" ]; then - echo "Running $f" - ./"$f" - break - fi - done + case "${{ matrix.language }}" in + python) uv tool install ruff ;; + php) composer install --prefer-dist --no-progress --no-interaction ;; + esac - name: Extract baseline snapshot working-directory: openapi-spec diff --git a/scripts/sdk-generate-all.sh b/scripts/sdk-generate-all.sh index 06404a2..45bda04 100755 --- a/scripts/sdk-generate-all.sh +++ b/scripts/sdk-generate-all.sh @@ -32,15 +32,6 @@ for lang in $LANGUAGES; do echo "Generating SDK: $lang -> $OUTPUT" echo "========================================" - # Run the SDK's setup script if it exists (installs formatters, deps, etc.) - for f in script/setup scripts/setup script/setup.sh scripts/setup.sh; do - if [[ -x "$OUTPUT/$f" ]]; then - echo "Running setup: $OUTPUT/$f" - (cd "$OUTPUT" && ./"$f") - break - fi - done - if ! npm --prefix "$REPO_ROOT" run sdk:generate -- --lang "$lang" --output "$OUTPUT"; then echo "[FAIL] Generation failed for: $lang" FAILED+=("$lang (generation failed)") From 752732a5a9c934cb3b56ef31b659e32770eab8a2 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 17:18:21 -0400 Subject: [PATCH 27/38] fix: cross-category merge for same-field changes in SDK compat report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge logic now uses a two-pass approach: 1. Same-category merge (existing) — collapses rows with matching category + normalized local symbol name across non-overlapping langs. 2. Cross-category merge (new) — rows sharing the same routeKey AND a common merge hint (affected field name) are folded together even when categories differ or languages overlap. When a language appears in multiple merged rows, the before/after cells are concatenated with deduplication. This collapses e.g. AdminPortal.generateLink param rename (php/ruby) with AdminPortalGenerateLinkOptions.AdminEmails removal (dotnet/go) into a single report entry, since they all stem from the same spec change on the same endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/sdk-compat-pr-comment.mjs | 121 +++++++++++++++++++++++++----- 1 file changed, 102 insertions(+), 19 deletions(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index 300f045..d992f3a 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -240,18 +240,61 @@ function normalizeLocalName(symbol) { return local.replace(/_/g, '').toLowerCase(); } +/** + * Merge a source row into a target bucket. When the same language already + * exists in the target, the before/after cells are concatenated (the two + * rows represent different symbols affected by the same spec change in + * that language, e.g. an options-type field + a model field). + */ +function foldRowInto(target, row) { + for (const [lang, entry] of Object.entries(row.perLanguage)) { + const existing = target.perLanguage[lang]; + if (existing) { + // Combine cells: deduplicate identical references + const prevSet = new Set(existing.previous.split('
')); + const nowSet = new Set(existing.now.split('
')); + for (const part of entry.previous.split('
')) { + if (part && !prevSet.has(part)) { + existing.previous += `
${part}`; + prevSet.add(part); + } + } + for (const part of entry.now.split('
')) { + if (part && !nowSet.has(part)) { + existing.now += `
${part}`; + nowSet.add(part); + } + } + } else { + target.perLanguage[lang] = { ...entry }; + } + } + target.severity = highestSeverity(target.severity, row.severity); + if (!target.routeKey && row.routeKey) target.routeKey = row.routeKey; + if (!target.operationId && row.operationId) target.operationId = row.operationId; + if (!target.service && row.service) target.service = row.service; + if (!target.detail && row.detail) target.detail = row.detail; +} + /** * Merge rows that represent the same spec-level change across languages. * - * Different languages produce different conceptualChangeIds for the same - * underlying change (e.g. AdminPortalGenerateLinkOptions.AdminEmails in - * dotnet vs GenerateLink.admin_emails in Ruby). This pass collapses them - * into a single row when: - * - they share the same change category - * - their normalized local symbol names match - * - their language sets don't overlap (no two entries for the same lang) + * Two merge passes: + * + * 1. **Category-local merge** — rows with the same change category and + * normalized local symbol name are merged when their language sets + * don't overlap (original logic). + * + * 2. **Cross-category merge** — rows that share a routeKey AND a common + * merge hint (the affected field name) are folded together even if + * their categories differ and languages overlap. This catches cases + * like a parameter rename in PHP/Ruby being the same spec change as a + * field removal in dotnet/Go + * (e.g. AdminPortal.generateLink param admin_emails ↔ + * AdminPortalGenerateLinkOptions.AdminEmails). */ function mergeRelatedRows(rows) { + // --- Pass 1: category + local name (existing logic) --- const groups = new Map(); for (const row of rows) { const key = `${row.category}:${normalizeLocalName(row.symbol)}`; @@ -259,10 +302,10 @@ function mergeRelatedRows(rows) { groups.get(key).push(row); } - const result = []; + const pass1 = []; for (const group of groups.values()) { if (group.length === 1) { - result.push(group[0]); + pass1.push(group[0]); continue; } @@ -284,22 +327,41 @@ function mergeRelatedRows(rows) { } if (target) { - for (const [lang, entry] of Object.entries(row.perLanguage)) { - target.perLanguage[lang] = entry; - } - target.severity = highestSeverity(target.severity, row.severity); - if (!target.routeKey && row.routeKey) target.routeKey = row.routeKey; - if (!target.operationId && row.operationId) target.operationId = row.operationId; - if (!target.service && row.service) target.service = row.service; - if (!target.detail && row.detail) target.detail = row.detail; + foldRowInto(target, row); } else { buckets.push(row); } } - result.push(...buckets); + pass1.push(...buckets); + } + + // --- Pass 2: cross-category merge via routeKey + mergeHints --- + // Only attempt this when rows share a routeKey (same HTTP endpoint) + // and have a common normalized merge hint (the affected field name). + const routeGroups = new Map(); + for (const row of pass1) { + if (!row.routeKey) continue; + for (const hint of row.mergeHints ?? []) { + const key = `${row.routeKey}:${hint}`; + if (!routeGroups.has(key)) routeGroups.set(key, []); + routeGroups.get(key).push(row); + } + } + + const absorbed = new Set(); + for (const group of routeGroups.values()) { + if (group.length <= 1) continue; + // Pick the first row as the target; fold the rest into it + const target = group[0]; + for (let i = 1; i < group.length; i++) { + const row = group[i]; + if (row === target || absorbed.has(row)) continue; + foldRowInto(target, row); + absorbed.add(row); + } } - return result; + return pass1.filter((row) => !absorbed.has(row)); } function buildRollup(languageData) { @@ -327,9 +389,30 @@ function buildRollup(languageData) { operationId: meta.operationId ?? '', service: manifestEntry?.service ?? '', symbol: change.symbol, + mergeHints: [], perLanguage: {}, }; + // Collect normalized field names for cross-category merge. + // The local symbol name (e.g. "AdminEmails" from + // "AdminPortalGenerateLinkOptions.AdminEmails") and any affected + // parameter name (e.g. "adminEmails" from a parameter rename) + // are all normalized so they can match across languages. + const symbolHint = normalizeLocalName(change.symbol); + if (symbolHint && !row.mergeHints.includes(symbolHint)) row.mergeHints.push(symbolHint); + for (const key of ['parameter', 'name']) { + const oldVal = change.old?.[key]; + if (oldVal && oldVal !== '(removed)' && oldVal !== '(absent)') { + const hint = normalizeLocalName(oldVal); + if (hint && !row.mergeHints.includes(hint)) row.mergeHints.push(hint); + } + const newVal = change.new?.[key]; + if (newVal && newVal !== '(removed)' && newVal !== '(absent)') { + const hint = normalizeLocalName(newVal); + if (hint && !row.mergeHints.includes(hint)) row.mergeHints.push(hint); + } + } + row.severity = highestSeverity(row.severity, change.severity); if (!row.routeKey && routeKey) row.routeKey = routeKey; if (!row.operationId && meta.operationId) row.operationId = meta.operationId; From e9053b76d5c75149d8cd5a17248f478926fb90c2 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 17:20:04 -0400 Subject: [PATCH 28/38] chore: update deps --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a94e21..1549896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "workos-openapi-spec", "version": "0.0.1", "dependencies": { - "@workos/oagen": "^0.10.2", + "@workos/oagen": "^0.10.3", "@workos/oagen-emitters": "^0.6.1", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" @@ -518,9 +518,9 @@ } }, "node_modules/@workos/oagen": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.10.2.tgz", - "integrity": "sha512-h61yIxsagzeV5EKfnKwaQ4Yi1WaxBwPgABI4I+AV7f4uE3BH2+IIrV4i1La3u0ZdOFqzSBn6+g1KwQosTC4lyA==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.10.3.tgz", + "integrity": "sha512-rCnlXRaxknpTlhl1M15gTOURhm4BxN8g7aUdrNHwIvGZoSzR+ajJvmFAJBMklkofAgbZ4AXqECMlIgSZQBbaoQ==", "license": "MIT", "dependencies": { "@redocly/openapi-core": "^2.25.1", diff --git a/package.json b/package.json index 5f1e9bd..cec0294 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prepare": "husky" }, "dependencies": { - "@workos/oagen": "^0.10.2", + "@workos/oagen": "^0.10.3", "@workos/oagen-emitters": "^0.6.1", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" From 660dece2a44dff79253cb7f88483d680450d3d28 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 19:02:29 -0400 Subject: [PATCH 29/38] fix: collapse related SDK compat changes into single entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single spec-level rename (e.g. admin_emails → it_contact_emails) was appearing as up to 5 separate entries in the PR comment because type-level changes (Options/Params field remove+add) couldn't merge with method-level changes (parameter renames) across languages. Three new merge passes: - Pair symbol_removed + symbol_added into symbol_renamed when they share an owner type and language set (1:1 only, to avoid false positives) - Absorb non-routeKey rows (type-level) into routeKey anchors (method-level) via shared merge hints - Group additive section by service and show method signatures for callable symbols --- scripts/sdk-compat-pr-comment.mjs | 169 ++++++++++++++++++++++++++++-- 1 file changed, 158 insertions(+), 11 deletions(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index d992f3a..b7c31e6 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -293,6 +293,73 @@ function foldRowInto(target, row) { * (e.g. AdminPortal.generateLink param admin_emails ↔ * AdminPortalGenerateLinkOptions.AdminEmails). */ +/** + * Pair symbol_removed + symbol_added rows that share an owner type and + * language set into a single symbol_renamed row. This collapses the common + * pattern where a spec field rename surfaces as a removal + addition in + * languages that use Options/Params types (dotnet, go) while appearing as a + * parameter rename in languages that use positional args (php, ruby). + * + * Only pairs when there is exactly one removal and one addition for a given + * (owner-type, language-set) combination to avoid false positives. + */ +function pairRemoveAddRows(rows) { + // Index remove/add rows by normalized (owner-type, language-set) key + const removeByKey = new Map(); + const addByKey = new Map(); + + for (const row of rows) { + if (row.category !== 'symbol_removed' && row.category !== 'symbol_added') continue; + const dot = row.symbol.lastIndexOf('.'); + if (dot === -1) continue; + const owner = row.symbol.substring(0, dot).replace(/_/g, '').toLowerCase(); + const langKey = Object.keys(row.perLanguage).sort().join(','); + const groupKey = `${owner}:${langKey}`; + + const map = row.category === 'symbol_removed' ? removeByKey : addByKey; + if (!map.has(groupKey)) map.set(groupKey, []); + map.get(groupKey).push(row); + } + + const absorbed = new Set(); + const renamed = []; + + for (const [groupKey, removeRows] of removeByKey) { + if (removeRows.length !== 1) continue; // ambiguous — skip + const addRows = addByKey.get(groupKey); + if (!addRows || addRows.length !== 1) continue; // ambiguous — skip + + const rr = removeRows[0]; + const ar = addRows[0]; + if (absorbed.has(rr) || absorbed.has(ar)) continue; + + absorbed.add(rr); + absorbed.add(ar); + + const merged = { + ...rr, + category: 'symbol_renamed', + severity: 'breaking', + perLanguage: {}, + mergeHints: [...new Set([...(rr.mergeHints ?? []), ...(ar.mergeHints ?? [])])], + }; + if (!merged.routeKey && ar.routeKey) merged.routeKey = ar.routeKey; + if (!merged.operationId && ar.operationId) merged.operationId = ar.operationId; + if (!merged.service && ar.service) merged.service = ar.service; + + for (const lang of Object.keys(rr.perLanguage)) { + merged.perLanguage[lang] = { + previous: rr.perLanguage[lang]?.previous || '—', + now: ar.perLanguage[lang]?.now || '—', + }; + } + + renamed.push(merged); + } + + return [...rows.filter((r) => !absorbed.has(r)), ...renamed]; +} + function mergeRelatedRows(rows) { // --- Pass 1: category + local name (existing logic) --- const groups = new Map(); @@ -335,11 +402,14 @@ function mergeRelatedRows(rows) { pass1.push(...buckets); } + // --- Pass 1.5: pair symbol_removed + symbol_added into symbol_renamed --- + const afterPairing = pairRemoveAddRows(pass1); + // --- Pass 2: cross-category merge via routeKey + mergeHints --- // Only attempt this when rows share a routeKey (same HTTP endpoint) // and have a common normalized merge hint (the affected field name). const routeGroups = new Map(); - for (const row of pass1) { + for (const row of afterPairing) { if (!row.routeKey) continue; for (const hint of row.mergeHints ?? []) { const key = `${row.routeKey}:${hint}`; @@ -351,7 +421,6 @@ function mergeRelatedRows(rows) { const absorbed = new Set(); for (const group of routeGroups.values()) { if (group.length <= 1) continue; - // Pick the first row as the target; fold the rest into it const target = group[0]; for (let i = 1; i < group.length; i++) { const row = group[i]; @@ -361,7 +430,37 @@ function mergeRelatedRows(rows) { } } - return pass1.filter((row) => !absorbed.has(row)); + // --- Pass 3: absorb non-routeKey rows into unique anchors --- + // A row without a routeKey (e.g. an Options-type field change) can be + // folded into a routeKey row (e.g. a service method param change) when + // they share a merge hint — they represent the same spec-level change + // surfacing differently across languages. Only absorb when there is + // exactly one candidate anchor to avoid ambiguous merges. + const hintToAnchor = new Map(); + for (const row of afterPairing) { + if (!row.routeKey || absorbed.has(row)) continue; + for (const hint of row.mergeHints ?? []) { + if (hint.length < 4) continue; // skip generic hints like "id" + if (!hintToAnchor.has(hint)) hintToAnchor.set(hint, new Set()); + hintToAnchor.get(hint).add(row); + } + } + + for (const row of afterPairing) { + if (row.routeKey || absorbed.has(row)) continue; + const candidates = new Set(); + for (const hint of row.mergeHints ?? []) { + if (hint.length < 4) continue; + const anchors = hintToAnchor.get(hint); + if (anchors) for (const a of anchors) candidates.add(a); + } + if (candidates.size === 1) { + foldRowInto([...candidates][0], row); + absorbed.add(row); + } + } + + return afterPairing.filter((row) => !absorbed.has(row)); } function buildRollup(languageData) { @@ -391,6 +490,8 @@ function buildRollup(languageData) { symbol: change.symbol, mergeHints: [], perLanguage: {}, + kind: '', + signature: '', }; // Collect normalized field names for cross-category merge. @@ -418,6 +519,10 @@ function buildRollup(languageData) { if (!row.operationId && meta.operationId) row.operationId = meta.operationId; if (!row.service && manifestEntry?.service) row.service = manifestEntry.service; if (!row.detail && change.message) row.detail = change.message; + if (!row.kind && meta.kind) row.kind = meta.kind; + if (!row.signature && meta.candidateSymbol?.parameters?.length) { + row.signature = formatParamList(meta.candidateSymbol.parameters); + } if (manifestEntries.length > 1) { row.perLanguage[entry.language] = { @@ -564,22 +669,64 @@ function renderDetailedSection(lines, title, rows, languages, open) { lines.push(''); } -/** Render additive section: compact list with language coverage. */ +/** Render additive section: grouped by service with method signatures. */ function renderCompactSection(lines, title, rows, languages) { lines.push('
'); lines.push(`

${title} (${rows.length})

`); lines.push(''); - lines.push('| Change | Languages |'); - lines.push('| --- | --- |'); - + // Group by service + const byService = new Map(); + const noService = []; for (const row of rows) { - const affected = languages.filter((lang) => row.perLanguage[lang]); - const langStr = affected.length === languages.length ? 'all' : affected.join(', '); - lines.push(`| ${escapeCell(`\`${row.symbol}\` ${categoryVerb(row.category)}`)} | ${langStr} |`); + if (row.service) { + if (!byService.has(row.service)) byService.set(row.service, []); + byService.get(row.service).push(row); + } else { + noService.push(row); + } + } + + function renderAdditiveGroup(groupRows) { + const methods = groupRows.filter((r) => r.kind === 'callable'); + const others = groupRows.filter((r) => r.kind !== 'callable'); + + if (methods.length > 0) { + for (const row of methods) { + const affected = languages.filter((lang) => row.perLanguage[lang]); + const langStr = affected.length === languages.length ? 'all' : affected.join(', '); + const sig = row.signature ? `(${row.signature})` : ''; + lines.push(`- \`${row.symbol}${sig}\` — ${langStr}`); + } + lines.push(''); + } + + if (others.length > 0) { + lines.push('| Change | Languages |'); + lines.push('| --- | --- |'); + for (const row of others) { + const affected = languages.filter((lang) => row.perLanguage[lang]); + const langStr = affected.length === languages.length ? 'all' : affected.join(', '); + lines.push(`| ${escapeCell(`\`${row.symbol}\` ${categoryVerb(row.category)}`)} | ${langStr} |`); + } + lines.push(''); + } + } + + for (const [service, serviceRows] of [...byService].sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push(`#### ${service}`); + lines.push(''); + renderAdditiveGroup(serviceRows); + } + + if (noService.length > 0) { + if (byService.size > 0) { + lines.push('#### Other'); + lines.push(''); + } + renderAdditiveGroup(noService); } - lines.push(''); lines.push('
'); lines.push(''); } From b904a8e55a7d6b1c840271d8e712f3d855dcb793 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 20:00:09 -0400 Subject: [PATCH 30/38] chore: update deps --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1549896..827d889 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "@workos/oagen": "^0.10.3", - "@workos/oagen-emitters": "^0.6.1", + "@workos/oagen-emitters": "^0.6.2", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" }, @@ -547,9 +547,9 @@ } }, "node_modules/@workos/oagen-emitters": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@workos/oagen-emitters/-/oagen-emitters-0.6.1.tgz", - "integrity": "sha512-1rNeA6O0rnLwheRT7W6mF/kqi4R/H5VuqhO2haT0VlhIOAepIhCkwP6cvYVuWyBsaBkLlkqpacXKhenmMzmgSw==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@workos/oagen-emitters/-/oagen-emitters-0.6.2.tgz", + "integrity": "sha512-4oet4oDQUHr4gBiVjdMf/e7LkM6mIyH8oepvFp97jqjVWNjjXbMyS/EeDLYDLZ8N971ZdPCC31A08sRO8eCNQQ==", "license": "MIT", "dependencies": { "@workos/oagen": "^0.9.0" diff --git a/package.json b/package.json index cec0294..f2ff392 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@workos/oagen": "^0.10.3", - "@workos/oagen-emitters": "^0.6.1", + "@workos/oagen-emitters": "^0.6.2", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" }, From e592b19c430858c6805b3377172cf99180710124 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 20:06:41 -0400 Subject: [PATCH 31/38] fix: collapse same-method changes into a single table Multiple param removals (or reorders, renames, etc.) on the same method were each rendered as a separate heading + table, making the report noisy. Group rows that share the same symbol and category into one block with a combined table. --- scripts/sdk-compat-pr-comment.mjs | 41 +++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index b7c31e6..d4c8c31 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -611,23 +611,50 @@ function compactCell(entry) { return `${prev} → ${now}`; } -/** Render changes as per-change blocks with before/after code samples. */ +/** Render changes as per-change blocks with before/after code samples. + * Rows that share the same symbol + category are collapsed into a single + * table so that e.g. three param removals on one method render as one block. + */ function renderChangeBlocks(lines, rows, languages) { + // Group rows by symbol + category + const groups = []; + const groupIndex = new Map(); for (const row of rows) { - const activeLangs = languages.filter((lang) => row.perLanguage[lang]); + const key = `${row.symbol}:${row.category}`; + let idx = groupIndex.get(key); + if (idx === undefined) { + idx = groups.length; + groupIndex.set(key, idx); + groups.push([]); + } + groups[idx].push(row); + } + + for (const group of groups) { + const first = group[0]; + + // Collect all active languages across the group + const activeLangs = languages.filter((lang) => group.some((r) => r.perLanguage[lang])); if (activeLangs.length === 0) continue; - const desc = `\`${row.symbol}\` ${categoryVerb(row.category)}`; - const route = row.routeKey ? ` — \`${row.routeKey}\`` : ''; + const desc = `\`${first.symbol}\` ${categoryVerb(first.category)}`; + const route = first.routeKey ? ` — \`${first.routeKey}\`` : ''; lines.push(`**${desc}**${route}`); lines.push(''); lines.push('| Language | Before | After |'); lines.push('| --- | --- | --- |'); for (const lang of activeLangs) { - const entry = row.perLanguage[lang]; - const before = entry.previous || '—'; - const after = entry.now || '—'; + const befores = []; + const afters = []; + for (const row of group) { + const entry = row.perLanguage[lang]; + if (!entry) continue; + if (entry.previous && entry.previous !== '—') befores.push(entry.previous); + if (entry.now && entry.now !== '—') afters.push(entry.now); + } + const before = befores.length > 0 ? befores.join('
') : '—'; + const after = afters.length > 0 ? afters.join('
') : '—'; lines.push(`| ${lang} | ${escapeCell(before)} | ${escapeCell(after)} |`); } lines.push(''); From 721d20e792440fe24c49c4a7f4c7f2c5d1013de4 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 20:47:39 -0400 Subject: [PATCH 32/38] chore: update deps --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 827d889..6e96900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,8 @@ "name": "workos-openapi-spec", "version": "0.0.1", "dependencies": { - "@workos/oagen": "^0.10.3", - "@workos/oagen-emitters": "^0.6.2", + "@workos/oagen": "^0.10.4", + "@workos/oagen-emitters": "^0.6.3", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" }, @@ -518,9 +518,9 @@ } }, "node_modules/@workos/oagen": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.10.3.tgz", - "integrity": "sha512-rCnlXRaxknpTlhl1M15gTOURhm4BxN8g7aUdrNHwIvGZoSzR+ajJvmFAJBMklkofAgbZ4AXqECMlIgSZQBbaoQ==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.10.4.tgz", + "integrity": "sha512-EZVJdZeppGrZdDzPX4y8T6VyWezsWqJizSxIfHDKp2RDsnwTeHYse0puScwx41K+H49eOu8Ko4DWMoyR1inSeg==", "license": "MIT", "dependencies": { "@redocly/openapi-core": "^2.25.1", @@ -547,9 +547,9 @@ } }, "node_modules/@workos/oagen-emitters": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@workos/oagen-emitters/-/oagen-emitters-0.6.2.tgz", - "integrity": "sha512-4oet4oDQUHr4gBiVjdMf/e7LkM6mIyH8oepvFp97jqjVWNjjXbMyS/EeDLYDLZ8N971ZdPCC31A08sRO8eCNQQ==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@workos/oagen-emitters/-/oagen-emitters-0.6.3.tgz", + "integrity": "sha512-IV0FmwcUBSQpqkkbUxvDuNf7yZlrrOpdDAdGCnJYgoQut49rTkJmpLtZuHhwKmp1geoyu9DrlYKyE/dQknFOHw==", "license": "MIT", "dependencies": { "@workos/oagen": "^0.9.0" diff --git a/package.json b/package.json index f2ff392..da42ee0 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "prepare": "husky" }, "dependencies": { - "@workos/oagen": "^0.10.3", - "@workos/oagen-emitters": "^0.6.2", + "@workos/oagen": "^0.10.4", + "@workos/oagen-emitters": "^0.6.3", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" }, From d30ed8389a1f71c63e4cfc1bb1afca5b54a8c176 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 21:11:17 -0400 Subject: [PATCH 33/38] chore: update deps --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6e96900..8c1539a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "workos-openapi-spec", "version": "0.0.1", "dependencies": { - "@workos/oagen": "^0.10.4", + "@workos/oagen": "^0.11.0", "@workos/oagen-emitters": "^0.6.3", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" @@ -518,9 +518,9 @@ } }, "node_modules/@workos/oagen": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.10.4.tgz", - "integrity": "sha512-EZVJdZeppGrZdDzPX4y8T6VyWezsWqJizSxIfHDKp2RDsnwTeHYse0puScwx41K+H49eOu8Ko4DWMoyR1inSeg==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.11.0.tgz", + "integrity": "sha512-ZaSQmJJmx7IPJdSyvA/jp9NWX5M6/tdMhJMamfXxUN19v86WoQ/3ZBS9zM3LjO4By3uB0eM46aGfKsEvTfHVsw==", "license": "MIT", "dependencies": { "@redocly/openapi-core": "^2.25.1", diff --git a/package.json b/package.json index da42ee0..459009c 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prepare": "husky" }, "dependencies": { - "@workos/oagen": "^0.10.4", + "@workos/oagen": "^0.11.0", "@workos/oagen-emitters": "^0.6.3", "js-yaml": "^4.1.1", "openapi-to-postmanv2": "^6.0.1" From a068cb8e9d8e60b54319ab266e0c77f18d9609a2 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 21:11:42 -0400 Subject: [PATCH 34/38] feat: show symbol kind and source file in compat PR comment Add kind labels (function, field, enum, type, etc.) and source file paths to the SDK compatibility report so reviewers can quickly identify what each symbol is and where it lives. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/sdk-compat-pr-comment.mjs | 35 +++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index d4c8c31..5295179 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -108,6 +108,7 @@ function pickSymbolMeta(change, baselineIndex, candidateIndex) { route: routeMatch?.route, operationId: routeMatch?.operationId ?? anyMatch?.operationId, kind: routeMatch?.kind ?? anyMatch?.kind, + sourceFile: candidateSymbol?.sourceFile ?? baselineSymbol?.sourceFile, }; } @@ -524,6 +525,7 @@ function buildRollup(languageData) { row.signature = formatParamList(meta.candidateSymbol.parameters); } + const langSourceFile = meta.sourceFile ?? ''; if (manifestEntries.length > 1) { row.perLanguage[entry.language] = { previous: formatPreviousState(change, meta.baselineSymbol), @@ -531,11 +533,13 @@ function buildRollup(languageData) { .map((item) => formatNowState(change, meta.candidateSymbol, item)) .filter(Boolean) .join('

'), + sourceFile: langSourceFile, }; } else if (manifestEntry) { row.perLanguage[entry.language] = { previous: formatPreviousState(change, meta.baselineSymbol), now: formatNowState(change, meta.candidateSymbol, manifestEntry), + sourceFile: langSourceFile, }; } else { row.perLanguage[entry.language] = { @@ -543,6 +547,7 @@ function buildRollup(languageData) { now: formatNowState(change, meta.candidateSymbol, undefined) || (routeKey ? `manifest entry missing for ${routeKey}` : 'non-operation symbol'), + sourceFile: langSourceFile, }; } @@ -568,6 +573,21 @@ function buildRollup(languageData) { // Rendering helpers // --------------------------------------------------------------------------- +const KIND_LABELS = { + service_accessor: 'service', + callable: 'function', + constructor: 'constructor', + field: 'field', + property: 'property', + enum: 'enum', + enum_member: 'enum value', + alias: 'type', +}; + +function kindLabel(kind) { + return KIND_LABELS[kind] ?? ''; +} + const CATEGORY_VERBS = { symbol_removed: 'removed', symbol_added: 'added', @@ -637,7 +657,9 @@ function renderChangeBlocks(lines, rows, languages) { const activeLangs = languages.filter((lang) => group.some((r) => r.perLanguage[lang])); if (activeLangs.length === 0) continue; - const desc = `\`${first.symbol}\` ${categoryVerb(first.category)}`; + const kl = kindLabel(first.kind); + const kindTag = kl ? ` _(${kl})_` : ''; + const desc = `\`${first.symbol}\` ${categoryVerb(first.category)}${kindTag}`; const route = first.routeKey ? ` — \`${first.routeKey}\`` : ''; lines.push(`**${desc}**${route}`); lines.push(''); @@ -647,15 +669,18 @@ function renderChangeBlocks(lines, rows, languages) { for (const lang of activeLangs) { const befores = []; const afters = []; + const sourceFiles = new Set(); for (const row of group) { const entry = row.perLanguage[lang]; if (!entry) continue; + if (entry.sourceFile) sourceFiles.add(entry.sourceFile); if (entry.previous && entry.previous !== '—') befores.push(entry.previous); if (entry.now && entry.now !== '—') afters.push(entry.now); } const before = befores.length > 0 ? befores.join('
') : '—'; const after = afters.length > 0 ? afters.join('
') : '—'; - lines.push(`| ${lang} | ${escapeCell(before)} | ${escapeCell(after)} |`); + const fileSuffix = sourceFiles.size > 0 ? `
📄 ${[...sourceFiles].join(', ')}` : ''; + lines.push(`| ${lang} | ${escapeCell(before + fileSuffix)} | ${escapeCell(after)} |`); } lines.push(''); } @@ -723,7 +748,7 @@ function renderCompactSection(lines, title, rows, languages) { const affected = languages.filter((lang) => row.perLanguage[lang]); const langStr = affected.length === languages.length ? 'all' : affected.join(', '); const sig = row.signature ? `(${row.signature})` : ''; - lines.push(`- \`${row.symbol}${sig}\` — ${langStr}`); + lines.push(`- \`${row.symbol}${sig}\` _(function)_ — ${langStr}`); } lines.push(''); } @@ -734,7 +759,9 @@ function renderCompactSection(lines, title, rows, languages) { for (const row of others) { const affected = languages.filter((lang) => row.perLanguage[lang]); const langStr = affected.length === languages.length ? 'all' : affected.join(', '); - lines.push(`| ${escapeCell(`\`${row.symbol}\` ${categoryVerb(row.category)}`)} | ${langStr} |`); + const kl = kindLabel(row.kind); + const kindTag = kl ? ` _(${kl})_` : ''; + lines.push(`| ${escapeCell(`\`${row.symbol}\` ${categoryVerb(row.category)}${kindTag}`)} | ${langStr} |`); } lines.push(''); } From 543f335498c4d4a557953876099fe96d8041394c Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 21:17:41 -0400 Subject: [PATCH 35/38] fix: include hidden files in diagnostic artifact upload The .oagen-compat-snapshot.json files were being excluded from CI artifact uploads because upload-artifact skips dotfiles by default. This meant the PR comment script had no snapshot data to read symbol kind or sourceFile from. Adding include-hidden-files: true fixes this. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/validate-sdks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index 8750db0..655fd42 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -184,6 +184,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: oagen-diagnostics-${{ matrix.language }} + include-hidden-files: true path: openapi-spec/.oagen/${{ matrix.language }}/ retention-days: 14 From 3cd0b0c5b3dc61891cbb70ee4d9baf33dcb09e6a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 21:22:18 -0400 Subject: [PATCH 36/38] fix: move source file annotation to Language column Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/sdk-compat-pr-comment.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index 5295179..859d7c0 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -680,7 +680,7 @@ function renderChangeBlocks(lines, rows, languages) { const before = befores.length > 0 ? befores.join('
') : '—'; const after = afters.length > 0 ? afters.join('
') : '—'; const fileSuffix = sourceFiles.size > 0 ? `
📄 ${[...sourceFiles].join(', ')}` : ''; - lines.push(`| ${lang} | ${escapeCell(before + fileSuffix)} | ${escapeCell(after)} |`); + lines.push(`| ${lang}${fileSuffix} | ${escapeCell(before)} | ${escapeCell(after)} |`); } lines.push(''); } From ea036d917b4f27bae517ef95758920bc5ab3345a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 21:23:53 -0400 Subject: [PATCH 37/38] fix: use table for all additive changes, show filenames per-language Replace the bulleted list for callable additions with a unified table. Show source file paths per-language in the Languages column. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/sdk-compat-pr-comment.mjs | 43 ++++++++++++++----------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index 859d7c0..b216e82 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -739,32 +739,29 @@ function renderCompactSection(lines, title, rows, languages) { } } - function renderAdditiveGroup(groupRows) { - const methods = groupRows.filter((r) => r.kind === 'callable'); - const others = groupRows.filter((r) => r.kind !== 'callable'); - - if (methods.length > 0) { - for (const row of methods) { - const affected = languages.filter((lang) => row.perLanguage[lang]); - const langStr = affected.length === languages.length ? 'all' : affected.join(', '); - const sig = row.signature ? `(${row.signature})` : ''; - lines.push(`- \`${row.symbol}${sig}\` _(function)_ — ${langStr}`); - } - lines.push(''); + function formatAdditiveLangs(row) { + const affected = languages.filter((lang) => row.perLanguage[lang]); + if (affected.length === languages.length && affected.every((lang) => !row.perLanguage[lang]?.sourceFile)) { + return 'all'; } + return affected + .map((lang) => { + const sf = row.perLanguage[lang]?.sourceFile; + return sf ? `${lang}
📄 ${sf}` : lang; + }) + .join('
'); + } - if (others.length > 0) { - lines.push('| Change | Languages |'); - lines.push('| --- | --- |'); - for (const row of others) { - const affected = languages.filter((lang) => row.perLanguage[lang]); - const langStr = affected.length === languages.length ? 'all' : affected.join(', '); - const kl = kindLabel(row.kind); - const kindTag = kl ? ` _(${kl})_` : ''; - lines.push(`| ${escapeCell(`\`${row.symbol}\` ${categoryVerb(row.category)}${kindTag}`)} | ${langStr} |`); - } - lines.push(''); + function renderAdditiveGroup(groupRows) { + lines.push('| Change | Languages |'); + lines.push('| --- | --- |'); + for (const row of groupRows) { + const kl = kindLabel(row.kind); + const kindTag = kl ? ` _(${kl})_` : ''; + const sig = row.kind === 'callable' && row.signature ? `(${row.signature})` : ''; + lines.push(`| ${escapeCell(`\`${row.symbol}${sig}\` ${categoryVerb(row.category)}${kindTag}`)} | ${escapeCell(formatAdditiveLangs(row))} |`); } + lines.push(''); } for (const [service, serviceRows] of [...byService].sort((a, b) => a[0].localeCompare(b[0]))) { From 8882073ae8f72e6784802e2bf5a7f8ed254947a4 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sun, 26 Apr 2026 21:46:41 -0400 Subject: [PATCH 38/38] feat: restructure compat report with domain grouping and compact rendering The PR comment was ~750 lines for 104 breaking changes, requiring reviewers to scroll through individual Before/After tables for every symbol. The new format adds a "Changes by domain" summary table, groups breaking changes by API domain and change type (removals, renames, param changes), and renders removals as bullet lists and param changes as per-method summary rows. Also adds `npm run sdk:compat-report` for local report generation. --- .gitignore | 1 + package.json | 1 + scripts/sdk-compat-pr-comment.mjs | 348 ++++++++++++++++++++++++++---- 3 files changed, 308 insertions(+), 42 deletions(-) diff --git a/.gitignore b/.gitignore index 11b1111..69d269b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.local.json .oagen +compat-report.md diff --git a/package.json b/package.json index 459009c..01d5dec 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "sdk:compat-extract": "bash scripts/sdk-compat-extract.sh", "sdk:compat-diff": "bash scripts/sdk-compat-diff.sh", "sdk:compat-summary": "bash scripts/sdk-compat-summary.sh", + "sdk:compat-report": "node scripts/sdk-compat-pr-comment.mjs --artifacts-root .oagen --output compat-report.md --build-result success", "sdk:languages": "node -e \"console.log(JSON.parse(require('fs').readFileSync('.github/sdk-matrix.json','utf8')).map(e=>e.language).join('\\n'))\"", "sdk:generate-all": "bash scripts/sdk-generate-all.sh --parent-dir", "sdk:check": "npx oagen resolve --spec spec/open-api-spec.yaml --format json > /dev/null && echo 'Config loaded successfully'", diff --git a/scripts/sdk-compat-pr-comment.mjs b/scripts/sdk-compat-pr-comment.mjs index b216e82..4f2e1c1 100644 --- a/scripts/sdk-compat-pr-comment.mjs +++ b/scripts/sdk-compat-pr-comment.mjs @@ -631,6 +631,120 @@ function compactCell(entry) { return `${prev} → ${now}`; } +// --------------------------------------------------------------------------- +// Domain inference and compact rendering helpers +// --------------------------------------------------------------------------- + +/** + * Build a function that maps each row to a logical API domain. + * + * Resolution order: + * 1. row.service (set from the manifest for callable symbols) + * 2. Prefix-match the symbol root against known service names + * 3. Fall back to the symbol root itself (part before the first dot), + * consolidated so that if root A is a prefix of root B both map to A. + * e.g. DirectoryUser & DirectoryUserWithGroups → DirectoryUser; + * EventSchema & EventSchemaContext → EventSchema. + */ +function buildDomainResolver(rows) { + const services = new Set(); + for (const row of rows) { + if (row.service) services.add(row.service); + } + const sortedServices = [...services].sort((a, b) => b.length - a.length); + + // Collect every root (pre-dot component) and consolidate: when one root + // is a strict prefix of another, the longer one maps to the shorter. + const roots = new Set(); + for (const row of rows) { + const root = row.symbol.split('.')[0]; + if (root) roots.add(root); + } + + const rootToDomain = new Map(); + const sortedRoots = [...roots].sort((a, b) => a.length - b.length); + for (const root of roots) { + let best = root; + for (const shorter of sortedRoots) { + if (shorter.length >= root.length) break; + if (root.startsWith(shorter)) { + best = shorter; + break; + } + } + rootToDomain.set(root, best); + } + + return function deriveDomain(row) { + if (row.service) return row.service; + const root = row.symbol.split('.')[0]; + for (const svc of sortedServices) { + if (root.startsWith(svc)) return svc; + } + return rootToDomain.get(root) ?? (root || 'Other'); + }; +} + +const SUPER_CATEGORIES = { + symbol_removed: 'removed', + symbol_renamed: 'renamed', + symbol_added: 'added', + parameter_removed: 'params', + parameter_renamed: 'params', + parameter_type_narrowed: 'params', + parameter_requiredness_increased: 'params', + parameter_position_changed_order_sensitive: 'params', + parameter_added_optional_terminal: 'params', + parameter_added_non_terminal_optional: 'params', + constructor_position_changed_order_sensitive: 'params', + constructor_reordered_named_friendly: 'params', + field_type_changed: 'type_changed', + return_type_changed: 'type_changed', + enum_member_value_changed: 'type_changed', +}; + +function superCategory(category) { + return SUPER_CATEGORIES[category] ?? 'other'; +} + +/** + * Extract a compact description of a parameter change from a row, + * parsing the formatted per-language cells for parameter and position info. + */ +function describeParamChange(row) { + const firstEntry = Object.values(row.perLanguage)[0]; + if (!firstEntry) return categoryVerb(row.category); + + const oldParamMatch = firstEntry.previous?.match(/parameter:\s*`([^`]+)`/); + const newParamMatch = firstEntry.now?.match(/parameter:\s*`([^`]+)`/); + const param = oldParamMatch?.[1] ?? newParamMatch?.[1]; + + if (!param) return categoryVerb(row.category); + + if (row.category.includes('removed')) return `\`${param}\` removed`; + if (row.category.includes('renamed')) { + const newParam = newParamMatch?.[1]; + if (newParam && newParam !== param) return `\`${param}\` → \`${newParam}\``; + return `\`${param}\` renamed`; + } + if (row.category.includes('position') || row.category.includes('reordered')) { + const oldPos = firstEntry.previous?.match(/position:\s*`(\d+)`/)?.[1]; + const newPos = firstEntry.now?.match(/position:\s*`(\d+)`/)?.[1]; + if (oldPos && newPos) return `\`${param}\` moved ${oldPos}\u2192${newPos}`; + return `\`${param}\` reordered`; + } + if (row.category.includes('type')) { + const oldType = firstEntry.previous?.match(/type:\s*`([^`]+)`/)?.[1]; + const newType = firstEntry.now?.match(/type:\s*`([^`]+)`/)?.[1]; + if (oldType && newType) return `\`${param}\` type: \`${oldType}\` \u2192 \`${newType}\``; + return `\`${param}\` type changed`; + } + if (row.category.includes('required')) return `\`${param}\` now required`; + if (row.category.includes('added')) return `\`${param}\` added`; + + return `\`${param}\` ${categoryVerb(row.category)}`; +} + /** Render changes as per-change blocks with before/after code samples. * Rows that share the same symbol + category are collapsed into a single * table so that e.g. three param removals on one method render as one block. @@ -686,57 +800,60 @@ function renderChangeBlocks(lines, rows, languages) { } } -/** Render breaking / soft-risk section: grouped by service, with route shown inline. */ -function renderDetailedSection(lines, title, rows, languages, open) { +/** Render breaking / soft-risk section: grouped by domain, then by change type. */ +function renderDetailedSection(lines, title, rows, languages, open, deriveDomain) { lines.push(``); lines.push(`

${title} (${rows.length})

`); lines.push(''); - const byService = new Map(); - const noService = []; + if (!deriveDomain) deriveDomain = buildDomainResolver(rows); + + const byDomain = new Map(); for (const row of rows) { - if (row.service) { - if (!byService.has(row.service)) byService.set(row.service, []); - byService.get(row.service).push(row); - } else { - noService.push(row); - } + const domain = deriveDomain(row); + if (!byDomain.has(domain)) byDomain.set(domain, []); + byDomain.get(domain).push(row); } - for (const [service, serviceRows] of byService) { - lines.push(`#### ${service}`); + for (const [domain, domainRows] of byDomain) { + lines.push(`#### ${domain}`); lines.push(''); - renderChangeBlocks(lines, serviceRows, languages); - } - if (noService.length > 0) { - if (byService.size > 0) { - lines.push('#### Other changes'); - lines.push(''); + const removed = domainRows.filter((r) => superCategory(r.category) === 'removed'); + const renamed = domainRows.filter((r) => superCategory(r.category) === 'renamed'); + const params = domainRows.filter((r) => superCategory(r.category) === 'params'); + const typeChanged = domainRows.filter((r) => superCategory(r.category) === 'type_changed'); + const other = domainRows.filter((r) => { + const sc = superCategory(r.category); + return sc !== 'removed' && sc !== 'renamed' && sc !== 'params' && sc !== 'type_changed'; + }); + + renderRemovedRows(lines, removed, languages); + renderRenamedRows(lines, renamed, languages); + renderParamChangeRows(lines, params, languages); + renderTypeChangeRows(lines, typeChanged, languages); + if (other.length > 0) { + renderChangeBlocks(lines, other, languages); } - renderChangeBlocks(lines, noService, languages); } lines.push(''); lines.push(''); } -/** Render additive section: grouped by service with method signatures. */ -function renderCompactSection(lines, title, rows, languages) { +/** Render additive section: grouped by domain with method signatures. */ +function renderCompactSection(lines, title, rows, languages, deriveDomain) { lines.push('
'); lines.push(`

${title} (${rows.length})

`); lines.push(''); - // Group by service - const byService = new Map(); - const noService = []; + // Group by domain (matches the detailed section grouping) + if (!deriveDomain) deriveDomain = buildDomainResolver(rows); + const byDomain = new Map(); for (const row of rows) { - if (row.service) { - if (!byService.has(row.service)) byService.set(row.service, []); - byService.get(row.service).push(row); - } else { - noService.push(row); - } + const domain = deriveDomain(row); + if (!byDomain.has(domain)) byDomain.set(domain, []); + byDomain.get(domain).push(row); } function formatAdditiveLangs(row) { @@ -764,21 +881,162 @@ function renderCompactSection(lines, title, rows, languages) { lines.push(''); } - for (const [service, serviceRows] of [...byService].sort((a, b) => a[0].localeCompare(b[0]))) { - lines.push(`#### ${service}`); + for (const [domain, domainRows] of [...byDomain].sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push(`#### ${domain}`); + lines.push(''); + renderAdditiveGroup(domainRows); + } + + lines.push('
'); + lines.push(''); +} + +// --------------------------------------------------------------------------- +// Compact sub-renderers (used by the improved renderDetailedSection) +// --------------------------------------------------------------------------- + +/** Render removed rows compactly: methods as a table, types/fields in a collapsible table. */ +function renderRemovedRows(lines, rows, languages) { + if (rows.length === 0) return; + + const methods = rows.filter( + (r) => r.kind === 'callable' || r.kind === 'constructor' || r.kind === 'service_accessor', + ); + const others = rows.filter((r) => !methods.includes(r)); + + if (methods.length > 0) { + lines.push(`**Removed methods** (${methods.length})`); + lines.push(''); + lines.push('| Method | Languages |'); + lines.push('| --- | --- |'); + for (const row of methods) { + const langs = Object.keys(row.perLanguage).sort().join(', '); + const sig = row.signature ? `(${row.signature})` : ''; + lines.push(`| \`${row.symbol}${sig}\` | ${langs} |`); + } + lines.push(''); + } + + if (others.length > 0) { + const kindCounts = {}; + for (const row of others) { + const k = kindLabel(row.kind) || 'symbol'; + kindCounts[k] = (kindCounts[k] || 0) + 1; + } + const kindsDesc = Object.entries(kindCounts) + .map(([k, c]) => `${c} ${k}${c !== 1 ? 's' : ''}`) + .join(', '); + + lines.push(`
`); + lines.push(`${kindsDesc} removed`); + lines.push(''); + lines.push('| Symbol | Kind | Languages |'); + lines.push('| --- | --- | --- |'); + for (const row of others) { + const kl = kindLabel(row.kind) || 'symbol'; + const langs = Object.keys(row.perLanguage).sort().join(', '); + lines.push(`| \`${row.symbol}\` | ${kl} | ${langs} |`); + } + lines.push(''); + lines.push('
'); lines.push(''); - renderAdditiveGroup(serviceRows); } +} + +/** Render renamed rows as a compact before/after table. */ +function renderRenamedRows(lines, rows, languages) { + if (rows.length === 0) return; - if (noService.length > 0) { - if (byService.size > 0) { - lines.push('#### Other'); - lines.push(''); + lines.push(`**Renamed** (${rows.length})`); + lines.push(''); + lines.push('| Symbol | Before | After | Languages |'); + lines.push('| --- | --- | --- | --- |'); + + for (const row of rows) { + const langs = Object.keys(row.perLanguage).sort().join(', '); + const firstEntry = Object.values(row.perLanguage)[0]; + const before = extractRef(firstEntry?.previous); + const after = extractRef(firstEntry?.now); + lines.push( + `| \`${row.symbol}\` | ${escapeCell(before)} | ${escapeCell(after)} | ${langs} |`, + ); + } + lines.push(''); +} + +/** Render parameter changes grouped by parent method — one row per method. */ +function renderParamChangeRows(lines, rows, languages) { + if (rows.length === 0) return; + + const byMethod = new Map(); + for (const row of rows) { + if (!byMethod.has(row.symbol)) byMethod.set(row.symbol, []); + byMethod.get(row.symbol).push(row); + } + + lines.push(`**Parameter changes** (${rows.length})`); + lines.push(''); + lines.push('| Method | Changes | Languages |'); + lines.push('| --- | --- | --- |'); + + for (const [method, methodRows] of byMethod) { + const descriptions = methodRows.map((r) => describeParamChange(r)); + const langs = new Set(); + for (const r of methodRows) { + for (const lang of Object.keys(r.perLanguage)) langs.add(lang); } - renderAdditiveGroup(noService); + lines.push( + `| \`${method}\` | ${escapeCell(descriptions.join('; '))} | ${[...langs].sort().join(', ')} |`, + ); } + lines.push(''); +} - lines.push(''); +/** Render type/field/enum changes using the existing Before/After block format. */ +function renderTypeChangeRows(lines, rows, languages) { + if (rows.length === 0) return; + + lines.push(`**Type changes** (${rows.length})`); + lines.push(''); + renderChangeBlocks(lines, rows, languages); +} + +/** Render a domain-grouped summary table showing change counts per API domain. */ +function renderDomainSummary(lines, rows, languages, deriveDomain) { + if (!deriveDomain) deriveDomain = buildDomainResolver(rows); + const domainData = new Map(); + + for (const row of rows) { + const domain = deriveDomain(row); + if (!domainData.has(domain)) { + domainData.set(domain, { breaking: 0, softRisk: 0, additive: 0, languages: new Set() }); + } + const d = domainData.get(domain); + if (row.severity === 'breaking') d.breaking++; + else if (row.severity === 'soft-risk') d.softRisk++; + else d.additive++; + for (const lang of Object.keys(row.perLanguage)) d.languages.add(lang); + } + + const sorted = [...domainData.entries()].sort((a, b) => { + const totalA = a[1].breaking * 100 + a[1].softRisk * 10 + a[1].additive; + const totalB = b[1].breaking * 100 + b[1].softRisk * 10 + b[1].additive; + return totalB - totalA; + }); + + lines.push('### Changes by domain'); + lines.push(''); + lines.push('| Domain | Breaking | Soft-risk | Additive | Languages |'); + lines.push('| --- | --- | --- | --- | --- |'); + + for (const [domain, data] of sorted) { + const b = data.breaking || '\u2014'; + const s = data.softRisk || '\u2014'; + const a = data.additive || '\u2014'; + const langs = + data.languages.size === languages.length ? 'all' : [...data.languages].sort().join(', '); + lines.push(`| ${domain} | ${b} | ${s} | ${a} | ${langs} |`); + } lines.push(''); } @@ -830,16 +1088,22 @@ function renderMarkdown(languageData, buildResult) { const softRisk = rollup.rows.filter((r) => r.severity === 'soft-risk'); const additive = rollup.rows.filter((r) => r.severity === 'additive'); + // Build domain resolver once from all rows so type-only domains (e.g. + // EventSchema) are correctly grouped even when they have no service field. + const deriveDomain = buildDomainResolver(rollup.rows); + lines.push(''); + renderDomainSummary(lines, rollup.rows, rollup.languages, deriveDomain); + if (breaking.length > 0) { - renderDetailedSection(lines, 'Breaking', breaking, rollup.languages, true); + renderDetailedSection(lines, 'Breaking', breaking, rollup.languages, true, deriveDomain); } if (softRisk.length > 0) { - renderDetailedSection(lines, 'Soft-risk', softRisk, rollup.languages, false); + renderDetailedSection(lines, 'Soft-risk', softRisk, rollup.languages, false, deriveDomain); } if (additive.length > 0) { - renderCompactSection(lines, 'Additive', additive, rollup.languages); + renderCompactSection(lines, 'Additive', additive, rollup.languages, deriveDomain); } return lines.join('\n') + '\n';