{"openapi":"3.0.3","info":{"title":"A/SIDE API","version":"1.0.0","description":"API for A/SIDE, an invite-only social network built around mutual follows, groups, stories, and direct messaging."},"servers":[{"url":"/v1","description":"V1 API"}],"components":{"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}},"schemas":{"User":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"display_name":{"type":"string","minLength":1,"maxLength":50},"avatar_url":{"type":"string","nullable":true},"bio":{"type":"string","nullable":true},"email":{"type":"string"},"push_token":{"type":"string","nullable":true},"subscription_status":{"type":"string","enum":["free","active","expired","cancelled"]},"trial_ends_at":{"type":"string","format":"date-time","nullable":true},"referral_bonus_granted_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"deleted_at":{"type":"string","format":"date-time","nullable":true}}},"PlanLimits":{"type":"object","description":"Current plan limits for the authenticated user. feed_history_days is null for unlimited (paid/trial).","properties":{"feed_history_days":{"type":"integer","nullable":true,"description":"Number of days of feed history visible, or null for unlimited"},"max_display_name_length":{"type":"integer"},"max_photos_per_post":{"type":"integer"},"max_groups":{"type":"integer"},"max_video_story_seconds":{"type":"integer"},"max_invites":{"type":"integer"},"max_bio_length":{"type":"integer"},"max_caption_length":{"type":"integer"},"max_comment_length":{"type":"integer"},"max_group_name_length":{"type":"integer"},"story_expiration_hours":{"type":"integer"}}},"UserProfile":{"type":"object","description":"Public profile. bio is included only for mutual follows.","properties":{"id":{"type":"string","format":"uuid"},"display_name":{"type":"string"},"avatar_url":{"type":"string","nullable":true},"bio":{"type":"string","nullable":true},"is_mutual_follow":{"type":"boolean"},"is_following":{"type":"boolean"},"is_followed_by":{"type":"boolean","description":"True if this user follows the authenticated user"},"mutual_follow_count":{"type":"integer"}}},"Post":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"caption":{"type":"string","nullable":true},"voice_caption_duration_ms":{"type":"integer","nullable":true,"minimum":1,"maximum":60000,"description":"Optional voice caption; fetch through the authenticated post endpoint"},"audience_type":{"type":"string","enum":["all_connections","lists"],"description":"Whether the post is visible to all mutual connections or a publish-time list snapshot"},"audience_member_count":{"type":"integer","description":"Number of snapshotted recipients (create response only)"},"display_name":{"type":"string"},"avatar_url":{"type":"string","nullable":true},"media":{"type":"array","items":{"$ref":"#/components/schemas/PostMedia"}},"comment_count":{"type":"integer","description":"Total number of comments on this post (feed only)"},"recent_comments":{"type":"array","items":{"$ref":"#/components/schemas/CommentPreview"},"description":"Latest 2 comments with display name and avatar (feed only)"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"deleted_at":{"type":"string","format":"date-time","nullable":true}}},"CommentPreview":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"body":{"type":"string"},"display_name":{"type":"string"},"avatar_url":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"PostMedia":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"post_id":{"type":"string","format":"uuid"},"position":{"type":"integer"},"media_url":{"type":"string"},"media_type":{"type":"string","enum":["photo","video"]},"width":{"type":"integer","nullable":true},"height":{"type":"integer","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"MediaInput":{"type":"object","required":["key","media_type","position"],"properties":{"key":{"type":"string","description":"The key returned from upload-url"},"media_type":{"type":"string","enum":["photo","video"]},"width":{"type":"integer","nullable":true},"height":{"type":"integer","nullable":true},"position":{"type":"integer"}}},"Comment":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"post_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"body":{"type":"string"},"display_name":{"type":"string"},"avatar_url":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time","nullable":true},"deleted_at":{"type":"string","format":"date-time","nullable":true},"reply_to_comment_id":{"type":"string","format":"uuid","nullable":true},"reply_to_user_id":{"type":"string","format":"uuid","nullable":true},"reply_to_display_name":{"type":"string","nullable":true},"like_count":{"type":"integer","minimum":0},"is_liked":{"type":"boolean"}}},"Story":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"media_url":{"type":"string"},"media_type":{"type":"string","enum":["photo","video"]},"expires_at":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time"}}},"GroupedStories":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/UserProfile"},"stories":{"type":"array","items":{"$ref":"#/components/schemas/Story"}}}},"Conversation":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"conversation_type":{"type":"string","enum":["direct","group"]},"name":{"type":"string","nullable":true},"created_by":{"type":"string","format":"uuid","nullable":true},"user_a_id":{"type":"string","format":"uuid","nullable":true},"user_b_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"last_message_at":{"type":"string","format":"date-time","nullable":true},"last_read_at":{"type":"string","format":"date-time","nullable":true},"unread_count":{"type":"integer"},"other_user_id":{"type":"string","format":"uuid","nullable":true},"other_display_name":{"type":"string","nullable":true},"other_avatar_url":{"type":"string","nullable":true},"members":{"type":"array","nullable":true,"items":{"$ref":"#/components/schemas/UserProfile"}}}},"Message":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"conversation_id":{"type":"string","format":"uuid"},"sender_id":{"type":"string","format":"uuid"},"body":{"type":"string","nullable":true},"media_url":{"type":"string","nullable":true},"sender_display_name":{"type":"string"},"sender_avatar_url":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"deleted_at":{"type":"string","format":"date-time","nullable":true}}},"Group":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"color":{"type":"string","nullable":true},"position":{"type":"integer","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"Invite":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"created_by_user_id":{"type":"string","format":"uuid"},"used_by_user_id":{"type":"string","format":"uuid","nullable":true},"code":{"type":"string"},"status":{"type":"string","enum":["pending","used","expired","revoked"]},"expires_at":{"type":"string","format":"date-time","nullable":true},"used_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"Follow":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"follower_id":{"type":"string","format":"uuid"},"followee_id":{"type":"string","format":"uuid"},"created_at":{"type":"string","format":"date-time"}}},"InboundFollow":{"type":"object","description":"A user who follows you but you do not follow back.","properties":{"id":{"type":"string","format":"uuid"},"display_name":{"type":"string"},"avatar_url":{"type":"string","nullable":true}}},"Error":{"type":"object","properties":{"error":{"type":"string"}}}}},"paths":{"/auth/request-otp":{"post":{"tags":["Auth"],"summary":"Request a one-time password","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email","maxLength":254}}}}}},"responses":{"200":{"description":"OTP sent","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}},"400":{"description":"Missing or invalid email address or JSON body"},"422":{"description":"Email provider cannot deliver to this recipient"},"429":{"description":"OTP cooldown or request rate limit exceeded"},"503":{"description":"Email delivery is unavailable; no automatic resend is attempted"}}}},"/auth/verify-otp":{"post":{"tags":["Auth"],"summary":"Verify OTP and authenticate or register","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","code"],"properties":{"email":{"type":"string"},"code":{"type":"string"},"refresh_protocol":{"type":"integer","enum":[2],"description":"Opt into rotating persistent sessions"},"invite_code":{"type":"string","description":"Optional for new registrations"},"display_name":{"type":"string","minLength":1,"maxLength":50,"description":"Required for new registrations"}}}}}},"responses":{"200":{"description":"Authenticated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"access_token":{"type":"string"},"refresh_token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}}}}}},"400":{"description":"Invalid, expired, or malformed code"},"429":{"description":"OTP attempt or request rate limit exceeded"}}}},"/auth/refresh":{"post":{"tags":["Auth"],"summary":"Refresh an access token","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refresh_token"],"properties":{"refresh_token":{"type":"string"},"rotation_nonce":{"type":"string","minLength":43,"maxLength":43,"description":"Persisted random base64url retry secret; reuse only for the same unacknowledged refresh attempt"}}}}}},"responses":{"200":{"description":"New access token","content":{"application/json":{"schema":{"type":"object","properties":{"access_token":{"type":"string"},"refresh_token":{"type":"string","description":"Rotated credential; persist before the next renewal"}}}}}},"401":{"description":"Invalid, inactive, revoked, idle-expired or reused session"},"409":{"description":"An acknowledged refresh attempt has already been superseded"}}}},"/auth/session":{"delete":{"security":[],"tags":["Auth"],"summary":"End a session (logout)","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refresh_token"],"properties":{"refresh_token":{"type":"string"}}}}}},"responses":{"200":{"description":"Session ended","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}}}}},"/users/me":{"get":{"tags":["Users"],"summary":"Get the current user with plan limits","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Current user and plan limits","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/User"},"plan_limits":{"$ref":"#/components/schemas/PlanLimits"}}}}}}}},"patch":{"tags":["Users"],"summary":"Update the current user profile","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"display_name":{"type":"string","minLength":1,"maxLength":50},"bio":{"type":"string"},"avatar_url":{"type":"string"}}}}}},"responses":{"200":{"description":"Updated user","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/User"}}}}}}}},"delete":{"tags":["Users"],"summary":"Permanently delete the current user account","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Account deleted","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}}}}},"/users/me/upload-url":{"post":{"tags":["Users"],"summary":"Get presigned upload URL for avatar media","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["content_type"],"properties":{"content_type":{"type":"string"}}}}}},"responses":{"200":{"description":"Upload URL","content":{"application/json":{"schema":{"type":"object","properties":{"upload_url":{"type":"string"},"key":{"type":"string"}}}}}}}}},"/users/{id}":{"get":{"tags":["Users"],"summary":"Get a user profile by ID","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"User profile (bio included only for mutual follows)","content":{"application/json":{"schema":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/UserProfile"}}}}}}}}},"/follows":{"post":{"tags":["Follows"],"summary":"Follow a user","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["user_id"],"properties":{"user_id":{"type":"string","format":"uuid"}}}}}},"responses":{"201":{"description":"Follow created","content":{"application/json":{"schema":{"type":"object","properties":{"follow":{"$ref":"#/components/schemas/Follow"},"is_mutual":{"type":"boolean"}}}}}}}}},"/follows/{user_id}":{"delete":{"tags":["Follows"],"summary":"Unfollow a user","security":[{"BearerAuth":[]}],"parameters":[{"name":"user_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Unfollowed","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}}}}},"/follows/mutual":{"get":{"tags":["Follows"],"summary":"List mutual follows","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Mutual follows","content":{"application/json":{"schema":{"type":"object","properties":{"users":{"type":"array","items":{"$ref":"#/components/schemas/UserProfile"}}}}}}}}}},"/follows/inbound":{"get":{"tags":["Follows"],"summary":"List inbound follows (users who follow you but you do not follow back)","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Inbound follows","content":{"application/json":{"schema":{"type":"object","properties":{"users":{"type":"array","items":{"$ref":"#/components/schemas/InboundFollow"}}}}}}}}}},"/follows/outbound":{"get":{"tags":["Follows"],"summary":"List outbound follows (users you follow who do not follow you back)","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Outbound follows","content":{"application/json":{"schema":{"type":"object","properties":{"users":{"type":"array","items":{"$ref":"#/components/schemas/InboundFollow"}}}}}}}}}},"/invites":{"get":{"tags":["Invites"],"summary":"List current user invites","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Invites list","content":{"application/json":{"schema":{"type":"object","properties":{"invites":{"type":"array","items":{"$ref":"#/components/schemas/Invite"}}}}}}}}},"post":{"tags":["Invites"],"summary":"Generate a new invite code","security":[{"BearerAuth":[]}],"responses":{"201":{"description":"Invite created","content":{"application/json":{"schema":{"type":"object","properties":{"invite":{"$ref":"#/components/schemas/Invite"}}}}}}}}},"/invites/{id}":{"delete":{"tags":["Invites"],"summary":"Revoke a pending invite","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Invite revoked","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}}}}},"/invites/validate/{code}":{"get":{"tags":["Invites"],"summary":"Validate an invite code (no auth required)","parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","properties":{"valid":{"type":"boolean"},"inviter":{"type":"object","nullable":true,"properties":{"username":{"type":"string"},"display_name":{"type":"string"}}}}}}}}}}},"/invites/redeem":{"post":{"tags":["Invites"],"summary":"Redeem an invite code as an existing user to connect with the inviter","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string","description":"The invite code to redeem"}}}}}},"responses":{"200":{"description":"Invite redeemed","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"is_mutual":{"type":"boolean","description":"True if both users now follow each other"}}}}}},"400":{"description":"Cannot redeem own invite","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Invalid or expired code","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Already connected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/feed":{"get":{"tags":["Feed"],"summary":"Get the chronological feed from mutual follows","security":[{"BearerAuth":[]}],"parameters":[{"name":"before","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"Cursor for pagination; return posts created before this timestamp"},{"name":"group_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"Filter to posts by members of this group"}],"responses":{"200":{"description":"Feed posts","content":{"application/json":{"schema":{"type":"object","properties":{"posts":{"type":"array","items":{"$ref":"#/components/schemas/Post"}}}}}}}}}},"/posts/upload-url":{"post":{"tags":["Posts"],"summary":"Get presigned upload URL(s) for post media","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["content_type"],"properties":{"content_type":{"type":"string","description":"MIME type, e.g. image/jpeg"},"count":{"type":"integer","minimum":1,"maximum":5,"default":1}}}}}},"responses":{"200":{"description":"Upload URLs","content":{"application/json":{"schema":{"type":"object","properties":{"uploads":{"type":"array","items":{"type":"object","properties":{"upload_url":{"type":"string"},"key":{"type":"string"}}}}}}}}}}}},"/posts/voice-captions":{"post":{"tags":["Posts"],"summary":"Upload a private voice caption for a photo post","security":[{"BearerAuth":[]}],"parameters":[{"in":"header","name":"X-Voice-Duration-Ms","required":true,"schema":{"type":"integer","minimum":1,"maximum":60000}}],"requestBody":{"required":true,"content":{"audio/mp4":{"schema":{"type":"string","format":"binary","description":"M4A recording, at most 2 MiB"}}}},"responses":{"201":{"description":"Uploader-owned, single-use reservation","content":{"application/json":{"schema":{"type":"object","properties":{"voice_caption":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"duration_ms":{"type":"integer"}}}}}}}},"400":{"description":"Invalid duration or recording"},"401":{"description":"Authentication required"},"413":{"description":"Recording exceeds 2 MiB"},"429":{"description":"Upload limit reached"}}}},"/posts/{id}/voice-caption":{"get":{"tags":["Posts"],"summary":"Read audio after checking the post audience and history limits","security":[{"BearerAuth":[]}],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Private audio; Cache-Control: private, no-store","content":{"audio/mp4":{"schema":{"type":"string","format":"binary"}}}},"401":{"description":"Authentication required"},"404":{"description":"Post or audio unavailable to this viewer"}}}},"/posts":{"post":{"tags":["Posts"],"summary":"Create a new post","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["media"],"properties":{"caption":{"type":"string","maxLength":2200},"voice_caption_id":{"type":"string","format":"uuid","description":"Optional uploader-owned reservation; requires at least one photo"},"media":{"type":"array","items":{"$ref":"#/components/schemas/MediaInput"},"minItems":1,"maxItems":5},"group_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Private lists whose current connected members become the immutable post audience"}}}}}},"responses":{"201":{"description":"Post created","content":{"application/json":{"schema":{"type":"object","properties":{"post":{"$ref":"#/components/schemas/Post"}}}}}}}}},"/posts/{id}":{"get":{"tags":["Posts"],"summary":"Get a post by ID","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Post","content":{"application/json":{"schema":{"type":"object","properties":{"post":{"$ref":"#/components/schemas/Post"}}}}}}}},"patch":{"tags":["Posts"],"summary":"Edit own post caption","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"caption":{"type":"string","maxLength":2200}}}}}},"responses":{"200":{"description":"Post updated","content":{"application/json":{"schema":{"type":"object","properties":{"post":{"$ref":"#/components/schemas/Post"}}}}}}}},"delete":{"tags":["Posts"],"summary":"Delete a post","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Post deleted","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}}}}},"/posts/by-user/{id}":{"get":{"tags":["Posts"],"summary":"Get posts by a user","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"before","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"Cursor for pagination"}],"responses":{"200":{"description":"Posts by user","content":{"application/json":{"schema":{"type":"object","properties":{"posts":{"type":"array","items":{"$ref":"#/components/schemas/Post"}}}}}}}}}},"/posts/{postId}/comments":{"get":{"tags":["Comments"],"summary":"List comments on a post","security":[{"BearerAuth":[]}],"parameters":[{"name":"postId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Comments list","content":{"application/json":{"schema":{"type":"object","properties":{"comments":{"type":"array","items":{"$ref":"#/components/schemas/Comment"}}}}}}}}},"post":{"tags":["Comments"],"summary":"Create a comment on a post","security":[{"BearerAuth":[]}],"parameters":[{"name":"postId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["body"],"properties":{"body":{"type":"string","maxLength":1000},"reply_to_comment_id":{"type":"string","format":"uuid","nullable":true,"description":"If set, the comment is a reply — parent must belong to the same post and not be deleted."}}}}}},"responses":{"201":{"description":"Comment created","content":{"application/json":{"schema":{"type":"object","properties":{"comment":{"$ref":"#/components/schemas/Comment"}}}}}}}}},"/comments/{id}":{"put":{"tags":["Comments"],"summary":"Edit own comment","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["body"],"properties":{"body":{"type":"string","maxLength":1000}}}}}},"responses":{"200":{"description":"Comment updated","content":{"application/json":{"schema":{"type":"object","properties":{"comment":{"$ref":"#/components/schemas/Comment"}}}}}}}},"delete":{"tags":["Comments"],"summary":"Delete a comment","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Comment deleted","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}}}}},"/comments/{id}/like":{"post":{"tags":["Comments"],"summary":"Like a comment (idempotent)","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Comment liked","content":{"application/json":{"schema":{"type":"object","properties":{"liked":{"type":"boolean"},"like_count":{"type":"integer","minimum":0}}}}}}}},"delete":{"tags":["Comments"],"summary":"Unlike a comment","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Comment unliked","content":{"application/json":{"schema":{"type":"object","properties":{"liked":{"type":"boolean"},"like_count":{"type":"integer","minimum":0}}}}}}}}},"/stories":{"get":{"tags":["Stories"],"summary":"List active stories from mutual follows, grouped by user","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Grouped stories","content":{"application/json":{"schema":{"type":"object","properties":{"story_groups":{"type":"array","items":{"$ref":"#/components/schemas/GroupedStories"}}}}}}}}},"post":{"tags":["Stories"],"summary":"Create a story","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["media_url","media_type"],"properties":{"media_url":{"type":"string"},"media_type":{"type":"string","enum":["photo","video"]}}}}}},"responses":{"201":{"description":"Story created","content":{"application/json":{"schema":{"type":"object","properties":{"story":{"$ref":"#/components/schemas/Story"}}}}}}}}},"/stories/upload-url":{"post":{"tags":["Stories"],"summary":"Get presigned upload URL for story media","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["content_type"],"properties":{"content_type":{"type":"string"}}}}}},"responses":{"200":{"description":"Upload URL","content":{"application/json":{"schema":{"type":"object","properties":{"upload_url":{"type":"string"},"key":{"type":"string"}}}}}}}}},"/stories/{id}":{"delete":{"tags":["Stories"],"summary":"Delete a story","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Story deleted","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}}}}},"/conversations":{"get":{"tags":["Conversations"],"summary":"List conversations for the current user","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Conversations list","content":{"application/json":{"schema":{"type":"object","properties":{"conversations":{"type":"array","items":{"$ref":"#/components/schemas/Conversation"}}}}}}}}},"post":{"tags":["Conversations"],"summary":"Create or get a conversation. Direct (user_id) or group (member_ids + name).","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Exactly one of user_id OR (member_ids + name) should be provided.","properties":{"user_id":{"type":"string","format":"uuid","nullable":true},"member_ids":{"type":"array","nullable":true,"items":{"type":"string","format":"uuid"},"description":"User ids to include as members (excluding the creator). 1–9 others, all must be mutual follows."},"name":{"type":"string","nullable":true,"maxLength":50}}}}}},"responses":{"200":{"description":"Existing direct conversation returned","content":{"application/json":{"schema":{"type":"object","properties":{"conversation":{"$ref":"#/components/schemas/Conversation"}}}}}},"201":{"description":"New conversation created","content":{"application/json":{"schema":{"type":"object","properties":{"conversation":{"$ref":"#/components/schemas/Conversation"}}}}}}}}},"/conversations/{id}":{"get":{"tags":["Conversations"],"summary":"Single conversation by id","description":"Unlike GET /conversations (which filters by last_message_at IS NOT NULL), this returns a single conversation regardless of whether it has messages yet — used by the client's detail screen on a freshly-created conversation before the first send.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Conversation","content":{"application/json":{"schema":{"type":"object","properties":{"conversation":{"$ref":"#/components/schemas/Conversation"}}}}}},"404":{"description":"Conversation not found or not a member"}}},"patch":{"tags":["Conversations"],"summary":"Rename a group conversation (creator only)","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","maxLength":50}}}}}},"responses":{"200":{"description":"Conversation renamed","content":{"application/json":{"schema":{"type":"object","properties":{"conversation":{"$ref":"#/components/schemas/Conversation"}}}}}}}}},"/conversations/{id}/members":{"post":{"tags":["Conversations"],"summary":"Add members to a group conversation (creator only)","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["user_ids"],"properties":{"user_ids":{"type":"array","items":{"type":"string","format":"uuid"}}}}}}},"responses":{"200":{"description":"Members added","content":{"application/json":{"schema":{"type":"object","properties":{"conversation":{"$ref":"#/components/schemas/Conversation"}}}}}}}}},"/conversations/{id}/members/{userId}":{"delete":{"tags":["Conversations"],"summary":"Remove a member from a group conversation (creator only)","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"userId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Member removed","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}}}}},"/conversations/{id}/leave":{"post":{"tags":["Conversations"],"summary":"Leave a group conversation. Dissolves the conversation if you were the last member.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Left the conversation","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"dissolved":{"type":"boolean"}}}}}}}}},"/conversations/{id}/messages":{"get":{"tags":["Conversations"],"summary":"Get messages for a conversation","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"before","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"Cursor for pagination"}],"responses":{"200":{"description":"Messages list","content":{"application/json":{"schema":{"type":"object","properties":{"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"}}}}}}}}},"post":{"tags":["Conversations"],"summary":"Send a message in a conversation","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"body":{"type":"string"},"media_url":{"type":"string"}}}}}},"responses":{"201":{"description":"Message sent","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"$ref":"#/components/schemas/Message"}}}}}}}}},"/conversations/{id}/upload-url":{"post":{"tags":["Conversations"],"summary":"Get presigned upload URL for conversation media","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["content_type"],"properties":{"content_type":{"type":"string"}}}}}},"responses":{"200":{"description":"Upload URL","content":{"application/json":{"schema":{"type":"object","properties":{"upload_url":{"type":"string"},"key":{"type":"string"}}}}}}}}},"/conversations/{id}/read":{"post":{"tags":["Conversations"],"summary":"Mark a conversation as read","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Marked as read","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}}}}},"/lists":{"get":{"tags":["Lists"],"summary":"List the current user's friend lists","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Groups list","content":{"application/json":{"schema":{"type":"object","properties":{"groups":{"type":"array","items":{"$ref":"#/components/schemas/Group"}}}}}}}}},"post":{"tags":["Lists"],"summary":"Create a list","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","maxLength":30},"color":{"type":"string"},"position":{"type":"integer"}}}}}},"responses":{"201":{"description":"List created","content":{"application/json":{"schema":{"type":"object","properties":{"group":{"$ref":"#/components/schemas/Group"}}}}}}}}},"/lists/{id}":{"patch":{"tags":["Lists"],"summary":"Update a list","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":30},"color":{"type":"string"},"position":{"type":"integer"}}}}}},"responses":{"200":{"description":"List updated","content":{"application/json":{"schema":{"type":"object","properties":{"group":{"$ref":"#/components/schemas/Group"}}}}}}}},"delete":{"tags":["Lists"],"summary":"Delete a list","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"List deleted","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}}}}},"/lists/{id}/members":{"get":{"tags":["Lists"],"summary":"List the members of a list","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Group members","content":{"application/json":{"schema":{"type":"object","properties":{"members":{"type":"array","items":{"$ref":"#/components/schemas/UserProfile"}}}}}}}}},"put":{"tags":["Lists"],"summary":"Replace the full member list of a list","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["user_ids"],"properties":{"user_ids":{"type":"array","items":{"type":"string","format":"uuid"}}}}}}},"responses":{"200":{"description":"Members updated","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}}}}}}}},"/devices/keys/upload":{"post":{"tags":["E2EE"],"summary":"Upload or reconcile a saved key bundle","description":"Stores the user's identity key, signed prekey, one-time prekey batch, and Kyber prekey batch (post-quantum hybrid). Identical retries preserve consumed prekeys. A different identity or signed prekey requires explicit recovery; never automatically revoke saved keys. A prekey material conflict returns key_prekey_conflict; an owner-bound client may verify allocation bounds and retry with fresh IDs while retaining private records.","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["identity_key_pub","signed_prekey","one_time_prekeys","kyber_prekeys"],"properties":{"owner_user_id":{"type":"string","format":"uuid","description":"Must match the authenticated account when supplied"},"existing_only":{"type":"boolean","description":"Legacy unbound material may only reconcile an existing matching registry"},"identity_key_pub":{"type":"string","description":"Base64-encoded 33-byte libsignal IdentityKey"},"signed_prekey":{"type":"object","required":["id","public","signature"],"properties":{"id":{"type":"integer"},"public":{"type":"string","description":"base64 33 bytes"},"signature":{"type":"string","description":"base64 64 bytes"}}},"one_time_prekeys":{"type":"array","maxItems":200,"items":{"type":"object","required":["id","public"],"properties":{"id":{"type":"integer"},"public":{"type":"string","description":"base64 33 bytes"}}}},"kyber_prekeys":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","required":["id","public","signature"],"properties":{"id":{"type":"integer"},"public":{"type":"string","description":"base64 ~1568 bytes (Kyber1024)"},"signature":{"type":"string","description":"base64 64 bytes"}}}}}}}}},"responses":{"200":{"description":"Keys uploaded","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"one_time_prekey_count":{"type":"integer"},"kyber_prekey_count":{"type":"integer"}}}}}},"400":{"description":"Validation error (byte lengths, batch size, key ids)"},"409":{"description":"Different active identity/signed key (key_bundle_conflict) or prekey material (key_prekey_conflict)"}}}},"/devices/keys/prekey-allocation":{"post":{"tags":["E2EE"],"summary":"Read prekey allocation bounds for an unchanged registered identity","description":"Requires the authenticated owner and exact registered identity and signed prekey. Returns highest historical prekey IDs including consumed rows. Does not reserve IDs, replace keys or restore consumed keys.","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["owner_user_id","identity_key_pub","signed_prekey"],"properties":{"owner_user_id":{"type":"string","format":"uuid"},"identity_key_pub":{"type":"string","description":"base64 33 bytes"},"signed_prekey":{"type":"object","required":["id","public","signature"],"properties":{"id":{"type":"integer","minimum":1,"maximum":2147483647},"public":{"type":"string","description":"base64 33 bytes"},"signature":{"type":"string","description":"base64 64 bytes"}}}}}}}},"responses":{"200":{"description":"Historical allocation bounds","content":{"application/json":{"schema":{"type":"object","required":["one_time_floor","kyber_floor"],"properties":{"one_time_floor":{"type":"integer","minimum":0,"maximum":2147483647},"kyber_floor":{"type":"integer","minimum":0,"maximum":2147483647}}}}}},"400":{"description":"Invalid key fields"},"401":{"description":"Authentication required"},"403":{"description":"Owner mismatch"},"409":{"description":"No active matching identity and signed prekey"}}}},"/devices/keys/replenish":{"post":{"tags":["E2EE"],"summary":"Add more OTPKs and/or Kyber prekeys","description":"At least one of the two arrays must be non-empty. Idempotent for identical (user_id, key_id) material; different material is rejected with key_prekey_conflict.","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"one_time_prekeys":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"public":{"type":"string"}}}},"kyber_prekeys":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"public":{"type":"string"},"signature":{"type":"string"}}}}}}}}},"responses":{"200":{"description":"Prekeys added","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"one_time_prekeys_added":{"type":"integer"},"kyber_prekeys_added":{"type":"integer"}}}}}},"404":{"description":"No active key set for this user"}}}},"/devices/keys/rotate-signed":{"post":{"tags":["E2EE"],"summary":"Swap in a fresh signed prekey (weekly rotation)","security":[{"BearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["signed_prekey"],"properties":{"signed_prekey":{"type":"object","required":["id","public","signature"],"properties":{"id":{"type":"integer"},"public":{"type":"string"},"signature":{"type":"string"}}}}}}}},"responses":{"200":{"description":"Signed prekey rotated"},"404":{"description":"No active key set for this user"}}}},"/devices/revoke":{"post":{"tags":["E2EE"],"summary":"Revoke the active key set (sign-out)","description":"Marks device_keys.revoked_at = now and DELETEs all OTPKs + Kyber prekeys so the (user_id, key_id) namespace is free for the next upload. Idempotent when no active set exists.","security":[{"BearerAuth":[]}],"responses":{"200":{"description":"Revoked (or no-op)"}}}},"/users/{id}/keybundle":{"get":{"tags":["E2EE"],"summary":"Fetch a peer's key bundle for X3DH + PQ-hybrid setup","description":"Atomically consumes one OTPK + one Kyber prekey. Rate limited at 60/hour per requester to cap OTPK-exhaustion abuse. Returns 503 if the target has no unconsumed Kyber prekeys (libsignal can't build a PreKeyBundle without one); the OTPK consumption is rolled back in that case.","security":[{"BearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Peer's key bundle","content":{"application/json":{"schema":{"type":"object","properties":{"identity_key_pub":{"type":"string"},"signed_prekey":{"type":"object","properties":{"id":{"type":"integer"},"public":{"type":"string"},"signature":{"type":"string"}}},"one_time_prekey":{"nullable":true,"type":"object","properties":{"id":{"type":"integer"},"public":{"type":"string"}}},"kyber_prekey":{"type":"object","properties":{"id":{"type":"integer"},"public":{"type":"string"},"signature":{"type":"string"}}}}}}}},"404":{"description":"No active key set for target user"},"503":{"description":"Kyber prekey pool exhausted"}}}}},"tags":[{"name":"Auth","description":"Authentication and session management"},{"name":"Users","description":"User profiles"},{"name":"Follows","description":"Follow and mutual-follow relationships"},{"name":"Invites","description":"Invite code management"},{"name":"Feed","description":"Chronological post feed"},{"name":"Posts","description":"Post CRUD and media uploads"},{"name":"Comments","description":"Comments on posts"},{"name":"Stories","description":"Ephemeral stories (24h)"},{"name":"Conversations","description":"Direct messaging"},{"name":"Lists","description":"Curated friend lists for scoping post visibility and filtering the feed"},{"name":"E2EE","description":"End-to-end-encrypted DM key registry + session setup"}]}