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 WorkOS session cookie 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.
  - name: Platform admin
    description: Staff-only administration and metrics.

components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: portrait_session
      description: |
        Iron-sealed WorkOS session cookie, scoped to `.portrait.intrasys.ai`.
        Set by the headless Magic Auth flow; 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: DiceBear seed + 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 users WorkOS has no name for. Writes WorkOS
        first (the shared directory), then the local row. 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 }
                last_name: { type: string, maxLength: 64, nullable: true, example: Lovelace }
      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: |
        Render once (fixed seed) → fan the identical bytes out to every verified
        hash in R2 → upsert the DB rows. `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
                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: DiceBear options for the collection.
            example:
              collection: avataaars
              options: { top: shortFlat, hairColor: "2c1b18", skinColor: "f8d25c" }
      responses:
        "200":
          description: Saved and fanned out.
          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: Drop R2 objects and DB rows across all verified hashes; reads fall back to generated.
      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 and resized to PNG variants; GIFs are
        stored raw to keep the animation. Fans out to every verified hash.
        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: |
        Reads a photo with an on-infrastructure vision model
        (`@cf/meta/llama-3.2-11b-vision-instruct`) and returns `avataaars`
        options resembling it. **The photo is transient** — downscaled,
        described, and discarded; never written to R2, never logged. Nothing is
        saved; the console seeds the Design editor with the result. Rate-limited
        hard (each call costs Workers AI neurons).
      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 avataaars 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: Deletes that hash's R2 objects so it reverts to generated. 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 }
                code: { type: string, example: "123456" }
      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. }

  # ─────────────────────────── Platform admin ───────────────────────────
  /platform/admins:
    get:
      tags: [Platform admin]
      operationId: listAdmins
      summary: List platform admins
      responses:
        "200":
          description: Admins.
          content:
            application/json:
              schema:
                type: object
                properties: { admins: { type: array, items: { type: object, additionalProperties: true } } }
        "403": { description: Not an admin. }
    post:
      tags: [Platform admin]
      operationId: grantAdmin
      summary: Grant platform admin
      description: The address must already be a verified claim on some account (Portrait creates no stub rows).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties: { email: { type: string, format: email } }
      responses:
        "200": { description: Granted. }
        "404": { description: No verified account for that address. }
        "429": { description: Rate limited. }
  /platform/admins/{userId}:
    delete:
      tags: [Platform admin]
      operationId: revokeAdmin
      summary: Revoke platform admin
      parameters:
        - name: userId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: Revoked. }
        "400": { description: You cannot revoke yourself. }
  /platform/metrics:
    get:
      tags: [Platform admin]
      operationId: getMetrics
      summary: Platform metrics
      description: |
        Aggregate-only by construction — counts over data the service already
        stores. Portrait keeps no per-hash request log, which is what lets the
        privacy policy say so.
      responses:
        "200":
          description: Metrics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  totals:
                    type: object
                    properties:
                      accounts: { type: integer }
                      verified_emails: { type: integer }
                      accounts_with_avatar: { type: integer }
                      adoption_pct: { type: number }
                      multi_email_accounts: { type: integer }
                  by_kind: { type: array, items: { type: object, additionalProperties: true } }
                  by_style: { type: array, items: { type: object, additionalProperties: true } }
                  generated_at: { type: string, format: date-time }
