# GENERATED by scripts/gen-api-docs.ts — edit src/lib/api-docs.ts instead.
openapi: 3.1.0
info:
  title: Luminary Public API
  version: '1.0.0'
  description: |
    Public JSON API powering the Luminary native mobile app.
    Every response uses the envelope { ok: true, data } or
    { ok: false, error: { code, message } }.
    All endpoints except /meta require a Supabase access token as
    'Authorization: Bearer <token>'.
servers:
  - url: https://theluminary.xyz
    description: Production
  - url: https://the-luminary-eight.vercel.app
    description: Preview
tags:
  - name: Service
  - name: Account
  - name: Chart
  - name: Today
  - name: Actions
  - name: Goals
  - name: Journal
  - name: Relationships
  - name: Breathwork
  - name: Codex
  - name: Chat
  - name: Billing
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Supabase access token from the client SDK session.
  schemas:
    SuccessEnvelope:
      type: object
      required: [ok, data]
      properties:
        ok: { const: true }
        data: {}
    ErrorEnvelope:
      type: object
      required: [ok, error]
      properties:
        ok: { const: false }
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [unauthorized, forbidden, invalid_request, quota_exceeded, not_found, upstream_unavailable, server_error]
            message: { type: string }
security:
  - bearerAuth: []
paths:
  '/api/public/v1/meta':
    get:
      operationId: getMeta
      tags: [Service]
      summary: "Service descriptor"
      description: "Unauthenticated. Call once on app launch to discover the API version, plan catalogue, per-tier quotas, the breathwork technique catalogue, and the endpoint list. Cache it for the session so the client never ships a hardcoded copy that can drift from the server."
      security: []
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "apiVersion": "1.0.0",
                    "envelope": {
                      "success": "{ ok: true, data }",
                      "failure": "{ ok: false, error: { code, message } }"
                    },
                    "auth": "Authorization: Bearer <supabase access token> on every endpoint except /meta",
                    "tiers": [
                      {
                        "id": "seeker",
                        "name": "Seeker",
                        "tagline": "Start the practice",
                        "priceMonthlyCents": 0,
                        "priceLabel": "Free",
                        "priceId": null,
                        "features": [
                          "Daily briefing",
                          "Natal chart"
                        ],
                        "quotas": {
                          "chat": 10,
                          "journal": 5,
                          "briefing": 30
                        }
                      }
                    ],
                    "breathworkTechniques": [
                      {
                        "slug": "box-breathing",
                        "name": "Box Breathing",
                        "tagline": "Steady the nervous system",
                        "goals": [
                          "focus",
                          "calm"
                        ],
                        "moods": [
                          "anxious",
                          "scattered"
                        ],
                        "defaultRounds": 6,
                        "pattern": {
                          "inhale": 4,
                          "holdIn": 4,
                          "exhale": 4,
                          "holdOut": 4
                        }
                      }
                    ],
                    "endpoints": [
                      {
                        "method": "GET",
                        "path": "/api/public/v1/me",
                        "auth": true,
                        "desc": "Profile, entitlements, billing state"
                      }
                    ]
                  }
                }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/health':
    get:
      operationId: getHealth
      tags: [Service]
      summary: "Liveness and optional connectivity probe"
      description: "Anonymous callers get `{ apiVersion, status: \"ok\" }` only. Pass a valid `X-Luminary-Ai-Token` to run the full Resend + Supabase + Edge Function probe (includes hook-secret configuration)."
      security: []
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "apiVersion": "1.0.0",
                    "status": "ok"
                  }
                }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/me':
    get:
      operationId: getMe
      tags: [Account]
      summary: "Profile, entitlements, and chart completeness"
      description: "Everything the account screen needs in one call: the profile row, the resolved tier/entitlement, and boolean flags telling the client whether onboarding or extra birth details are still required."
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "userId": "9c1f7e4a-2b30-4a6d-9f0e-4a1d2c3b5e77",
                    "profile": {
                      "id": "9c1f7e4a-2b30-4a6d-9f0e-4a1d2c3b5e77",
                      "display_name": "Ava",
                      "avatar_url": null,
                      "pronouns": "she/her",
                      "gender_identity": "woman",
                      "sexual_orientation": null,
                      "identity_note": null,
                      "birth_date": "1992-03-21",
                      "birth_time": "07:45",
                      "birth_location": "Austin, Texas, United States",
                      "birth_lat": 30.2672,
                      "birth_lng": -97.7431,
                      "sun_sign": "Aries",
                      "moon_sign": "Pisces",
                      "rising_sign": "Taurus",
                      "natal_chart_data": {
                        "source": "astrology-api",
                        "planets": []
                      },
                      "onboarding_complete": true,
                      "tier": "oracle",
                      "access_status": "active",
                      "trial_ends_at": null,
                      "created_at": "2026-01-14T18:02:11.482Z"
                    },
                    "chartComplete": true,
                    "needsBirthTime": false,
                    "needsBirthLocation": false,
                    "onboardingComplete": true,
                    "entitlement": {
                      "tier": "oracle",
                      "status": "active",
                      "source": "subscription"
                    }
                  }
                }
        '401':
          description: "No bearer token, or the token has expired."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    patch:
      operationId: patchMe
      tags: [Account]
      summary: "Update birth details and re-cast the chart"
      description: "Persists any supplied birth fields and immediately recomputes the natal chart. Send only the fields that changed. `birthTime` is 24-hour `HH:MM` (seconds optional and ignored). Supplying `lat`/`lng`/`tz` skips geocoding; otherwise `birthLocation` is geocoded server-side. Sun sign is resolved against the exact ingress table, so cusp birthdays are correct. Moon and Rising are never fabricated — if birth time or location is missing they stay null and the flags below say why."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                displayName:
                  type: string
                  description: "Display name. (string)"
                birthDate:
                  type: string
                  description: "Birth date. (string (YYYY-MM-DD))"
                birthTime:
                  type: string
                  description: "Local birth time, 24-hour. Required for Moon/Rising accuracy. (string (HH:MM))"
                birthLocation:
                  type: string
                  description: "Human-readable birth place; geocoded when lat/lng are absent. (string)"
                lat:
                  type: number
                  description: "Latitude, if already resolved client-side. (number)"
                lng:
                  type: number
                  description: "Longitude, if already resolved client-side. (number)"
                tz:
                  type: string
                  description: "Timezone, e.g. `America/Chicago`. (string (IANA))"
            example:
              {
                "birthDate": "1992-03-21",
                "birthTime": "07:45",
                "birthLocation": "Austin, Texas, United States",
                "lat": 30.2672,
                "lng": -97.7431,
                "tz": "America/Chicago"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "sun": "Aries",
                    "moon": "Pisces",
                    "rising": "Taurus",
                    "birth": {
                      "date": "1992-03-21",
                      "time": "07:45",
                      "location": "Austin, Texas, United States",
                      "lat": 30.2672,
                      "lng": -97.7431
                    },
                    "needsBirthTime": false,
                    "needsBirthLocation": false,
                    "cuspWarning": "Born within 12 hours of the Aries ingress — verified against the exact ingress table."
                  }
                }
        '400':
          description: "A field fails validation (e.g. a malformed time or date)."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '503':
          description: "The ephemeris provider is unreachable and no verified fallback exists."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    delete:
      operationId: deleteMe
      tags: [Account]
      summary: "Permanently delete the signed-in account"
      description: "App Store guideline 5.1.1(v). Cancels Stripe subscriptions, marks Apple IAP rows canceled locally, then deletes the Auth user. Profile and related rows cascade. Irreversible. The iOS app must call this from Profile — not only open a web page."
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "deleted": true
                  }
                }
        '401':
          description: "No bearer token, or the token has expired."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/auth/apple/notifications':
    post:
      operationId: appleSiwaNotifications
      tags: [Account]
      summary: "Sign in with Apple server-to-server notifications"
      description: "Apple POSTs a signed JWT when a user revokes consent, deletes their Apple Account, or changes Hide My Email forwarding. Paste `https://theluminary.xyz/api/public/v1/auth/apple/notifications` into Apple Developer → App ID → Sign in with Apple → Server-to-Server Notification Endpoint. `consent-revoked` and `account-deleted` delete the matching Luminary user. Not called by the iOS app."
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payload]
              properties:
                payload:
                  type: string
                  description: "Apple-signed JWS. Verify with https://appleid.apple.com/auth/keys. (string)"
            example:
              {
                "payload": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "received": true,
                    "type": "consent-revoked",
                    "action": "deleted"
                  }
                }
        '401':
          description: "The JWS signature, issuer, or audience is invalid."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/chart':
    get:
      operationId: getChart
      tags: [Chart]
      summary: "Natal chart"
      description: "The caller's natal chart. `incompleteReason` is non-null whenever Moon/Rising are absent — render the matching prompt instead of a placeholder sign. Possible values: `missing_birth_time`, `missing_birth_location`, `not_yet_computed`, or `null` when the chart is complete."
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "sun": "Aries",
                    "moon": "Pisces",
                    "rising": "Taurus",
                    "planets": [
                      {
                        "name": "Sun",
                        "sign": "Aries",
                        "degree": 1.24,
                        "house": 11
                      },
                      {
                        "name": "Moon",
                        "sign": "Pisces",
                        "degree": 18.9,
                        "house": 10
                      }
                    ],
                    "incompleteReason": null,
                    "birth": {
                      "date": "1992-03-21",
                      "time": "07:45",
                      "location": "Austin, Texas, United States",
                      "lat": 30.2672,
                      "lng": -97.7431
                    },
                    "meta": {
                      "sunSource": "ref_sun_ingress",
                      "cuspWarning": null,
                      "source": "astrology-api"
                    }
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/today':
    get:
      operationId: getToday
      tags: [Today]
      summary: "Home screen aggregate"
      description: "One round trip for the home screen: profile summary, today's briefing (or `null` if it has not been generated yet), today's actions plus yesterday's still-pending ones, up to five active goals, and any date-significance cards. This endpoint never generates AI content and never spends quota — the client decides when to call `POST /today`."
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "date": "2026-07-29",
                    "profile": {
                      "display_name": "Ava",
                      "sun_sign": "Aries",
                      "moon_sign": "Pisces",
                      "rising_sign": "Taurus"
                    },
                    "briefing": {
                      "id": "1f2e...",
                      "briefing_date": "2026-07-29",
                      "headline": "Momentum favours the direct ask",
                      "body": "Mars trines your natal Sun...",
                      "focus_areas": [
                        "career",
                        "communication"
                      ]
                    },
                    "actions": {
                      "today": [
                        {
                          "id": "a1b2...",
                          "title": "Send the proposal",
                          "status": "planned",
                          "action_date": "2026-07-29"
                        }
                      ],
                      "yesterdayPending": []
                    },
                    "activeGoals": [
                      {
                        "id": "g1...",
                        "title": "Ship the app",
                        "category": "career",
                        "status": "active",
                        "progress": 40
                      }
                    ],
                    "dateSignificance": [
                      {
                        "headline": "Perihelion of comet 12P",
                        "significance": "...",
                        "traditions": [
                          "astronomical"
                        ],
                        "category": "historical"
                      }
                    ]
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    post:
      operationId: postToday
      tags: [Today]
      summary: "Generate today's briefing"
      description: "Generates (or returns the already-generated) daily briefing. Counts against the caller's monthly briefing quota. Pass `force: true` to regenerate an existing briefing — that always consumes quota. Optionally pass `significanceCategory` to generate the date-significance card in the same call."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                force:
                  type: boolean
                  description: "Regenerate even if today's briefing already exists. Defaults to false. (boolean)"
                significanceCategory:
                  type: string
                  description: "Also generate the On This Day card for this category. (\"spiritual\" | \"historical\" | \"mythological\")"
            example:
              {
                "force": false,
                "significanceCategory": "historical"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "briefing": {
                      "id": "1f2e...",
                      "briefing_date": "2026-07-29",
                      "headline": "Momentum favours the direct ask",
                      "body": "Mars trines your natal Sun...",
                      "focus_areas": [
                        "career",
                        "communication"
                      ]
                    },
                    "significance": {
                      "headline": "Perihelion of comet 12P",
                      "significance": "...",
                      "category": "historical"
                    }
                  }
                }
        '400':
          description: "Onboarding is incomplete or the birth date is missing."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '402':
          description: "The tier's monthly briefing allowance is spent. Show `error.message` verbatim and offer an upgrade."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/actions':
    get:
      operationId: getActions
      tags: [Actions]
      summary: "Today's actions"
      description: "Returns today's actions and yesterday's actions so the client can offer a carry-forward prompt."
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "today": [
                      {
                        "id": "a1b2c3d4-...",
                        "title": "Send the proposal",
                        "intention": "Move the Saturn goal forward",
                        "status": "planned",
                        "action_date": "2026-07-29",
                        "linked_goal_id": "g1...",
                        "reflection": null,
                        "created_at": "2026-07-29T08:12:04.221Z"
                      }
                    ],
                    "yesterday": []
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    post:
      operationId: postActions
      tags: [Actions]
      summary: "Create an action"
      description: "Creates a daily action. Omit `date` to file it under today."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                  description: "What the user will do. (string)"
                intention:
                  type: string
                  description: "Why it matters — surfaced in reflections. (string)"
                goalId:
                  type: string
                  description: "Link the action to an existing goal. (string (uuid))"
                date:
                  type: string
                  description: "Defaults to today in UTC. (string (YYYY-MM-DD))"
            example:
              {
                "title": "Send the proposal",
                "intention": "Move the Saturn goal forward",
                "goalId": "g1b2c3d4-...",
                "date": "2026-07-29"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "id": "a1b2c3d4-...",
                    "title": "Send the proposal",
                    "intention": "Move the Saturn goal forward",
                    "status": "planned",
                    "action_date": "2026-07-29",
                    "linked_goal_id": "g1b2c3d4-...",
                    "created_at": "2026-07-29T08:12:04.221Z"
                  }
                }
        '400':
          description: "`title` is missing or empty."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/actions/{id}':
    patch:
      operationId: patchAction
      tags: [Actions]
      summary: "Update an action"
      description: "Marks an action done or skipped and/or attaches the user's reflection."
      security: [{ bearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          description: "Action id."
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                status:
                  type: string
                  description: "New status. (\"planned\" | \"done\" | \"skipped\")"
                reflection:
                  type: string
                  description: "Free-text reflection captured on completion. (string)"
            example:
              {
                "status": "done",
                "reflection": "Easier than I expected."
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "id": "a1b2c3d4-...",
                    "title": "Send the proposal",
                    "status": "done",
                    "reflection": "Easier than I expected.",
                    "action_date": "2026-07-29"
                  }
                }
        '400':
          description: "The action does not exist or belongs to another user."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    delete:
      operationId: deleteAction
      tags: [Actions]
      summary: "Delete an action"
      description: "Permanently removes the action."
      security: [{ bearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          description: "Action id."
          schema: { type: string }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "id": "a1b2c3d4-...",
                    "deleted": true
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/goals':
    get:
      operationId: getGoals
      tags: [Goals]
      summary: "List goals"
      description: "All of the caller's goals, newest first."
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": [
                    {
                      "id": "g1b2c3d4-...",
                      "title": "Ship the mobile app",
                      "description": "Public beta in the App Store",
                      "category": "career",
                      "aligned_planet": "Saturn",
                      "status": "active",
                      "progress": 40,
                      "target_date": "2026-10-01",
                      "created_at": "2026-06-02T14:20:00.000Z"
                    }
                  ]
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    post:
      operationId: postGoals
      tags: [Goals]
      summary: "Create a goal"
      description: "Creates a goal. `category` defaults to `general` when omitted."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                  description: "Goal title. (string)"
                description:
                  type: string
                  description: "Longer description. (string)"
                category:
                  type: string
                  description: "e.g. `career`, `health`, `relationships`. Defaults to `general`. (string)"
                alignedPlanet:
                  type: string
                  description: "Planet the goal is aligned to, e.g. `Saturn`. (string)"
                targetDate:
                  type: string
                  description: "Target completion date. (string (YYYY-MM-DD))"
            example:
              {
                "title": "Ship the mobile app",
                "description": "Public beta in the App Store",
                "category": "career",
                "alignedPlanet": "Saturn",
                "targetDate": "2026-10-01"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "id": "g1b2c3d4-...",
                    "title": "Ship the mobile app",
                    "category": "career",
                    "aligned_planet": "Saturn",
                    "status": "active",
                    "progress": 0,
                    "target_date": "2026-10-01"
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    patch:
      operationId: patchGoals
      tags: [Goals]
      summary: "Update goal progress or status"
      description: "The goal id travels in the body, not the path."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id:
                  type: string
                  description: "Goal id. (string (uuid))"
                progress:
                  type: number
                  description: "Completion percentage. (number (0-100))"
                status:
                  type: string
                  description: "New status. (\"active\" | \"paused\" | \"complete\")"
            example:
              {
                "id": "g1b2c3d4-...",
                "progress": 75,
                "status": "active"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "id": "g1b2c3d4-...",
                    "progress": 75,
                    "status": "active"
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/journal':
    get:
      operationId: getJournal
      tags: [Journal]
      summary: "Recent journal entries"
      description: "The 50 most recent entries, newest first, each with its stored AI analysis."
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": [
                    {
                      "id": "j1b2...",
                      "title": "Long day",
                      "content": "Kept circling the same conversation...",
                      "mood": "restless",
                      "themes": [
                        "boundaries",
                        "work"
                      ],
                      "ai_analysis": {
                        "summary": "You are naming a boundary you have not yet enforced.",
                        "archetypes": [
                          "Saturn in the 10th"
                        ],
                        "prompt": "What would enforcing it cost you this week?"
                      },
                      "created_at": "2026-07-28T22:41:00.000Z"
                    }
                  ]
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    post:
      operationId: postJournal
      tags: [Journal]
      summary: "Write an entry and get AI analysis"
      description: "Writes the entry and returns it together with the AI analysis in one call. Counts against the caller's monthly journal quota."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  description: "Entry body. (string)"
                title:
                  type: string
                  description: "Optional title; one is inferred when omitted. (string)"
            example:
              {
                "title": "Long day",
                "content": "Kept circling the same conversation..."
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "entry": {
                      "id": "j1b2...",
                      "title": "Long day",
                      "content": "Kept circling the same conversation...",
                      "mood": "restless",
                      "themes": [
                        "boundaries",
                        "work"
                      ],
                      "created_at": "2026-07-28T22:41:00.000Z"
                    },
                    "analysis": {
                      "summary": "You are naming a boundary you have not yet enforced.",
                      "archetypes": [
                        "Saturn in the 10th"
                      ],
                      "prompt": "What would enforcing it cost you this week?"
                    }
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '402':
          description: "Monthly journal-analysis allowance is spent."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '503':
          description: "The AI gateway is unavailable — the entry is not written; retry."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/relationships':
    get:
      operationId: getRelationships
      tags: [Relationships]
      summary: "Saved relationships"
      description: "Every relationship the caller has saved. `name` is the person's display name. `synastry_summary` is the stored together-summary when a reading has been generated."
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": [
                    {
                      "id": "r1b2...",
                      "name": "Sam",
                      "relationship_type": "partner",
                      "birth_date": "1990-11-02",
                      "birth_time": "14:10",
                      "birth_location": "Chicago, Illinois, United States",
                      "sun_sign": "Scorpio",
                      "moon_sign": "Leo",
                      "rising_sign": "Aquarius",
                      "synastry_summary": "Fixed-sign gridlock with a generous Venus trine...",
                      "created_at": "2026-05-11T16:03:00.000Z"
                    }
                  ]
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    post:
      operationId: postRelationships
      tags: [Relationships]
      summary: "Add a person or generate synastry"
      description: "Creates a saved person from birth details (Sun always; Moon/Rising when time and location are present) or generates a synastry reading. The iOS app must be able to add people in-app (guideline 2.1 / 4.2) — do not send reviewers to the website. `GET` maps `display_name` to `name`. Generating synastry counts against the synastry quota."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "Their name. Required unless `action` is `synastry`. (string)"
                relationshipType:
                  type: string
                  description: "Defaults to `partner`. (\"partner\" | \"family\" | \"friend\" | \"collaborator\" | \"ex\" | \"other\")"
                birthDate:
                  type: string
                  description: "Required when creating. (string (YYYY-MM-DD))"
                birthTime:
                  type: string
                  description: "Optional; needed for Moon/Rising. (string (HH:MM))"
                birthLocation:
                  type: string
                  description: "Optional; geocoded server-side. (string)"
                notes:
                  type: string
                  description: "Optional context for synastry. (string)"
                label:
                  type: string
                  description: "Optional label, e.g. wife. (string)"
                action:
                  type: string
                  description: "`synastry` to generate a reading for an existing person. (string)"
                id:
                  type: string
                  description: "Relationship id when `action` is `synastry`. (string (uuid))"
            example:
              {
                "name": "Sam",
                "relationshipType": "partner",
                "birthDate": "1990-11-02",
                "birthTime": "14:10",
                "birthLocation": "Chicago, Illinois, United States"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "id": "r1b2...",
                    "name": "Sam",
                    "relationship_type": "partner",
                    "birth_date": "1990-11-02",
                    "sun_sign": "Scorpio",
                    "moon_sign": "Leo",
                    "rising_sign": "Aquarius",
                    "synastry_summary": null
                  }
                }
        '400':
          description: "Name or birth date is missing, or the relationship id is not yours."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '402':
          description: "Monthly synastry allowance is spent."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/breathwork':
    get:
      operationId: getBreathwork
      tags: [Breathwork]
      summary: "Session history"
      description: "The caller's logged sessions. The technique catalogue itself lives on `/meta` so it can be cached without a token."
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": [
                    {
                      "id": "b1c2...",
                      "technique_slug": "box-breathing",
                      "duration_seconds": 300,
                      "rounds_completed": 6,
                      "mood_before": "anxious",
                      "mood_after": "settled",
                      "goal": "calm",
                      "notes": null,
                      "created_at": "2026-07-29T06:50:00.000Z"
                    }
                  ]
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    post:
      operationId: postBreathwork
      tags: [Breathwork]
      summary: "Log a session"
      description: "Records a completed breathwork session. `techniqueSlug` must be one of the slugs returned by `/meta`."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [techniqueSlug, durationSeconds]
              properties:
                techniqueSlug:
                  type: string
                  description: "Technique slug from `/meta`. (string)"
                durationSeconds:
                  type: number
                  description: "Total session length in seconds. (number)"
                roundsCompleted:
                  type: number
                  description: "Completed rounds. (number)"
                moodBefore:
                  type: string
                  description: "Mood before the session. (string)"
                moodAfter:
                  type: string
                  description: "Mood after the session. (string)"
                goal:
                  type: string
                  description: "Intent, e.g. `calm`, `focus`, `sleep`. (string)"
                notes:
                  type: string
                  description: "Free-text notes. (string)"
            example:
              {
                "techniqueSlug": "box-breathing",
                "durationSeconds": 300,
                "roundsCompleted": 6,
                "moodBefore": "anxious",
                "moodAfter": "settled",
                "goal": "calm"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "id": "b1c2...",
                    "technique_slug": "box-breathing",
                    "duration_seconds": 300,
                    "rounds_completed": 6,
                    "created_at": "2026-07-29T06:50:00.000Z"
                  }
                }
        '400':
          description: "Unknown technique slug."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/codex':
    get:
      operationId: getCodex
      tags: [Codex]
      summary: "Lessons with the caller's progress"
      description: "Returns the lesson catalogue and the caller's progress rows in one call; join them client-side on `lesson_id`."
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "lessons": [
                      {
                        "id": "l1b2...",
                        "slug": "the-luminaries",
                        "title": "The Luminaries",
                        "section": "foundations",
                        "summary": "Sun, Moon, and why the pair matters more than either alone.",
                        "order_index": 1
                      }
                    ],
                    "progress": [
                      {
                        "lesson_id": "l1b2...",
                        "status": "complete",
                        "reflection": "The Moon framing landed.",
                        "updated_at": "2026-07-20T12:00:00.000Z"
                      }
                    ]
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    post:
      operationId: postCodex
      tags: [Codex]
      summary: "Record lesson progress"
      description: "Upserts the caller's progress on one lesson."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lessonId, status]
              properties:
                lessonId:
                  type: string
                  description: "Lesson id from `GET /codex`. (string (uuid))"
                status:
                  type: string
                  description: "New progress status. (\"in_progress\" | \"complete\")"
                reflection:
                  type: string
                  description: "The user's reflection on the lesson. (string)"
            example:
              {
                "lessonId": "l1b2...",
                "status": "complete",
                "reflection": "The Moon framing landed."
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "lesson_id": "l1b2...",
                    "status": "complete",
                    "updated_at": "2026-07-29T09:00:00.000Z"
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/chat/threads':
    get:
      operationId: getThreads
      tags: [Chat]
      summary: "List threads"
      description: "The caller's chat threads, most recently updated first."
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": [
                    {
                      "id": "t1b2...",
                      "title": "Career timing",
                      "created_at": "2026-07-01T10:00:00.000Z",
                      "updated_at": "2026-07-28T21:00:00.000Z"
                    }
                  ]
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    post:
      operationId: postThreads
      tags: [Chat]
      summary: "Create a thread"
      description: "Creates an empty thread. A title is generated from the first message when omitted."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  description: "Optional thread title. (string)"
            example:
              {
                "title": "Career timing"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "id": "t1b2...",
                    "title": "Career timing",
                    "created_at": "2026-07-29T09:10:00.000Z"
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    patch:
      operationId: patchThreads
      tags: [Chat]
      summary: "Rename a thread"
      description: "The thread id travels in the body."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, title]
              properties:
                id:
                  type: string
                  description: "Thread id. (string (uuid))"
                title:
                  type: string
                  description: "New title. (string)"
            example:
              {
                "id": "t1b2...",
                "title": "Saturn return plan"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "id": "t1b2...",
                    "title": "Saturn return plan"
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    delete:
      operationId: deleteThreads
      tags: [Chat]
      summary: "Delete a thread"
      description: "Deletes the thread and all of its messages. The id travels in the body."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id:
                  type: string
                  description: "Thread id. (string (uuid))"
            example:
              {
                "id": "t1b2..."
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "id": "t1b2...",
                    "deleted": true
                  }
                }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/chat/messages':
    get:
      operationId: getMessages
      tags: [Chat]
      summary: "Read a thread"
      description: "All messages in one thread, oldest first."
      security: [{ bearerAuth: [] }]
      parameters:
        - name: threadId
          in: query
          required: true
          description: "Thread to read."
          schema: { type: string }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": [
                    {
                      "id": "m1...",
                      "role": "user",
                      "content": "When should I make the move?",
                      "created_at": "2026-07-28T20:59:00.000Z"
                    },
                    {
                      "id": "m2...",
                      "role": "assistant",
                      "content": "Your progressed Moon...",
                      "created_at": "2026-07-28T21:00:00.000Z"
                    }
                  ]
                }
        '400':
          description: "`threadId` is missing."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    post:
      operationId: postMessages
      tags: [Chat]
      summary: "Send a message"
      description: "Non-streaming send: writes the user message, runs the Codex-grounded model, stores and returns the reply. Counts against the caller's monthly chat quota. Expect multi-second latency — use a generous client timeout (60s) and an optimistic UI."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [threadId, message]
              properties:
                threadId:
                  type: string
                  description: "Target thread. (string (uuid))"
                message:
                  type: string
                  description: "The user's message. (string)"
            example:
              {
                "threadId": "t1b2...",
                "message": "When should I make the move?"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "userMessage": {
                      "id": "m1...",
                      "role": "user",
                      "content": "When should I make the move?"
                    },
                    "assistantMessage": {
                      "id": "m2...",
                      "role": "assistant",
                      "content": "Your progressed Moon..."
                    }
                  }
                }
        '400':
          description: "The thread does not exist or belongs to another user."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '402':
          description: "Monthly chat allowance is spent."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '503':
          description: "The AI gateway failed. Safe to retry."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/billing/apple/notifications':
    post:
      operationId: postBillingAppleNotifications
      tags: [Billing]
      summary: "App Store Server Notifications V2"
      description: "Apple POSTs a `signedPayload` JWS on renew, expire, refund, and revoke. Paste `https://theluminary.xyz/api/public/v1/billing/apple/notifications` into App Store Connect → App Information → App Store Server Notifications (Production and Sandbox). `REFUND` / `REVOKE` / `EXPIRED` mark the matching `source=apple` row canceled/expired and drop the user to Nebula when no other active plan remains. Not called by the iOS app."
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signedPayload]
              properties:
                signedPayload:
                  type: string
                  description: "Apple-signed notification JWS (ResponseBodyV2). Inner `data.signedTransactionInfo` is a StoreKit 2 transaction JWS. (string)"
            example:
              {
                "signedPayload": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyIuLi4iXX0.eyJub3RpZmljYXRpb25UeXBlIjoiVEVTVCJ9.sig"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "received": true,
                    "type": "REFUND",
                    "action": "revoked",
                    "originalTransactionId": "1000000123456789"
                  }
                }
        '400':
          description: "Body is not a signedPayload JWS, or bundleId / productId do not match Luminary."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/billing/apple':
    post:
      operationId: postBillingApple
      tags: [Billing]
      summary: "Verify StoreKit 2 JWS"
      description: "iOS In-App Purchase verification. Send a StoreKit 2 signed transaction JWS. The server checks bundle id, product id, and (on Vercel production) the x5c signature rooted at Apple Root CA - G3 with Apple receipt-signing OIDs, then writes `public.subscriptions` with `source=apple` and updates `profiles.tier`. Never send a client-chosen tier. Restore with `{ \"action\": \"restore\", \"signedTransactions\": [\"...\"] }`. iOS must not call `/billing/checkout` (guideline 3.1.1)."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                signedTransaction:
                  type: string
                  description: "StoreKit 2 JWS for a single purchase. Required unless restoring. (string)"
                action:
                  type: string
                  description: "`restore` to verify a list of transactions and apply the latest. (string)"
                signedTransactions:
                  type: string
                  description: "JWS list when `action` is `restore`. (string[])"
            example:
              {
                "signedTransaction": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyIuLi4iXX0.eyJidW5kbGVJZCI6Inh5ei50aGVsdW1pbmFyeS5hcHAifQ.sig"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "tier": "constellation",
                    "originalTransactionId": "1000000123456789",
                    "expiresAt": "2026-09-25T16:00:00.000Z"
                  }
                }
        '400':
          description: "JWS is missing, forged, or the wrong bundle/product."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429':
          description: "More than 20 IAP verifies per minute from this IP."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/billing/checkout':
    post:
      operationId: postBillingCheckout
      tags: [Billing]
      summary: "Stripe hosted checkout (web/Android)"
      description: "Creates a Stripe Checkout Session and returns a hosted `url`. Send `X-Client-Platform: ios` and the API rejects the call — iOS digital subscriptions must use StoreKit and `POST /billing/apple`."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [successUrl, cancelUrl, environment]
              properties:
                tierId:
                  type: string
                  description: "Paid plan. Alternative to `priceId`. (constellation | cosmos)"
                priceId:
                  type: string
                  description: "Stripe Price lookup_key (e.g. constellation_monthly). (string)"
                successUrl:
                  type: string
                  description: "Return URL after payment. (string (url))"
                cancelUrl:
                  type: string
                  description: "Return URL if the user backs out. (string (url))"
                environment:
                  type: string
                  description: "Stripe mode. (sandbox | live)"
            example:
              {
                "tierId": "constellation",
                "successUrl": "https://theluminary.xyz/billing/return",
                "cancelUrl": "https://theluminary.xyz/billing",
                "environment": "live"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "url": "https://checkout.stripe.com/c/pay/cs_..."
                  }
                }
        '400':
          description: "iOS client, missing URLs, or Stripe has no matching price."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
  '/api/public/v1/billing/portal':
    post:
      operationId: postBillingPortal
      tags: [Billing]
      summary: "Stripe customer portal"
      description: "Opens the Stripe customer portal for a web/Android subscription. Allowed on iOS for users who originally subscribed on the web (reader-style manage). Apple IAP subscribers manage in Settings → Subscriptions."
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [environment]
              properties:
                returnUrl:
                  type: string
                  description: "Where Stripe should send the user after the portal. (string (url))"
                environment:
                  type: string
                  description: "Stripe mode. (sandbox | live)"
            example:
              {
                "returnUrl": "https://theluminary.xyz/billing",
                "environment": "live"
              }
      responses:
        '200':
          description: Success envelope
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuccessEnvelope' }
              example:
                {
                  "ok": true,
                  "data": {
                    "url": "https://billing.stripe.com/p/session/..."
                  }
                }
        '400':
          description: "No Stripe customer on file for this account."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401':
          description: "Missing or invalid bearer token."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '500':
          description: "Unexpected server fault."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
