openapi: 3.1.0
info:
  title: The Wall Dev API
  summary: Backend for the Win95-styled filianislost.com overlay extension.
  version: 2.0.0
  description: |
    Two parallel surfaces live under `/api/`:

    - **v1** (`/api/auth/*`, `/api/clan/*`, `/api/templates/*`, …) is the
      legacy frozen API. Existing extension installs keep using it. No new
      features land here.
    - **v2** (`/api/v2/*`) is the actively developed API: two-axis roles
      (global + per-clan), unified mailbox, modmail, OIDC linking, anti-grief,
      profile extras. All new clients target v2.

    Shared (un-versioned) endpoints: `/api/auth/*`, `/api/meta`,
    `/api/i18n/{lang}`, `/api/avatar`, `/api/discord/avatar`.
  contact:
    email: vadim+the_wall@vai-rice.space
    url: https://the-wall.vai-rice.space
servers:
  - url: https://the-wall.vai-rice.space
    description: Production
  - url: http://localhost:3080
    description: Local dev

tags:
  - { name: meta,        description: Health / version info }
  - { name: auth,        description: Session lifecycle }
  - { name: v2-auth,     description: Rich v2 /me payload }
  - { name: v2-mailbox,  description: Unified inbox }
  - { name: v2-clans,    description: Clans, modmail, role management }
  - { name: v2-templates,description: Template CRUD + protection }
  - { name: v2-profile,  description: Bio, social links, stats }
  - { name: v2-links,    description: OIDC linking (Telegram, Discord, email) }
  - { name: v2-feed,     description: Server-built snapshots + signal mirror }
  - { name: v2-achievements, description: Badges }
  - { name: v2-vai,      description: VAI Account System }
  - { name: v2-admin,    description: Global admin tools }

components:
  securitySchemes:
    BearerJWT:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Wall session JWT obtained from `POST /api/auth/site`.
  schemas:
    Meta:
      type: object
      properties:
        version:           { type: string, example: "2.0.0" }
        extension_version: { type: string, example: "4.0.0" }
        built_at:          { type: string, nullable: true, format: date-time }
        deployed_at:       { type: string, nullable: true, format: date-time }
        started_at:        { type: string, format: date-time }
        uptime_secs:       { type: integer, format: int64 }
    Error:
      type: object
      properties:
        error: { type: string }
    User:
      type: object
      properties:
        id:               { type: string, format: uuid }
        username:         { type: string }
        global_role:      { type: string, enum: [user, admin, owner] }
        source:           { type: string, example: filianislost }
        external_id:      { type: string }
        discord_id:       { type: string, nullable: true }
        discord_username: { type: string, nullable: true }
        bio:              { type: string, nullable: true }
        social_links:     { type: array, items: { type: object } }
        has_custom_avatar:{ type: boolean }
        clans:            { type: array, items: { $ref: '#/components/schemas/ClanMembership' } }
        created_at:       { type: string, format: date-time }
    ClanMembership:
      type: object
      properties:
        clan_id:           { type: string, format: uuid }
        clan_external_id:  { type: string }
        clan_name:         { type: string }
        clan_role:         { type: string, enum: [member, moderator, admin] }
        clan_icon:         { type: string, nullable: true }
    MailboxItem:
      type: object
      properties:
        id:           { type: string, format: uuid }
        kind:         { type: string, enum: [announcement, clan_alert, clan_announcement, mod_alert, modmail, dm, template_event] }
        sender_id:    { type: string, format: uuid, nullable: true }
        sender_username: { type: string, nullable: true }
        recipient_user_id: { type: string, format: uuid, nullable: true }
        recipient_clan_id: { type: string, format: uuid, nullable: true }
        thread_id:    { type: string, format: uuid, nullable: true }
        title:        { type: string, nullable: true }
        body:         { type: string }
        payload:      { type: object }
        created_at:   { type: string, format: date-time }
        is_read:      { type: boolean }
    Template:
      type: object
      properties:
        id:         { type: string, format: uuid }
        user_id:    { type: string, format: uuid }
        author:     { type: string, nullable: true }
        name:       { type: string }
        x:          { type: integer }
        y:          { type: integer }
        scale:      { type: number }
        scope:      { type: string, enum: [public, clan] }
        status:     { type: string, enum: [pending, approved, rejected] }
        share:      { type: string }
        created_at: { type: string, format: date-time }
    Snapshot:
      type: object
      properties:
        id:           { type: string, format: uuid }
        size:         { type: string, enum: [small, medium, large] }
        bytes:        { type: integer, nullable: true }
        width:        { type: integer, nullable: true }
        height:       { type: integer, nullable: true }
        discord_url:
          type: string
          nullable: true
          description: |
            Direct attachment URL on cdn.discordapp.com (e.g.
            `https://cdn.discordapp.com/attachments/{channel_id}/{attachment_id}/wall_…png?ex=…&hm=…`).
            Suitable as the `src` for an `<img>` element on the frontend.
        telegram_url:
          type: string
          nullable: true
          description: |
            Public Telegram channel post URL in the form
            `https://t.me/<channel_username>/<message_id>`.
        batch_id:
          type: string
          format: uuid
          nullable: true
          description: All three sizes (small/medium/large) of one tick share a batch_id.
        created_at:   { type: string, format: date-time }
    Signal:
      type: object
      properties:
        id:           { type: string, format: uuid }
        num_id:       { type: integer, nullable: true }
        external_id:  { type: string,  nullable: true }
        tag:          { type: string,  nullable: true }
        name:         { type: string,  nullable: true }
        location:     { type: string,  nullable: true }
        image_file:   { type: string,  nullable: true }
        image_url:
          type: string
          nullable: true
          description: Resolved URL on signal.filianislost.com/static/uploads/<image_file>.
        submitted_at: { type: string, format: date-time, nullable: true, description: Upstream submission time. }
        sampled_at:   { type: string, format: date-time, description: When we mirrored this signal. }
        notes:
          type: object
          additionalProperties: { type: string }
          description: Admin-written notes keyed by language code (en/ru/de/fr).
    SignalNoteUpsert:
      type: object
      required: [lang, note]
      properties:
        lang: { type: string, enum: [en, ru, de, fr] }
        note: { type: string, description: Empty string deletes the note for this language. }
    Achievement:
      type: object
      properties:
        id:          { type: string }
        name:        { type: string }
        description: { type: string }
        icon:        { type: string }
        category:    { type: string }
        points:      { type: integer }
        secret:      { type: boolean }
        unlocked_at: { type: string, format: date-time, nullable: true }

paths:
  /api/meta:
    get:
      tags: [meta]
      summary: Server identity, version, uptime
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Meta' } } } }

  /api/auth/site:
    post:
      tags: [auth]
      summary: Exchange a filianislost.com session for a Wall JWT
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [siteToken]
              properties: { siteToken: { type: string } }
      responses:
        "200":
          description: Session created
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:         { type: string }
                  refresh_token: { type: string }
                  user:          { $ref: '#/components/schemas/User' }
        "401": { $ref: '#/components/schemas/Error' }
  /api/auth/refresh:
    post:
      tags: [auth]
      summary: Rotate refresh → new access token
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, properties: { refreshToken: { type: string } } } } }
      responses: { "200": { description: OK } }

  /api/v2/auth/me:
    get:
      tags: [v2-auth]
      summary: Rich profile + memberships
      security: [{ BearerJWT: [] }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/User' } } } }

  /api/v2/mailbox:
    get:
      tags: [v2-mailbox]
      summary: List inbox (DMs + clan + announcements + events)
      security: [{ BearerJWT: [] }]
      parameters:
        - { in: query, name: kind,   schema: { type: string } }
        - { in: query, name: unread, schema: { type: boolean } }
        - { in: query, name: limit,  schema: { type: integer, default: 50, maximum: 200 } }
        - { in: query, name: cursor, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/MailboxItem' } } } }
  /api/v2/mailbox/dm:
    post:
      tags: [v2-mailbox]
      summary: Send a DM
      security: [{ BearerJWT: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, body]
              properties:
                to:    { type: string, format: uuid }
                body:  { type: string, maxLength: 4096 }
                title: { type: string, nullable: true }
      responses: { "201": { description: Created } }
  /api/v2/mailbox/{id}/read:
    post:
      tags: [v2-mailbox]
      summary: Mark item read
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { "204": { description: OK } }
  /api/v2/mailbox/{id}/reply:
    post:
      tags: [v2-mailbox]
      summary: Reply in a thread (DM or modmail)
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { body: { type: string } } } } } }
      responses: { "201": { description: Created } }

  /api/v2/clans/mine:
    get:
      tags: [v2-clans]
      summary: My memberships
      security: [{ BearerJWT: [] }]
      responses: { "200": { description: OK } }
  /api/v2/clans/{id}/members:
    get:
      tags: [v2-clans]
      summary: Roster
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: OK } }
  /api/v2/clans/{id}/announcements:
    post:
      tags: [v2-clans]
      summary: Post pinned announcement (clan_admin only)
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { title: {type: string}, body: {type: string} } } } } }
      responses: { "201": { description: Created } }
  /api/v2/clans/{id}/alerts:
    post:
      tags: [v2-clans]
      summary: Broadcast to clan members (clan_mod+)
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { "201": { description: Created } }
  /api/v2/clans/{id}/mod-dm:
    post:
      tags: [v2-clans]
      summary: Member → all clan moderators
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { "201": { description: Created } }
  /api/v2/clans/{id}/modmail:
    post:
      tags: [v2-clans]
      summary: Open a modmail thread
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { "201": { description: Created } }
    get:
      tags: [v2-clans]
      summary: List modmail threads (mod-only)
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: OK } }

  /api/v2/templates:
    get:
      tags: [v2-templates]
      summary: List templates with pagination
      security: [{ BearerJWT: [] }]
      parameters:
        - { in: query, name: scope,  schema: { type: string, enum: [public, clan] } }
        - { in: query, name: status, schema: { type: string, enum: [pending, approved, rejected] } }
        - { in: query, name: author, schema: { type: string, format: uuid } }
        - { in: query, name: limit,  schema: { type: integer, default: 50, maximum: 200 } }
        - { in: query, name: cursor, schema: { type: string, format: date-time } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/Template' } } } } }
    post:
      tags: [v2-templates]
      summary: Share a template
      security: [{ BearerJWT: [] }]
      responses: { "200": { description: OK } }
  /api/v2/templates/{id}/approve:
    post:
      tags: [v2-templates]
      summary: Approve (admin)
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: OK } }
  /api/v2/templates/{id}/reject:
    post:
      tags: [v2-templates]
      summary: Reject (admin)
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: OK } }
  /api/v2/templates/{id}/protect:
    post:
      tags: [v2-templates]
      summary: Enable anti-grief monitoring
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                clan_id:         { type: string, format: uuid, nullable: true }
                alert_threshold: { type: integer, default: 25 }
                check_interval:  { type: integer, default: 600 }
      responses: { "200": { description: OK } }
    delete:
      tags: [v2-templates]
      summary: Disable monitoring
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { "204": { description: OK } }

  /api/v2/users/{id}:
    get:
      tags: [v2-profile]
      summary: Public profile
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: OK } }
  /api/v2/me/profile:
    put:
      tags: [v2-profile]
      summary: Update bio / social links
      security: [{ BearerJWT: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                bio: { type: string }
                social_links:
                  type: array
                  items: { type: object, properties: { label: {type: string}, url: {type: string} } }
      responses: { "200": { description: OK } }

  /api/v2/me/links:
    get:
      tags: [v2-links]
      summary: Linked-account status
      security: [{ BearerJWT: [] }]
      responses: { "200": { description: OK } }
  /api/v2/me/links/telegram/oauth-url:
    get:
      tags: [v2-links]
      summary: Begin Telegram OIDC (PKCE)
      security: [{ BearerJWT: [] }]
      responses: { "200": { description: OK } }
  /api/v2/me/links/telegram/exchange:
    post:
      tags: [v2-links]
      summary: Finish Telegram OIDC
      security: [{ BearerJWT: [] }]
      responses: { "200": { description: OK } }
  /api/v2/me/links/discord/oauth-url:
    get:
      tags: [v2-links]
      summary: Begin Discord OAuth2
      security: [{ BearerJWT: [] }]
      responses: { "200": { description: OK } }
  /api/v2/me/links/discord/exchange:
    post:
      tags: [v2-links]
      summary: Finish Discord OAuth2
      security: [{ BearerJWT: [] }]
      responses: { "200": { description: OK } }
  /api/v2/me/links/email/request:
    post:
      tags: [v2-links]
      summary: Request email link via support
      security: [{ BearerJWT: [] }]
      responses: { "200": { description: OK } }

  /api/v2/signals:
    get:
      tags: [v2-feed]
      summary: Mirrored canvas signals (normalised + with notes)
      parameters:
        - { in: query, name: since, schema: { type: string, format: date-time } }
        - { in: query, name: limit, schema: { type: integer, default: 50, maximum: 200 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Signal' } }
  /api/v2/signals/{id}:
    get:
      tags: [v2-feed]
      summary: One signal by id (with notes)
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { $ref: '#/components/schemas/Signal' } } }
        "404": { description: Not found }
  /api/v2/signals/{id}/notes:
    post:
      tags: [v2-feed]
      summary: Upsert (or delete with empty body) a signal note for a language
      security: [{ BearerJWT: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SignalNoteUpsert' }
      responses:
        "200": { description: Saved }
        "204": { description: Deleted }
        "400": { description: Unsupported lang }
        "403": { description: Admin only }
  /api/v2/snapshots:
    get:
      tags: [v2-feed]
      summary: Server-built wall snapshots (URL-only)
      parameters:
        - { in: query, name: size,  schema: { type: string, enum: [small, medium, large] } }
        - { in: query, name: limit, schema: { type: integer, default: 20 } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/Snapshot' } } } } }

  /api/v2/me/achievements:
    get:
      tags: [v2-achievements]
      summary: My catalogue + unlocks
      security: [{ BearerJWT: [] }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/Achievement' } } } } }
  /api/v2/users/{id}/achievements:
    get:
      tags: [v2-achievements]
      summary: Public unlocks for a user
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      responses: { "200": { description: OK } }

  /api/v2/vai/signup:
    post:
      tags: [v2-vai]
      summary: Create a VAI Account
      responses: { "200": { description: OK } }
  /api/v2/vai/login:
    post:
      tags: [v2-vai]
      summary: VAI Account login
      responses: { "200": { description: OK } }
  /api/v2/me/link-vai:
    post:
      tags: [v2-vai]
      summary: Link the current Wall user to a VAI Account
      security: [{ BearerJWT: [] }]
      responses: { "200": { description: OK } }

  /api/v2/admin/users:
    get:
      tags: [v2-admin]
      summary: List all users (admin only)
      security: [{ BearerJWT: [] }]
      responses: { "200": { description: OK } }
  /api/v2/admin/email-link-requests:
    get:
      tags: [v2-admin]
      summary: Pending support-link requests
      security: [{ BearerJWT: [] }]
      responses: { "200": { description: OK } }
