openapi: 3.0.3
info:
  title: Shoehorn Admin API
  description: |
    Administrative endpoints for tenant settings, API keys, policies, features,
    scorecards, role bundles, database management, and system info.
  version: 1.0.0

servers:
  - url: https://shoehorn.example.com/api/v1
    description: Production

paths:
  # --- Settings ---
  /admin/settings:
    get:
      summary: Get tenant settings
      tags: [Settings]
      responses:
        '200':
          description: Current tenant settings
    put:
      summary: Update tenant settings
      tags: [Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        '200':
          description: Settings updated

  # --- API Keys ---
  /admin/api-keys:
    get:
      summary: List API keys
      tags: [API Keys]
      responses:
        '200':
          description: API keys (tokens are masked)
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { $ref: '#/components/schemas/APIKey' }

    post:
      summary: Generate API key
      tags: [API Keys]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scopes]
              properties:
                name: { type: string }
                scopes:
                  type: array
                  items: { type: string, enum: [catalog, forge, repositories, k8s-agents, operations, admin, internal:service] }
                expires_at: { type: string, format: date-time }
      responses:
        '201':
          description: API key generated (token shown only once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  name: { type: string }
                  token: { type: string, description: "Full token (shp_svc_...) - shown only at creation" }
                  scopes: { type: array, items: { type: string } }

  /admin/api-keys/{id}/revoke:
    post:
      summary: Revoke API key
      tags: [API Keys]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: API key revoked

  # --- Policies ---
  /admin/policies:
    get:
      summary: List platform policies
      description: Returns pre-seeded platform policies (config-only, not user-created).
      tags: [Policies]
      responses:
        '200':
          description: Platform policies

  /admin/policies/{id}:
    put:
      summary: Update platform policy
      tags: [Policies]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: { type: boolean }
                config: { type: object }
      responses:
        '200':
          description: Policy updated

  # --- Feature Flags ---
  /admin/features:
    get:
      summary: List feature flags
      tags: [Features]
      responses:
        '200':
          description: All feature flags

    post:
      summary: Create feature flag
      tags: [Features]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key, name]
              properties:
                key: { type: string }
                name: { type: string }
                description: { type: string }
                enabled: { type: boolean, default: false }
      responses:
        '201':
          description: Feature flag created

  /admin/features/{key}:
    put:
      summary: Update feature flag
      tags: [Features]
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Flag updated

    delete:
      summary: Delete feature flag
      tags: [Features]
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
      responses:
        '204':
          description: Flag deleted

  /admin/features/{key}/tenants/{tenantId}:
    post:
      summary: Set tenant feature override
      tags: [Features]
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
        - name: tenantId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Override set

    delete:
      summary: Remove tenant feature override
      tags: [Features]
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
        - name: tenantId
          in: path
          required: true
          schema: { type: string }
      responses:
        '204':
          description: Override removed

  # --- Scorecards ---
  /admin/scorecards/categories:
    get:
      summary: List scorecard categories
      tags: [Scorecards]
      responses:
        '200':
          description: Scorecard categories

    post:
      summary: Create scorecard category
      tags: [Scorecards]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, weight]
              properties:
                name: { type: string }
                description: { type: string }
                weight: { type: number, minimum: 0, maximum: 1 }
      responses:
        '201':
          description: Category created

  /admin/scorecards/categories/{id}:
    put:
      summary: Update scorecard category
      tags: [Scorecards]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Category updated

    delete:
      summary: Delete scorecard category
      tags: [Scorecards]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '204':
          description: Category deleted

  /admin/scorecards/rules:
    get:
      summary: List scorecard rules
      tags: [Scorecards]
      responses:
        '200':
          description: Global scorecard rules

    post:
      summary: Create scorecard rule
      tags: [Scorecards]
      responses:
        '201':
          description: Rule created

  /admin/scorecards/rules/{id}:
    put:
      summary: Update scorecard rule
      tags: [Scorecards]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Rule updated

    delete:
      summary: Delete scorecard rule
      tags: [Scorecards]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '204':
          description: Rule deleted

  /admin/scorecards/grading:
    get:
      summary: Get grading configuration
      tags: [Scorecards]
      responses:
        '200':
          description: Grading thresholds

    put:
      summary: Update grading configuration
      tags: [Scorecards]
      responses:
        '200':
          description: Grading config updated

  # --- Role Bundles ---
  /admin/role-bundles:
    get:
      summary: List role bundles
      tags: [Role Bundles]
      responses:
        '200':
          description: All role bundles

    post:
      summary: Create role bundle
      tags: [Role Bundles]
      responses:
        '201':
          description: Bundle created

  /admin/role-bundles/{bundleId}:
    get:
      summary: Get role bundle
      tags: [Role Bundles]
      parameters:
        - name: bundleId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Bundle details

    put:
      summary: Update role bundle
      tags: [Role Bundles]
      parameters:
        - name: bundleId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Bundle updated

    delete:
      summary: Delete role bundle
      tags: [Role Bundles]
      parameters:
        - name: bundleId
          in: path
          required: true
          schema: { type: string }
      responses:
        '204':
          description: Bundle deleted

  # --- Roles ---
  /roles:
    get:
      summary: List all roles
      tags: [Roles]
      responses:
        '200':
          description: Available roles

  /roles/users/{userId}/roles:
    post:
      summary: Assign role to user
      tags: [Roles]
      parameters:
        - name: userId
          in: path
          required: true
          schema: { type: string }
      responses:
        '201':
          description: Role assigned

    delete:
      summary: Remove role from user
      tags: [Roles]
      parameters:
        - name: userId
          in: path
          required: true
          schema: { type: string }
      responses:
        '204':
          description: Role removed

  # --- Database ---
  /database/info:
    get:
      summary: Get database info
      tags: [Database]
      responses:
        '200':
          description: Database version, size, connection stats

  /database/connections:
    get:
      summary: Get database connections
      tags: [Database]
      responses:
        '200':
          description: Active database connections

  /database/backups:
    get:
      summary: List database backups
      tags: [Database]
      responses:
        '200':
          description: Available backups

  /database/backup:
    post:
      summary: Create database backup
      tags: [Database]
      responses:
        '201':
          description: Backup initiated

  # --- System ---
  /system:
    get:
      summary: Get system services info
      tags: [System]
      responses:
        '200':
          description: Status of all microservices

  # --- License ---
  /license:
    get:
      summary: Get license status
      description: |
        Returns the active license for the calling tenant. Beta tenants also
        get `beta_ends_at`, `beta_grace_ends_at`, and `is_read_only` populated
        when applicable.
      tags: [License]
      responses:
        '200':
          description: Current license info
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LicenseStatus'

  /license/activate:
    post:
      summary: Activate license
      tags: [License]
      responses:
        '200':
          description: License activated

  /license/deactivate:
    post:
      summary: Deactivate license
      tags: [License]
      responses:
        '200':
          description: License deactivated

  # --- Cluster overage approval (Standard / Beta) ---
  /license/pending-clusters:
    get:
      summary: List pending cluster registrations
      description: |
        Agents that register a cluster past the tenant's purchased capacity
        park here for admin approval. Returns an empty array when nothing is
        pending.
      tags: [License]
      responses:
        '200':
          description: Pending registrations
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PendingClusterRegistration'

  /license/pending-clusters/{cluster_id}/approve:
    post:
      summary: Approve a pending cluster registration
      description: |
        Bumps purchased_clusters by 1 and clears the pending row. The agent
        comes online on its next heartbeat (around 30 seconds). On Standard
        tier this adds €99/month to the subscription.
      tags: [License]
      parameters:
        - in: path
          name: cluster_id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Approved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApprovePendingClusterResponse'
        '404':
          description: No pending registration for that cluster_id

  /license/pending-clusters/{cluster_id}:
    delete:
      summary: Reject a pending cluster registration
      description: |
        Removes the pending row without bumping purchased_clusters. The agent
        will re-park on its next register attempt unless its deployment is
        changed.
      tags: [License]
      parameters:
        - in: path
          name: cluster_id
          required: true
          schema: { type: string }
      responses:
        '204':
          description: Rejected
        '404':
          description: No pending registration for that cluster_id

components:
  schemas:
    APIKey:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        prefix: { type: string, description: "First 8 chars of token (shp_svc_)" }
        scopes: { type: array, items: { type: string } }
        last_used: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        revoked: { type: boolean }

    LicenseStatus:
      type: object
      required:
        - edition
        - resource_limits
        - is_expired
        - is_valid
        - has_license
        - is_read_only
      properties:
        edition:
          type: string
          enum: [free, beta, standard, airgapped]
        type:
          type: string
          enum: [selfhosted, saas, partner]
        clusters:
          type: integer
          description: Per-license cluster-limit override (0 = use tier default)
        licensee: { type: string }
        issued_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, nullable: true }
        activated_at: { type: string, format: date-time, nullable: true }
        key_prefix: { type: string }
        resource_limits:
          type: object
          required: [clusters, nodes]
          properties:
            clusters: { type: integer, description: "0 = unlimited" }
            nodes: { type: integer, description: "0 = unlimited" }
        resource_usage:
          type: array
          items:
            type: object
            properties:
              resource: { type: string, enum: [clusters, nodes] }
              used: { type: integer }
              limit: { type: integer, description: "0 = unlimited" }
              percent: { type: integer }
        warnings:
          type: array
          items:
            type: object
            properties:
              type: { type: string, enum: [expired, expiring_soon, resource_high, resource_exceeded] }
              message: { type: string }
              level: { type: string, enum: [warning, error] }
        is_expired: { type: boolean }
        is_valid: { type: boolean }
        has_license: { type: boolean }
        beta_ends_at:
          type: string
          format: date-time
          nullable: true
          description: |
            For Beta licenses: when the 6-month free period ends. Floored at
            2026-11-10 for legacy Beta licenses so existing customers don't
            flip read-only the day per-cluster pricing took over.
        beta_grace_ends_at:
          type: string
          format: date-time
          nullable: true
          description: |
            For Beta licenses: when the 30-day post-end grace expires. Set
            only after the first license-status read past beta_ends_at has
            marked the grace start. After this timestamp the auto-disconnect
            job runs.
        is_read_only:
          type: boolean
          description: |
            True while a Beta tenant is inside the 30-day grace window. UI
            mutations return 423 BETA_ENDED_READ_ONLY. Catalog reads and
            agent traffic stay live; license activation also keeps working
            so the tenant can switch to Standard without restoring writes
            first.

    PendingClusterRegistration:
      type: object
      required: [cluster_id, name, first_attempted_at, last_attempted_at, attempts]
      properties:
        cluster_id: { type: string }
        name: { type: string }
        first_attempted_at: { type: string, format: date-time }
        last_attempted_at: { type: string, format: date-time }
        attempts: { type: integer }

    ApprovePendingClusterResponse:
      type: object
      required: [purchased_clusters]
      properties:
        purchased_clusters:
          type: integer
          description: Tenant's purchased_clusters count after the bump.
