openapi: 3.0.3
info:
  title: Duuble API (Strict Contract Draft)
  version: 0.2.0
  description: Strict endpoint contracts for planning and backend handoff.
servers:
  - url: /api/v1
security:
  - bearerAuth: []
tags:
  - name: auth
  - name: onboarding
  - name: uploads
  - name: users
  - name: posts
  - name: votes
  - name: hubs
  - name: search
  - name: notifications
  - name: reports
paths:
  /auth/login-config:
    get:
      tags: [auth]
      security: []
      operationId: getAuthLoginConfig
      summary: 'Returns data needed to render the phone login screen.'
      description: 'Returns data needed to render the phone login screen: app logo URL, enabled login methods, OTP settings, and legal text references.'
      responses:
        '200':
          description: Login config loaded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthLoginConfigResponse'
  /auth/otp/request:
    post:
      tags: [auth]
      security: []
      operationId: postAuthOtpRequest
      summary: 'Starts phone sign-in by sending a 4-digit OTP code.'
      description: 'Starts phone sign-in by sending a 4-digit OTP code. Used on the "enter phone" step and for resend after 60 seconds.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthOtpRequestBody'
      responses:
        '200':
          description: OTP sent
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthOtpRequestResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /auth/otp/verify:
    post:
      tags: [auth]
      security: []
      operationId: postAuthOtpVerify
      summary: 'Verifies the 4-digit OTP entered by the user and creates an authenticated session if valid...'
      description: 'Verifies the 4-digit OTP entered by the user and creates an authenticated session if valid.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthOtpVerifyBody'
      responses:
        '200':
          description: OTP verified
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthSessionResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /auth/firebase/token:
    post:
      tags: [auth]
      security: []
      operationId: postAuthFirebaseToken
      summary: 'Exchanges a Firebase phone-auth or anonymous ID token for a Duuble backend session.'
      description: 'Exchanges a Firebase phone-auth or anonymous ID token for a Duuble backend access/refresh session. The token must be issued by the configured Firebase project and use the phone or anonymous sign-in provider; other Firebase providers are rejected.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthFirebaseTokenBody'
      responses:
        '200':
          description: Firebase sign-in successful
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthSessionResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'
  /auth/anonymous/session:
    post:
      tags: [auth]
      security: []
      operationId: postAuthAnonymousSession
      summary: 'Creates or restores a backend anonymous user session for this app install.'
      description: 'Creates or restores a backend anonymous user session using the app-install device id. This provides a real backend identity for public reads, approved anonymous report/block actions, abuse controls, rate limits, and later merge behavior. Registered participation and phone-confirmed authoring still require their higher product levels.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AuthAnonymousSessionBody'
      responses:
        '200':
          description: Anonymous session started
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthSessionResponse'
        '422':
          $ref: '#/components/responses/ValidationError'
  /auth/session/refresh:
    post:
      tags: [auth]
      security: []
      operationId: postAuthSessionRefresh
      summary: 'Renews access token using refresh token so users stay signed in after app restart or token...'
      description: 'Renews access token using refresh token so users stay signed in after app restart or token expiry.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken]
              properties:
                refreshToken: { type: string }
      responses:
        '200':
          description: Session refreshed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthRefreshResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /auth/logout:
    post:
      tags: [auth]
      operationId: postAuthLogout
      summary: 'Logs out the current device session by revoking the submitted refresh token.'
      description: 'Logs out the current device session by revoking the submitted refresh token.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken, logoutFromAllDevices]
              properties:
                refreshToken: { type: string }
                logoutFromAllDevices: { type: boolean }
      responses:
        '200':
          $ref: '#/components/responses/OkEmpty'

  /users/me/onboarding/status:
    get:
      tags: [onboarding]
      operationId: getUsersMeOnboardingStatus
      summary: 'Returns public-profile, phone-confirmation, and contact-sync progress.'
      description: 'Returns onboarding progress for the current backend session. Profile basics may be completed before phone confirmation; contacts_synced is an independent Hub-readiness acknowledgement and is not folded into phone confirmation.'
      responses:
        '200':
          description: Onboarding status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingStatusResponse'
  /users/me/onboarding/profile-basics:
    put:
      tags: [onboarding]
      operationId: putUsersMeOnboardingProfileBasics
      summary: 'Saves required public-profile basics and terms approval.'
      description: 'Saves required public-profile basics, including fullName and username, plus terms approval. This creates the registered public-profile level and may happen before phone confirmation.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OnboardingProfileBasicsBody'
      responses:
        '200':
          description: Profile basics saved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingNextStepResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'
        '409':
          $ref: '#/components/responses/Conflict'
  /users/me/onboarding/media:
    put:
      tags: [onboarding]
      operationId: putUsersMeOnboardingMedia
      summary: 'Optionally attaches an uploaded profile photo and completes onboarding.'
      description: 'Optionally attaches an uploaded profile photo to the user account and completes onboarding. If profileAssetId is omitted or null, onboarding completes without a profile photo.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                profileAssetId: { type: string, nullable: true }
      responses:
        '200':
          description: Onboarding completed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingMediaResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'

  /users/me/onboarding/contacts:
    put:
      tags: [onboarding]
      operationId: putUsersMeOnboardingContacts
      summary: 'Records successful on-device contact access for Hub readiness.'
      description: 'After the phone-confirmed user completes profile setup and the app successfully reads granted or OS-limited contacts, records the one-time contacts_synced acknowledgement. An accessible empty address book qualifies. The full address book is not sent to this endpoint, and later OS permission changes do not automatically clear the acknowledgement.'
      responses:
        '200':
          description: Contacts sync acknowledgement saved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingStatusResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /uploads/presign:
    post:
      tags: [uploads]
      operationId: postUploadsPresign
      summary: 'Generates a short-lived V4 signed URL so the mobile app uploads binary data directly to GC...'
      description: 'Generates a short-lived V4 signed URL so the mobile app uploads binary data directly to GCS (not through Cloud Run). Supports user profile images, hub images, post content images, and PDFs.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UploadPresignBody'
      responses:
        '200':
          description: Signed URL generated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadPresignResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'
  /uploads/complete:
    post:
      tags: [uploads]
      operationId: postUploadsComplete
      summary: 'Finalizes a previously presigned upload after the client successfully PUTs the file to GCS...'
      description: 'Finalizes a previously presigned upload after the client successfully PUTs the file to GCS. Validates object existence and returns a reusable file asset ID.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [uploadId, purpose, checksum]
              properties:
                uploadId: { type: string }
                purpose:
                  type: string
                  enum: [profile_photo, hub_photo, post_image, document]
                  description: 'Same purpose value used during presign. `profile_photo` = user profile image; `hub_photo` = hub image; `post_image` = post article media plus legacy comment/discussion images; `document` = PDF.'
                checksum:
                  type: string
                  pattern: '^[0-9a-fA-F]{64}$'
      responses:
        '201':
          description: Upload finalized
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadCompleteResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
  /uploads/{assetId}/download-url:
    get:
      tags: [uploads]
      operationId: getUploadsDownloadUrl
      summary: 'Issues a short-lived signed GET URL for a completed uploaded asset.'
      description: 'Issues a short-lived signed GET URL for a completed uploaded asset.'
      parameters:
        - in: path
          name: assetId
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Download URL issued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadDownloadUrlResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /users/by-username/{username}:
    get:
      tags: [users]
      security: []
      operationId: getUsersByUsername
      summary: 'Returns the public profile page data for a user, including profile visuals, social counts,...'
      description: 'Returns the public profile page data for a user, including profile visuals, social counts, activity stats, admin hubs, and profile relationship state for the current viewer. This endpoint never returns phone numbers.'
      parameters:
        - in: path
          name: username
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Profile loaded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserProfileResponse'
        '404':
          $ref: '#/components/responses/NotFound'
  /users/handles/availability:
    post:
      tags: [users]
      operationId: postUsersHandleAvailability
      summary: 'Canonicalizes a proposed public handle and checks whether it is available.'
      description: 'Advisory, non-reserving availability preview for registration and Edit Profile. Requires a normal Appinion bearer session, including an anonymous session, but not phone confirmation or contact access. Unavailable and policy-rejected values receive up to three generic available numeric-suffix alternatives.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HandleAvailabilityBody'
      responses:
        '200':
          description: Handle availability checked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HandleAvailabilityResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /users/me/profile:
    get:
      tags: [users]
      operationId: getUsersMeProfile
      summary: "Returns the authenticated user's own profile page data."
      description: "Returns the authenticated user's own profile page data, including editable profile fields, social counts, activity stats, and admin hubs."
      responses:
        '200':
          description: Profile loaded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserProfileResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [users]
      operationId: patchUsersMeProfile
      summary: 'Updates editable profile fields: fullName, username, bio, and profile image.'
      description: 'Updates editable profile fields: fullName, username, bio, and profile image. When profileAssetId is supplied, the new image replaces the previous profile image and the old image is deleted after the replacement is committed.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                fullName:
                  type: string
                  minLength: 2
                  maxLength: 80
                username:
                  type: string
                  minLength: 3
                  maxLength: 30
                  pattern: '^[a-z0-9_]+$'
                bio:
                  type: string
                  maxLength: 160
                  nullable: true
                profileAssetId:
                  type: string
      responses:
        '200':
          description: Profile updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserProfilePatchResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
  /users/me/hub-profile:
    get:
      tags: [users]
      operationId: getUsersMeHubProfile
      summary: "Returns the authenticated user's private Hub identity."
      description: 'Requires a Hub-ready account. The Hub identity is separate from the public profile and may include the owner phone number.'
      responses:
        '200':
          description: Hub profile loaded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubPrivateProfileResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [users]
      operationId: patchUsersMeHubProfile
      summary: "Updates the authenticated user's private Hub display name or profile image."
      description: 'Requires a Hub-ready account. This update does not change the public profile.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HubPrivateProfilePatchBody'
      responses:
        '200':
          description: Hub profile updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubPrivateProfileResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
  /users/me/hubs:
    get:
      tags: [hubs]
      operationId: getUsersMeHubs
      summary: 'Returns the current user''s hubs screen.'
      description: 'Returns active hubs, dark invited hubs, and pending admin-approval hubs for the current user.'
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit50'
      responses:
        '200':
          description: My hubs
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MyHubsResponse'
  /users/{userId}/followers:
    get:
      tags: [users]
      operationId: getUsersFollowers
      summary: 'Returns paginated followers for a user. Supports local list search via query `q`.'
      description: 'Returns paginated followers for a user. Supports local list search via query `q`. Each item carries the display name and whether the viewer already follows that user.'
      parameters:
        - $ref: '#/components/parameters/UserId'
        - $ref: '#/components/parameters/Q'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit100'
      responses:
        '200':
          description: Followers list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserFollowListResponse'
  /users/{userId}/following:
    get:
      tags: [users]
      operationId: getUsersFollowing
      summary: 'Returns paginated following list for a user. Supports local list search via query `q`.'
      description: 'Returns paginated following list for a user. Supports local list search via query `q`. Each item carries the display name and whether the viewer already follows that user.'
      parameters:
        - $ref: '#/components/parameters/UserId'
        - $ref: '#/components/parameters/Q'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit100'
      responses:
        '200':
          description: Following list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserFollowListResponse'
  /users/{userId}/follow:
    post:
      tags: [users]
      operationId: postUsersFollow
      summary: 'Creates a follow relationship from current user to target user.'
      description: 'Creates a follow relationship from current user to target user and creates a user_followed notification for the followed user. Requires registered public-profile basics; phone confirmation is not required.'
      parameters: [{ $ref: '#/components/parameters/UserId' }]
      responses:
        '200': { $ref: '#/components/responses/FollowStateTrue' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
    delete:
      tags: [users]
      operationId: deleteUsersFollow
      summary: 'Removes follow relationship from current user to target user.'
      description: 'Removes follow relationship from current user to target user. Requires registered public-profile basics; phone confirmation is not required.'
      parameters: [{ $ref: '#/components/parameters/UserId' }]
      responses: { '200': { $ref: '#/components/responses/FollowStateFalse' } }
  /users/{userId}/block:
    post:
      tags: [users]
      operationId: postUsersBlock
      summary: 'Blocks a user. Blocked users cannot interact with current user content or profile.'
      description: 'Blocks a user. Blocked users cannot interact with current user content or profile.'
      parameters: [{ $ref: '#/components/parameters/UserId' }]
      responses: { '200': { $ref: '#/components/responses/BlockStateTrue' } }
    delete:
      tags: [users]
      operationId: deleteUsersBlock
      summary: 'Unblocks a previously blocked user.'
      description: 'Unblocks a previously blocked user.'
      parameters: [{ $ref: '#/components/parameters/UserId' }]
      responses: { '200': { $ref: '#/components/responses/BlockStateFalse' } }
  /users/{userId}/feed:
    get:
      tags: [users]
      security:
        - {}
        - bearerAuth: []
      operationId: getUsersFeed
      summary: 'Returns profile feed: original posts, reposts, and shared posts.'
      description: 'Returns profile feed: original posts and reposts published by the user, plus repeatable feed/hub share events. Authentication is optional and enriches viewer-specific fields when a valid bearer token is supplied. Feed-share points to the exact shared post or repost frame and does not create a new post frame. Each post frame appears once, including across pagination; later shares can bump that card but do not add another copy. Feed cards do not include share-by attribution. Private locked hub posts are omitted from profile feeds.'
      parameters:
        - $ref: '#/components/parameters/UserId'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit50'
      responses:
        '200':
          description: User feed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostFeedResponse'
  /users/me/saved-discussions:
    get:
      tags: [users]
      operationId: getUsersMeSavedDiscussions
      summary: 'Returns the current user''s saved hub discussions.'
      description: 'Returns Hub discussion/chat items saved by the current user and still visible to them. Items from Hubs where the user no longer has active membership are omitted. Preview authors use the same Hub-only phone projection as the full private discussion.'
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit50'
      responses:
        '200':
          description: Saved discussions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SavedHubDiscussionListResponse'

  /posts:
    get:
      tags: [posts]
      security:
        - {}
        - bearerAuth: []
      operationId: getPostsFeed
      summary: 'Returns the main feed timeline with optional authentication and default feed fallback.'
      description: 'Returns the Home feed timeline. Registered public-profile users get personalized public candidates from their own posts, followed users, shares, and public interactions without requiring phone confirmation. Hub-ready users additionally get eligible Hub/co-member and private locked Hub candidates. Guests, anonymous-only sessions, and users with no personal feed sources get public posts from admin-managed default feed users. Each post frame appears once, including across pagination; later shares can bump that card but do not add another copy. Feed cards do not include share-by attribution.'
      parameters:
        - in: query
          name: hubId
          schema: { type: string }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit50'
        - in: query
          name: sort
          schema: { type: string, enum: [recent, trending], default: recent }
      responses:
        '200':
          description: Main feed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostFeedResponse'
    post:
      tags: [posts]
      operationId: postPostsCreate
      summary: 'Creates an original post with optional article, thought, and slider.'
      description: 'Creates an original post with optional article, optional thoughtText, optional sliderText/slider, and explicit visibility. Public General creation requires verified phone identity and completed profile setup but not contact access. Any Hub target additionally requires Hub readiness and active membership. At least one of article, thoughtText, or sliderText is required. There is no separate sliderTitle field.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostCreateBody'
      responses:
        '201':
          description: Post created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostCreateResponse'
  /posts/{postId}:
    get:
      tags: [posts]
      security:
        - {}
        - bearerAuth: []
      operationId: getPostById
      summary: 'Returns full post detail with optional article, thought, and slider.'
      description: 'Returns full post detail for the post page and discussion page, including optional article, the requested post frame thoughtText, optional sliderText/slider, visibility, permissions, and repost metadata. Missing auth is allowed and returns public/public-hub posts only. A valid optional bearer token identifies the viewer and can reveal private locked hub posts when the viewer is the author or an active member of one selected hub. Invalid or revoked optional bearer tokens are currently treated as anonymous, so this optional read does not return 401 for bad optional credentials. Hidden private locked hub posts return 404 POST_NOT_FOUND. For reposts, the top-level article is null and sourcePost contains the original post display content. Clients render source fallback as sourcePost.article, then sourcePost.thoughtText, then sourcePost.sliderText.'
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      responses:
        '200':
          description: Post detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostDetailResponse'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [posts]
      operationId: deletePostById
      summary: 'Deletes a post owned by current user.'
      description: 'Deletes a post owned by current user.'
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      responses:
        '200': { $ref: '#/components/responses/OkEmpty' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /posts/{postId}/reposts:
    post:
      tags: [posts]
      operationId: postPostRepost
      summary: 'Creates a repost with new thought and/or slider.'
      description: 'Creates a repost as a new post frame that points to an original post. Public General reposting requires verified phone identity and completed profile setup but not contact access. Any Hub target additionally requires Hub readiness and active membership. The repost body must include new thoughtText and/or sliderText/slider, and must not include article. Repost cards return original article content only as sourcePost.article; the top-level article stays null. Empty no-new-content feed share uses POST /posts/{postId}/share/feed instead. Reposting a repost is not allowed. Private locked hub posts cannot be reposted. Creates a post_reposted notification for the original source post author when reposter is not the author.'
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostRepostCreateBody'
      responses:
        '201':
          description: Repost created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostRepostCreateResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
  /posts/{postId}/frames:
    get:
      tags: [posts]
      security:
        - {}
        - bearerAuth: []
      operationId: getPostFrames
      summary: 'Returns visible original/repost frames for the same source lineage.'
      description: 'Returns the visible original post and direct repost frames in the same source lineage. Missing auth is allowed and returns only public/public-hub frames. A valid optional bearer token identifies the viewer and can include private locked hub frames when the viewer is the author or an active member of one selected hub. Invalid or revoked optional bearer tokens are currently treated as anonymous, so this optional read does not return 401 for bad optional credentials. Hidden seed posts return 404 POST_NOT_FOUND. If postId is an original, returns it plus posts whose sourcePostId equals it. If postId is a repost, returns its source original plus posts whose sourcePostId equals that original. Hub shares of the same post do not create additional frames.'
      parameters:
        - $ref: '#/components/parameters/PostId'
      responses:
        '200':
          description: Post frames
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostFramesResponse'
        '404':
          $ref: '#/components/responses/NotFound'
  /posts/{postId}/comments:
    get:
      tags: [posts]
      security:
        - {}
        - bearerAuth: []
      operationId: getPostComments
      summary: 'Returns public comments for a post with one-level reply threading.'
      description: 'Returns public comments for a post, including `parentCommentId` and `replyCount` threading metadata. Authors use public usernames and never phone numbers. Threads are one level deep. `private_locked_hub` posts are rejected even for members; Hub-scoped discussion/chat is handled by Hub discussion endpoints.'
      parameters:
        - $ref: '#/components/parameters/PostId'
        - $ref: '#/components/parameters/Cursor'
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 30 }
      responses:
        '200':
          description: Comments list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CommentListResponse'
    post:
      tags: [posts]
      operationId: postPostComment
      summary: 'Creates a public comment on a post, optionally as a one-level reply.'
      description: 'Creates a public-username comment on a public, public-profile, or public-Hub post with text and optional image, optionally as a reply to an existing comment via `parentCommentId`. Requires registered public-profile basics; phone confirmation and contact access are not required for public comments, including public Hub-originated content viewed in General. `private_locked_hub` posts are rejected even for members. Threads are one level deep; replying to a reply attaches the comment to the thread root. Hub-scoped discussion/chat is handled by Hub endpoints and requires Hub readiness. Creates a post_commented notification for the post owner when commenter is not the owner.'
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                text: { type: string, maxLength: 2000 }
                assetId:
                  type: string
                  description: 'Owned completed image upload asset with purpose `post_image`.'
                parentCommentId:
                  type: string
                  description: 'Existing comment on the same post to reply to. If it is itself a reply, the new comment attaches to that thread''s root comment.'
              anyOf:
                - required: [text]
                - required: [assetId]
      responses:
        '201':
          description: Comment created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CommentCreateResponse'
  /comments/{commentId}:
    delete:
      tags: [posts]
      operationId: deleteComment
      summary: 'Deletes a public post comment if owner or authorized moderator.'
      description: 'Deletes a public post comment if owner or authorized moderator.'
      parameters: [{ $ref: '#/components/parameters/CommentId' }]
      responses:
        '200': { $ref: '#/components/responses/OkEmpty' }

  /posts/{postId}/vote:
    post:
      tags: [votes]
      operationId: postPostVote
      summary: 'Casts current user''s one-time 5-position slider vote for a post.'
      description: 'Casts a one-time vote for a user with registered public-profile basics into the anonymous global pool and every eligible Hub pool. A Hub pool is eligible when the post is already visible there and the Hub-ready voter is an active member; there is no minimum Hub size. Phone confirmation and contact access are not required for a public vote, including public Hub-originated content viewed in General. Private locked-Hub content returns VOTE_NOT_ALLOWED when the voter has no eligible Hub pool. Only posts with slider are votable; non-slider posts return POST_NOT_VOTABLE. The selected position is used only during the write transaction and is never returned or stored in user-linked receipt tables.'
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [position]
              properties:
                position: { $ref: '#/components/schemas/VotePosition' }
      responses:
        '201':
          description: Vote cast
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoteCastResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
  /posts/{postId}/vote/me:
    get:
      tags: [votes]
      operationId: getPostVoteMe
      summary: 'Returns current user''s vote status for this post.'
      description: 'Returns whether the current user has voted on this post frame. It never returns the selected vote position because user-linked vote storage does not keep it. Non-slider posts return hasVoted=false.'
      parameters:
        - $ref: '#/components/parameters/PostId'
        - in: query
          name: hubId
          schema: { type: string }
          description: 'When supplied, includes whether the vote counted in this hub scope.'
      responses:
        '200':
          description: My vote status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoteMeResponse'
  /posts/{postId}/vote/summary:
    get:
      tags: [votes]
      operationId: getPostVoteSummary
      summary: 'Returns anonymous aggregate vote analytics grouped into left, neutral, and right buckets.'
      description: 'Returns anonymous aggregate vote analytics for votable posts grouped into left, neutral, and right buckets from the first vote. A zero-vote scope is returned as an empty result. Non-slider posts return POST_NOT_VOTABLE.'
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      responses:
        '200':
          description: Vote summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoteSummaryResponse'
  /posts/{postId}/vote/distribution:
    get:
      tags: [votes]
      operationId: getPostVoteDistribution
      summary: 'Returns anonymous detailed 5-position vote distribution.'
      description: 'Returns anonymous detailed 5-position vote distribution for votable posts from the first vote. A zero-vote scope has an empty distribution. Non-slider posts return POST_NOT_VOTABLE.'
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      responses:
        '200':
          description: Vote distribution
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoteDistributionResponse'
  /posts/{postId}/vote/hubs:
    get:
      tags: [votes]
      operationId: getPostVoteHubs
      summary: 'Returns hub-specific anonymous voting analytics so users compare sentiment across communities.'
      description: 'Returns hub-specific anonymous voting analytics for votable posts so users compare sentiment across communities. Each Hub exposes aggregate counts from its first vote regardless of Hub size. Hub analytics are scoped counters for the same post frame, not separate polls; later hub joins or later shares do not backfill old votes. Non-slider posts return POST_NOT_VOTABLE.'
      parameters:
        - $ref: '#/components/parameters/PostId'
        - in: query
          name: hubIds
          schema: { type: string }
      responses:
        '200':
          description: Hub vote analytics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoteByHubResponse'
  /posts/{postId}/share/feed:
    post:
      tags: [posts]
      operationId: postPostShareFeed
      summary: "Shares an existing post or repost to the current user's feed."
      description: "Shares an existing post or repost to the current user's feed without creating a new post frame, slider, vote pool, or repost count. Requires registered public-profile basics; phone confirmation and contact access are not required. The share points to the exact postId supplied, including repost frames. Private locked hub posts cannot be shared."
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note: { type: string, maxLength: 500 }
      responses:
        '201':
          description: Shared to feed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostShareFeedResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
  /posts/{postId}/share/hubs:
    post:
      tags: [posts]
      operationId: postPostShareHubs
      summary: 'Shares an existing post into one or more hubs without creating a new poll.'
      description: 'Shares an existing post into one or more hubs internally without creating a new post frame or new poll. Requires Hub readiness plus active membership in every target Hub. Feed cards do not show share-by attribution. One share action creates one share event and one target row per selected hub, so repeat shares are allowed; the target Hub returns one canonical card per post, including across pagination, bumped by the latest share while reusing its existing discussion. Use /share/feed for a no-new-content feed share; use reposts only when creating a new thought and/or slider around an original post. Private locked hub posts cannot be shared to another hub.'
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hubIds]
              properties:
                hubIds:
                  type: array
                  minItems: 1
                  maxItems: 20
                  uniqueItems: true
                  items: { type: string }
                note: { type: string, maxLength: 500 }
      responses:
        '201':
          description: Shared to hubs
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostShareHubsResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
  /posts/{postId}/share-link:
    get:
      tags: [posts]
      security: []
      operationId: getPostShareLink
      summary: 'Returns canonical deep link for external sharing using native platform share sheets.'
      description: 'Returns canonical deep link for external sharing using native platform share sheets. This powers external phone sharing, not share-to-feed. Share-to-feed uses POST /posts/{postId}/share/feed. Private locked hub posts do not expose share links.'
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      responses:
        '200':
          description: Share link
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShareLinkResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
  /hubs:
    post:
      tags: [hubs]
      operationId: postHubsCreate
      summary: 'Creates a Hub for a phone-confirmed, contacts-synced user.'
      description: 'Creates a hub with required name and hub image. Description and custom link are optional. Requires Hub readiness: verified phone identity, completed profile setup, and the one-time contacts_synced acknowledgement. The API does not receive or store the user''s full contact book. Creator becomes backend super_admin, displayed in the app as manager. Optional initialInvitees creates admin invitations that become active when accepted.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HubCreateBody'
      responses:
        '201':
          description: Hub created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubCreateResponse'
  /hubs/{hubId}:
    get:
      tags: [hubs]
      operationId: getHubById
      summary: 'Returns hub profile details, member counts, active user count, and viewer status/role.'
      description: 'Returns hub profile details, member counts, active user count, and viewer status/role.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      responses:
        '200':
          description: Hub detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubDetailResponse'
    patch:
      tags: [hubs]
      operationId: patchHubById
      summary: 'Edits hub description and hub image. Allowed for admin and super admin.'
      description: 'Edits hub description and hub image. Allowed for admin and super admin.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                description: { type: string, maxLength: 1000, nullable: true }
                profileAssetId: { type: string }
      responses:
        '200': { $ref: '#/components/responses/HubUpdated' }
  /hubs/{hubId}/membership:
    delete:
      tags: [hubs]
      operationId: deleteHubMembership
      summary: 'Leaves a hub. Super admin cannot leave until ownership is transferred.'
      description: 'Leaves a hub. Super admin cannot leave until ownership is transferred.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      responses: { '200': { $ref: '#/components/responses/JoinedFalse' } }
  /hubs/{hubId}/invitations:
    post:
      tags: [hubs]
      operationId: postHubInvitations
      summary: 'Creates hub invitations for selected phone numbers only.'
      description: 'Creates hub invitations for selected phone numbers only. Admin-created invitations become active when accepted. Member-created invitations require admin approval after the invited user clicks join. The server returns invite links/share text but does not send SMS.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HubInvitationCreateBody'
      responses:
        '201':
          description: Invitations created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubInvitationCreateResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
    get:
      tags: [hubs]
      operationId: getHubInvitations
      summary: 'Lists outstanding hub invitations and dark invitees.'
      description: 'Lists outstanding hub invitations and dark invitees. Active members see safe placeholders; admins see management details.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - in: query
          name: status
          schema: { type: string, enum: [invited, pending_admin_approval, declined, expired] }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit100'
      responses:
        '200':
          description: Hub invitations
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubInvitationListResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
  /hub-invitations/{invitationId}/accept:
    post:
      tags: [hubs]
      operationId: postHubInvitationAccept
      summary: 'Accepts a hub invitation.'
      description: 'Accepts a hub invitation. Admin-created invitations activate membership immediately. Member-created invitations become pending admin approval.'
      parameters: [{ $ref: '#/components/parameters/InvitationId' }]
      responses:
        '200':
          description: Invitation accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubInvitationActionResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '410':
          $ref: '#/components/responses/Gone'
  /hub-invitations/{invitationId}/decline:
    post:
      tags: [hubs]
      operationId: postHubInvitationDecline
      summary: 'Declines a hub invitation.'
      description: 'Declines a hub invitation and removes the dark hub from the invited user''s hubs screen.'
      parameters: [{ $ref: '#/components/parameters/InvitationId' }]
      responses:
        '200':
          description: Invitation declined
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubInvitationActionResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
  /hubs/{hubId}/members:
    get:
      tags: [hubs]
      operationId: getHubMembers
      summary: 'Lists hub members with local search.'
      description: 'Lists hub members with local search. Active members may receive phone numbers. Invited or pending-approval viewers may use this as a dark-hub member preview, but phone numbers are not returned for them.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/Q'
        - in: query
          name: role
          schema: { type: string, enum: [member, admin, super_admin] }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit100'
      responses:
        '200':
          description: Hub members
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubMemberListResponse'
  /hubs/{hubId}/members/{userId}/remove:
    post:
      tags: [hubs]
      operationId: postHubMemberRemove
      summary: 'Admin/super-admin removes a regular user from hub.'
      description: 'Admin/super-admin removes a regular user from hub.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/UserId'
      responses: { '200': { $ref: '#/components/responses/OkUserId' } }
  /hubs/{hubId}/admins:
    get:
      tags: [hubs]
      operationId: getHubAdmins
      summary: 'Lists admin and super-admin users in a hub.'
      description: 'Lists admin and super-admin users in a hub.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      responses:
        '200':
          description: Hub admins
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubAdminsResponse'
    post:
      tags: [hubs]
      operationId: postHubAdminAdd
      summary: 'Super admin promotes a member to admin.'
      description: 'Super admin promotes a member to admin.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userId, role]
              properties:
                userId: { type: string }
                role:
                  type: string
                  enum: [admin]
                  description: 'Role to assign. Currently only `admin` can be granted via this endpoint; `super_admin` cannot be assigned via the API.'
      responses: { '200': { $ref: '#/components/responses/OkUserRole' } }
  /hubs/{hubId}/admins/{userId}:
    delete:
      tags: [hubs]
      operationId: deleteHubAdmin
      summary: 'Super admin demotes or removes an admin role.'
      description: 'Super admin demotes or removes an admin role.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/UserId'
      responses: { '200': { $ref: '#/components/responses/OkUserId' } }
  /hubs/{hubId}/link:
    patch:
      tags: [hubs]
      operationId: patchHubLink
      summary: 'Changes hub custom link/slug. Allowed for admins and super admins.'
      description: 'Changes hub custom link/slug. Allowed for admins and super admins.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customLink]
              properties:
                customLink:
                  type: string
                  minLength: 3
                  maxLength: 40
                  pattern: '^[a-z0-9-]+$'
      responses:
        '200':
          description: Link updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubLinkResponse'
  /hubs/{hubId}/invite-links:
    post:
      tags: [hubs]
      operationId: postHubInviteLink
      summary: 'Creates a limited-time hub invite link.'
      description: 'Creates a limited-time hub invite link. Anyone joining through a link becomes pending admin approval; the link never creates active membership directly.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                expiresInDays: { type: integer, minimum: 1, maximum: 7, default: 7 }
      responses:
        '201':
          description: Invite link created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubInviteLinkCreateResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
  /hubs/{hubId}/join-requests:
    get:
      tags: [hubs]
      operationId: getHubJoinRequests
      summary: 'Lists pending hub join requests that require manager/admin approval.'
      description: 'Lists pending hub join requests that require manager/admin approval.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - in: query
          name: status
          schema: { type: string, enum: [pending_admin_approval, approved, rejected] }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit100'
      responses:
        '200':
          description: Join requests
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubJoinRequestListResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
  /hubs/{hubId}/join-requests/{requestId}/approve:
    post:
      tags: [hubs]
      operationId: postHubJoinRequestApprove
      summary: 'Approves a pending hub join request and creates active membership.'
      description: 'Approves a pending hub join request and creates active membership.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Join request approved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubJoinRequestDecisionResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
  /hubs/{hubId}/join-requests/{requestId}/reject:
    post:
      tags: [hubs]
      operationId: postHubJoinRequestReject
      summary: 'Rejects a pending hub join request.'
      description: 'Rejects a pending hub join request. The requester remains outside the hub.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Join request rejected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubJoinRequestDecisionResponse'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
  /hubs/{hubId}/notification-settings:
    get:
      tags: [hubs]
      operationId: getHubNotificationSettings
      summary: 'Returns current user notification settings for one hub.'
      description: 'Returns the current user''s per-hub content mute setting. Content mute suppresses only hub_post_created and post_shared_to_hub for this user/hub, including the in-app notifications feed.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      responses:
        '200':
          description: Hub notification settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubNotificationSettingsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [hubs]
      operationId: patchHubNotificationSettings
      summary: 'Updates current user notification settings for one hub.'
      description: 'Updates the current user''s per-hub content mute setting. Content mute suppresses only hub_post_created and post_shared_to_hub for this user/hub, including the in-app notifications feed.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contentMuted]
              properties:
                contentMuted: { type: boolean }
                mutedUntil: { type: string, format: date-time, nullable: true }
      responses:
        '200':
          description: Hub notification settings updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubNotificationSettingsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
  /hubs/{hubId}/feed:
    get:
      tags: [hubs]
      operationId: getHubFeed
      summary: 'Returns posts feed scoped to one hub, including native and shared posts.'
      description: 'Returns posts feed scoped to one hub, including native and shared posts. Each post appears once, including across pagination; the latest share event can bump that card while preserving its existing Hub discussion. Feed cards do not include share-by attribution. Feed cards may include hubDiscussion metadata for the private LETS TALK discussion.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit50'
        - in: query
          name: sort
          schema: { type: string, enum: [recent, trending] }
      responses:
        '200':
          description: Hub feed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostFeedResponse'
  /hubs/{hubId}/discussions:
    get:
      tags: [hubs]
      operationId: getHubDiscussions
      summary: 'Lists post-bound hub discussions ordered by latest message activity.'
      description: 'Lists private post-bound Hub discussions for active members, ordered by latest message activity like a chat list. Preview authors use verified phone identity for active non-anonymous authors in this Hub.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit50'
      responses:
        '200':
          description: Hub discussions
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubDiscussionListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /hubs/{hubId}/discussions/{discussionId}/save:
    post:
      tags: [hubs]
      operationId: postHubDiscussionSave
      summary: 'Saves a hub discussion/chat item for the current user.'
      description: 'Saves a hub discussion/chat item for the current user. This endpoint persists saved discussion context only; the public saved-post/bookmark tab is separate from this endpoint.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/DiscussionId'
      responses:
        '200':
          $ref: '#/components/responses/SavedHubDiscussionStateTrue'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [hubs]
      operationId: deleteHubDiscussionSave
      summary: 'Removes a saved hub discussion/chat item for the current user.'
      description: 'Removes a saved hub discussion/chat item for the current user. Removing a save is idempotent when the discussion exists and the user has active hub membership.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/DiscussionId'
      responses:
        '200':
          $ref: '#/components/responses/SavedHubDiscussionStateFalse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /hubs/{hubId}/discussions/{discussionId}/messages:
    get:
      tags: [hubs]
      operationId: getHubDiscussionMessages
      summary: 'Returns messages for a private hub discussion with one-level reply threading.'
      description: 'Returns messages for a private Hub discussion tied to a post, including `parentMessageId` and `replyCount` threading metadata. Active non-anonymous authors expose verified `phoneNumber` only to active members of this Hub; the public `UserLite.username` shape is not used. Threads are one level deep.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/DiscussionId'
        - $ref: '#/components/parameters/Cursor'
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          description: Hub discussion messages
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubDiscussionMessageListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [hubs]
      operationId: postHubDiscussionMessage
      summary: 'Creates a message in a private hub discussion, optionally as a one-level reply.'
      description: 'Creates a message in a private hub discussion tied to a post, optionally as a reply to an existing message via `parentMessageId`. Threads are one level deep; replying to a reply attaches the message to the thread root. Message activity updates hub last-activity ordering but does not create notification events.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/DiscussionId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                text: { type: string, maxLength: 2000 }
                assetId:
                  type: string
                  description: 'Owned completed image upload asset with purpose `post_image`.'
                parentMessageId:
                  type: string
                  description: 'Existing message in the same discussion to reply to. If it is itself a reply, the new message attaches to that thread''s root message.'
              anyOf:
                - required: [text]
                - required: [assetId]
      responses:
        '201':
          description: Hub discussion message created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubDiscussionMessageCreateResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
  /hub-invite-links/{token}:
    get:
      tags: [hubs]
      security:
        - {}
        - bearerAuth: []
      operationId: getHubInviteLink
      summary: 'Returns a safe public preview for a hub invite link.'
      description: 'Returns a safe public preview for a hub invite link before the viewer requests to join.'
      parameters: [{ $ref: '#/components/parameters/InviteToken' }]
      responses:
        '200':
          description: Hub invite link preview
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubInviteLinkPreviewResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          $ref: '#/components/responses/Gone'
  /hub-invite-links/{token}/join-requests:
    post:
      tags: [hubs]
      operationId: postHubInviteLinkJoinRequest
      summary: 'Creates a pending admin-approval request from an invite link.'
      description: 'Creates a pending admin-approval request from an invite link. Link joins never create active membership directly.'
      parameters: [{ $ref: '#/components/parameters/InviteToken' }]
      responses:
        '201':
          description: Join request created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubJoinRequestCreateResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          $ref: '#/components/responses/Gone'

  /search/hubs:
    get:
      tags: [search]
      operationId: getSearchHubs
      summary: 'Searches hubs by name and returns recommended hubs when `q` is empty, including active use...'
      description: 'Searches hubs by name and returns recommended hubs when `q` is empty, including active user count.'
      parameters:
        - $ref: '#/components/parameters/Q'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit50'
      responses:
        '200':
          description: Hub search
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubSearchResponse'
  /search/users:
    get:
      tags: [search]
      security:
        - {}
        - bearerAuth: []
      operationId: getSearchUsers
      summary: 'Lists recent public profiles or searches them by name or username.'
      description: 'Returns recent public profiles when q is empty, or searches by name or username when q is provided. Authentication is optional and only enriches viewer follow state. Phone numbers are never returned.'
      parameters:
        - in: query
          name: q
          required: false
          schema: { type: string, maxLength: 100 }
          description: Optional user search query; omit to list recent public profiles
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit50'
      responses:
        '200':
          description: User search
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSearchResponse'
        '422':
          $ref: '#/components/responses/ValidationError'

  /notifications:
    get:
      tags: [notifications]
      operationId: getNotifications
      summary: 'Returns notifications feed.'
      description: 'Returns notifications feed. Includes vote-on-post, comment-on-post, repost, feed share, follow, hub post, share/repost-to-hub, hub invitation, hub join-request, member-joined, member-removed, and hub role-change events. Hub discussion/chat messages, saved discussions, reports, and blocks do not create user-facing notification events. Muted hub content activity is suppressed from this feed.'
      parameters:
        - $ref: '#/components/parameters/Cursor'
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 30 }
        - in: query
          name: unreadOnly
          schema: { type: boolean, default: false }
      responses:
        '200':
          description: Notifications list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationsListResponse'
  /notifications/read:
    patch:
      tags: [notifications]
      operationId: patchNotificationsRead
      summary: 'Marks notifications as read.'
      description: 'Marks notifications as read.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                notificationIds:
                  type: array
                  items: { type: string }
                markAll: { type: boolean }
              anyOf:
                - required: [notificationIds]
                - required: [markAll]
      responses:
        '200':
          description: Read state updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationsReadResponse'
  /notifications/stream:
    get:
      tags: [notifications]
      operationId: getNotificationsStream
      summary: 'Server-Sent Events stream for real-time notifications.'
      description: 'Server-Sent Events stream for real-time notification events. Hub discussion/chat messages, saved discussions, reports, and blocks are excluded because they do not create user-facing notification events. Muted hub content activity is also excluded for the muted user.'
      responses:
        '200':
          description: SSE stream
          content:
            text/event-stream:
              schema:
                type: string

  /reports/users/{userId}:
    post:
      tags: [reports]
      operationId: postReportUser
      summary: 'Reports a user profile for moderation review.'
      description: 'Reports a user profile for moderation review.'
      parameters: [{ $ref: '#/components/parameters/UserId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportBodyUser'
      responses:
        '201':
          $ref: '#/components/responses/CreatedReport'
  /reports/posts/{postId}:
    post:
      tags: [reports]
      operationId: postReportPost
      summary: 'Reports a post for moderation review.'
      description: 'Reports a post for moderation review.'
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportBodyPost'
      responses:
        '201':
          $ref: '#/components/responses/CreatedReport'
  /reports/hubs/{hubId}:
    post:
      tags: [reports]
      operationId: postReportHub
      summary: 'Reports a hub for moderation.'
      description: 'Reports a hub for moderation.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportBodyHub'
      responses:
        '201':
          $ref: '#/components/responses/CreatedReport'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  parameters:
    UserId:
      in: path
      name: userId
      required: true
      schema: { type: string }
    PostId:
      in: path
      name: postId
      required: true
      schema: { type: string }
    HubId:
      in: path
      name: hubId
      required: true
      schema: { type: string }
    InvitationId:
      in: path
      name: invitationId
      required: true
      schema: { type: string }
    InviteToken:
      in: path
      name: token
      required: true
      schema: { type: string }
    RequestId:
      in: path
      name: requestId
      required: true
      schema: { type: string }
    CommentId:
      in: path
      name: commentId
      required: true
      schema: { type: string }
    DiscussionId:
      in: path
      name: discussionId
      required: true
      schema: { type: string }
    Q:
      in: query
      name: q
      schema: { type: string }
    Cursor:
      in: query
      name: cursor
      schema: { type: string }
    Limit50:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
    Limit100:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
  schemas:
    ErrorCode:
      type: string
      enum:
        - UNAUTHORIZED
        - FORBIDDEN
        - NOT_FOUND
        - VALIDATION_FAILED
        - CONFLICT
        - GONE
        - RATE_LIMITED
        - INTERNAL_ERROR
        - SERVICE_UNAVAILABLE
        - LOGIN_CONFIG_UNAVAILABLE
        - OTP_RATE_LIMITED
        - OTP_INVALID_OR_EXPIRED
        - REFRESH_TOKEN_INVALID
        - USER_NOT_FOUND
        - USERNAME_TAKEN
        - ALREADY_FOLLOWING
        - CANNOT_FOLLOW_SELF
        - ALREADY_BLOCKED
        - CANNOT_BLOCK_SELF
        - POST_NOT_FOUND
        - NOT_POST_OWNER
        - COMMENT_NOT_FOUND
        - NOT_COMMENT_OWNER
        - VOTE_NOT_ALLOWED
        - VOTE_ALREADY_CAST
        - POST_NOT_VOTABLE
        - INVALID_SLIDER_VALUE
        - REPOST_NOT_ALLOWED
        - POST_SHARE_NOT_ALLOWED
        - HUB_NOT_FOUND
        - HUB_LINK_TAKEN
        - HUB_ROLE_FORBIDDEN
        - HUB_MEMBERSHIP_REQUIRED
        - HUB_SUPER_ADMIN_REQUIRED
        - CANNOT_REMOVE_SUPER_ADMIN
        - HUB_INVITATION_NOT_FOUND
        - HUB_INVITATION_EXPIRED
        - HUB_INVITATION_ALREADY_HANDLED
        - HUB_INVITE_LINK_NOT_FOUND
        - HUB_INVITE_LINK_EXPIRED
        - HUB_JOIN_REQUEST_NOT_FOUND
        - HUB_JOIN_REQUEST_ALREADY_HANDLED
        - HUB_DISCUSSION_NOT_FOUND
        - UPLOAD_POLICY_VIOLATION
        - UPLOAD_NOT_FINALIZABLE
        - INVALID_IMAGE_REFERENCE
        - REPORT_REASON_INVALID
        - NOTIFICATION_NOT_FOUND
        - SSE_NOT_SUPPORTED
    ApiError:
      type: object
      required: [success, error]
      properties:
        success: { type: boolean, enum: [false] }
        error:
          type: object
          required: [code, message, details]
          properties:
            code: { $ref: '#/components/schemas/ErrorCode' }
            message: { type: string }
            details: { type: object, additionalProperties: true }
    HandleAvailabilityBody:
      type: object
      required: [handle]
      properties:
        handle:
          type: string
          minLength: 1
          maxLength: 100
          description: 'Proposed public handle or display-name-derived text. A leading `@` is decorative and is removed during canonicalization.'
    HandleAvailabilityResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [normalizedHandle, available, suggestions]
          properties:
            normalizedHandle:
              type: string
              description: 'Canonical lowercase handle candidate without the decorative `@`.'
            available: { type: boolean }
            suggestions:
              type: array
              maxItems: 3
              items:
                type: string
                minLength: 3
                maxLength: 30
                pattern: '^[a-z0-9_]+$'
    Device:
      type: object
      required: [deviceId, platform]
      properties:
        deviceId: { type: string }
        platform:
          type: string
          enum: [ios, android]
          description: 'Mobile platform. `ios` = Apple iOS device; `android` = Android device.'
    UserLite:
      type: object
      required: [id, username, profilePhotoUrl]
      properties:
        id: { type: string }
        username: { type: string }
        profilePhotoUrl: { type: string, nullable: true }
    HubMemberLite:
      type: object
      required: [id, name, username, profilePhotoUrl, phoneNumber]
      properties:
        id: { type: string }
        name: { type: string }
        username: { type: string }
        profilePhotoUrl: { type: string, nullable: true }
        phoneNumber: { type: string, nullable: true, description: 'Visible only in approved hub member contexts' }
    PostVisibility:
      type: string
      enum: [public, public_profile, public_hub, private_locked_hub]
      description: |
        Post visibility scope.
        - `public`: visible on the global feed to everyone
        - `public_profile`: visible on the author's public profile and in followers' Home feeds, but not in General
        - `public_hub`: visible on the global feed and shared into one or more hubs
        - `private_locked_hub`: visible only to members of the target hub(s); not on global feed
    PostSlider:
      type: object
      required: [leftLabel, rightLabel]
      properties:
        leftLabel: { type: string }
        rightLabel: { type: string }
    PostActionRestrictionReason:
      type: string
      enum: [private_locked_hub, article_frame_required, already_repost]
    PostActionCapability:
      type: object
      required: [allowed]
      properties:
        allowed: { type: boolean }
        reason:
          type: string
          allOf:
            - $ref: '#/components/schemas/PostActionRestrictionReason'
          nullable: true
          description: 'Null when allowed. When denied, explains the content-level restriction without applying viewer authentication or membership gates.'
    PostContentActions:
      type: object
      required: [repost, shareToFeed, shareExternally, shareToHubs]
      description: 'Viewer-independent content eligibility. Authentication, Hub readiness, and Hub membership are enforced separately when an action is attempted.'
      properties:
        repost: { $ref: '#/components/schemas/PostActionCapability' }
        shareToFeed: { $ref: '#/components/schemas/PostActionCapability' }
        shareExternally: { $ref: '#/components/schemas/PostActionCapability' }
        shareToHubs: { $ref: '#/components/schemas/PostActionCapability' }
    PostCard:
      type: object
      required: [id, author, isRepost, visibility, contentActions, createdAt]
      properties:
        id: { type: string }
        author:
          type: object
          required: [id, username]
          properties:
            id: { type: string }
            username: { type: string }
            fullName: { type: string, nullable: true }
            profilePhotoUrl: { type: string, nullable: true }
        article:
          type: object
          allOf:
            - $ref: '#/components/schemas/PostArticle'
          nullable: true
          description: 'Optional article/link/media/text content for this post. Null on repost cards; use sourcePost.article for the original/source article.'
        thoughtText:
          type: string
          nullable: true
          maxLength: 250
          description: 'Author-specific thought/take for this post or repost. Never inherited by reposts.'
        sliderText:
          type: string
          nullable: true
          minLength: 1
          maxLength: 260
          description: 'Visible slider title/prompt when this post has a slider. There is no separate sliderTitle field.'
        slider:
          type: object
          allOf:
            - $ref: '#/components/schemas/PostSlider'
          nullable: true
          description: 'Present only when this current card frame has sliderText. Provides labels for the current frame vote control; sourcePost.slider does not make a repost votable.'
        voteSummary:
          type: object
          allOf:
            - $ref: '#/components/schemas/VoteSummary'
          nullable: true
          description: 'Null when this post has no slider.'
        voteDistribution:
          type: object
          allOf:
            - $ref: '#/components/schemas/VoteDistribution'
          nullable: true
          description: 'Present for slider cards when the authenticated viewer has already voted. Lets clients render the detailed breakdown without a separate distribution fetch; buckets remain hidden until resultsVisible=true.'
        viewerVote:
          allOf:
            - $ref: '#/components/schemas/PostViewerVote'
          description: 'Viewer vote status for this card; never includes selected vote position.'
        isRepost: { type: boolean }
        sourcePostId: { type: string, nullable: true }
        sourcePost:
          type: object
          allOf:
            - $ref: '#/components/schemas/PostSourcePost'
          nullable: true
          description: 'Present only for reposts; source original post display content. Source fallback order is sourcePost.article, then sourcePost.thoughtText, then sourcePost.sliderText. sourcePost.slider is optional source metadata and does not make the current repost frame votable.'
        visibility: { $ref: '#/components/schemas/PostVisibility' }
        contentActions: { $ref: '#/components/schemas/PostContentActions' }
        hubDiscussion:
          type: object
          nullable: true
          description: 'Present on hub-scoped feed cards when the viewer can open LETS TALK.'
          properties:
            discussionId: { type: string }
            hubId: { type: string }
            messageCount: { type: integer }
            lastMessageAt: { type: string, format: date-time, nullable: true }
        feedContext:
          type: object
          nullable: true
          description: 'Present on Home feed cards when the client should explain why this post appears.'
          required: [reason, actors]
          properties:
            reason:
              type: string
              enum: [own_post, followed_author, hub_co_member_author, joined_hub_post, friend_interaction, shared_to_hub, default_feed_user]
              description: 'Why this post appears in the viewer''s home feed. `own_post` = viewer''s own post; `followed_author` = viewer follows the author; `hub_co_member_author` = author shares a hub with viewer; `joined_hub_post` = post from a joined hub; `friend_interaction` = a followed user interacted with it; `shared_to_hub` = shared into a hub the viewer is in; `default_feed_user` = fallback for cold-start feed.'
            interactionType:
              type: string
              nullable: true
              enum: [post_voted, post_commented, post_reposted, post_shared_to_feed, post_shared_to_hub]
              description: 'The interaction that caused `reason=friend_interaction`. Null otherwise.'
            hubId: { type: string, nullable: true }
            actors:
              type: array
              maxItems: 3
              items:
                type: object
                required: [id, username, profilePhotoUrl]
                properties:
                  id: { type: string }
                  username: { type: string }
                  profilePhotoUrl: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
    PostSourcePost:
      type: object
      required: [id, author, visibility, createdAt]
      properties:
        id: { type: string }
        author:
          type: object
          required: [id, username]
          properties:
            id: { type: string }
            username: { type: string }
            fullName: { type: string, nullable: true }
            profilePhotoUrl: { type: string, nullable: true }
        article:
          type: object
          allOf:
            - $ref: '#/components/schemas/PostArticle'
          nullable: true
          description: 'Original/source article for repost fallback. This is where original article content appears on repost cards.'
        thoughtText:
          type: string
          nullable: true
          maxLength: 250
          description: 'Original/source thought fallback when sourcePost.article is null.'
        sliderText:
          type: string
          nullable: true
          minLength: 1
          maxLength: 260
          description: 'Original/source slider prompt fallback when sourcePost.article and sourcePost.thoughtText are null.'
        slider:
          type: object
          allOf:
            - $ref: '#/components/schemas/PostSlider'
          nullable: true
          description: 'Optional original/source slider labels for source display metadata only. This does not make the current repost frame votable.'
        visibility: { $ref: '#/components/schemas/PostVisibility' }
        createdAt: { type: string, format: date-time }
    PostArticle:
      type: object
      required: [id, kind]
      properties:
        id: { type: string }
        kind:
          type: string
          enum: [text, media, link, mixed]
          description: 'Content type. `text` = text only; `media` = image/video asset; `link` = URL preview card; `mixed` = two or more of text/media/link.'
        text: { type: string, nullable: true, maxLength: 3000 }
        imageUrl:
          type: string
          nullable: true
          description: 'Backward-compatible first media URL. New clients should use media[].'
        media:
          type: array
          maxItems: 4
          description: 'Ordered article media. The first item is the feed preview media.'
          items:
            type: object
            required: [assetId, url, contentType]
            properties:
              assetId: { type: string }
              url: { type: string }
              contentType:
                type: string
                enum: [image/jpeg, image/png, image/webp, video/mp4, video/webm, video/quicktime]
        linkUrl: { type: string, format: uri, nullable: true }
        linkTitle: { type: string, nullable: true, maxLength: 200 }
        linkDescription: { type: string, nullable: true, maxLength: 500 }
    AuthLoginConfigResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [logoUrl, enabledMethods, otp, terms, privacyPolicy]
          properties:
            logoUrl: { type: string }
            enabledMethods:
              type: array
              items: { type: string, enum: [phone] }
            otp:
              type: object
              required: [length, resendCooldownSeconds]
              properties:
                length: { type: integer, enum: [4] }
                resendCooldownSeconds: { type: integer, enum: [60] }
            terms:
              type: object
              required: [version, url]
              properties:
                version: { type: string }
                url: { type: string }
            privacyPolicy:
              type: object
              required: [version, url]
              properties:
                version: { type: string }
                url: { type: string }
    AuthOtpRequestBody:
      type: object
      required: [channel, phoneCountryCode, phoneNumber, purpose]
      properties:
        channel:
          type: string
          enum: [phone]
        phoneCountryCode: { type: string }
        phoneNumber: { type: string }
        purpose:
          type: string
          enum: [login, signup]
          description: 'Whether this OTP is for signing into an existing account (`login`) or creating a new one (`signup`).'
    AuthOtpRequestResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [challengeId, otpLength, resendAllowedAt, expiresAt, maskedDestination]
          properties:
            challengeId: { type: string }
            otpLength: { type: integer, enum: [4] }
            resendAllowedAt: { type: string, format: date-time }
            expiresAt: { type: string, format: date-time }
            maskedDestination: { type: string }
    AuthOtpVerifyBody:
      type: object
      required: [challengeId, otpCode, device]
      properties:
        challengeId: { type: string }
        otpCode: { type: string, pattern: '^[0-9]{4}$' }
        device: { $ref: '#/components/schemas/Device' }
    AuthFirebaseTokenBody:
      type: object
      required: [idToken, device]
      properties:
        idToken: { type: string }
        device: { $ref: '#/components/schemas/Device' }
    AuthAnonymousSessionBody:
      type: object
      required: [device]
      properties:
        device: { $ref: '#/components/schemas/Device' }
    AuthSessionResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [accessToken, refreshToken, isNewUser, onboarding]
          properties:
            accessToken: { type: string }
            refreshToken: { type: string }
            accessTokenExpiresAt: { type: string, format: date-time }
            isNewUser: { type: boolean }
            onboarding:
              type: object
              required: [nextStep]
              properties:
                nextStep:
                  type: string
                  enum: [verified_identity, profile_basics, profile_media, completed]
              description: 'Next onboarding step the app should navigate to. `verified_identity` = phone verified (first time); `profile_basics` = fill in fullName/username; `profile_media` = optional profile photo; `completed` = onboarding done.'
    AuthRefreshResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [accessToken, refreshToken, accessTokenExpiresAt]
          properties:
            accessToken: { type: string }
            refreshToken: { type: string }
            accessTokenExpiresAt: { type: string, format: date-time }
    OnboardingStatusResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [isCompleted, nextStep, steps]
          properties:
            isCompleted: { type: boolean }
            nextStep:
              type: string
              enum: [verified_identity, profile_basics, profile_media, completed]
              description: 'Next onboarding step. `verified_identity` = phone verified; `profile_basics` = fill in fullName/username; `profile_media` = optional profile photo; `completed` = all steps done.'
            steps:
              type: object
              required: [verified_identity, profile_basics, profile_media, contacts_synced]
              properties:
                verified_identity: { type: boolean }
                profile_basics: { type: boolean }
                profile_media: { type: boolean }
                contacts_synced:
                  type: boolean
                  description: 'One-time Hub-readiness acknowledgement, separate from phone confirmation and linear onboarding nextStep.'
    OnboardingProfileBasicsBody:
      type: object
      required: [fullName, username, acceptedTermsVersion, acceptedPrivacyVersion, acceptedAt]
      properties:
        fullName: { type: string, minLength: 2, maxLength: 80 }
        username:
          type: string
          minLength: 3
          maxLength: 30
          pattern: '^[a-z0-9_]+$'
        acceptedTermsVersion: { type: string }
        acceptedPrivacyVersion: { type: string }
        acceptedAt: { type: string, format: date-time }
    OnboardingNextStepResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [nextStep]
          properties:
            nextStep:
              type: string
              enum: [profile_media, completed]
              description: 'Next step after profile basics. `profile_media` = optional profile photo step; `completed` = onboarding done.'
    OnboardingMediaResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [onboardingCompleted, user]
          properties:
            onboardingCompleted: { type: boolean, enum: [true] }
            user:
              type: object
              required: [id, fullName, username, profilePhotoUrl]
              properties:
                id: { type: string }
                fullName: { type: string }
                username: { type: string }
                profilePhotoUrl: { type: string, nullable: true }
    UploadPresignBody:
      type: object
      required: [purpose, contentType, fileName, fileSizeBytes, checksumSha256]
      properties:
        purpose:
          type: string
          enum: [profile_photo, hub_photo, post_image, document]
          description: 'Intended use of the asset. `profile_photo` = user profile image; `hub_photo` = hub image; `post_image` = post article media plus legacy comment/discussion images; `document` = PDF document.'
        contentType:
          type: string
          enum: [image/jpeg, image/png, image/webp, video/mp4, video/webm, video/quicktime, application/pdf]
        fileName: { type: string, minLength: 1, maxLength: 200 }
        fileSizeBytes: { type: integer, minimum: 1 }
        checksumSha256:
          type: string
          pattern: '^[0-9a-fA-F]{64}$'
    UploadPresignResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [uploadId, objectPath, method, signedUrl, requiredHeaders, expiresAt, maxSizeBytes]
          properties:
            uploadId: { type: string }
            objectPath: { type: string }
            method: { type: string, enum: [PUT] }
            signedUrl: { type: string }
            requiredHeaders:
              type: object
              required: [Content-Type, x-goog-content-sha256]
              properties:
                Content-Type: { type: string }
                x-goog-content-sha256:
                  type: string
                  pattern: '^[0-9a-fA-F]{64}$'
            expiresAt: { type: string, format: date-time }
            maxSizeBytes: { type: integer }
    UploadCompleteResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [assetId, purpose, contentType, sizeBytes, url]
          properties:
            assetId:
              type: string
            purpose:
              type: string
              enum: [profile_photo, hub_photo, post_image, document]
              description: 'The purpose declared at presign time. `profile_photo` = user profile image; `hub_photo` = hub image; `post_image` = post article media plus legacy comment/discussion images; `document` = PDF.'
            contentType: { type: string }
            sizeBytes: { type: integer }
            url: { type: string }
    UploadDownloadUrlResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [assetId, contentType, sizeBytes, method, signedUrl, expiresAt]
          properties:
            assetId: { type: string }
            contentType: { type: string }
            sizeBytes: { type: integer }
            method: { type: string, enum: [GET] }
            signedUrl: { type: string }
            expiresAt: { type: string, format: date-time }
    UserProfileResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [user, social, stats]
          properties:
            user:
              type: object
              required: [id, fullName, username, profilePhotoUrl, coverPhotoUrl, bio]
              properties:
                id: { type: string }
                fullName: { type: string }
                username: { type: string }
                profilePhotoUrl: { type: string, nullable: true }
                coverPhotoUrl: { type: string, nullable: true }
                bio: { type: string, nullable: true }
            social:
              type: object
              required: [followersCount, followingCount, viewerFollows, viewerBlocked]
              properties:
                followersCount: { type: integer }
                followingCount: { type: integer }
                viewerFollows: { type: boolean }
                viewerBlocked: { type: boolean }
            stats:
              type: object
              required: [postsCount, hubsCount, votesCastCount, adminHubs]
              properties:
                postsCount: { type: integer }
                hubsCount: { type: integer }
                votesCastCount: { type: integer }
                adminHubs:
                  type: array
                  items:
                    type: object
                    required: [id, name, role]
                    properties:
                      id: { type: string }
                      name: { type: string }
                      role:
                        type: string
                        enum: [admin, super_admin]
                        description: 'Admin-tier role in this hub. `admin` = can manage members/invitations; `super_admin` = full hub manager.'
    UserProfilePatchResponse:
      allOf:
        - $ref: '#/components/schemas/UserProfileResponse'
    HubPrivateProfileResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [displayName, profilePhotoUrl, phoneNumber]
          properties:
            displayName: { type: string }
            profilePhotoUrl: { type: string, format: uri, nullable: true }
            phoneNumber: { type: string, nullable: true }
    HubPrivateProfilePatchBody:
      type: object
      minProperties: 1
      properties:
        displayName:
          type: string
          minLength: 2
          maxLength: 80
        profileAssetId:
          type: string
          format: uuid
    UserListResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/UserLite' }
            nextCursor: { type: string, nullable: true }
    UserFollowListResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items:
                type: object
                required: [id, username, fullName, profilePhotoUrl, viewerFollows]
                properties:
                  id: { type: string }
                  username: { type: string }
                  fullName: { type: string }
                  profilePhotoUrl: { type: string, nullable: true }
                  viewerFollows:
                    type: boolean
                    description: 'Whether the authenticated viewer follows this user. Always `false` for anonymous viewers and for the viewer''s own row.'
            nextCursor: { type: string, nullable: true }
    UserSearchResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items:
                type: object
                required: [id, name, username, profilePhotoUrl, bio]
                properties:
                  id: { type: string }
                  name: { type: string }
                  username: { type: string }
                  profilePhotoUrl: { type: string, nullable: true }
                  bio: { type: string, nullable: true }
                  viewerFollows: { type: boolean, description: 'Present only when request has a valid authenticated viewer' }
            nextCursor: { type: string, nullable: true }
    HubMemberListResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/HubMemberLite' }
            nextCursor: { type: string, nullable: true }
    PostCreateBody:
      type: object
      required: [visibility]
      anyOf:
        - required: [article]
        - required: [thoughtText]
        - required: [sliderText]
      description: 'At least one of article, thoughtText, or sliderText is required. slider is required when sliderText is supplied and omitted when sliderText is omitted.'
      properties:
        article:
          type: object
          nullable: true
          description: 'Optional article/link/media/text content.'
          properties:
            text: { type: string, minLength: 1, maxLength: 3000 }
            assetId:
              type: string
              description: 'Owned completed upload asset with purpose `post_image`. Backward-compatible single media field.'
            assetIds:
              type: array
              maxItems: 4
              uniqueItems: true
              description: 'Ordered post article media assets. Images and videos can be mixed up to 4 total.'
              items: { type: string }
            linkUrl: { type: string, format: uri }
            linkTitle: { type: string, maxLength: 200, nullable: true }
            linkDescription: { type: string, maxLength: 500, nullable: true }
          anyOf:
            - required: [text]
            - required: [assetId]
            - required: [assetIds]
            - required: [linkUrl]
        thoughtText:
          type: string
          nullable: true
          maxLength: 250
          description: 'Author-specific thought/take for this post.'
        sliderText:
          type: string
          nullable: true
          minLength: 1
          maxLength: 260
          description: 'Visible slider title/prompt. Required only when slider is supplied. There is no separate sliderTitle field.'
        slider:
          type: object
          nullable: true
          description: 'Required when sliderText is supplied and omitted when sliderText is omitted.'
          required: [leftLabel, rightLabel]
          properties:
            leftLabel: { type: string, minLength: 1, maxLength: 20 }
            rightLabel: { type: string, minLength: 1, maxLength: 20 }
        visibility: { $ref: '#/components/schemas/PostVisibility' }
        hubIds:
          type: array
          description: 'Non-empty for Hub visibilities. General only maps to public; General plus one or more Hubs maps to public_hub; General off plus one or more Hubs maps to private_locked_hub.'
          items: { type: string }
    PostCreateResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [postId]
          properties:
            postId: { type: string }
    PostRepostCreateBody:
      type: object
      required: [visibility]
      anyOf:
        - required: [thoughtText]
        - required: [sliderText]
      description: 'At least one of thoughtText or sliderText is required. Reposts do not accept article. Original article content is returned only as sourcePost.article on repost cards; top-level article is null. slider is required when sliderText is supplied and omitted when sliderText is omitted.'
      properties:
        thoughtText:
          type: string
          nullable: true
          maxLength: 250
          description: 'Repost author-specific thought/take.'
        sliderText:
          type: string
          nullable: true
          minLength: 1
          maxLength: 260
          description: 'Visible slider title/prompt for the repost. Required only when slider is supplied. There is no separate sliderTitle field.'
        slider:
          type: object
          nullable: true
          description: 'Required when sliderText is supplied and omitted when sliderText is omitted.'
          required: [leftLabel, rightLabel]
          properties:
            leftLabel: { type: string, minLength: 1, maxLength: 20 }
            rightLabel: { type: string, minLength: 1, maxLength: 20 }
        visibility: { $ref: '#/components/schemas/PostVisibility' }
        hubIds:
          type: array
          description: 'Non-empty for Hub visibilities. General only maps to public; General plus one or more Hubs maps to public_hub; General off plus one or more Hubs maps to private_locked_hub.'
          items: { type: string }
    PostRepostCreateResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [postId]
          properties:
            postId: { type: string }
    PostDetailResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data: { $ref: '#/components/schemas/PostCard' }
    PostFeedResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            feedSource:
              type: string
              enum: [personalized, default_feed_users]
              nullable: true
              description: 'Feed algorithm used. `personalized` = tailored to the viewer; `default_feed_users` = fallback algorithm for new/cold-start users. Null when the source is not relevant (e.g. hub feed).'
            items:
              type: array
              items: { $ref: '#/components/schemas/PostCard' }
            nextCursor: { type: string, nullable: true }
    PostFramesResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items]
          properties:
            items:
              type: array
              description: 'Visible original/repost frames for the same source lineage.'
              items: { $ref: '#/components/schemas/PostCard' }
    Comment:
      type: object
      required: [id, author, text, imageUrl, createdAt, parentCommentId, replyCount]
      properties:
        id: { type: string }
        author: { $ref: '#/components/schemas/UserLite' }
        text: { type: string, nullable: true }
        imageUrl: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        parentCommentId:
          type: string
          nullable: true
          description: 'Root comment of the reply thread this comment belongs to; null for top-level comments.'
        replyCount:
          type: integer
          description: 'Number of non-deleted replies under this top-level comment; 0 for replies.'
    CommentListResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/Comment' }
            nextCursor: { type: string, nullable: true }
    CommentCreateResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [commentId]
          properties:
            commentId: { type: string }
    HubDiscussion:
      type: object
      required: [discussionId, hubId, postId, messageCount, lastMessageAt, createdAt, preview]
      properties:
        discussionId: { type: string }
        hubId: { type: string }
        postId: { type: string }
        messageCount: { type: integer }
        lastMessageAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        preview:
          type: array
          maxItems: 3
          items: { $ref: '#/components/schemas/HubDiscussionPreviewMessage' }
    HubDiscussionListResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/HubDiscussion' }
            nextCursor: { type: string, nullable: true }
    SavedHubDiscussionItem:
      type: object
      required: [discussionId, hub, postId, messageCount, lastMessageAt, savedAt, preview]
      properties:
        discussionId: { type: string }
        hub:
          type: object
          required: [hubId, name]
          properties:
            hubId: { type: string }
            name: { type: string }
        postId: { type: string }
        messageCount: { type: integer }
        lastMessageAt: { type: string, format: date-time, nullable: true }
        savedAt: { type: string, format: date-time }
        preview:
          type: array
          maxItems: 3
          items: { $ref: '#/components/schemas/HubDiscussionPreviewMessage' }
    SavedHubDiscussionListResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/SavedHubDiscussionItem' }
            nextCursor: { type: string, nullable: true }
    HubDiscussionAuthor:
      type: object
      required: [id, phoneNumber, publicUsername, profilePhotoUrl, isAnonymous]
      properties:
        id:
          type: string
          description: 'Stable author id for non-anonymous history; empty for historical anonymous content.'
        phoneNumber:
          type: string
          nullable: true
          description: 'Verified phone for an active non-anonymous author in this Hub. Visible only because the viewer is also an active member of this Hub.'
        publicUsername:
          type: string
          nullable: true
          description: 'Public-handle fallback only when the historical author is no longer an active Hub member and therefore has no phone projection.'
        profilePhotoUrl: { type: string, format: uri, nullable: true }
        isAnonymous: { type: boolean }
    HubDiscussionPreviewMessage:
      type: object
      required: [messageId, author, text, imageUrl, createdAt]
      properties:
        messageId: { type: string }
        author: { $ref: '#/components/schemas/HubDiscussionAuthor' }
        text: { type: string, nullable: true }
        imageUrl: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
    HubDiscussionMessage:
      type: object
      required: [messageId, author, text, imageUrl, createdAt, parentMessageId, replyCount]
      properties:
        messageId: { type: string }
        author: { $ref: '#/components/schemas/HubDiscussionAuthor' }
        text: { type: string, nullable: true }
        imageUrl: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        parentMessageId:
          type: string
          nullable: true
          description: 'Root message of the reply thread this message belongs to; null for top-level messages.'
        replyCount:
          type: integer
          description: 'Number of replies under this top-level message; 0 for replies.'
    HubDiscussionMessageListResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/HubDiscussionMessage' }
            nextCursor: { type: string, nullable: true }
    HubDiscussionMessageCreateResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [messageId, discussionId]
          properties:
            messageId: { type: string }
            discussionId: { type: string }
    VotePosition:
      type: string
      enum: [strong_left, left, neutral, right, strong_right]
      description: |
        One of 5 positions on the vote slider.
        - `strong_left`: far-left position
        - `left`: left-of-center position
        - `neutral`: center position
        - `right`: right-of-center position
        - `strong_right`: far-right position
        `strong_left` + `left` aggregate into the `left` bucket; `right` + `strong_right` aggregate into the `right` bucket.
    VoteBucket:
      type: string
      enum: [left, neutral, right]
      description: 'Aggregated vote direction. `left` = strong_left + left; `neutral` = neutral only; `right` = right + strong_right.'
    VoteSummary:
      type: object
      required: [totalVotes, resultsVisible, leftCount, neutralCount, rightCount]
      properties:
        totalVotes: { type: integer }
        resultsVisible: { type: boolean, description: 'False only when the relevant result scope has no votes' }
        leftCount: { type: integer, nullable: true, description: 'strong_left + left; null when the scope has no votes' }
        neutralCount: { type: integer, nullable: true, description: 'null when the scope has no votes' }
        rightCount: { type: integer, nullable: true, description: 'right + strong_right; null when the scope has no votes' }
    VoteHubReceiptStatus:
      type: object
      required: [hubId, countedInHub, notCountedReason]
      properties:
        hubId: { type: string }
        countedInHub: { type: boolean }
        notCountedReason:
          type: string
          nullable: true
          enum: [voted_before_active_hub_membership, voted_before_post_shared_to_hub, post_not_visible_in_hub, not_member_of_hub]
          description: 'Why this vote was not counted in the hub scope. `voted_before_active_hub_membership` = user voted before becoming a member; `voted_before_post_shared_to_hub` = voted before post was shared to hub; `post_not_visible_in_hub` = post is not visible in hub; `not_member_of_hub` = user is not a hub member.'
    PostViewerVote:
      type: object
      required: [hasVoted, votedAt]
      properties:
        hasVoted: { type: boolean }
        votedAt: { type: string, format: date-time, nullable: true }
    VoteReceiptStatus:
      type: object
      required: [hasVoted, votedAt, hubVoteScope]
      properties:
        hasVoted: { type: boolean }
        votedAt: { type: string, format: date-time, nullable: true }
        hubVoteScope:
          type: object
          allOf:
            - $ref: '#/components/schemas/VoteHubReceiptStatus'
          nullable: true
          description: 'Present when a hub scope was requested or displayed; never includes selected vote position.'
    VoteCastResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [postId, voteReceipt, summary]
          properties:
            postId: { type: string }
            voteReceipt:
              $ref: '#/components/schemas/VoteReceiptStatus'
            summary:
              $ref: '#/components/schemas/VoteSummary'
    VoteMeResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          $ref: '#/components/schemas/VoteReceiptStatus'
    VoteSummaryResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          $ref: '#/components/schemas/VoteSummary'
    VoteDistributionBucket:
      type: object
      required: [position, count]
      properties:
        position: { $ref: '#/components/schemas/VotePosition' }
        count: { type: integer }
    VoteDistribution:
      type: object
      required: [totalVotes, resultsVisible, buckets]
      properties:
        totalVotes: { type: integer }
        resultsVisible: { type: boolean, description: 'False only when the post has no votes' }
        buckets:
          type: array
          description: 'Empty only when the scope has no votes'
          items: { $ref: '#/components/schemas/VoteDistributionBucket' }
    VoteDistributionResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          $ref: '#/components/schemas/VoteDistribution'
    VoteByHubResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items]
          properties:
            items:
              type: array
              items:
                type: object
                required: [hub, memberCount, totalVotes, resultsVisible, leftCount, neutralCount, rightCount, distribution]
                properties:
                  hub:
                    type: object
                    required: [id, name]
                    properties:
                      id: { type: string }
                      name: { type: string }
                  memberCount: { type: integer, description: 'Current active-member count for display only; it does not gate voting or result visibility' }
                  totalVotes: { type: integer }
                  resultsVisible: { type: boolean, description: 'False only when this Hub scope has no votes' }
                  leftCount: { type: integer, nullable: true }
                  neutralCount: { type: integer, nullable: true }
                  rightCount: { type: integer, nullable: true }
                  distribution:
                    type: array
                    description: 'Empty only when this Hub scope has no votes'
                    items: { $ref: '#/components/schemas/VoteDistributionBucket' }
    PostShareHubsResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [shares]
          properties:
            shares:
              type: array
              items:
                type: object
                required: [hubId, shareId]
                properties:
                  hubId: { type: string }
                  shareId: { type: string }
    PostShareFeedResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [shareId, postId]
          properties:
            shareId: { type: string }
            postId: { type: string }
    ShareLinkResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [url]
          properties:
            url: { type: string, format: uri }
    HubCreateBody:
      type: object
      required: [name, profileAssetId]
      properties:
        name: { type: string, minLength: 3, maxLength: 60 }
        description: { type: string, maxLength: 1000, nullable: true }
        profileAssetId: { type: string }
        initialInvitees:
          type: array
          maxItems: 50
          items: { $ref: '#/components/schemas/HubInviteeInput' }
        customLink:
          type: string
          minLength: 3
          maxLength: 40
          pattern: '^[a-z0-9-]+$'
    HubCreateResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [hubId, role]
          properties:
            hubId: { type: string }
            role:
              type: string
              enum: [super_admin]
              description: 'Hub role assigned to the creator. Always `super_admin` (displayed in the app as "Manager").'
            initialInvitations:
              type: array
              items: { $ref: '#/components/schemas/HubInvitationItem' }
    HubDetailResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [id, name, description, profilePhotoUrl, customLink, memberCount, activeUsers24h, viewerStatus, viewerRole]
          properties:
            id: { type: string }
            name: { type: string }
            description: { type: string, nullable: true }
            profilePhotoUrl: { type: string }
            customLink: { type: string, nullable: true }
            memberCount: { type: integer }
            activeUsers24h: { type: integer }
            viewerStatus:
              type: string
              enum: [none, invited, pending_admin_approval, member, admin, super_admin]
              description: 'Current viewer''s relationship to the hub. `none` = no relationship; `invited` = has a pending invitation; `pending_admin_approval` = submitted a join request awaiting approval; `member` = active member; `admin` = hub admin; `super_admin` = hub creator/manager.'
            viewerRole:
              type: string
              nullable: true
              enum: [member, admin, super_admin]
              description: 'Active membership role. `member` = regular member; `admin` = can manage members/invitations; `super_admin` = full hub manager. Null when viewer is not an active member.'
    MyHubsResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items:
                type: object
                required: [id, name, description, profilePhotoUrl, memberCount, viewerStatus, viewerRole, isDark, invitationId, joinRequestId, lastActivityAt, invitedBy]
                properties:
                  id: { type: string }
                  name: { type: string }
                  description: { type: string, nullable: true }
                  profilePhotoUrl: { type: string, nullable: true }
                  memberCount: { type: integer }
                  viewerStatus:
                    type: string
                    enum: [invited, pending_admin_approval, member, admin, super_admin]
                    description: 'Current viewer relationship to this hub. `member/admin/super_admin` = active membership with that role; `invited` = pending invitation; `pending_admin_approval` = join request awaits admin approval.'
                  viewerRole:
                    type: string
                    nullable: true
                    enum: [member, admin, super_admin]
                    description: 'Your active membership role. Null for invited or pending-admin-approval hubs.'
                  isDark: { type: boolean }
                  invitationId: { type: string, nullable: true }
                  joinRequestId: { type: string, nullable: true }
                  lastActivityAt: { type: string, format: date-time, nullable: true }
                  invitedBy:
                    type: object
                    nullable: true
                    required: [id, username, fullName, profilePhotoUrl]
                    properties:
                      id: { type: string }
                      username: { type: string }
                      fullName: { type: string }
                      profilePhotoUrl: { type: string, nullable: true }
            nextCursor: { type: string, nullable: true }
    HubInviteeInput:
      type: object
      required: [phoneCountryCode, phoneNumber]
      properties:
        phoneCountryCode: { type: string }
        phoneNumber: { type: string }
    HubInvitationCreateBody:
      type: object
      required: [invitees]
      properties:
        invitees:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/HubInviteeInput' }
    HubInvitationItem:
      type: object
      required: [id, hubId, phoneNumberMasked, createdByRole, status, requiresAdminApprovalAfterAccept, inviteUrl, shareText, expiresAt]
      properties:
        id: { type: string }
        hubId: { type: string }
        phoneNumberMasked: { type: string }
        createdByRole:
          type: string
          enum: [member, admin, super_admin]
          description: 'Hub role of the member who created this invitation at the time it was created.'
        status:
          type: string
          enum: [invited, accepted_active, pending_admin_approval, declined, expired, cancelled]
          description: |
            Invitation lifecycle state.
            - `invited`: sent and awaiting recipient action
            - `accepted_active`: accepted and recipient is now an active member
            - `pending_admin_approval`: accepted but hub requires admin approval before membership is granted
            - `declined`: recipient declined the invitation
            - `expired`: invitation TTL passed without action
            - `cancelled`: revoked by the inviter
        requiresAdminApprovalAfterAccept: { type: boolean }
        inviteUrl: { type: string, format: uri, nullable: true }
        shareText: { type: string, nullable: true, description: 'Text for device native SMS/share. Server does not send SMS.' }
        joinRequestId: { type: string, nullable: true }
        expiresAt: { type: string, format: date-time }
    HubInvitationCreateResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/HubInvitationItem' }
    HubInvitationListResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items: { $ref: '#/components/schemas/HubInvitationItem' }
            nextCursor: { type: string, nullable: true }
    HubInvitationActionResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [invitationId, hubId, membershipStatus, joinRequestId]
          properties:
            invitationId: { type: string }
            hubId: { type: string }
            membershipStatus:
              type: string
              enum: [active, pending_admin_approval, none]
              description: 'Resulting membership state. `active` = now an active member; `pending_admin_approval` = join request created, awaiting admin approval; `none` = invitation declined, no membership.'
            joinRequestId: { type: string, nullable: true }
    HubInviteLinkCreateResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [inviteLinkId, hubId, url, expiresAt]
          properties:
            inviteLinkId: { type: string }
            hubId: { type: string }
            url: { type: string, format: uri }
            expiresAt: { type: string, format: date-time }
    HubInviteLinkPreviewResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [hub, viewerStatus, expiresAt]
          properties:
            hub:
              type: object
              required: [id, name, profilePhotoUrl, memberCount]
              properties:
                id: { type: string }
                name: { type: string }
                profilePhotoUrl: { type: string, nullable: true }
                memberCount: { type: integer }
            viewerStatus:
              type: string
              enum: [none, invited, pending_admin_approval, member, admin, super_admin]
              description: 'Viewer''s relationship to the hub. `none` = no relationship; `invited` = has pending invitation; `pending_admin_approval` = submitted join request; `member/admin/super_admin` = active member with that role.'
            expiresAt: { type: string, format: date-time }
    HubJoinRequestCreateResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [joinRequestId, hubId, status]
          properties:
            joinRequestId: { type: string }
            hubId: { type: string }
            status: { type: string, enum: [pending_admin_approval] }
    HubJoinRequestListResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items:
                type: object
                required: [id, hubId, requester, source, status, createdAt]
                properties:
                  id: { type: string }
                  hubId: { type: string }
                  requester:
                    type: object
                    required: [id, name, username, profilePhotoUrl]
                    properties:
                      id: { type: string }
                      name: { type: string }
                      username: { type: string }
                      profilePhotoUrl: { type: string, nullable: true }
                  source:
                    type: string
                    enum: [member_invitation, invite_link]
                    description: 'How the join request was initiated. `member_invitation` = from a direct member invitation; `invite_link` = from a shared invite link.'
                  status:
                    type: string
                    enum: [pending_admin_approval, approved, rejected]
                    description: 'Join request state. `pending_admin_approval` = awaiting admin decision; `approved` = admitted to hub; `rejected` = denied by admin.'
                  createdAt: { type: string, format: date-time }
            nextCursor: { type: string, nullable: true }
    HubJoinRequestDecisionResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [joinRequestId, hubId, userId, status, membershipStatus]
          properties:
            joinRequestId: { type: string }
            hubId: { type: string }
            userId: { type: string }
            status:
              type: string
              enum: [approved, rejected]
              description: 'Admin''s decision on the join request. `approved` = admitted to hub; `rejected` = denied.'
            membershipStatus:
              type: string
              enum: [active, none]
              description: 'Resulting membership state. `active` = now an active member; `none` = rejected, no membership.'
    HubAdminsResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items]
          properties:
            items:
              type: array
              items:
                type: object
                required: [userId, role]
                properties:
                  userId: { type: string }
                  role:
                    type: string
                    enum: [admin, super_admin]
                    description: 'Admin-tier role. `admin` = can manage members and invitations; `super_admin` = full hub manager (creator).'
    HubNotificationSettingsResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [hubId, contentMuted, mutedUntil, mutedTypes, suppressedSurfaces]
          properties:
            hubId: { type: string }
            contentMuted: { type: boolean }
            mutedUntil: { type: string, format: date-time, nullable: true }
            mutedTypes:
              type: array
              description: 'Notification types the user has muted for this hub.'
              items:
                type: string
                enum: [hub_post_created, post_shared_to_hub]
                description: '`hub_post_created` = new post created in hub; `post_shared_to_hub` = post shared into hub.'
            suppressedSurfaces:
              type: array
              description: 'Delivery surfaces where hub notifications are suppressed.'
              items:
                type: string
                enum: [in_app_notifications_feed, notifications_stream, push, app_icon_badge, hub_footer_badge]
                description: 'Surface identifier. `in_app_notifications_feed` = notifications tab list; `notifications_stream` = SSE stream; `push` = device push notification; `app_icon_badge` = app badge count; `hub_footer_badge` = hub tab badge.'
    HubSearchResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor]
          properties:
            items:
              type: array
              items:
                type: object
                required: [id, name, activeUsers24h, isRecommended]
                properties:
                  id: { type: string }
                  name: { type: string }
                  activeUsers24h: { type: integer }
                  isRecommended: { type: boolean }
            nextCursor: { type: string, nullable: true }
    NotificationsListResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [items, nextCursor, unreadCount]
          properties:
            items:
              type: array
              items:
                type: object
                required: [id, type, actor, createdAt, read]
                properties:
                  id: { type: string }
                  type:
                    type: string
                    enum: [post_voted, post_commented, post_reposted, post_shared_to_feed, post_shared_to_hub, user_followed, hub_post_created, hub_invite_received, hub_join_request_created, hub_join_request_approved, hub_join_request_rejected, hub_member_joined, hub_member_removed, hub_role_changed]
                    description: |
                      Notification trigger type.
                      - `post_voted`: someone voted on your post
                      - `post_commented`: someone commented on your post
                      - `post_reposted`: someone reposted your post
                      - `post_shared_to_feed`: your post was shared to a feed
                      - `post_shared_to_hub`: your post was shared into a hub
                      - `user_followed`: someone followed you
                      - `hub_post_created`: a new post was created in a hub you belong to
                      - `hub_invite_received`: you received a hub invitation
                      - `hub_join_request_created`: someone submitted a join request to a hub you admin
                      - `hub_join_request_approved`: your join request was approved
                      - `hub_join_request_rejected`: your join request was rejected
                      - `hub_member_joined`: a new member joined a hub you belong to
                      - `hub_member_removed`: a member was removed from a hub you belong to
                      - `hub_role_changed`: your role in a hub was changed
                  actor:
                    type: object
                    required: [id, username]
                    properties:
                      id: { type: string }
                      username: { type: string }
                  postId: { type: string, nullable: true }
                  sourcePostId: { type: string, nullable: true }
                  commentId: { type: string, nullable: true }
                  hub:
                    type: object
                    nullable: true
                    properties:
                      id: { type: string }
                      name: { type: string }
                  invitationId: { type: string, nullable: true }
                  joinRequestId: { type: string, nullable: true }
                  createdAt: { type: string, format: date-time }
                  read: { type: boolean }
            nextCursor: { type: string, nullable: true }
            unreadCount: { type: integer }
    NotificationsReadResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [updated]
          properties:
            updated: { type: integer }
    ReportBodyUser:
      type: object
      required: [reason]
      properties:
        reason:
          type: string
          enum: [spam, harassment, hate, impersonation, other]
          description: 'Reason for reporting a user. `spam` = unsolicited/repetitive content; `harassment` = targeted hostile behaviour; `hate` = hate speech or symbols; `impersonation` = pretending to be someone else; `other` = any other violation.'
        details: { type: string, maxLength: 1000 }
    ReportBodyPost:
      type: object
      required: [reason]
      properties:
        reason:
          type: string
          enum: [spam, abuse, hate, misinformation, other]
          description: 'Reason for reporting a post. `spam` = spam or repetitive content; `abuse` = abusive or harmful content; `hate` = hate speech; `misinformation` = deliberately false information; `other` = any other violation.'
        details: { type: string, maxLength: 1000 }
    ReportBodyHub:
      type: object
      required: [reason]
      properties:
        reason:
          type: string
          enum: [spam, abuse, hate, other]
          description: 'Reason for reporting a hub. `spam` = spam hub or activity; `abuse` = abusive hub activity; `hate` = hate speech or symbols; `other` = any other violation.'
        details: { type: string, maxLength: 1000 }
    ReportCreatedResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [reportId]
          properties:
            reportId: { type: string }
    StateBoolResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          additionalProperties:
            type: boolean
    HubLinkResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [customLink]
          properties:
            customLink: { type: string }
    OkUserIdResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [userId]
          properties:
            userId: { type: string }
    OkUserRoleResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [userId, role]
          properties:
            userId: { type: string }
            role:
              type: string
              enum: [member, admin, super_admin]
              description: 'The updated hub role for the user. `member` = regular member; `admin` = can manage members/invitations; `super_admin` = full hub manager.'
    HubUpdatedResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [hubId]
          properties:
            hubId: { type: string }
    OkEmptyResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          maxProperties: 0
  responses:
    Unauthorized:
      description: Unauthorized
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    Forbidden:
      description: Forbidden
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    NotFound:
      description: Not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    Conflict:
      description: Conflict
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    Gone:
      description: Gone
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    TooManyRequests:
      description: Too many requests
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    ValidationError:
      description: Validation failed
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    OkEmpty:
      description: Success
      content:
        application/json:
          schema: { $ref: '#/components/schemas/OkEmptyResponse' }
    FollowStateTrue:
      description: Followed
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/StateBoolResponse'
              - properties: { data: { properties: { following: { enum: [true] } } } }
    FollowStateFalse:
      description: Unfollowed
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/StateBoolResponse'
              - properties: { data: { properties: { following: { enum: [false] } } } }
    BlockStateTrue:
      description: Blocked
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/StateBoolResponse'
              - properties: { data: { properties: { blocked: { enum: [true] } } } }
    BlockStateFalse:
      description: Unblocked
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/StateBoolResponse'
              - properties: { data: { properties: { blocked: { enum: [false] } } } }
    SavedHubDiscussionStateTrue:
      description: Discussion saved
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/StateBoolResponse'
              - properties: { data: { properties: { saved: { enum: [true] } } } }
    SavedHubDiscussionStateFalse:
      description: Discussion unsaved
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/StateBoolResponse'
              - properties: { data: { properties: { saved: { enum: [false] } } } }
    JoinedFalse:
      description: Left hub
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/StateBoolResponse'
              - properties: { data: { properties: { joined: { enum: [false] } } } }
    OkUserId:
      description: Success with userId
      content:
        application/json:
          schema: { $ref: '#/components/schemas/OkUserIdResponse' }
    OkUserRole:
      description: Success with user role
      content:
        application/json:
          schema: { $ref: '#/components/schemas/OkUserRoleResponse' }
    HubUpdated:
      description: Hub updated
      content:
        application/json:
          schema: { $ref: '#/components/schemas/HubUpdatedResponse' }
    CreatedReport:
      description: Report created
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ReportCreatedResponse' }
