openapi: 3.0.3
info:
  title: Duuble API (Strict Contract Draft)
  version: 0.3.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:
  /api/v1/admin/auth/config:
    get:
      tags: [auth]
      security: []
      operationId: getAdminAuthConfig
      summary: 'Returns public configuration for the isolated staff sign-in surface.'
      description: 'Returns whether staff sign-in is enabled and the configured Google OAuth client ID. The response is no-store and does not authenticate a mobile Duuble session.'
      responses:
        '200':
          description: Staff authentication configuration
          content:
            application/json:
              schema:
                type: object
                required: [success, message, data]
                properties:
                  success: { type: boolean, enum: [true] }
                  message: { type: string }
                  data:
                    type: object
                    required: [enabled, googleClientId]
                    properties:
                      enabled: { type: boolean }
                      googleClientId: { type: string }
  /api/v1/admin/auth/google:
    post:
      tags: [auth]
      security: []
      operationId: postAdminAuthGoogle
      summary: 'Creates an isolated staff session from an authorized Google Workspace identity.'
      description: 'Verifies a Google ID token and issues an HttpOnly staff-only session cookie when the exact Workspace access policy allows the identity. The response is no-store.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [idToken]
              properties:
                idToken: { type: string, minLength: 1 }
      responses:
        '200':
          description: Staff session created
          content:
            application/json:
              schema:
                type: object
                required: [success, message, data]
                properties:
                  success: { type: boolean, enum: [true] }
                  message: { type: string }
                  data:
                    type: object
                    required: [email, expiresAt]
                    properties:
                      email: { type: string, format: email }
                      expiresAt: { type: string, format: date-time }
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
        '503':
          description: Staff sign-in is not configured
  /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. An optional notificationDeviceToken removes that installation''s push registration as a redundant cleanup path; logoutFromAllDevices removes every notification token owned by the user.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken, logoutFromAllDevices]
              properties:
                refreshToken: { type: string }
                logoutFromAllDevices: { type: boolean }
                notificationDeviceToken: { type: string, minLength: 16, maxLength: 4096, pattern: '^\S+$' }
      responses:
        '200':
          $ref: '#/components/responses/OkEmpty'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'

  /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: 'Acknowledges contact access and starts a background snapshot upload.'
      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 and creates a user-owned pending upload. The acknowledgement does not wait for contact names, phone numbers, or thumbnails to upload.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactSyncAcknowledgementBody'
      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'

  /users/me/contact-sync/uploads/{uploadId}:
    put:
      tags: [contact-sync]
      operationId: putUsersMeContactSyncUpload
      summary: 'Completes a pending contact snapshot without blocking Hub readiness.'
      parameters:
        - in: path
          name: uploadId
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactSyncUploadBody'
      responses:
        '200':
          description: Contact snapshot stored
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactSyncUploadResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'

  /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/{userId}/profile:
    get:
      tags: [users]
      security:
        - {}
        - bearerAuth: []
      operationId: getUsersById
      summary: 'Returns the public profile page data for a stable user ID.'
      description: 'Returns the exact same public-profile projection as the username lookup, addressed by the user stable ID. A valid optional bearer token enriches viewer relationship fields; an invalid supplied bearer is rejected. This endpoint never returns phone numbers or private Hub identity fields, and its viewer-specific response is private and no-store.'
      parameters:
        - $ref: '#/components/parameters/UserId'
      responses:
        '200':
          description: Profile loaded
          headers:
            Cache-Control:
              description: Prevents viewer-specific profile relationship state from entering shared or browser caches.
              schema: { type: string, example: 'private, no-cache, no-store' }
            Vary:
              description: Separates responses by bearer authorization at intermediaries.
              schema: { type: string, example: Authorization }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserProfileResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '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 authored profile posts, reposts, and explicit My Feed shares.'
      description: 'Returns original posts and reposts published by the profile owner plus non-anonymous shares that owner explicitly targeted to their own feed. Hub-target share events never add or reorder profile cards, regardless of the viewer''s Hub readiness or membership; authored public_hub frames remain normal authored items. 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 explicit feed shares can bump that card but do not add another copy. Feed cards do not include share-by attribution. Private authored posts remain omitted unless an explicit feed share places the exact frame on this timeline, and that private feed share is returned only when the viewer independently belongs to a direct or onward-share target Hub. An exact non-shareable single-Hub frame can include authorPhoneNumber only for an active member of its sole direct target Hub while its non-anonymous author remains active there. Responses are private and no-store.'
      parameters:
        - $ref: '#/components/parameters/UserId'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit50'
      responses:
        '200':
          description: User feed
          headers:
            Cache-Control:
              description: Prevents viewer-specific post-card identity from entering shared or browser caches.
              schema: { type: string, example: 'private, no-cache, no-store' }
            Vary:
              description: Separates responses by bearer authorization at intermediaries.
              schema: { type: string, example: Authorization }
          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. An exact non-shareable single-Hub frame can include authorPhoneNumber only for an active member of its sole direct target Hub while its non-anonymous author remains active there. Public/shareable and sourcePost cards omit it. Responses are private and no-store.'
      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
          headers:
            Cache-Control:
              description: Prevents viewer-specific post-card identity from entering shared or browser caches.
              schema: { type: string, example: 'private, no-cache, no-store' }
            Vary:
              description: Separates responses by bearer authorization at intermediaries.
              schema: { type: string, example: Authorization }
          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-scope 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 direct or onward-share target 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. An exact non-shareable single-Hub frame can include authorPhoneNumber only for an active member of its sole direct target Hub while its non-anonymous author remains active there; sourcePost never carries it. Responses are private and no-store.'
      parameters: [{ $ref: '#/components/parameters/PostId' }]
      responses:
        '200':
          description: Post detail
          headers:
            Cache-Control:
              description: Prevents viewer-specific post-card identity from entering shared or browser caches.
              schema: { type: string, example: 'private, no-cache, no-store' }
            Vary:
              description: Separates responses by bearer authorization at intermediaries.
              schema: { type: string, example: Authorization }
          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. The source must be currently visible to the actor; knowing a private post UUID does not bypass direct/onward Hub membership. Public-scope reposting requires verified phone identity and completed profile setup but not contact access. A private source and any Hub destination additionally require Hub readiness; every destination Hub requires 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. Public-off posts with exactly one private Hub target cannot be reposted; private posts with two or more Hub targets can. 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 direct or onward-share target 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. Each exact non-shareable single-Hub item independently projects authorPhoneNumber only to an active member of its sole direct target Hub while the author remains active there; expanded sourcePost shapes never carry it. Responses are private and no-store.'
      parameters:
        - $ref: '#/components/parameters/PostId'
      responses:
        '200':
          description: Post frames
          headers:
            Cache-Control:
              description: Prevents viewer-specific post-card identity from entering shared or browser caches.
              schema: { type: string, example: 'private, no-cache, no-store' }
            Vary:
              description: Separates responses by bearer authorization at intermediaries.
              schema: { type: string, example: Authorization }
          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 Public. `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 Public. 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. The source must be currently visible to the actor; knowing a private post UUID does not bypass direct/onward Hub membership. Public-source sharing requires registered public-profile basics but not phone confirmation or contact access. A private source additionally requires Hub readiness. The share points to the exact postId supplied, including repost frames. Public-off posts with exactly one private Hub target cannot be shared; private posts with two or more Hub targets can. Private feed shares surface only to viewers independently authorized for the source."
      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, current visibility of the source, and active membership in every target Hub; knowing a private post UUID does not bypass source membership. 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. An onward target-Hub membership participates in private detail, frames, vote, report, and feed visibility without widening access outside that Hub. 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. Public-off posts with exactly one private Hub target cannot be shared to another Hub; private posts with two or more Hub targets can.'
      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 a canonical deep link for externally shareable posts using native platform share sheets. This powers external phone sharing, not share-to-feed. Share-to-feed uses POST /posts/{postId}/share/feed. Public-off posts with exactly one private Hub target do not expose links; Public-off posts with two or more Hub targets remain private but can expose one.'
      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. Contact snapshot delivery is independent of this Hub action. 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. One unexpired invited row is allowed per Hub and normalized phone; a sequential or concurrent duplicate fails atomically without another notification.'
      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'
        '409':
          $ref: '#/components/responses/Conflict'
        '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'
  /hub-invitations/by-token/{token}:
    get:
      tags: [hubs]
      operationId: getHubInvitationByToken
      summary: 'Resolves a phone-targeted Hub invitation for its authenticated recipient.'
      description: 'Returns only safe Hub preview fields for the Hub-ready user whose verified phone matches this capability. The response is private and no-store.'
      parameters: [{ $ref: '#/components/parameters/InviteToken' }]
      responses:
        '200':
          description: Phone-targeted Hub invitation preview
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubInvitationTokenPreviewResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          $ref: '#/components/responses/Gone'
  /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:
    get:
      tags: [hubs]
      operationId: getHubInviteLinks
      summary: 'Lists active Hub invite links visible to a Hub member.'
      description: 'Lists active, unexpired shared invitation records without returning their capability tokens. Each item says whether the current member may revoke it. The response is private and no-store.'
      parameters: [{ $ref: '#/components/parameters/HubId' }]
      responses:
        '200':
          description: Active Hub invite links
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubInviteLinkListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    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}/invite-links/{inviteLinkId}:
    delete:
      tags: [hubs]
      operationId: deleteHubInviteLink
      summary: 'Revokes a shared Hub invite link.'
      description: 'Idempotently revokes a shared invite link. Its creator or a Hub admin/super-admin may revoke it. The capability stops resolving immediately.'
      parameters:
        - $ref: '#/components/parameters/HubId'
        - $ref: '#/components/parameters/InviteLinkId'
      responses:
        '200':
          description: Hub invite link revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubInviteLinkRevokeResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /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 hub_post_created, post_shared_to_hub, and Hub-scoped post_reposted across the durable inbox, SSE, push, app-icon badge, and Hub-footer badge.'
      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 hub_post_created, post_shared_to_hub, and Hub-scoped post_reposted across the durable inbox, SSE, push, app-icon badge, and Hub-footer badge.'
      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. An exact non-shareable card whose sole direct target is this Hub can include authorPhoneNumber only while its non-anonymous author remains active here; shareable and sourcePost cards omit it. Responses are private and no-store.'
      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
          headers:
            Cache-Control:
              description: Prevents viewer-specific post-card identity from entering shared or browser caches.
              schema: { type: string, example: 'private, no-cache, no-store' }
            Vary:
              description: Separates responses by bearer authorization at intermediaries.
              schema: { type: string, example: Authorization }
          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]
      operationId: getHubInviteLink
      summary: 'Returns a safe private preview for a Hub invite link.'
      description: 'Returns safe Hub preview and viewer-relationship fields to an authenticated Hub-ready user before they explicitly request to join. The response is private and no-store.'
      parameters: [{ $ref: '#/components/parameters/InviteToken' }]
      responses:
        '200':
          description: Hub invite link preview
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HubInviteLinkPreviewResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '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 the authoritative durable notifications inbox and unread count. 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 before persistence. Hub rows require live Hub readiness and membership except for narrow recipient-only hub_invite_received, hub_join_request_rejected, and hub_member_removed rows, whose recipients do not have active membership at delivery time.'
      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/device-tokens:
    post:
      tags: [notifications]
      operationId: postNotificationDeviceToken
      summary: 'Registers or refreshes the current device FCM token.'
      description: 'Level 2 (completed public profile basics) is required; phone confirmation and contact synchronization are not. Idempotently associates one Android or iOS Firebase Cloud Messaging token with the current user. Existing ownership of the same token is atomically reassigned to the current user. At most five tokens per user are retained and registrations not refreshed for 60 days are stale.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotificationDeviceTokenRegisterBody'
      responses:
        '200':
          description: Notification device registered
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationDeviceTokenRegisterResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/ValidationError'
    delete:
      tags: [notifications]
      operationId: deleteNotificationDeviceToken
      summary: 'Removes the current user registration for one FCM token.'
      description: 'Idempotently removes only the matching token registration owned by the current user.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotificationDeviceTokenDeleteBody'
      responses:
        '200':
          description: Notification device removed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationDeviceTokenDeleteResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'
  /notifications/read:
    patch:
      tags: [notifications]
      operationId: patchNotificationsRead
      summary: 'Marks notifications as read.'
      description: 'Marks currently visible notifications as read. Explicit IDs absent from the current user''s rows return 404; an owned but currently hidden Hub row returns success with updated=0.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                notificationIds:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { type: string }
                markAll: { type: boolean }
              oneOf:
                - required: [notificationIds]
                  properties:
                    markAll: { type: boolean, enum: [false] }
                - required: [markAll]
                  properties:
                    notificationIds: { type: array, maxItems: 0 }
                    markAll: { type: boolean, enum: [true] }
      responses:
        '200':
          description: Read state updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotificationsReadResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
  /notifications/stream:
    get:
      tags: [notifications]
      operationId: getNotificationsStream
      summary: 'Server-Sent Events stream for real-time notifications.'
      description: 'Server-Sent Events stream of committed notification envelopes. Clients deduplicate by id, refresh the durable inbox on every signal, reconnect after the one-hour Cloud Run timeout, and reconcile with GET /notifications after a gap. Muted and currently unauthorized Hub activity is excluded; recipient-only hub_invite_received, hub_join_request_rejected, and hub_member_removed rows have narrow Hub-ready exceptions.'
      responses:
        '200':
          description: SSE stream
          content:
            text/event-stream:
              schema:
                type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '426':
          description: Streaming transport is required
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiError' }

  /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 }
    InviteLinkId:
      in: path
      name: inviteLinkId
      required: true
      schema: { type: string }
    InviteToken:
      in: path
      name: token
      required: true
      schema:
        type: string
        minLength: 43
        maxLength: 43
        pattern: '^[A-Za-z0-9_-]{43}$'
    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_INVITATION_ALREADY_ACTIVE
        - 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 Public
        - `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, isShareable, 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 }
        authorPhoneNumber:
          type: string
          nullable: true
          pattern: '^\+[1-9][0-9]{7,14}$'
          description: 'Omitted unless this exact card is a non-shareable single-direct-Hub private frame and the viewer is Hub-ready, is an active member of that sole target Hub, and the non-anonymous author is still an active member there with a verified phone identity. This field belongs only to the exact top-level frame; it is never included in author, sourcePost, public/shareable cards, public profile/search payloads, or responses to anonymous/nonmember viewers.'
        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' }
        isShareable:
          type: boolean
          description: 'Server-authoritative outward-shareability signal for this exact frame. True for every Public-enabled frame and for Public-off frames with two or more direct private Hub targets; false for the exactly-one-Hub private locked case.'
        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, isShareable, createdAt]
      description: 'Reusable public source-frame display data. This shape never carries the exact-card authorPhoneNumber projection.'
      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' }
        isShareable:
          type: boolean
          description: 'Server-authoritative source-frame capability used when offering another first-level repost. True only when the source is intrinsically shareable and the current viewer can open that source; a public repost therefore reports false to a viewer outside its private source Hubs. Clients must not derive this value from visibility.'
        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 }
    ContactSyncAcknowledgementBody:
      type: object
      required: [uploadId, capturedAt, expectedEntryCount]
      properties:
        uploadId: { type: string, format: uuid }
        capturedAt: { type: string, format: date-time }
        expectedEntryCount: { type: integer, minimum: 0, maximum: 5000 }
    ContactSyncUploadBody:
      type: object
      required: [contacts]
      properties:
        contacts:
          type: array
          maxItems: 5000
          items:
            type: object
            required: [deviceContactId, displayName, phoneNumbers]
            properties:
              deviceContactId: { type: string, minLength: 1, maxLength: 255 }
              displayName: { type: string, minLength: 1, maxLength: 255 }
              phoneNumbers:
                type: array
                minItems: 1
                maxItems: 20
                items: { type: string, pattern: '^\\+[1-9][0-9]{1,14}$' }
              thumbnailBase64:
                type: string
                nullable: true
                description: 'Optional device-provided contact thumbnail, limited to 512 KiB after Base64 decoding and 16 MiB across the snapshot.'
    ContactSyncUploadResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [uploadId, status, entryCount, photoCount, uploadedAt]
          properties:
            uploadId: { type: string, format: uuid }
            status: { type: string, enum: [completed] }
            entryCount: { type: integer }
            photoCount: { type: integer }
            uploadedAt: { 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. Public only maps to public; Public plus one or more Hubs maps to public_hub; Public off plus one or more Hubs maps to private_locked_hub. Any Public destination is shareable; with Public off, exactly one Hub is locked and two or more Hubs are shareable.'
          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. Public only maps to public; Public plus one or more Hubs maps to public_hub; Public off plus one or more Hubs maps to private_locked_hub. Any Public destination is shareable; with Public off, exactly one Hub is locked and two or more Hubs are shareable.'
          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
          description: 'Private Hub profile image for an active author, falling back to the global public profile image when no private image is set. Former or nonactive authors expose only the global public image; anonymous history exposes none.'
        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 }
    HubInvitationTokenPreviewResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [invitationId, hub, status, requiresAdminApprovalAfterAccept, expiresAt]
          properties:
            invitationId: { type: string }
            hub: { $ref: '#/components/schemas/HubInviteLinkHub' }
            status:
              type: string
              enum: [invited, accepted_active, pending_admin_approval]
            requiresAdminApprovalAfterAccept: { type: boolean }
            expiresAt: { type: string, format: date-time }
    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, invitation, expiresAt]
          properties:
            hub: { $ref: '#/components/schemas/HubInviteLinkHub' }
            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.'
            invitation:
              type: object
              nullable: true
              required: [id, requiresAdminApprovalAfterAccept]
              properties:
                id: { type: string }
                requiresAdminApprovalAfterAccept: { type: boolean }
            expiresAt: { type: string, format: date-time }
    HubInviteLinkHub:
      type: object
      required: [id, name, profilePhotoUrl, memberCount]
      properties:
        id: { type: string }
        name: { type: string }
        profilePhotoUrl: { type: string, nullable: true }
        memberCount: { type: integer, minimum: 0 }
    HubInviteLinkListResponse:
      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: [id, hubId, createdById, canRevoke, expiresAt, createdAt]
                properties:
                  id: { type: string }
                  hubId: { type: string }
                  createdById: { type: string }
                  canRevoke: { type: boolean }
                  expiresAt: { type: string, format: date-time }
                  createdAt: { type: string, format: date-time }
    HubInviteLinkRevokeResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string }
        data:
          type: object
          required: [inviteLinkId, hubId, revoked]
          properties:
            inviteLinkId: { type: string }
            hubId: { type: string }
            revoked: { type: boolean, enum: [true] }
    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, post_reposted]
                description: '`hub_post_created` = new post created in hub; `post_shared_to_hub` = post shared into hub; `post_reposted` applies only when the repost notification is scoped to this 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`: you were removed from a hub
                      - `hub_role_changed`: your role in a hub was changed
                  actor:
                    type: object
                    required: [id, username, isAnonymous]
                    properties:
                      id: { type: string }
                      username: { type: string }
                      isAnonymous:
                        type: boolean
                        description: 'True when the triggering actor must remain anonymous. Votes are always anonymous; historical anonymous-user activity also preserves anonymity. In that case id and username are intentionally empty and clients render a neutral label such as Someone.'
                  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 }
    NotificationDeviceTokenRegisterBody:
      type: object
      required: [token, platform]
      additionalProperties: false
      properties:
        token: { type: string, minLength: 16, maxLength: 4096, pattern: '^[^\s\u0000-\u001F\u007F]+$' }
        platform: { type: string, enum: [android, ios] }
    NotificationDeviceTokenDeleteBody:
      type: object
      required: [token]
      additionalProperties: false
      properties:
        token: { type: string, minLength: 16, maxLength: 4096, pattern: '^[^\s\u0000-\u001F\u007F]+$' }
    NotificationDeviceTokenRegisterResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string, enum: ['Notification device registered'] }
        data:
          type: object
          required: [registered]
          properties:
            registered: { type: boolean, enum: [true] }
    NotificationDeviceTokenDeleteResponse:
      type: object
      required: [success, message, data]
      properties:
        success: { type: boolean, enum: [true] }
        message: { type: string, enum: ['Notification device removed'] }
        data:
          type: object
          required: [removed]
          properties:
            removed: { type: boolean }
    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' }
