openapi: 3.1.0
info:
  title: Portrait API
  version: "1.0.0"
  description: |
    Portrait is Intrasys's self-hosted, Gravatar-style avatar service. Two
    surfaces on two hosts:

    - **`media.portrait.intrasys.ai`** — public, unauthenticated **reads**. Every
      well-formed hash resolves to a saved avatar (designed / uploaded) or a
      deterministic generated one.
    - **`api.portrait.intrasys.ai`** — session-gated **writes**. Requires a
      Portrait sign-in and only ever acts on hashes the session user has
      verified.

    The identity key is `sha256(lower(trim(email)))` — byte-identical to
    Gravatar's spec, public, safe in client-side HTML.
servers:
  - url: https://api.portrait.intrasys.ai
    description: Session API (authenticated writes)
  - url: https://media.portrait.intrasys.ai
    description: Media (public reads)

tags:
  - name: Read
    description: Public avatar reads on `media.portrait.intrasys.ai`.
  - name: Account
    description: The signed-in identity.
  - name: Avatar
    description: Design, upload, Ask AI, and reset the account's avatar.
  - name: Emails
    description: Claim, verify, and manage the addresses that resolve to the account.

components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: portrait_session
      description: |
        Session cookie scoped to `.portrait.intrasys.ai`, set by the
        magic-link sign-in and sent automatically by the console.
  schemas:
    Email:
      type: object
      properties:
        email: { type: string, format: email, example: "ada@intrasys.com.sg" }
        hash:
          type: string
          description: "`sha256(lower(trim(email)))`"
          example: "b1c9e3...f04a"
        verified: { type: boolean, example: true }
        primary: { type: boolean, example: true }
    AvatarConfig:
      type: object
      nullable: true
      properties:
        kind: { type: string, enum: [designed, uploaded], example: designed }
        collection:
          type: string
          enum: [personas, initials, identicon, bottts, avataaars]
          example: avataaars
        config:
          type: object
          description: Generator seed and options, persisted for re-editing.
          additionalProperties: true
        version: { type: integer, example: 3 }
        updated_at: { type: string, format: date-time }
    Error:
      type: object
      properties:
        error: { type: string, example: rate_limited }

security:
  - sessionCookie: []

paths:
  # ─────────────────────────── Read (media) ───────────────────────────
  /{hash}:
    get:
      tags: [Read]
      operationId: getAvatar
      summary: Fetch an Avatar
      description: |
        Public, unauthenticated. Returns the stored PNG at the nearest size ≥ `s`
        when an avatar is saved; otherwise generates a deterministic avatar from
        the hash. **Never 404s for a well-formed hash** unless the caller asks via
        `d=404`. A malformed hash is a `400`.
      servers:
        - url: https://media.portrait.intrasys.ai
      security: []
      parameters:
        - name: hash
          in: path
          required: true
          schema: { type: string }
          description: "`sha256(lower(trim(email)))` — 64 hex chars."
          example: "b1c9e3d2f0a4...f04a"
        - name: s
          in: query
          schema: { type: integer, minimum: 16, maximum: 512, default: 80 }
          description: Square size in pixels.
        - name: d
          in: query
          schema:
            type: string
            default: personas
          description: |
            Default when nothing is saved: `personas` | `initials` | `identicon`
            | `bottts` | `404` | `blank` | a URL to 302 to.
        - name: f
          in: query
          schema: { type: string, enum: [y] }
          description: Force the generated default even if an avatar is saved.
        - name: fmt
          in: query
          schema: { type: string, enum: [png, svg], default: png }
          description: "`svg` is only available for generated output."
        - name: v
          in: query
          schema: { type: string }
          description: Opaque cache-buster → immutable caching.
      responses:
        "200":
          description: The avatar image.
          content:
            image/png: { schema: { type: string, format: binary } }
            image/svg+xml: { schema: { type: string } }
        "302":
          description: Redirect to the `d=<url>` fallback.
        "400":
          description: Malformed hash.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: Only when the caller passed `d=404`.
  /{hash}.json:
    get:
      tags: [Read]
      operationId: getAvatarMeta
      summary: Avatar metadata
      description: |
        Lets an app build a cache-busting `v=`. Deliberately **account-blind** —
        exposes nothing about the account, its email, or sibling claims.
      servers:
        - url: https://media.portrait.intrasys.ai
      security: []
      parameters:
        - name: hash
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Metadata.
          content:
            application/json:
              schema:
                type: object
                properties:
                  hash: { type: string }
                  has_avatar: { type: boolean }
                  kind: { type: string, enum: [designed, uploaded], nullable: true }
                  version: { type: integer }
                  updated_at: { type: string, format: date-time, nullable: true }
              example:
                hash: "b1c9e3...f04a"
                has_avatar: true
                kind: uploaded
                version: 3
                updated_at: "2026-07-22T04:37:12Z"

  # ─────────────────────────── Account ───────────────────────────
  /me:
    get:
      tags: [Account]
      operationId: getMe
      summary: Get the signed-in identity
      description: The identity plus the primary hash the console renders as the avatar.
      responses:
        "200":
          description: The account.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    type: object
                    properties:
                      first_name: { type: string, nullable: true }
                      last_name: { type: string, nullable: true }
                      name: { type: string, description: Derived display name (mononym-safe). }
                      id: { type: string }
                      email: { type: string, format: email }
                      hash: { type: string, nullable: true }
                  emails:
                    type: array
                    items: { $ref: "#/components/schemas/Email" }
                  is_admin: { type: boolean }
              example:
                user:
                  first_name: Ada
                  last_name: Lovelace
                  name: Ada Lovelace
                  id: "usr_2a9…"
                  email: "ada@intrasys.com.sg"
                  hash: "b1c9e3…f04a"
                emails:
                  - { email: "ada@intrasys.com.sg", hash: "b1c9e3…f04a", verified: true, primary: true }
                is_admin: false
        "401": { description: No session., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /me/name:
    post:
      tags: [Account]
      operationId: setName
      summary: Set the Account's name
      description: |
        One-time name capture for accounts that have no name yet. Only the given
        name is required — a mononym is a real name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [first_name]
              properties:
                first_name:
                  type: string
                  maxLength: 64
                  example: Ada
                  description: The person's given name. The only required field.
                last_name:
                  type: string
                  maxLength: 64
                  nullable: true
                  example: Lovelace
                  description: The person's family name. Omit for a mononym.
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  first_name: { type: string }
                  last_name: { type: string, nullable: true }
        "400": { description: Missing given name or bad JSON. }
        "429": { description: Rate limited. }

  # ─────────────────────────── Avatar ───────────────────────────
  /me/avatar:
    get:
      tags: [Avatar]
      operationId: getMyAvatar
      summary: Get the current design
      description: The saved design for re-editing; `null` when the account uses the generated avatar.
      responses:
        "200":
          description: The current avatar config (or null).
          content:
            application/json:
              schema:
                type: object
                properties:
                  avatar: { $ref: "#/components/schemas/AvatarConfig" }
    post:
      tags: [Avatar]
      operationId: saveAvatar
      summary: Save a designed Avatar
      description: |
        Saves the designed avatar for the account; it then serves identically
        across every verified address. `version` bumps each save.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                collection:
                  type: string
                  enum: [personas, initials, identicon, bottts, avataaars]
                  default: personas
                  description: The style to render the avatar in.
                seed:
                  type: string
                  description: Fixed seed so the design is identical across the account's hashes. Defaults to the primary hash.
                options:
                  type: object
                  additionalProperties: true
                  description: Options for the chosen style.
            example:
              collection: avataaars
              options: { top: shortFlat, hairColor: "2c1b18", skinColor: "f8d25c" }
      responses:
        "200":
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  kind: { type: string, example: designed }
                  collection: { type: string }
                  version: { type: integer }
                  hashes: { type: array, items: { type: string } }
        "400": { description: Bad collection or JSON. }
        "409": { description: No verified email on the account. }
        "429": { description: Rate limited. }
    delete:
      tags: [Avatar]
      operationId: resetAvatar
      summary: Reset to the generated Avatar
      description: Removes the saved avatar; reads fall back to the generated one.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  deleted: { type: boolean }
                  hashes: { type: array, items: { type: string } }
  /me/avatar/upload:
    post:
      tags: [Avatar]
      operationId: uploadAvatar
      summary: Upload a photo
      description: |
        Static images are centre-cropped to a square; GIFs keep their animation.
        The upload then serves across every verified address.
        Accepts `image/png|jpeg|webp|gif`, ≤ 2 MB.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                image: { type: string, format: binary }
      responses:
        "200":
          description: Uploaded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  kind: { type: string, example: uploaded }
                  version: { type: integer }
                  hashes: { type: array, items: { type: string } }
        "413": { description: Larger than 2 MB. }
        "415": { description: Unsupported content type. }
        "429": { description: Rate limited. }
  /me/avatar/from-photo:
    post:
      tags: [Avatar]
      operationId: askAiFromPhoto
      summary: ✨ Ask AI — suggest a design from a photo
      description: |
        Suggests a designed avatar from a photo. **The photo is transient** —
        used to produce the suggestion, then discarded; never stored, never
        logged. Nothing is saved; the console opens the Design editor seeded with
        the result. Rate-limited.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                image: { type: string, format: binary, description: "png/jpeg/webp only" }
      responses:
        "200":
          description: Suggested editor options.
          content:
            application/json:
              schema:
                type: object
                properties:
                  style: { type: string, example: avataaars }
                  options: { type: object, additionalProperties: true }
        "415": { description: Unsupported type (GIF is not a portrait source). }
        "422": { description: No face detected. }
        "429": { description: Rate limited. }
        "502": { description: Generation failed. }
        "503": { description: AI not configured. }

  # ─────────────────────────── Emails ───────────────────────────
  /me/emails:
    get:
      tags: [Emails]
      operationId: listEmails
      summary: List claimed addresses
      responses:
        "200":
          description: The account's addresses.
          content:
            application/json:
              schema:
                type: object
                properties:
                  emails: { type: array, items: { $ref: "#/components/schemas/Email" } }
    post:
      tags: [Emails]
      operationId: claimEmail
      summary: Start a claim
      description: |
        Emails the account a claim-purpose magic code. Refuses early (`409`) if
        the address is already verified on another account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties: { email: { type: string, format: email } }
      responses:
        "200": { description: Claim email sent (or a no-op if already yours). }
        "409": { description: Already connected to another account. }
        "429": { description: Rate limited (per inbox and per account). }
    delete:
      tags: [Emails]
      operationId: removeEmail
      summary: Release a claim
      description: The released address reverts to a generated avatar. Refused on the primary.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties: { email: { type: string, format: email } }
      responses:
        "200": { description: Removed. }
        "409": { description: That address is the primary — promote another first. }
        "404": { description: Not a claim on this account. }
  /me/emails/verify:
    post:
      tags: [Emails]
      operationId: verifyEmail
      summary: Finish a claim
      description: Proves control of the address with the emailed code and attaches it to this account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email:
                  type: string
                  format: email
                  description: The address being claimed, as sent the code.
                code:
                  type: string
                  example: "123456"
                  description: The one-time code from the claim email.
      responses:
        "200": { description: Claimed. }
        "401": { description: Invalid code (or MFA required). }
        "409": { description: Already connected to another account. }
  /me/emails/primary:
    post:
      tags: [Emails]
      operationId: setPrimaryEmail
      summary: Set the primary address
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties: { email: { type: string, format: email } }
      responses:
        "200": { description: Primary updated. }
        "404": { description: Not a verified claim on this account. }
