{
  "openapi": "3.0.3",
  "info": {
    "title": "PitchStation Apps API",
    "version": "2026-09-17",
    "description": "Owner-scoped app hosting; every route requires sign-in and app.deploy. Publishing configuration/operations are opt-in behind APP_PUBLISH_CONFIG_READ / APP_PUBLISH_OPERATIONS. Live mutations additionally require APP_DEPLOY_LIVE=1 and APP_PUBLISH_PILOT=1 on the server and an admin's live approval of the app's host (otherwise HOST_NOT_APPROVED; APP_PUBLISH_TARGET_IDS, when set, is only a ceiling); operator onboarding and a pinned target-local policy image are prerequisites. Status in this release: live on Apps targets 4 (Oregon, x86) and 5 (Singapore, ARM64); new apps use the managed route from their first deploy (first in production 2026-09-27); six apps inventoried on 2026-09-18 keep legacy routes until their next domains/policy apply; custom-host ownership TXT proof is implemented, first production use pending. This status is not general availability: discover capabilities and each owned app's configuration at runtime. Existing apps need verified inventory; unknown legacy policy is never inferred public. Domain/policy operations do not build, create releases or restart application containers; env PATCH does restart the current release. Accepted intent and validated immutable source survive client disconnects. Prefer: respond-async opts deploy/rollback into 202 operations; old clients retain the final 200/502 envelope. App env, hashes, SSH commands and arbitrary remote logs are never returned. A partial container update can affect runtime even when routing is restored; code rollback does NOT undo migrations. Clients: the Go-live UI (/apps.html), the standalone client (/downloads/pitchstation-apps.cjs), six MCP tools (list, status, logs, deploy, operation, readiness) and the create-pitchstation-app skill. Documentation: /apps-docs/. APP_ACME_EMAIL does not enable renewal alerts; monitoring and email delivery require separate configuration and acceptance."
  },
  "servers": [
    {
      "url": "https://pitchstation.ai"
    }
  ],
  "security": [
    {
      "pat": []
    }
  ],
  "components": {
    "securitySchemes": {
      "pat": {
        "type": "http",
        "scheme": "bearer",
        "description": "Personal Access Token (pst_…) whose user holds the app.deploy capability Scoped tokens: each operation's x-required-scope names the scope a token needs for it (public: none; session: a web session only; host-token: a host report token, never an API token). The catalogue and presets are at GET /api/auth/scopes; GET /api/auth/tokens/self shows what a token holds. A full-access (legacy) token is not checked against scopes."
      }
    },
    "schemas": {
      "App": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "slug": {
            "type": "string",
            "description": "DNS-safe name; the app serves at <slug>.<target base domain>"
          },
          "name": {
            "type": "string"
          },
          "targetId": {
            "type": "integer"
          },
          "webService": {
            "type": "string",
            "description": "The compose service that receives traffic"
          },
          "containerPort": {
            "type": "integer"
          },
          "hostPort": {
            "type": "integer",
            "description": "Allocated by the platform, permanent for the app"
          },
          "healthPath": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "deploying",
              "live",
              "failed",
              "stopped"
            ]
          },
          "currentRelease": {
            "type": "integer",
            "nullable": true
          },
          "envKeys": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Names of stored env keys — values are never returned"
          },
          "migrateConfigured": {
            "type": "boolean",
            "description": "Whether a command is configured; command text is redacted."
          },
          "publishing": {
            "$ref": "#/components/schemas/PublishingConfig"
          },
          "hardening": {
            "type": "object",
            "description": "The owner's opt-ins (E17 phase 2).",
            "properties": {
              "readOnlyRootfs": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "deploySettings": {
            "type": "object",
            "description": "Deploy settings without their commands (E9, E8).",
            "properties": {
              "migrateConfigured": {
                "type": "boolean"
              },
              "migrateService": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "hooks": {
                "type": "object",
                "properties": {
                  "preDeploy": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "service": {
                          "type": "string"
                        },
                        "timeoutSec": {
                          "type": "integer"
                        }
                      }
                    }
                  },
                  "postDeploy": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "service": {
                          "type": "string"
                        },
                        "timeoutSec": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              },
              "composeFile": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "envScopes": {
            "type": "object",
            "additionalProperties": {
              "type": "string",
              "enum": [
                "build",
                "runtime"
              ]
            },
            "description": "Declared env scopes (E11)."
          },
          "buildScopedKeys": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Configured keys that are build-time, declared or by prefix (E11)."
          },
          "buildStale": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "keys": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "since": {
                "type": "string",
                "format": "date-time"
              }
            },
            "description": "Build-time keys changed since the running release was built; cleared by the next successful deploy (E11)."
          },
          "tags": {
            "type": "object",
            "maxProperties": 20,
            "additionalProperties": {
              "type": "string",
              "maxLength": 160
            },
            "description": "Owner labels; keys are 1–40 lowercase letters/digits/underscore/hyphen, starting with a letter. customer identifies a customer for list filtering and spend labels. Never put secrets in tags."
          }
        }
      },
      "PlanStep": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "where": {
            "type": "string"
          },
          "display": {
            "type": "string"
          },
          "secret": {
            "type": "boolean",
            "description": "Step carries secret material; its command is never logged"
          }
        }
      },
      "PublicationPolicy": {
        "type": "object",
        "properties": {
          "access": {
            "type": "string",
            "enum": [
              "public",
              "private",
              "unknown"
            ]
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "eventAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "applicationPolicy": {
            "type": "string",
            "enum": [
              "unknown",
              "none",
              "restricted"
            ]
          },
          "source": {
            "type": "string",
            "enum": [
              "unknown",
              "platform",
              "mixed"
            ]
          },
          "expired": {
            "type": "boolean"
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "PolicyPatch": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "access": {
            "type": "string",
            "enum": [
              "public",
              "private"
            ]
          },
          "password": {
            "type": "string",
            "writeOnly": true,
            "description": "12–72 UTF-8 bytes. Omission preserves an existing gate. Explicit public removes only the platform gate. Never returned; retain separately."
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "eventAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "applicationPolicy": {
            "type": "string",
            "enum": [
              "unknown",
              "none",
              "restricted"
            ],
            "description": "Owner's declaration about the application's OWN restrictions. New apps start at \"unknown\" (never inferred open). \"none\" = the app restricts nobody; readiness certifies a PUBLIC app only then (otherwise ACCESS_POLICY_MISMATCH with fields [\"applicationPolicy\"]). \"restricted\" = the app has its own login/expiry; readiness does not certify it."
          },
          "publicPaths": {
            "type": "array",
            "maxItems": 5,
            "items": {
              "oneOf": [
                {
                  "type": "string",
                  "pattern": "^/(?:[A-Za-z0-9._~-]+/){1,12}$"
                },
                {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "path",
                    "methods"
                  ],
                  "properties": {
                    "path": {
                      "type": "string",
                      "maxLength": 200,
                      "pattern": "^/(?:[A-Za-z0-9._~-]+/){0,11}[A-Za-z0-9._~-]+/?$"
                    },
                    "methods": {
                      "type": "array",
                      "minItems": 1,
                      "maxItems": 1,
                      "items": {
                        "type": "string",
                        "enum": [
                          "POST"
                        ]
                      }
                    }
                  }
                }
              ]
            },
            "description": "E6: path prefixes a private app serves without its gate, for signed machine callbacks (payment webhooks, OAuth redirects). Expiry still applies; the app verifies each callback's signature. Never / or /__ps/. Typed {path,methods:[\"POST\"]} entries match that exact path (optional query), POST only; other methods return 405 at the proxy. Legacy strings remain all-method prefixes. Root, reserved platform routes, dot segments, encodings and wildcards are rejected. The app must verify signatures and prevent replay."
          },
          "cachePaths": {
            "type": "array",
            "maxItems": 5,
            "items": {
              "type": "string",
              "pattern": "^/(?:[A-Za-z0-9._~-]+/){1,12}$"
            },
            "description": "E7: in private mode, responses under these prefixes (e.g. /_next/static/) may stay in the viewer's own browser cache (private, max-age=31536000, immutable). A response the app marks immutable qualifies anywhere; everything else stays no-store."
          },
          "accounts": {
            "type": [
              "object",
              "null"
            ],
            "description": "E5: PitchStation people (by username) and teams (id, handle or @owner/handle) who may open the private app with their account (Sign in with PitchStation on its login page). Stored resolved: {users: [{id, username}], teams: [{id, handle}]}. Private apps only.",
            "properties": {
              "users": {
                "type": "array",
                "maxItems": 50,
                "items": {
                  "type": "string"
                }
              },
              "teams": {
                "type": "array",
                "maxItems": 20,
                "items": {
                  "type": [
                    "string",
                    "integer"
                  ]
                }
              }
            }
          }
        }
      },
      "ExpectedContent": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "marker"
        ],
        "properties": {
          "marker": {
            "type": "string",
            "minLength": 12,
            "maxLength": 256
          },
          "assets": {
            "type": "array",
            "maxItems": 16,
            "items": {
              "type": "string",
              "description": "Same-app absolute path; no URL, query, fragment or traversal."
            }
          }
        }
      },
      "Domains": {
        "type": "object",
        "properties": {
          "primary": {
            "type": "string",
            "example": "example.com"
          },
          "aliases": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "hostname",
                "mode"
              ],
              "properties": {
                "hostname": {
                  "type": "string"
                },
                "mode": {
                  "type": "string",
                  "enum": [
                    "serve",
                    "redirect"
                  ]
                }
              }
            }
          },
          "platform": {
            "type": "string",
            "enum": [
              "retain",
              "redirect",
              "disabled"
            ]
          },
          "detach": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Explicit detach only; omitted existing hosts remain attached. Claims release only after confirmed owned-route removal."
          },
          "expected": {
            "$ref": "#/components/schemas/ExpectedContent"
          }
        }
      },
      "PublishingConfig": {
        "type": "object",
        "properties": {
          "appId": {
            "type": "integer"
          },
          "revision": {
            "type": "integer"
          },
          "appliedRevision": {
            "type": "integer"
          },
          "etag": {
            "type": "string"
          },
          "provenance": {
            "type": "string",
            "enum": [
              "new",
              "inventory_verified",
              "unknown"
            ]
          },
          "domains": {
            "$ref": "#/components/schemas/Domains"
          },
          "policy": {
            "$ref": "#/components/schemas/PublicationPolicy"
          },
          "envRevision": {
            "type": "integer"
          },
          "envAppliedRevision": {
            "type": "integer"
          },
          "expected": {
            "$ref": "#/components/schemas/ExpectedContent"
          }
        },
        "description": "Desired and applied state are separate; env applied state is independently encrypted. A legacy app without inventory returns status:inventory_required instead."
      },
      "PublishingError": {
        "type": "object",
        "required": [
          "code",
          "message",
          "retryable",
          "actor"
        ],
        "properties": {
          "code": {
            "type": "string"
          },
          "message": {
            "type": "string"
          },
          "nextAction": {
            "type": "string"
          },
          "retryable": {
            "type": "boolean"
          },
          "actor": {
            "type": "string",
            "enum": [
              "publisher",
              "operator"
            ]
          },
          "appId": {
            "type": "integer"
          },
          "operationId": {
            "type": "string"
          },
          "currentRevision": {
            "type": "integer"
          },
          "at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Operation": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "appId": {
            "type": "integer"
          },
          "action": {
            "type": "string",
            "enum": [
              "deploy",
              "rollback",
              "domains",
              "policy",
              "env"
            ]
          },
          "revision": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "waiting_external",
              "cancel_requested",
              "cancelled",
              "succeeded",
              "failed"
            ]
          },
          "phase": {
            "type": "string"
          },
          "statusUrl": {
            "type": "string"
          },
          "attempt": {
            "type": "integer"
          },
          "nextRunAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "cleanupState": {
            "type": "string"
          },
          "error": {
            "$ref": "#/components/schemas/PublishingError"
          },
          "result": {
            "type": "object",
            "description": "Redacted effective state / readiness / recovery; never commands, payloads or secrets."
          },
          "steps": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "index": {
                  "type": "integer"
                },
                "name": {
                  "type": "string"
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "running",
                    "succeeded",
                    "failed",
                    "compensated"
                  ]
                },
                "startedAt": {
                  "type": "string",
                  "format": "date-time"
                },
                "finishedAt": {
                  "type": "string",
                  "format": "date-time",
                  "nullable": true
                },
                "error": {
                  "$ref": "#/components/schemas/PublishingError"
                }
              }
            }
          }
        }
      },
      "RuntimeStatus": {
        "type": "object",
        "properties": {
          "project": {
            "type": "string"
          },
          "checkedAt": {
            "type": "string",
            "format": "date-time"
          },
          "cached": {
            "type": "boolean"
          },
          "running": {
            "type": "integer"
          },
          "services": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "budget": {
            "$ref": "#/components/schemas/Resources"
          },
          "slice": {
            "type": "object",
            "nullable": true,
            "properties": {
              "memoryCurrentMb": {
                "type": "integer"
              },
              "memoryMaxMb": {
                "type": "integer"
              },
              "tasksCurrent": {
                "type": "integer"
              },
              "tasksMax": {
                "type": "integer"
              },
              "cpuQuota": {
                "type": "string"
              }
            }
          },
          "containers": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "service": {
                  "type": "string"
                },
                "state": {
                  "type": "string"
                },
                "health": {
                  "type": "string",
                  "nullable": true
                },
                "restarts": {
                  "type": "integer"
                },
                "exitCode": {
                  "type": "integer",
                  "nullable": true
                },
                "oomKilled": {
                  "type": "boolean"
                },
                "startedAt": {
                  "type": "string",
                  "nullable": true
                },
                "finishedAt": {
                  "type": "string",
                  "nullable": true
                },
                "image": {
                  "type": "string"
                },
                "usage": {
                  "type": "object",
                  "nullable": true,
                  "properties": {
                    "cpuPercent": {
                      "type": "number"
                    },
                    "memMb": {
                      "type": "number"
                    },
                    "memLimitMb": {
                      "type": "number"
                    },
                    "pids": {
                      "type": "integer"
                    }
                  }
                },
                "limits": {
                  "type": "object",
                  "properties": {
                    "memMb": {
                      "type": "integer",
                      "nullable": true
                    },
                    "cpus": {
                      "type": "number",
                      "nullable": true
                    },
                    "pids": {
                      "type": "integer",
                      "nullable": true
                    }
                  }
                }
              }
            }
          }
        }
      },
      "Resources": {
        "type": "object",
        "description": "Per-app budget (E2). Defaults 512 MB, 1 CPU, 256 processes.",
        "properties": {
          "memMb": {
            "type": "integer",
            "minimum": 64,
            "maximum": 16384
          },
          "cpus": {
            "type": "number",
            "minimum": 0.1,
            "maximum": 16
          },
          "pids": {
            "type": "integer",
            "minimum": 32,
            "maximum": 4096
          }
        }
      },
      "DeployHook": {
        "type": "object",
        "required": [
          "service",
          "cmd"
        ],
        "additionalProperties": false,
        "properties": {
          "service": {
            "type": "string",
            "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
          },
          "cmd": {
            "type": "string",
            "maxLength": 2000,
            "description": "Split into words like sh does and run inside the service's container; ; | & < > ( ) ` $ outside single quotes are refused (wrap a command that needs a shell in sh -c '…')."
          },
          "timeoutSec": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1800,
            "default": 300
          }
        }
      },
      "DeploySettings": {
        "type": "object",
        "additionalProperties": false,
        "description": "How the app is deployed beyond its Compose file (E9, E8). null clears a setting.",
        "properties": {
          "migrateCmd": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000,
            "description": "Runs via `docker compose run --rm <migrateService> <words>` BEFORE the container is recreated; a non-zero exit stops the deploy. Split into words like sh does; shell operators outside single quotes are refused."
          },
          "migrateService": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
            "description": "The service the migration runs in; default the web service."
          },
          "hooks": {
            "type": [
              "object",
              "null"
            ],
            "additionalProperties": false,
            "properties": {
              "preDeploy": {
                "type": "array",
                "maxItems": 5,
                "items": {
                  "$ref": "#/components/schemas/DeployHook"
                },
                "description": "After the images exist, before the migration; a failure stops the deploy (the previous release keeps serving)."
              },
              "postDeploy": {
                "type": "array",
                "maxItems": 5,
                "items": {
                  "$ref": "#/components/schemas/DeployHook"
                },
                "description": "After the health check; a failure is a warning on the step, never undone."
              }
            }
          },
          "composeFile": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 300,
            "pattern": "\\.ya?ml$",
            "description": "The Compose file's path inside the source when it is not at the root, e.g. deploy/pitchstation/compose.yaml. Relative paths in it resolve from its own directory."
          }
        }
      },
      "ImageArchive": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^[a-f0-9]{64}$",
            "description": "The file's SHA-256."
          },
          "bytes": {
            "type": "integer"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "images": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "tags": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                },
                "architecture": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "enum": [
                    "amd64",
                    "arm64",
                    null
                  ]
                },
                "os": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "parameters": {
      "AppId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "integer",
          "minimum": 1
        }
      },
      "IfMatch": {
        "name": "If-Match",
        "in": "header",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "Exact configuration ETag, e.g. \"app-7-v3\". Missing:428, stale:409; same-key accepted retries bypass CAS."
      },
      "Idempotency": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.-]{8,128}$"
        },
        "description": "Required for configuration/env apply and async deploy/rollback; same key/body returns one operation, changed body returns409."
      },
      "Prefer": {
        "name": "Prefer",
        "in": "header",
        "schema": {
          "type": "string",
          "enum": [
            "respond-async"
          ]
        },
        "description": "Opt-in 202 response. Omit for the legacy final 200/502 deploy/rollback result."
      }
    }
  },
  "paths": {
    "/api/apps/targets": {
      "get": {
        "summary": "Hosts you can use: your own container hosts plus live shared hosts",
        "description": "Each host carries shared, live, arch, region, maxApps, appsRunning, freeSlots, committedMemMb, capacityMemMb and freeMemMb; a shared host never shows its SSH address. quota {poolApps, limit, poolEnabled} is your shared-host usage; defaultResources is the default budget. utilization is the host's last observed snapshot (memory used/available, free disk, load, CPUs; refreshed in the background every 3 minutes; stale:true when older) — host-wide numbers only, never other tenants' usage. Each host also reports hardening (standard | strict: the Compose allowlist is enforced and every service runs with no-new-privileges) and egress (open | restricted: outbound mail ports blocked and new outbound connections rate-limited).",
        "responses": {
          "200": {
            "description": "targets[] with capacity (maxApps, cpuCores, memMb) and appsRunning"
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/targets/{id}/runtime": {
      "put": {
        "summary": "Promote a deploy target to a container runtime",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "runtime": {
                    "type": "string",
                    "enum": [
                      "container",
                      "static"
                    ]
                  },
                  "maxApps": {
                    "type": "integer"
                  },
                  "cpuCores": {
                    "type": "integer"
                  },
                  "memMb": {
                    "type": "integer"
                  },
                  "tlsMode": {
                    "type": "string",
                    "enum": [
                      "per-host",
                      "wildcard"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated target. Existing static targets are untouched by default — only a promoted target can host apps."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps": {
      "get": {
        "summary": "List your apps",
        "responses": {
          "200": {
            "description": "apps[]"
          }
        },
        "x-required-scope": "apps:read",
        "parameters": [
          {
            "in": "query",
            "name": "customer",
            "schema": {
              "type": "string"
            },
            "description": "Exact match on the customer tag, within your own apps."
          }
        ]
      },
      "post": {
        "summary": "Create an app",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "slug"
                ],
                "properties": {
                  "slug": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  },
                  "targetId": {
                    "type": "integer",
                    "description": "A container host you own, or a live shared (pool) host. Omit it to let the platform place the app (see placement)."
                  },
                  "webService": {
                    "type": "string"
                  },
                  "containerPort": {
                    "type": "integer",
                    "default": 3000
                  },
                  "healthPath": {
                    "type": "string",
                    "default": "/"
                  },
                  "migrateCmd": {
                    "type": "string",
                    "description": "Runs via `docker compose run --rm <migrateService> <words>` BEFORE the container is recreated; a non-zero exit aborts the deploy. Split into words like sh does: shell operators outside single quotes are refused (use sh -c '…'). Also the place for idempotent seeding."
                  },
                  "env": {
                    "type": "object",
                    "description": "Stored encrypted (requires DEPLOY_SECRET on the server). Written to the release's .env at deploy time alongside PORT and HOST_PORT."
                  },
                  "placement": {
                    "type": "object",
                    "description": "Used when targetId is omitted: candidates are live shared hosts plus your own live hosts with a free slot and budget headroom, matching arch; ordered by region match, then most free memory.",
                    "properties": {
                      "region": {
                        "type": "string"
                      },
                      "arch": {
                        "type": "string",
                        "enum": [
                          "amd64",
                          "arm64"
                        ]
                      }
                    }
                  },
                  "arch": {
                    "type": "string",
                    "enum": [
                      "amd64",
                      "arm64"
                    ],
                    "description": "The architecture your images need; hosts of another architecture are not chosen."
                  },
                  "resources": {
                    "$ref": "#/components/schemas/Resources"
                  },
                  "publication": {
                    "type": "object",
                    "description": "The publication policy from the first request (E10). Omitted: public, applicationPolicy unknown. private requires password (write-only, 12–72 bytes).",
                    "properties": {
                      "access": {
                        "type": "string",
                        "enum": [
                          "public",
                          "private"
                        ]
                      },
                      "password": {
                        "type": "string",
                        "writeOnly": true,
                        "minLength": 12,
                        "maxLength": 72
                      },
                      "expiresAt": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "applicationPolicy": {
                        "type": "string",
                        "enum": [
                          "unknown",
                          "none",
                          "restricted"
                        ]
                      }
                    },
                    "required": [
                      "access"
                    ]
                  },
                  "migrateService": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
                    "description": "The service the migration runs in; default the web service."
                  },
                  "hooks": {
                    "type": [
                      "object",
                      "null"
                    ],
                    "additionalProperties": false,
                    "properties": {
                      "preDeploy": {
                        "type": "array",
                        "maxItems": 5,
                        "items": {
                          "$ref": "#/components/schemas/DeployHook"
                        },
                        "description": "After the images exist, before the migration; a failure stops the deploy (the previous release keeps serving)."
                      },
                      "postDeploy": {
                        "type": "array",
                        "maxItems": 5,
                        "items": {
                          "$ref": "#/components/schemas/DeployHook"
                        },
                        "description": "After the health check; a failure is a warning on the step, never undone."
                      }
                    }
                  },
                  "composeFile": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 300,
                    "pattern": "\\.ya?ml$",
                    "description": "The Compose file's path inside the source when it is not at the root, e.g. deploy/pitchstation/compose.yaml. Relative paths in it resolve from its own directory."
                  },
                  "fromApp": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Same-owner structural template: web/container/health/migration/Compose path/resources/architecture/hardening/backup only. Explicit input overrides copied values. Environments, publication credentials, hooks, domains, grants, releases and data are never copied. A private template requires an explicit new publication policy."
                  },
                  "tags": {
                    "type": "object",
                    "maxProperties": 20,
                    "additionalProperties": {
                      "type": "string",
                      "maxLength": 160
                    },
                    "description": "Owner labels; keys are 1–40 lowercase letters/digits/underscore/hyphen, starting with a letter. customer identifies a customer for list filtering and spend labels. Never put secrets in tags."
                  },
                  "hardening": {
                    "type": "object",
                    "properties": {
                      "readOnlyRootfs": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        },
                        "maxItems": 16
                      }
                    }
                  },
                  "backup": {
                    "type": "object",
                    "properties": {
                      "kind": {
                        "type": "string",
                        "enum": [
                          "command",
                          "volumes"
                        ]
                      },
                      "service": {
                        "type": "string"
                      },
                      "cmd": {
                        "type": "string"
                      },
                      "restoreCmd": {
                        "type": "string"
                      },
                      "volumes": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      },
                      "keep": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 10
                      },
                      "schedule": {
                        "type": "string",
                        "enum": [
                          "none",
                          "daily"
                        ]
                      },
                      "beforeDeploy": {
                        "type": "boolean"
                      }
                    },
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "App with allocated hostPort, its resources budget and placement {kind: owner|pool, targetId, reason, rejected[]}; warnings when an owner host is over budget capacity in warn mode.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/App"
                }
              }
            }
          },
          "400": {
            "description": "BAD_SLUG, NOT_CONTAINER_TARGET, POLICY_INVALID or RESOURCES_INVALID."
          },
          "404": {
            "description": "No such target, or a host you neither own nor may use as a shared host."
          },
          "409": {
            "description": "HOST_FULL (slot ceiling; formerly TARGET_FULL), QUOTA_EXCEEDED (shared-host quota), RESOURCE_ADMISSION, PLACEMENT_UNAVAILABLE (detail.rejected explains each host), RESOURCES_UNSUPPORTED or slug taken."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}": {
      "get": {
        "summary": "App detail + releases",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "app + releases[]"
          }
        },
        "x-required-scope": "apps:read"
      },
      "delete": {
        "summary": "Remove the app from the control plane",
        "description": "Owner-host apps: forgets the app in the control plane only — containers, volumes, releases and live hostname claims stay (stop first) — unless removeFromHost=1. Apps on a SHARED host (placement pool), and owner-host apps with removeFromHost=1: must be stopped (409 APP_RUNNING otherwise); the platform removes their containers, networks, routing and budget slice from the host, releases their hostname claims and budget, and KEEPS their data volumes. A running move (MOVE_IN_PROGRESS) or a stopped copy an earlier move left on another host (HOST_STATE_CONFLICT, detail.moves) must be finished or removed first.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "removeFromHost",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "On an owner-host app, also remove its containers, routing and budget slice from the host (volumes kept)."
          }
        ],
        "responses": {
          "200": {
            "description": "deleted, with a note about what was left running"
          },
          "409": {
            "description": "APP_RUNNING (the app must be stopped first), OPERATION_BUSY, MOVE_IN_PROGRESS, or HOST_STATE_CONFLICT (an earlier move's stopped copy remains)."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/env": {
      "put": {
        "summary": "Replace the app's stored environment",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "env": {
                    "type": "object"
                  }
                },
                "required": [
                  "env"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "App with the new envKeys (names only). Takes effect on the next deploy."
          }
        },
        "description": "Compatibility replace-all, stored-only. No required If-Match; if supplied it is enforced. PATCH always requires CAS. Both reject reserved PORT/HOST_PORT/COMPOSE_*/DOCKER_* keys, multiline/NUL values and invalid names. Managed state reports independent stored/applied env revisions.",
        "x-required-scope": "apps:deploy"
      },
      "patch": {
        "summary": "Preview/apply encrypted env set/unset, restarting only the existing release",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "set": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    }
                  },
                  "unset": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  },
                  "scopes": {
                    "type": "object",
                    "maxProperties": 200,
                    "additionalProperties": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "enum": [
                        "build",
                        "runtime",
                        null
                      ]
                    },
                    "description": "E11: declare keys build-time or runtime (null returns a key to its default: build for NEXT_PUBLIC_*, VITE_*, REACT_APP_*, NUXT_PUBLIC_*, EXPO_PUBLIC_*, GATSBY_* and PUBLIC_*, else runtime). Sent alone, scopes are stored without a restart or an operation."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Changed/preserved key names only; restartRequired."
          },
          "202": {
            "description": "Durable operation accepted. Poll Location/statusUrl; disconnect does not cancel it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Retry-After": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "accepted": {
                      "type": "boolean"
                    },
                    "duplicate": {
                      "type": "boolean"
                    },
                    "operation": {
                      "$ref": "#/components/schemas/Operation"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:deploy",
        "description": "Build-time keys (E11): a change to one is applied by the restart like any other, but the built assets keep the old value until the next deploy, so the dry run lists buildScopedKeys with rebuildRequired, and after the change the app carries buildStale {keys, since} until a deploy succeeds."
      }
    },
    "/api/apps/{id}/deploy": {
      "post": {
        "summary": "Deploy a release (dry run by default)",
        "description": "Immutable gzip USTAR/PAX intake (80MiB compressed,256MiB expanded,20k files). Reject traversal, links, devices, duplicate paths and recognized secret files/key material before202. Server sourceDir requires an explicit APP_SOURCE_ROOTS descendant. Managed deploy omits no saved domains/gate: omission preserves; empty previewPassword is invalid; public is an explicit policy change. Rendered plans redact exec and secret data. New hosting requires nginx/per-host TLS; unsupported legacy adapters stay on their existing rail until reviewed.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          },
          {
            "$ref": "#/components/parameters/Prefer"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "tarballBase64": {
                    "type": "string"
                  },
                  "releaseTs": {
                    "type": "string",
                    "description": "Release identifier; defaults to the server clock"
                  },
                  "note": {
                    "type": "string"
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  },
                  "previewPassword": {
                    "type": "string",
                    "description": "Managed apps: explicit private-gate rotation,12–72bytes; omission retains existing gate. Uninventoried legacy rail retains old request-scoped behavior until migrated."
                  },
                  "fqdn": {
                    "type": "string",
                    "description": "Serve on a full custom domain instead of <slug>.<base domain>"
                  },
                  "sourceDir": {
                    "type": "string",
                    "description": "Privileged server-side source within APP_SOURCE_ROOTS; prefer tarballBase64."
                  },
                  "altNames": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "With explicit fqdn, legacy directly served aliases; new domain plans support canonical redirects."
                  },
                  "expected": {
                    "$ref": "#/components/schemas/ExpectedContent"
                  },
                  "imageArchive": {
                    "oneOf": [
                      {
                        "type": "string",
                        "pattern": "^[a-f0-9]{64}$"
                      },
                      {
                        "type": "object",
                        "additionalProperties": false,
                        "required": [
                          "appId",
                          "id"
                        ],
                        "properties": {
                          "appId": {
                            "type": "integer",
                            "minimum": 1
                          },
                          "id": {
                            "type": "string",
                            "pattern": "^[a-f0-9]{64}$"
                          }
                        }
                      }
                    ],
                    "description": "E1b: an uploaded image archive (PUT /api/apps/{id}/images). The host loads it instead of building; the dry run checks the images' architecture against the host (IMAGE_ARCH_MISMATCH) and that every service with build: names an image the archive holds (IMAGE_ARCHIVE_INCOMPLETE). The source bundle still carries the Compose file. Use {appId,id} to reuse another app’s archive within the same owner account. Preview is read-only; acceptance retains an independent destination archive, so source removal cannot break it."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dry run: { plan: { hostname, liveUrl, steps: PlanStep[] } }. Live: { released: true, liveUrl, log }.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "202": {
            "description": "Durable operation accepted. Poll Location/statusUrl; disconnect does not cancel it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Retry-After": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "accepted": {
                      "type": "boolean"
                    },
                    "duplicate": {
                      "type": "boolean"
                    },
                    "operation": {
                      "$ref": "#/components/schemas/Operation"
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "Failed operation; inspect actual completed steps and recovery state. A changed container or database is not automatically restored by routing compensation."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/rollback": {
      "post": {
        "summary": "Roll back to a previous healthy release",
        "description": "Re-runs compose from the kept release directory (container recreate — seconds of downtime), health-checked before activation. It brings back that release's Compose file and the images it names; services built on the host keep their newest build unless each release tags its images differently. WARNING: a migration run by the abandoned release is still applied. Managed rollback retains persisted hosts/policy and last applied env instead of the historical release .env. Omitted release resolves once per idempotency key.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          },
          {
            "$ref": "#/components/parameters/Prefer"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "releaseId": {
                    "type": "integer",
                    "description": "Defaults to the newest healthy release other than the app's currentRelease. A failed deploy never becomes currentRelease, so after one the default skips the last good release, and after a rollback it is the release you left: pass the ID (GET /api/apps/:id lists app.releases)."
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "rolledBackTo + log (or the dry-run plan)"
          },
          "202": {
            "description": "Durable operation accepted. Poll Location/statusUrl; disconnect does not cancel it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Retry-After": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "accepted": {
                      "type": "boolean"
                    },
                    "duplicate": {
                      "type": "boolean"
                    },
                    "operation": {
                      "$ref": "#/components/schemas/Operation"
                    }
                  }
                }
              }
            }
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/preview": {
      "post": {
        "summary": "Mint a preview credential",
        "description": "Returns { previewUrl, credential: { user: 'preview', password } } once per call, but does not activate a gate. Retain the credential securely. For an existing managed app use a publication-policy plan/apply to set private access without a content deployment. For a private first deployment, pass previewPassword in the protected deploy body. A document share is not an app access gate; platform public access cannot remove application-managed login or expiry.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "previewUrl + one-time credential"
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/stop": {
      "post": {
        "summary": "Stop the app's containers",
        "description": "Frees CPU; keeps volumes and release directories. Redeploy to bring it back.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ok + log"
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/publishing-capabilities": {
      "get": {
        "summary": "Publishing rollout capabilities (requires app.deploy)",
        "description": "Discover configuration-read and operation feature flags. These booleans do not assert that a particular target is allowed, an app is inventoried, DNS ownership is verified, or a publication is ready. Read the owned app's domain-config and handle CONFIG_REVIEW_REQUIRED / LIVE_DISABLED explicitly; never enable flags or bypass proof based only on this response.",
        "responses": {
          "200": {
            "description": "configuration / operations / liveRequiresPilot / supportedAdapter / asyncOptIn"
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/{id}/domain-config": {
      "get": {
        "summary": "Unified desired/applied configuration, plans, DNS records, operations and readiness",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "Owner-scoped detail; Cache-Control:no-store, ETag when managed."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:read"
      },
      "put": {
        "summary": "Plan or apply domains",
        "description": "Dry-run defaults true; a plan expires after 15 minutes. Dry run can persist a plan/claim but does not touch the host/provider DNS. Apply requires dryRun:false + the reviewed planId, If-Match and Idempotency-Key. Target-wide fenced activation; unknown outcomes require operator reconciliation.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  },
                  "planId": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "domains": {
                    "$ref": "#/components/schemas/Domains"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved dry-run plan, exact DNS records, policy and zero-build/restart counters."
          },
          "202": {
            "description": "Durable operation accepted. Poll Location/statusUrl; disconnect does not cancel it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Retry-After": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "accepted": {
                      "type": "boolean"
                    },
                    "duplicate": {
                      "type": "boolean"
                    },
                    "operation": {
                      "$ref": "#/components/schemas/Operation"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/publication-policy": {
      "patch": {
        "summary": "Plan or apply policy",
        "description": "Dry-run defaults true; a plan expires after 15 minutes. Dry run can persist a plan/claim but does not touch the host/provider DNS. Apply requires dryRun:false + the reviewed planId, If-Match and Idempotency-Key. Target-wide fenced activation; unknown outcomes require operator reconciliation.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  },
                  "planId": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "policy": {
                    "$ref": "#/components/schemas/PolicyPatch"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved dry-run plan, exact DNS records, policy and zero-build/restart counters."
          },
          "202": {
            "description": "Durable operation accepted. Poll Location/statusUrl; disconnect does not cancel it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Retry-After": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "accepted": {
                      "type": "boolean"
                    },
                    "duplicate": {
                      "type": "boolean"
                    },
                    "operation": {
                      "$ref": "#/components/schemas/Operation"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/domain-config/verify": {
      "post": {
        "summary": "Bounded DNS/ownership check, without issuance or host mutation",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "planId": {
                    "type": "string",
                    "format": "uuid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-host authoritative and two-recursive A/AAAA/CNAME/CAA observations, expected target and typed failure. Refreshes coalesced for 30 seconds."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/operations": {
      "get": {
        "summary": "Read owner-scoped operations",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "Latest30 redacted operations."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/{id}/readiness": {
      "get": {
        "summary": "Read owner-scoped readiness",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "Five-minute revision/release-bound evidence. HTTP/TLS/policy/identity/assets only; interactiveSmoke is not_run unless separately verified."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:read"
      },
      "post": {
        "summary": "Run bounded public HTTPS readiness checks",
        "description": "DNS-pinned public IPs only; TLS authentication and hostname validation remain enabled. Redirect allowlist, aggregate request/byte/time budgets, short-lived private probe credentials, refresh coalescing. Never certifies application JS interaction. A public app additionally requires policy.applicationPolicy \"none\"; otherwise the host error is ACCESS_POLICY_MISMATCH with fields [\"applicationPolicy\"].",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "ready:false with per-host failure is a valid check result; applied does not mean publicly ready."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/publication-health": {
      "get": {
        "summary": "Read owner-scoped publication-health",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "Deduplicated per-host certificate/readiness transitions, notification delivery status and recovery."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/{id}/publication-receipt": {
      "get": {
        "summary": "Read owner-scoped publication-receipt",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "Redacted URLs, policy, applied/desired revisions and scoped verification; password handoff stays separate."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:read",
        "description": "Includes archiveDigest (compressed upload SHA-256), imageDigest (the web service’s actual running Docker image ID at deploy), imageDigests (all observed service image IDs), and imageCapturedAt. Evidence is frozen per release. imageEvidenceStatus is captured or unavailable. Null means a legacy release or unavailable inspection; archive hashes are never substituted for image IDs. Rollback does not prove mutable tags still point at the original build."
      }
    },
    "/api/apps/{id}/operations/{operationId}": {
      "get": {
        "summary": "Resume observing an accepted operation",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "operationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redacted durable operation and ordered steps."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/{id}/operations/{operationId}/retry": {
      "post": {
        "summary": "Retry safe failed steps of the same operation",
        "description": "Cancellation cannot undo migrations or terminate an uncertain SSH process. Ambiguous outcomes are quarantined, not replayed. TLS rate-limited attempts have a minimum one-hour cooldown and may require a longer issuer window.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "operationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current operation."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/operations/{operationId}/cancel": {
      "post": {
        "summary": "Request cancellation at a safe checkpoint",
        "description": "Cancellation cannot undo migrations or terminate an uncertain SSH process. Ambiguous outcomes are quarantined, not replayed. TLS rate-limited attempts have a minimum one-hour cooldown and may require a longer issuer window.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "operationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current operation."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/status": {
      "get": {
        "summary": "Container status and usage (owner or admin)",
        "description": "Read-only (no host lease): the containers of the app's Compose project with service, state, health, restart count, last exit code, OOM kill, start/finish times and image id, plus live CPU/memory/process usage against the app's budget and, where budgets are enforced, the app's systemd slice. Cached 15 s; fresh=1 bypasses the cache. Fixed commands only; the container environment is never read.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "fresh",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "status {project, checkedAt, cached, containers[], services[], running, budget, slice}",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RuntimeStatus"
                }
              }
            }
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing app, or neither its owner nor an admin."
          },
          "429": {
            "description": "RATE_LIMITED: more than 10 host reads a minute for this user and app; see Retry-After."
          },
          "502": {
            "description": "TARGET_UNREACHABLE or STEP_FAILED: the host could not be read."
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/{id}/logs": {
      "get": {
        "summary": "Recent container logs (owner or admin), redacted",
        "description": "docker logs of the app's own containers, interleaved by timestamp. service must be a name reported by the status call; tail 1–1000 (default 200); since like 10m, 2h or an ISO timestamp. At most 256 KiB. Every stored environment value of six or more characters and common token shapes are masked. Each read writes a logs_viewed audit event.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "service",
            "in": "query",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$"
            }
          },
          {
            "name": "tail",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 1000,
              "default": 200
            }
          },
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Relative (10m, 2h, 1d) or an ISO 8601 timestamp."
          }
        ],
        "responses": {
          "200": {
            "description": "logs {service, tail, since, lines, truncated, text}"
          },
          "400": {
            "description": "QUERY_INVALID with fields naming service, tail or since."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing app, or neither its owner nor an admin."
          },
          "429": {
            "description": "RATE_LIMITED: more than 10 host reads a minute for this user and app; see Retry-After."
          },
          "502": {
            "description": "TARGET_UNREACHABLE or STEP_FAILED: the host could not be read."
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/{id}/resources": {
      "patch": {
        "summary": "Change the app's resource budget (dry run first)",
        "description": "resources {memMb 64–16384, cpus 0.1–16, pids 32–4096}; omitted fields keep their value. Dry run returns the admission arithmetic (committed vs capacity). On a host that enforces budgets (shared hosts always; owner hosts with APP_RESOURCE_LIMITS=enforce) dryRun:false with an Idempotency-Key returns 202 and a 'resources' operation that updates the app's systemd slice live, without restarting it. Elsewhere the budget is stored and shown, not applied (200).",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "resources": {
                    "$ref": "#/components/schemas/Resources"
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dry run (mode, current, resources, admission) or stored without enforcement (applied:false)."
          },
          "202": {
            "description": "Accepted resources operation."
          },
          "400": {
            "description": "RESOURCES_INVALID or IDEMPOTENCY_KEY_REQUIRED."
          },
          "409": {
            "description": "RESOURCE_ADMISSION (would exceed host capacity), RESOURCES_UNSUPPORTED, HOST_NOT_APPROVED or OPERATION_BUSY."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/operations/{operationId}/reconcile": {
      "post": {
        "summary": "Resolve an uncertain operation yourself (app-scoped only)",
        "description": "For an operation in cleanupState reconciliation_required whose uncertain step touched only this app's containers (migrate, up, health, activate and their rollback/env equivalents) while the host lease is healthy — the operation summary says reconcile.by 'owner'. mode 'inspect' pauses the app, reads the host read-only (containers and the release each came from, the current link, the route, the host fence) and returns the evidence with an inspectionId valid 15 minutes. mode 'confirm' with {inspectionId, attest:{migrationsReviewed:true, note}} records reconciled_prior_state, unpauses and audits it; the failed operation stays failed and a new deploy or rollback converges the host. Host-scoped cases (routing, TLS, a quarantined lease) return RECONCILE_NOT_ALLOWED: an admin resolves them.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "operationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "mode"
                ],
                "properties": {
                  "mode": {
                    "type": "string",
                    "enum": [
                      "inspect",
                      "confirm"
                    ]
                  },
                  "inspectionId": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "attest": {
                    "type": "object",
                    "properties": {
                      "migrationsReviewed": {
                        "type": "boolean"
                      },
                      "note": {
                        "type": "string",
                        "minLength": 8,
                        "maxLength": 1000
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "inspect: {inspection}; confirm: {reconciled, operation}"
          },
          "400": {
            "description": "ATTESTATION_REQUIRED or QUERY_INVALID."
          },
          "409": {
            "description": "RECONCILE_NOT_ALLOWED (an operator must resolve it), INSPECTION_EXPIRED, OPERATION_NOT_RETRYABLE (nothing to reconcile) or OPERATION_BUSY."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/hardening": {
      "patch": {
        "summary": "Opt services into a read-only root filesystem (dry run first)",
        "description": "hardening {readOnlyRootfs: [service…]} (up to 16 service names). Stored now and applied by the next deploy, rollback or env change, which writes the platform override into the release: read_only: true and a tmpfs at /tmp for each listed service. On a strict host (GET /api/apps/targets → hardening: strict) every service also runs with no-new-privileges, and the Compose file must pass the strict allowlist at dry run and on the host.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "hardening": {
                    "type": "object",
                    "properties": {
                      "readOnlyRootfs": {
                        "type": "array",
                        "maxItems": 16,
                        "items": {
                          "type": "string",
                          "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
                        }
                      }
                    }
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dry run {plan: {current, desired, hostHardening, appliesAt}} or stored (dryRun:false) with the updated app."
          },
          "400": {
            "description": "QUERY_INVALID (unknown field or an invalid service name)."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/terms": {
      "get": {
        "summary": "The current publisher terms and whether you accepted them",
        "description": "With APP_TERMS_REQUIRED=1, creating an app, a live deploy and reserving an isolated host (its dry-run quote included) need the current version accepted (TERMS_REQUIRED otherwise); deploy dry runs do not. Accept on the Apps page while signed in: POST /terms/accept is session-only.",
        "responses": {
          "200": {
            "description": "{terms: {version, title, text[], required, accepted, acceptedAt}}"
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/terms/accept": {
      "post": {
        "summary": "Accept the current publisher terms",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "version"
                ],
                "properties": {
                  "version": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Recorded (idempotent)."
          },
          "403": {
            "description": "SESSION_REQUIRED: a scoped API token cannot accept the terms. TERMS_REQUIRED: the version is not the current one."
          }
        },
        "x-required-scope": "session"
      }
    },
    "/api/app-abuse-reports": {
      "post": {
        "summary": "Report an app (public, no account)",
        "security": [],
        "description": "Anyone can report an app address. The report is matched to the app that holds the hostname and queued for an administrator, who can take the app down. The response never says which app matched. Rate-limited per network address.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url",
                  "category",
                  "description"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri"
                  },
                  "category": {
                    "type": "string",
                    "enum": [
                      "malware",
                      "phishing",
                      "spam",
                      "illegal",
                      "copyright",
                      "harassment",
                      "other"
                    ]
                  },
                  "description": {
                    "type": "string",
                    "minLength": 10,
                    "maxLength": 4000
                  },
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "{received: true, reference: 'AR-<n>'}"
          },
          "400": {
            "description": "REPORT_INVALID with fields."
          },
          "429": {
            "description": "RATE_LIMITED."
          }
        },
        "x-required-scope": "public"
      }
    },
    "/api/apps/{id}/access-links": {
      "get": {
        "summary": "List a private app's access links",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "{access, links: [Link], limit}",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "links": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "label": {
                            "type": "string"
                          },
                          "state": {
                            "type": "string",
                            "enum": [
                              "active",
                              "expired",
                              "revoked"
                            ]
                          },
                          "expiresAt": {
                            "type": "string",
                            "nullable": true
                          },
                          "createdAt": {
                            "type": "string"
                          },
                          "revokedAt": {
                            "type": "string",
                            "nullable": true
                          },
                          "url": {
                            "type": "string",
                            "description": "Only while active."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-required-scope": "apps:read"
      },
      "post": {
        "summary": "Create a per-recipient access link (private apps)",
        "description": "The link opens the app without its password for one recipient until it is revoked or expires. The response carries the URL and an access-links operation that re-applies routing; the link works once the operation succeeds. Browsers that are not signed in see a login page instead of a Basic prompt (API clients keep the Basic challenge), and once signed in, the request's own Authorization header reaches the app.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "label"
                ],
                "properties": {
                  "label": {
                    "type": "string",
                    "maxLength": 80
                  },
                  "expiresAt": {
                    "type": "string",
                    "description": "ISO date with timezone, within a year."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "{link: Link (with url), operation}"
          },
          "400": {
            "description": "POLICY_INVALID (label, expiresAt, access: public apps have no links, links: 50 active)."
          },
          "409": {
            "description": "OPERATION_BUSY, APP_TAKEN_DOWN, HOST_NOT_APPROVED or LIVE_DISABLED."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/access-links/{linkId}": {
      "delete": {
        "summary": "Revoke an access link",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "linkId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "{link (revoked), operation}"
          },
          "404": {
            "description": "NOT_FOUND."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/backup-config": {
      "patch": {
        "summary": "Declare how the app's data is backed up (dry run first)",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "backup"
                ],
                "properties": {
                  "backup": {
                    "type": "object",
                    "properties": {
                      "kind": {
                        "type": "string",
                        "enum": [
                          "command",
                          "volumes"
                        ]
                      },
                      "service": {
                        "type": "string"
                      },
                      "cmd": {
                        "type": "string"
                      },
                      "restoreCmd": {
                        "type": "string"
                      },
                      "volumes": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      },
                      "keep": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 10
                      },
                      "schedule": {
                        "type": "string",
                        "enum": [
                          "none",
                          "daily"
                        ]
                      },
                      "beforeDeploy": {
                        "type": "boolean"
                      }
                    },
                    "nullable": true
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dry run {current, desired, hostCommand} or stored {config}."
          },
          "400": {
            "description": "QUERY_INVALID with the fields."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/backups": {
      "get": {
        "summary": "Backup configuration and backups",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "{config, backups[], storage}"
          }
        },
        "x-required-scope": "apps:read"
      },
      "post": {
        "summary": "Take a backup now",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dry run with the steps."
          },
          "202": {
            "description": "Accepted backup operation."
          },
          "409": {
            "description": "BACKUP_NOT_CONFIGURED, OPERATION_BUSY, APP_TAKEN_DOWN."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/backups/{backupId}/download": {
      "get": {
        "summary": "Download the control plane's copy of a backup",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "backupId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "application/octet-stream"
          },
          "404": {
            "description": "NOT_FOUND (no control-plane copy)."
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/{id}/restore": {
      "post": {
        "summary": "Restore the app's data from a backup (dry run, then confirmed)",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "backupId"
                ],
                "properties": {
                  "backupId": {
                    "type": "string"
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  },
                  "confirm": {
                    "type": "string",
                    "description": "The app slug."
                  },
                  "review": {
                    "type": "string",
                    "description": "The versioned plan.review fingerprint from a fresh dry run; required for live restore. The accepted operation freezes the original backup recipe."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dry run {plan}."
          },
          "202": {
            "description": "Accepted restore operation."
          },
          "400": {
            "description": "RESTORE_CONFIRMATION_REQUIRED or IDEMPOTENCY_KEY_REQUIRED."
          },
          "404": {
            "description": "NOT_FOUND."
          },
          "409": {
            "description": "BACKUP_NOT_CONFIGURED, OPERATION_BUSY, APP_TAKEN_DOWN."
          }
        },
        "x-required-scope": "apps:deploy",
        "description": " Live restore requires plan.review and uses the recipe recorded when that backup was produced. Historical backups without recipe metadata remain downloadable for manual recovery."
      }
    },
    "/api/apps/host-catalog": {
      "get": {
        "summary": "Isolated host sizes, regions and prices",
        "responses": {
          "200": {
            "description": "{catalog: {pricingVersion, regions[], classes[]}}"
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/hosts": {
      "get": {
        "summary": "Your isolated hosts and spend",
        "responses": {
          "200": {
            "description": "{hosts[], spend: {capUsd, committed}}"
          }
        },
        "x-required-scope": "apps:read"
      },
      "post": {
        "summary": "Reserve an isolated host of your own (dry run first)",
        "description": "Checked against the owner's monthly cap, shared with dev instances, in the same transaction that writes the reservation. An administrator launches it; it becomes usable once approved.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "sizeClass",
                  "arch",
                  "region"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "pattern": "^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$"
                  },
                  "sizeClass": {
                    "type": "string",
                    "enum": [
                      "S",
                      "M",
                      "L",
                      "XL"
                    ]
                  },
                  "arch": {
                    "type": "string",
                    "enum": [
                      "arm64",
                      "amd64"
                    ]
                  },
                  "region": {
                    "type": "string",
                    "enum": [
                      "us-west-2",
                      "ap-southeast-1"
                    ]
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dry run {quote}."
          },
          "201": {
            "description": "Reserved {host}."
          },
          "400": {
            "description": "QUERY_INVALID with fields."
          },
          "402": {
            "description": "COST_CAP."
          },
          "403": {
            "description": "TERMS_REQUIRED."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/hosts/{hostId}": {
      "delete": {
        "summary": "Cancel a reservation or release a host",
        "parameters": [
          {
            "name": "hostId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{host} cancelled or terminating."
          },
          "404": {
            "description": "NOT_FOUND."
          },
          "409": {
            "description": "HOST_STATE_CONFLICT (apps remain)."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/spend": {
      "get": {
        "summary": "Monthly cap, committed spend and each app's share of its host",
        "responses": {
          "200": {
            "description": "{spend: {capUsd, committed, headroomUsd, hosts[], apps[]}}"
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/{id}/move": {
      "post": {
        "summary": "Move the app to another host (dry run first)",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "targetId": {
                    "type": "integer"
                  },
                  "placement": {
                    "type": "object",
                    "properties": {
                      "region": {
                        "type": "string"
                      },
                      "arch": {
                        "type": "string"
                      }
                    }
                  },
                  "stateless": {
                    "type": "boolean",
                    "default": false
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dry run {plan}."
          },
          "202": {
            "description": "{move, plan}"
          },
          "409": {
            "description": "MOVE_UNSUPPORTED, MOVE_IN_PROGRESS, BACKUP_NOT_CONFIGURED, HOST_NOT_APPROVED, QUOTA_EXCEEDED, HOST_FULL, PLACEMENT_UNAVAILABLE, APP_TAKEN_DOWN."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/moves": {
      "get": {
        "summary": "The app's moves",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "{moves[]}"
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/{id}/moves/{moveId}": {
      "get": {
        "summary": "One move",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "moveId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{move}"
          },
          "404": {
            "description": "NOT_FOUND."
          }
        },
        "x-required-scope": "apps:read"
      }
    },
    "/api/apps/{id}/moves/{moveId}/abort": {
      "post": {
        "summary": "Abort an interrupted or queued move",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "moveId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{move}"
          },
          "409": {
            "description": "HOST_STATE_CONFLICT."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/moves/{moveId}/remove-source": {
      "post": {
        "summary": "Remove a finished move's retained source now",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "moveId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{move}"
          },
          "409": {
            "description": "HOST_STATE_CONFLICT."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/settings": {
      "patch": {
        "summary": "Change deploy settings: migrate command and service, deploy hooks, Compose file path (dry run first)",
        "description": "settings: any of migrateCmd, migrateService, hooks, composeFile (null clears one). Stored now and used by the next deploy; an operation already accepted keeps the settings it was accepted with. Commands are never returned: the app shows services, timeouts and the Compose path (deploySettings). The deploy dry run refuses settings that name a service the Compose file does not define.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "settings"
                ],
                "properties": {
                  "settings": {
                    "$ref": "#/components/schemas/DeploySettings"
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dry run {plan: {current, desired, appliesAt, note}} or stored (dryRun:false) with the updated app."
          },
          "400": {
            "description": "DEPLOY_SETTINGS_INVALID (fields names the setting; detail.reason says why) or QUERY_INVALID (an unknown field)."
          },
          "404": {
            "description": "No such app."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/images": {
      "get": {
        "summary": "List the app's image archives (E1b)",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "{archives: [ImageArchive], limits: {maxMb, keep}}"
          },
          "404": {
            "description": "No such app."
          }
        },
        "x-required-scope": "apps:read"
      },
      "put": {
        "summary": "Upload a prebuilt image archive: docker save <image:tag>… | gzip (E1b)",
        "description": "The raw request body (Content-Type application/octet-stream), in one request with Content-Length and the file's SHA-256 in X-Content-SHA256. At most APP_IMAGE_MAX_MB (1,024) MB; the newest three per app are kept, plus the one the running release came from. Images must be saved by name:tag. Then deploy with {\"imageArchive\": \"<id>\"}: the host loads the images instead of building (every service with build: needs an image: tag the archive holds). The same file uploaded again returns 200 with the existing archive.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "X-Content-SHA256",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[a-f0-9]{64}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/octet-stream": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Already uploaded: {archive}"
          },
          "201": {
            "description": "{archive: ImageArchive, next}"
          },
          "400": {
            "description": "IMAGE_ARCHIVE_INVALID (detail.reason: not gzip, not a docker save archive, an untagged image, a digest mismatch, too large)."
          },
          "404": {
            "description": "No such app."
          },
          "409": {
            "description": "APP_TAKEN_DOWN."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/apps/{id}/images/{archiveId}": {
      "delete": {
        "summary": "Remove an image archive (E1b)",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "archiveId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[a-f0-9]{64}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{removed}"
          },
          "404": {
            "description": "NOT_FOUND."
          },
          "409": {
            "description": "HOST_STATE_CONFLICT: the running release was deployed from it."
          }
        },
        "x-required-scope": "apps:deploy"
      }
    },
    "/api/app-enter/{id}": {
      "get": {
        "summary": "Sign in with PitchStation: open a private app with your account (E5)",
        "description": "A browser navigation, linked from a private app's login page when its owner listed PitchStation people or teams (publication policy accounts). Without a session it sends the browser to sign in and back. It decides against the app's applied policy: the owner, a listed person or a member of a listed team is sent to https://<primary>/__ps/enter with a hand-off token valid for five minutes, which the host's policy verifier turns into a session (twelve hours at most). Anyone else sees a 'not on the list' page. Audited as account_signed_in.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "next",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 2048
            },
            "description": "A path on the app to return to (default /)."
          }
        ],
        "security": [],
        "responses": {
          "302": {
            "description": "To the app's /__ps/enter (signed in and listed), or to /login.html (no session)."
          },
          "403": {
            "description": "An HTML page: the account is not on the app's list."
          },
          "404": {
            "description": "An HTML page: the app does not offer account sign-in."
          }
        },
        "x-required-scope": "session"
      }
    },
    "/api/apps/{id}/tags": {
      "patch": {
        "summary": "Replace your app’s customer labels",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "tags"
                ],
                "additionalProperties": false,
                "properties": {
                  "tags": {
                    "type": "object",
                    "maxProperties": 20,
                    "additionalProperties": {
                      "type": "string",
                      "maxLength": 160
                    },
                    "description": "Owner labels; keys are 1–40 lowercase letters/digits/underscore/hyphen, starting with a letter. customer identifies a customer for list filtering and spend labels. Never put secrets in tags."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated owner-scoped app"
          },
          "400": {
            "description": "Invalid tags"
          },
          "404": {
            "description": "No owned app"
          }
        },
        "x-required-scope": "apps:deploy"
      }
    }
  },
  "externalDocs": {
    "description": "PitchStation Apps documentation — deploy guide, API reference, architecture",
    "url": "https://pitchstation.ai/apps-docs/"
  }
}
