From ad46996d204050a121944f029d9868b0bd7bde59 Mon Sep 17 00:00:00 2001 From: Aries Clark Date: Wed, 23 Sep 2026 17:18:06 -0400 Subject: [PATCH 1/2] feat: add GET and PUT /auth/user/interestsAndPreferences Closes #605 --- openapi/components/paths.yaml | 2 + openapi/components/paths/authentication.yaml | 35 ++++ .../GetInterestsAndPreferencesResponse.yaml | 27 +++ ...dateInterestsAndPreferencesParseError.yaml | 11 + ...UpdateInterestsAndPreferencesResponse.yaml | 11 + .../schemas/InterestsAndPreferences.yaml | 38 ++++ test/arazzo.yaml | 198 ++++++++++++++++++ 7 files changed, 322 insertions(+) create mode 100644 openapi/components/responses/authentication/GetInterestsAndPreferencesResponse.yaml create mode 100644 openapi/components/responses/authentication/UpdateInterestsAndPreferencesParseError.yaml create mode 100644 openapi/components/responses/authentication/UpdateInterestsAndPreferencesResponse.yaml create mode 100644 openapi/components/schemas/InterestsAndPreferences.yaml diff --git a/openapi/components/paths.yaml b/openapi/components/paths.yaml index 2917745f..e40d04ca 100644 --- a/openapi/components/paths.yaml +++ b/openapi/components/paths.yaml @@ -52,6 +52,8 @@ $ref: "./paths/friends.yaml#/paths/~1auth~1user~1friends" "/auth/user/friends/{userId}": $ref: "./paths/friends.yaml#/paths/~1auth~1user~1friends~1{userId}" +/auth/user/interestsAndPreferences: + $ref: "./paths/authentication.yaml#/paths/~1auth~1user~1interestsAndPreferences" /auth/user/notifications: $ref: "./paths/notifications.yaml#/paths/~1auth~1user~1notifications" /auth/user/notifications/clear: diff --git a/openapi/components/paths/authentication.yaml b/openapi/components/paths/authentication.yaml index e6e6fa70..390e5a5a 100644 --- a/openapi/components/paths/authentication.yaml +++ b/openapi/components/paths/authentication.yaml @@ -290,6 +290,41 @@ paths: $ref: ../responses/authentication/CreateAvatarModerationResponse.yaml "401": $ref: ../responses/MissingCredentialsError.yaml + /auth/user/interestsAndPreferences: + get: + operationId: getInterestsAndPreferences + summary: Get Interests and Preferences + description: Returns the interests and preferences the current user has turned on. + tags: + - authentication + security: + - authCookie: [] + responses: + "200": + $ref: ../responses/authentication/GetInterestsAndPreferencesResponse.yaml + "401": + $ref: ../responses/MissingCredentialsError.yaml + put: + operationId: updateInterestsAndPreferences + summary: Update Interests and Preferences + description: Turns interests and preferences on with `true` and off with `false`. A key the body leaves out keeps its value, and an unknown key or a value that is not a boolean is ignored. + tags: + - authentication + requestBody: + required: true + content: + application/json: + schema: + $ref: ../schemas/InterestsAndPreferences.yaml + security: + - authCookie: [] + responses: + "200": + $ref: ../responses/authentication/UpdateInterestsAndPreferencesResponse.yaml + "400": + $ref: ../responses/authentication/UpdateInterestsAndPreferencesParseError.yaml + "401": + $ref: ../responses/MissingCredentialsError.yaml /auth/user/resendEmail: post: operationId: resendEmailConfirmation diff --git a/openapi/components/responses/authentication/GetInterestsAndPreferencesResponse.yaml b/openapi/components/responses/authentication/GetInterestsAndPreferencesResponse.yaml new file mode 100644 index 00000000..307ac7f0 --- /dev/null +++ b/openapi/components/responses/authentication/GetInterestsAndPreferencesResponse.yaml @@ -0,0 +1,27 @@ +description: OK +content: + application/json: + examples: + All Off: + value: {} + All On: + value: + Anime: true + Art: true + Avatars: true + BigGroup: true + Explore: true + Fantasy: true + Fashion: true + FindAvatars: true + Furries: true + Horror: true + LanguageLearning: true + MeetPeople: true + Music: true + Mystery: true + SciFi: true + SmallGroup: true + Surprise: true + schema: + $ref: ../../schemas/InterestsAndPreferences.yaml diff --git a/openapi/components/responses/authentication/UpdateInterestsAndPreferencesParseError.yaml b/openapi/components/responses/authentication/UpdateInterestsAndPreferencesParseError.yaml new file mode 100644 index 00000000..1b93d91e --- /dev/null +++ b/openapi/components/responses/authentication/UpdateInterestsAndPreferencesParseError.yaml @@ -0,0 +1,11 @@ +description: The body is not a JSON object. +content: + application/json: + examples: + JSON Failed To Parse: + value: + error: + message: JSON failed to parse. + status_code: 400 + schema: + $ref: ../../schemas/Error.yaml diff --git a/openapi/components/responses/authentication/UpdateInterestsAndPreferencesResponse.yaml b/openapi/components/responses/authentication/UpdateInterestsAndPreferencesResponse.yaml new file mode 100644 index 00000000..b4a826a1 --- /dev/null +++ b/openapi/components/responses/authentication/UpdateInterestsAndPreferencesResponse.yaml @@ -0,0 +1,11 @@ +description: OK +content: + application/json: + examples: + Update Interests And Preferences Success: + value: + success: + message: Interests and preferences updated! + status_code: 200 + schema: + $ref: ../../schemas/Success.yaml diff --git a/openapi/components/schemas/InterestsAndPreferences.yaml b/openapi/components/schemas/InterestsAndPreferences.yaml new file mode 100644 index 00000000..68179d4e --- /dev/null +++ b/openapi/components/schemas/InterestsAndPreferences.yaml @@ -0,0 +1,38 @@ +title: InterestsAndPreferences +type: object +description: Interests and preferences the current user has turned on. A key is present only while its value is `true`. +properties: + Anime: + type: boolean + Art: + type: boolean + Avatars: + type: boolean + BigGroup: + type: boolean + Explore: + type: boolean + Fantasy: + type: boolean + Fashion: + type: boolean + FindAvatars: + type: boolean + Furries: + type: boolean + Horror: + type: boolean + LanguageLearning: + type: boolean + MeetPeople: + type: boolean + Music: + type: boolean + Mystery: + type: boolean + SciFi: + type: boolean + SmallGroup: + type: boolean + Surprise: + type: boolean diff --git a/test/arazzo.yaml b/test/arazzo.yaml index 4c14cf30..22c4a218 100644 --- a/test/arazzo.yaml +++ b/test/arazzo.yaml @@ -507,6 +507,30 @@ workflows: successCriteria: - condition: $statusCode == 401 + - workflowId: get-interests-and-preferences + summary: Interests and preferences + parameters: + - reference: $components.parameters.userAgent + x-security: + - schemeName: authCookie + values: + apiKey: $workflows.session.outputs.sessionToken + steps: + - stepId: interests-and-preferences + operationId: $sourceDescriptions.default.getInterestsAndPreferences + successCriteria: + - condition: $statusCode == 200 + + - workflowId: get-interests-and-preferences-unauthenticated + summary: getInterestsAndPreferences refuses an anonymous caller + parameters: + - reference: $components.parameters.userAgent + steps: + - stepId: get-interests-and-preferences + operationId: $sourceDescriptions.default.getInterestsAndPreferences + successCriteria: + - condition: $statusCode == 401 + - workflowId: get-moderation-reports summary: Moderation reports parameters: @@ -632,6 +656,153 @@ workflows: value: test successCriteria: - condition: $statusCode == 400 + - workflowId: interests-and-preferences-lifecycle + summary: Turns every interest on, one off, then the rest off, reading each state back + parameters: + - reference: $components.parameters.userAgent + x-security: + - schemeName: authCookie + values: + apiKey: $workflows.session.outputs.sessionToken + steps: + - stepId: turn-all-on + operationId: $sourceDescriptions.default.updateInterestsAndPreferences + requestBody: + contentType: application/json + payload: + Anime: true + Art: true + Avatars: true + BigGroup: true + Explore: true + Fantasy: true + Fashion: true + FindAvatars: true + Furries: true + Horror: true + LanguageLearning: true + MeetPeople: true + Music: true + Mystery: true + SciFi: true + SmallGroup: true + Surprise: true + successCriteria: + - condition: $statusCode == 200 + onFailure: + - name: stop + type: end + + - stepId: read-all-on + operationId: $sourceDescriptions.default.getInterestsAndPreferences + successCriteria: + - condition: $statusCode == 200 + - condition: $response.body#/Anime == true + - condition: $response.body#/Art == true + - condition: $response.body#/Avatars == true + - condition: $response.body#/BigGroup == true + - condition: $response.body#/Explore == true + - condition: $response.body#/Fantasy == true + - condition: $response.body#/Fashion == true + - condition: $response.body#/FindAvatars == true + - condition: $response.body#/Furries == true + - condition: $response.body#/Horror == true + - condition: $response.body#/LanguageLearning == true + - condition: $response.body#/MeetPeople == true + - condition: $response.body#/Music == true + - condition: $response.body#/Mystery == true + - condition: $response.body#/SciFi == true + - condition: $response.body#/SmallGroup == true + - condition: $response.body#/Surprise == true + onFailure: + - name: stop + type: end + + - stepId: turn-one-off + operationId: $sourceDescriptions.default.updateInterestsAndPreferences + requestBody: + contentType: application/json + payload: + Art: false + successCriteria: + - condition: $statusCode == 200 + onFailure: + - name: stop + type: end + + - stepId: read-one-off + operationId: $sourceDescriptions.default.getInterestsAndPreferences + successCriteria: + - condition: $statusCode == 200 + - context: $response.body + condition: $[?(!@.Art)] + type: jsonpath + - condition: $response.body#/Music == true + onFailure: + - name: stop + type: end + + - stepId: send-unknown + operationId: $sourceDescriptions.default.updateInterestsAndPreferences + requestBody: + contentType: application/json + payload: + Music: loud + Unknown: true + successCriteria: + - condition: $statusCode == 200 + onFailure: + - name: stop + type: end + + - stepId: read-unknown-ignored + operationId: $sourceDescriptions.default.getInterestsAndPreferences + successCriteria: + - condition: $statusCode == 200 + - condition: $response.body#/Music == true + - context: $response.body + condition: $[?(!@.Unknown)] + type: jsonpath + onFailure: + - name: stop + type: end + + - stepId: turn-all-off + operationId: $sourceDescriptions.default.updateInterestsAndPreferences + requestBody: + contentType: application/json + payload: + Anime: false + Art: false + Avatars: false + BigGroup: false + Explore: false + Fantasy: false + Fashion: false + FindAvatars: false + Furries: false + Horror: false + LanguageLearning: false + MeetPeople: false + Music: false + Mystery: false + SciFi: false + SmallGroup: false + Surprise: false + successCriteria: + - condition: $statusCode == 200 + onFailure: + - name: stop + type: end + + - stepId: read-all-off + operationId: $sourceDescriptions.default.getInterestsAndPreferences + successCriteria: + - condition: $statusCode == 200 + - context: $response.body + condition: $[?(!@.Music)] + type: jsonpath + - workflowId: logout summary: >- Invalidates the session, so `pnpm test` skips it by name — running @@ -684,6 +855,33 @@ workflows: successCriteria: - condition: $statusCode == 401 + - workflowId: update-interests-and-preferences-invalid-body + summary: updateInterestsAndPreferences refuses a body that is not an object + parameters: + - reference: $components.parameters.userAgent + x-security: + - schemeName: authCookie + values: + apiKey: $workflows.session.outputs.sessionToken + steps: + - stepId: update-interests-and-preferences + operationId: $sourceDescriptions.default.updateInterestsAndPreferences + requestBody: + contentType: application/json + payload: not an object + successCriteria: + - condition: $statusCode == 400 + + - workflowId: update-interests-and-preferences-unauthenticated + summary: updateInterestsAndPreferences refuses an anonymous caller + parameters: + - reference: $components.parameters.userAgent + steps: + - stepId: update-interests-and-preferences + operationId: $sourceDescriptions.default.updateInterestsAndPreferences + successCriteria: + - condition: $statusCode == 401 + - workflowId: verify-2fa-unauthenticated summary: verify2FA refuses an anonymous caller parameters: From 4afd1fd26e34e0fc4481521bef590d5c95478d11 Mon Sep 17 00:00:00 2001 From: Aries Clark Date: Wed, 23 Sep 2026 17:18:07 -0400 Subject: [PATCH 2/2] feat: add POST and DELETE /users/{userId}/tutorial with platform and store headers --- openapi/components/parameters.yaml | 16 ++ openapi/components/paths/users.yaml | 35 ++- .../users/ClearTutorialsForbiddenError.yaml | 11 + .../users/CompleteTutorialForbiddenError.yaml | 11 + openapi/components/schemas/TutorialKey.yaml | 2 +- test/arazzo.yaml | 237 ++++++++++++++++++ 6 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 openapi/components/responses/users/ClearTutorialsForbiddenError.yaml create mode 100644 openapi/components/responses/users/CompleteTutorialForbiddenError.yaml diff --git a/openapi/components/parameters.yaml b/openapi/components/parameters.yaml index 9bb14369..134ee01f 100644 --- a/openapi/components/parameters.yaml +++ b/openapi/components/parameters.yaml @@ -1029,3 +1029,19 @@ worldId: in: path schema: $ref: ./schemas/WorldID.yaml +xPlatform: + name: X-Platform + description: The platform the tutorial belongs to. `standalonewindows`, `android` and `ios` are kept; any other value is recorded as `null`. + required: false + in: header + schema: + type: string + example: standalonewindows +xStore: + name: X-Store + description: The store the tutorial belongs to, recorded as sent. + required: false + in: header + schema: + type: string + example: steam diff --git a/openapi/components/paths/users.yaml b/openapi/components/paths/users.yaml index fb9aa89d..f8a84d80 100644 --- a/openapi/components/paths/users.yaml +++ b/openapi/components/paths/users.yaml @@ -602,11 +602,12 @@ paths: "/users/{userId}/tutorial": parameters: - $ref: ../parameters.yaml#/userId + - $ref: ../parameters.yaml#/xPlatform + - $ref: ../parameters.yaml#/xStore get: operationId: getUserTutorialStatus summary: Get User Tutorial Status - description: Gets the status of completed or outstanding tutorials for the - specified user. + description: Gets the status of completed or outstanding tutorials for the specified user. `tutorialKey` and `completed` describe the tutorial named by `X-Platform` and `X-Store`. tags: - users security: @@ -616,6 +617,36 @@ paths: $ref: ../responses/users/TutorialStatusResponse.yaml "401": $ref: ../responses/MissingCredentialsError.yaml + post: + operationId: completeUserTutorial + summary: Complete User Tutorial + description: Marks the tutorial named by `X-Platform` and `X-Store` completed, and returns the current user. + tags: + - users + security: + - authCookie: [] + responses: + "200": + $ref: ../responses/users/CurrentUserResponse.yaml + "401": + $ref: ../responses/MissingCredentialsError.yaml + "403": + $ref: ../responses/users/CompleteTutorialForbiddenError.yaml + delete: + operationId: clearUserTutorials + summary: Clear User Tutorials + description: Clears every tutorial the user completed on a platform, whatever `X-Platform` and `X-Store` name, and returns the current user. Tutorials of other kinds, such as `platform-agnostic:custom:onboarding-tutorial-world:v1`, stay completed. + tags: + - users + security: + - authCookie: [] + responses: + "200": + $ref: ../responses/users/CurrentUserResponse.yaml + "401": + $ref: ../responses/MissingCredentialsError.yaml + "403": + $ref: ../responses/users/ClearTutorialsForbiddenError.yaml "/users/{userId}/{worldId}/persist": parameters: - $ref: ../parameters.yaml#/userId diff --git a/openapi/components/responses/users/ClearTutorialsForbiddenError.yaml b/openapi/components/responses/users/ClearTutorialsForbiddenError.yaml new file mode 100644 index 00000000..82a46f63 --- /dev/null +++ b/openapi/components/responses/users/ClearTutorialsForbiddenError.yaml @@ -0,0 +1,11 @@ +description: A user can clear only their own tutorials. +content: + application/json: + examples: + Other User: + value: + error: + message: '"You do not have permission to clear tutorials completions for others."' + status_code: 403 + schema: + $ref: ../../schemas/Error.yaml diff --git a/openapi/components/responses/users/CompleteTutorialForbiddenError.yaml b/openapi/components/responses/users/CompleteTutorialForbiddenError.yaml new file mode 100644 index 00000000..74cf3522 --- /dev/null +++ b/openapi/components/responses/users/CompleteTutorialForbiddenError.yaml @@ -0,0 +1,11 @@ +description: A user can complete only their own tutorials. +content: + application/json: + examples: + Other User: + value: + error: + message: '"You do not have permission to complete tutorials for others."' + status_code: 403 + schema: + $ref: ../../schemas/Error.yaml diff --git a/openapi/components/schemas/TutorialKey.yaml b/openapi/components/schemas/TutorialKey.yaml index 43e95274..22bef033 100644 --- a/openapi/components/schemas/TutorialKey.yaml +++ b/openapi/components/schemas/TutorialKey.yaml @@ -1,5 +1,5 @@ title: TutorialKey type: string -description: "The ID of a tutorial, in the format `{platform}:{tutorial}:{version}`. `undefined:undefined:v1` is used as a null-ish or sentinel value." +description: "The ID of a tutorial. A platform tutorial is `{platform}:{store}:v1`, taken from the `X-Platform` and `X-Store` headers, with `undefined` for a header the request left out. Other tutorials take a longer form, such as `platform-agnostic:custom:onboarding-tutorial-world:v1`." default: undefined:undefined:v1 example: standalonewindows:steam:v1 diff --git a/test/arazzo.yaml b/test/arazzo.yaml index 22c4a218..fc6ee666 100644 --- a/test/arazzo.yaml +++ b/test/arazzo.yaml @@ -10453,6 +10453,243 @@ workflows: successCriteria: - condition: $statusCode == 401 + - workflowId: get-user-tutorial-status-platforms + summary: getUserTutorialStatus keys a tutorial by the platform and store headers + parameters: + - reference: $components.parameters.userAgent + x-security: + - schemeName: authCookie + values: + apiKey: $workflows.session.outputs.sessionToken + steps: + - stepId: read-android + operationId: $sourceDescriptions.default.getUserTutorialStatus + parameters: + - name: userId + in: path + value: $workflows.session.outputs.userId + - name: X-Platform + in: header + value: android + - name: X-Store + in: header + value: suite + successCriteria: + - condition: $statusCode == 200 + - condition: $response.body#/tutorialKey == 'android:suite:v1' + + - stepId: read-ios + operationId: $sourceDescriptions.default.getUserTutorialStatus + parameters: + - name: userId + in: path + value: $workflows.session.outputs.userId + - name: X-Platform + in: header + value: ios + - name: X-Store + in: header + value: suite + successCriteria: + - condition: $statusCode == 200 + - condition: $response.body#/tutorialKey == 'ios:suite:v1' + + - stepId: read-standalonewindows + operationId: $sourceDescriptions.default.getUserTutorialStatus + parameters: + - name: userId + in: path + value: $workflows.session.outputs.userId + - name: X-Platform + in: header + value: standalonewindows + - name: X-Store + in: header + value: suite + successCriteria: + - condition: $statusCode == 200 + - condition: $response.body#/tutorialKey == 'standalonewindows:suite:v1' + + - stepId: read-spectest + operationId: $sourceDescriptions.default.getUserTutorialStatus + parameters: + - name: userId + in: path + value: $workflows.session.outputs.userId + - name: X-Platform + in: header + value: spectest + - name: X-Store + in: header + value: suite + successCriteria: + - condition: $statusCode == 200 + - condition: $response.body#/tutorialKey == 'null:suite:v1' + + - workflowId: user-tutorial-lifecycle + summary: Completes a tutorial, clears every platform tutorial, then restores the account's own + parameters: + - reference: $components.parameters.userAgent + x-security: + - schemeName: authCookie + values: + apiKey: $workflows.session.outputs.sessionToken + steps: + - stepId: complete + operationId: $sourceDescriptions.default.completeUserTutorial + parameters: + - name: userId + in: path + value: $workflows.session.outputs.userId + - name: X-Platform + in: header + value: spectest + - name: X-Store + in: header + value: suite + successCriteria: + - condition: $statusCode == 200 + - condition: $response.body#/id == $workflows.session.outputs.userId + onFailure: + - name: stop + type: end + + - stepId: read-completed + operationId: $sourceDescriptions.default.getUserTutorialStatus + parameters: + - name: userId + in: path + value: $workflows.session.outputs.userId + - name: X-Platform + in: header + value: spectest + - name: X-Store + in: header + value: suite + successCriteria: + - condition: $statusCode == 200 + - condition: $response.body#/completed == true + - condition: $response.body#/tutorialKey == 'null:suite:v1' + onFailure: + - name: stop + type: end + + - stepId: clear + operationId: $sourceDescriptions.default.clearUserTutorials + parameters: + - name: userId + in: path + value: $workflows.session.outputs.userId + - name: X-Platform + in: header + value: spectest + - name: X-Store + in: header + value: suite + successCriteria: + - condition: $statusCode == 200 + onFailure: + - name: stop + type: end + + - stepId: restore + operationId: $sourceDescriptions.default.completeUserTutorial + parameters: + - name: userId + in: path + value: $workflows.session.outputs.userId + - name: X-Platform + in: header + value: standalonewindows + - name: X-Store + in: header + value: steam + successCriteria: + - condition: $statusCode == 200 + onFailure: + - name: stop + type: end + + - stepId: read-restored + operationId: $sourceDescriptions.default.getUserTutorialStatus + parameters: + - name: userId + in: path + value: $workflows.session.outputs.userId + - name: X-Platform + in: header + value: spectest + - name: X-Store + in: header + value: suite + successCriteria: + - condition: $statusCode == 200 + - condition: $response.body#/completed == false + + - workflowId: complete-user-tutorial-forbidden + summary: completeUserTutorial refuses another user's tutorials + parameters: + - reference: $components.parameters.userAgent + x-security: + - schemeName: authCookie + values: + apiKey: $workflows.session.outputs.sessionToken + steps: + - stepId: complete-user-tutorial + operationId: $sourceDescriptions.default.completeUserTutorial + parameters: + - name: userId + in: path + value: usr_00000000-0000-0000-0000-000000000000 + successCriteria: + - condition: $statusCode == 403 + + - workflowId: complete-user-tutorial-unauthenticated + summary: completeUserTutorial refuses an anonymous caller + parameters: + - reference: $components.parameters.userAgent + steps: + - stepId: complete-user-tutorial + operationId: $sourceDescriptions.default.completeUserTutorial + parameters: + - name: userId + in: path + value: usr_00000000-0000-0000-0000-000000000000 + successCriteria: + - condition: $statusCode == 401 + + - workflowId: clear-user-tutorials-forbidden + summary: clearUserTutorials refuses another user's tutorials + parameters: + - reference: $components.parameters.userAgent + x-security: + - schemeName: authCookie + values: + apiKey: $workflows.session.outputs.sessionToken + steps: + - stepId: clear-user-tutorials + operationId: $sourceDescriptions.default.clearUserTutorials + parameters: + - name: userId + in: path + value: usr_00000000-0000-0000-0000-000000000000 + successCriteria: + - condition: $statusCode == 403 + + - workflowId: clear-user-tutorials-unauthenticated + summary: clearUserTutorials refuses an anonymous caller + parameters: + - reference: $components.parameters.userAgent + steps: + - stepId: clear-user-tutorials + operationId: $sourceDescriptions.default.clearUserTutorials + parameters: + - name: userId + in: path + value: usr_00000000-0000-0000-0000-000000000000 + successCriteria: + - condition: $statusCode == 401 + - workflowId: remove-tags-unauthenticated summary: removeTags refuses an anonymous caller parameters: