{
  "openapi": "3.1.0",
  "info": {
    "title": "Suble v3 API",
    "version": "3.0.0",
    "description": "Provision and manage cloud infrastructure on Suble — order VPS instances, manage Docker containers and databases, private networks, billing and more.",
    "contact": {
      "name": "Suble",
      "url": "https://suble.io"
    }
  },
  "servers": [
    {
      "url": "https://api.v3.suble.io",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Catalog"
    },
    {
      "name": "Projects & members"
    },
    {
      "name": "Instances"
    },
    {
      "name": "Backups, restore, resize"
    },
    {
      "name": "Firewall"
    },
    {
      "name": "Private networks"
    },
    {
      "name": "Templates"
    },
    {
      "name": "Jobs (async progress)"
    },
    {
      "name": "API keys"
    },
    {
      "name": "DDoS protection"
    },
    {
      "name": "Colocation"
    },
    {
      "name": "IP transit"
    },
    {
      "name": "IP ranges"
    },
    {
      "name": "Account (SSH keys, security, limits)"
    },
    {
      "name": "Current user"
    },
    {
      "name": "Notifications"
    },
    {
      "name": "Billing & credits"
    },
    {
      "name": "Auth (email verification)"
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "API key or OAuth access token",
        "description": "Project API key (sk_proj_…) or OAuth 2.1 access token, sent as `Authorization: Bearer <token>`."
      }
    }
  },
  "paths": {
    "/plans": {
      "get": {
        "tags": [
          "Catalog"
        ],
        "summary": "List active compute plans",
        "description": "Returns every active compute plan (the Suble VPS catalog) ordered by display sort. Each plan describes its hardware resources and pricing in integer øre excluding VAT.",
        "operationId": "getplans",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "plans": [
                    {
                      "uid": "pln_bxs1",
                      "code": "bxs-1",
                      "name": "BXS 1",
                      "family": "bxs",
                      "vcpu": 1,
                      "memoryMb": 2048,
                      "diskGb": 40,
                      "networkMbps": 10000,
                      "priceMonthlyOre": 4000,
                      "priceHourlyOre": 6
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/images": {
      "get": {
        "tags": [
          "Catalog"
        ],
        "summary": "List active OS images",
        "description": "Returns every active OS image available for provisioning instances, grouped by family and ordered by version descending. Each image carries its default user, connection protocol, minimum disk requirement, and guest-agent support flag.",
        "operationId": "getimages",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "images": [
                    {
                      "uid": "img_ubuntu2404",
                      "family": "ubuntu",
                      "version": "24.04",
                      "displayName": "Ubuntu 24.04 LTS",
                      "protocol": "ssh",
                      "defaultUser": "ubuntu",
                      "minDiskGb": 10,
                      "hasGuestAgent": true
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/apps": {
      "get": {
        "tags": [
          "Catalog"
        ],
        "summary": "List one-click app catalog",
        "description": "Returns the catalog of one-click apps, deduplicated to the latest active version per slug (highest id wins). Each entry includes a derived category and the minimum resource/image requirements for installation.",
        "operationId": "getapps",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "apps": [
                    {
                      "uid": "app_coolify",
                      "slug": "coolify",
                      "version": "4.1.0",
                      "name": "Coolify",
                      "description": "Self-hostable PaaS to deploy apps and databases.",
                      "logo": "https://cdn.suble.io/apps/coolify.svg",
                      "category": "panel",
                      "requirements": {
                        "minVcpu": 2,
                        "minMemoryMb": 4096,
                        "minDiskGb": 40,
                        "image": {
                          "family": "ubuntu",
                          "version": "24.04"
                        }
                      }
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/apps/{slug}": {
      "get": {
        "tags": [
          "Catalog"
        ],
        "summary": "Get a one-click app by slug",
        "description": "Returns the latest active version of a single one-click app identified by its slug. Responds 404 when no active app matches the slug.",
        "operationId": "getapps_slug",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "App slug to look up (e.g. \"coolify\", \"wordpress\", \"postgresql\"). Matched against apps.slug where active = 1; ORDER BY id DESC LIMIT 1 returns the highest-id (newest) active row.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "app": {
                    "uid": "app_coolify",
                    "slug": "coolify",
                    "version": "4.1.0",
                    "name": "Coolify",
                    "description": "Self-hostable PaaS to deploy apps and databases.",
                    "logo": "https://cdn.suble.io/apps/coolify.svg",
                    "category": "panel",
                    "requirements": {
                      "minVcpu": 2,
                      "minMemoryMb": 4096,
                      "minDiskGb": 40,
                      "image": {
                        "family": "ubuntu",
                        "version": "24.04"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "app_not_found — No active app exists with the given slug"
          }
        }
      }
    },
    "/projects": {
      "get": {
        "tags": [
          "Projects & members"
        ],
        "summary": "List accessible projects",
        "description": "Returns every project the caller owns or is a member of. Staff callers see all projects in the system.",
        "operationId": "getprojects",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "uid": "prj_8kd2mq4zr1ab",
                    "name": "Production",
                    "category": "business",
                    "role": "owner",
                    "instanceCount": 3,
                    "memberCount": 2,
                    "createdAt": "2026-03-14T09:21:00.000Z"
                  }
                ]
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Projects & members"
        ],
        "summary": "Create a project",
        "description": "Creates a new project owned by the authenticated caller and returns the created project entity.",
        "operationId": "postprojects",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Project display name; coerced to string and trimmed, must be non-empty."
                  },
                  "category": {
                    "type": "string",
                    "description": "Accepted per the router docstring ({ name, category? }) but ignored by the handler; the response category is always \"business\"."
                  }
                },
                "required": [
                  "name"
                ]
              },
              "example": {
                "name": "Production",
                "category": "business"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "prj_8kd2mq4zr1ab",
                  "name": "Production",
                  "category": "business",
                  "role": "owner",
                  "instanceCount": 0,
                  "memberCount": 1,
                  "createdAt": "2026-06-20T12:00:00.000Z"
                }
              }
            }
          },
          "400": {
            "description": "invalid_name — `name` is missing, empty, or whitespace after trimming"
          }
        }
      }
    },
    "/projects/{project}": {
      "get": {
        "tags": [
          "Projects & members"
        ],
        "summary": "Get a project",
        "description": "Fetches a single project by its uid, with live instance and member counts.",
        "operationId": "getprojects_project",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_ prefix).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "prj_8kd2mq4zr1ab",
                  "name": "Production",
                  "category": "business",
                  "role": "owner",
                  "instanceCount": 3,
                  "memberCount": 2,
                  "createdAt": "2026-03-14T09:21:00.000Z"
                }
              }
            }
          },
          "404": {
            "description": "not_found — Project passes middleware but the handler read-back returns nothing (rare)"
          }
        }
      },
      "patch": {
        "tags": [
          "Projects & members"
        ],
        "summary": "Update a project",
        "description": "Renames a project. Only the name field is mutable; returns the updated project.",
        "operationId": "patchprojects_project",
        "security": [
          {
            "bearerAuth": [
              "WRITE_SETTINGS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_ prefix).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "New project name; if non-null it is coerced to string and trimmed before update. If null/omitted the project is returned unchanged."
                  }
                }
              },
              "example": {
                "name": "Production EU"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "prj_8kd2mq4zr1ab",
                  "name": "Production EU",
                  "category": "business",
                  "role": "owner",
                  "instanceCount": 3,
                  "memberCount": 2,
                  "createdAt": "2026-03-14T09:21:00.000Z"
                }
              }
            }
          },
          "404": {
            "description": "not_found — Project row read-back returns nothing after the update"
          }
        }
      },
      "delete": {
        "tags": [
          "Projects & members"
        ],
        "summary": "Delete a project",
        "description": "Deletes a project and its member rows. Refuses if the project still has non-deleted instances.",
        "operationId": "deleteprojects_project",
        "security": [
          {
            "bearerAuth": [
              "WRITE_SETTINGS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_ prefix).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          },
          "404": {
            "description": "not_found — Project uid does not resolve to a row in the handler"
          },
          "409": {
            "description": "project_not_empty — The project still has one or more instances whose status is not 'deleted'"
          }
        }
      }
    },
    "/projects/{project}/members": {
      "get": {
        "tags": [
          "Projects & members"
        ],
        "summary": "List project members",
        "description": "Lists the project owner plus all invited members with their role and acceptance status.",
        "operationId": "getprojects_project_members",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_ prefix).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "email": "owner@acme.com",
                    "fullname": "Ada Owner",
                    "role": "owner",
                    "status": "accepted"
                  },
                  {
                    "email": "dev@acme.com",
                    "fullname": "Dev Member",
                    "role": "member",
                    "status": "accepted"
                  }
                ]
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Projects & members"
        ],
        "summary": "Invite a project member",
        "description": "Adds an existing Suble user (by email) to the project as a member with full project permissions.",
        "operationId": "postprojects_project_members",
        "security": [
          {
            "bearerAuth": [
              "WRITE_MEMBERS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_ prefix).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "description": "Email of an existing Suble user to add; coerced to string, trimmed, and lower-cased."
                  },
                  "role": {
                    "type": "string",
                    "description": "Documented in the router header ({ email, role? }) but ignored by the handler; all members receive the same MEMBER_PERMS bitmask."
                  }
                },
                "required": [
                  "email"
                ]
              },
              "example": {
                "email": "dev@acme.com",
                "role": "member"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Success"
          },
          "400": {
            "description": "invalid_email — `email` is missing or empty after trimming"
          },
          "404": {
            "description": "not_found — Project uid does not resolve in the handler"
          },
          "409": {
            "description": "already_member — That user is already a member of the project"
          }
        }
      }
    },
    "/projects/{project}/members/{email}": {
      "patch": {
        "tags": [
          "Projects & members"
        ],
        "summary": "Update a member role (no-op)",
        "description": "Accepts a member role change. In the sandbox role is advisory (members always carry full permissions), so this is a no-op.",
        "operationId": "patchprojects_project_members_email",
        "security": [
          {
            "bearerAuth": [
              "WRITE_MEMBERS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_ prefix).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "email",
            "in": "path",
            "required": true,
            "description": "Member email address (URL-encoded path segment).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          }
        }
      },
      "delete": {
        "tags": [
          "Projects & members"
        ],
        "summary": "Remove a project member",
        "description": "Removes a member from the project, matched by the member's email address.",
        "operationId": "deleteprojects_project_members_email",
        "security": [
          {
            "bearerAuth": [
              "WRITE_MEMBERS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_ prefix).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "email",
            "in": "path",
            "required": true,
            "description": "Member email address; the handler decodeURIComponent-decodes the path segment.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          }
        }
      }
    },
    "/projects/{project}/instances": {
      "get": {
        "tags": [
          "Instances"
        ],
        "summary": "List instances in a project",
        "description": "Returns all non-deleted instances in the project with their plan, primary IP, image/app, and current job progress. Joins plans, public_ips, os_images, apps, and the active job for each instance.",
        "operationId": "getprojects_project_instances",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (e.g. prj_xxxxxxxxxxxx)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "instances": [
                    {
                      "uid": "ins_a1b2c3d4e5f6",
                      "name": "web-01",
                      "status": "running",
                      "power_state": "running",
                      "backup_tier": "basic",
                      "error_code": null,
                      "error_message": null,
                      "created_at": "2026-06-18T09:12:44.000Z",
                      "plan_code": "bxs-2-4",
                      "primary_ip": "185.234.12.7",
                      "image_name": "Ubuntu 24.04 LTS",
                      "app_slug": null,
                      "app_name": null,
                      "job_uid": null,
                      "job_type": null,
                      "progress_step": null,
                      "progress_total": null,
                      "progress_percent": null,
                      "progress_message": null
                    }
                  ]
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Instances"
        ],
        "summary": "Create an instance",
        "description": "Provisions a new instance from an OS image, a project template, or an app. Validates the plan against image/app minimums, enqueues an async instance.create job, and returns 202 with the queued instance and job.",
        "operationId": "postprojects_project_instances",
        "security": [
          {
            "bearerAuth": [
              "ORDER_PRODUCT"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Hostname: lowercase RFC1123 label, regex ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$. Must be unique among active instances in the project."
                  },
                  "plan": {
                    "type": "string",
                    "description": "Plan code (e.g. bxs-2-4); must reference an active plan (active = 1)."
                  },
                  "source": {
                    "type": "object",
                    "description": "Exactly one of {image: <os_image uid>}, {template: <template uid>}, or {app: <app slug>} (zod union)."
                  },
                  "ssh_keys": {
                    "type": "array",
                    "description": "SSH public keys snapshotted onto the instance (stored in instances.ssh_keys_snapshot as JSON). Not passed in the job payload."
                  },
                  "networks": {
                    "type": "array",
                    "description": "Private network uids to attach; passed through to the create job payload (defaults to [])."
                  },
                  "backup_tier": {
                    "type": "string",
                    "description": "One of none|basic|extended. Defaults to none."
                  },
                  "password": {
                    "type": "string",
                    "description": "Root/admin password, 8-72 chars. If omitted a random password is generated and returned once via the instance detail endpoint."
                  }
                },
                "required": [
                  "name",
                  "plan",
                  "source"
                ]
              },
              "example": {
                "name": "web-01",
                "plan": "bxs-2-4",
                "source": {
                  "image": "img_ubuntu2404lts"
                },
                "ssh_keys": [
                  "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... me@host"
                ],
                "networks": [
                  "net_9f8e7d6c5b4a"
                ],
                "backup_tier": "basic"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "instance": {
                    "uid": "ins_a1b2c3d4e5f6",
                    "name": "web-01",
                    "status": "queued"
                  },
                  "job": {
                    "uid": "job_112233445566",
                    "type": "instance.create",
                    "status": "queued"
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (bad hostname, missing plan/source, password length, etc.)"
          },
          "402": {
            "description": "billing_<reason> — Project owner is not billable (non-DK needs 50 DKK credit;"
          },
          "403": {
            "description": "email_unverified — Calling user's emailVerified flag is not set"
          },
          "404": {
            "description": "plan_not_found — plan code not found / inactive"
          },
          "409": {
            "description": "name_taken — An active instance with this name already exists in the project"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}": {
      "get": {
        "tags": [
          "Instances"
        ],
        "summary": "Get instance detail",
        "description": "Returns the full instance detail (same fields as list) plus the one-time root_password, which is shown decrypted until the caller acknowledges it.",
        "operationId": "getprojects_project_instances_instance",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid (ins_...)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "instance": {
                    "uid": "ins_a1b2c3d4e5f6",
                    "name": "web-01",
                    "status": "running",
                    "power_state": "running",
                    "backup_tier": "basic",
                    "error_code": null,
                    "error_message": null,
                    "created_at": "2026-06-18T09:12:44.000Z",
                    "plan_code": "bxs-2-4",
                    "primary_ip": "185.234.12.7",
                    "image_name": "Ubuntu 24.04 LTS",
                    "app_slug": null,
                    "app_name": null,
                    "job_uid": null,
                    "job_type": null,
                    "progress_step": null,
                    "progress_total": null,
                    "progress_percent": null,
                    "progress_message": null,
                    "root_password": "a1b2c3d4e5f6!k9m2p1X"
                  }
                }
              }
            }
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          }
        }
      },
      "patch": {
        "tags": [
          "Instances"
        ],
        "summary": "Rename an instance",
        "description": "Changes an instance's hostname/name. Validates the new name and enforces per-project uniqueness among active instances.",
        "operationId": "patchprojects_project_instances_instance",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "New hostname: regex ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$, unique among active instances in the project (excluding self)."
                  }
                },
                "required": [
                  "name"
                ]
              },
              "example": {
                "name": "web-02"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "instance": {
                    "uid": "ins_a1b2c3d4e5f6",
                    "name": "web-02"
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — name fails the hostname regex"
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          },
          "409": {
            "description": "name_taken — Another active instance in the project already uses this name"
          }
        }
      },
      "delete": {
        "tags": [
          "Instances"
        ],
        "summary": "Delete an instance",
        "description": "Enqueues an async instance.delete job that destroys the VM and frees its resources, optionally taking a final backup first.",
        "operationId": "deleteprojects_project_instances_instance",
        "security": [
          {
            "bearerAuth": [
              "VPS_DELETE"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "final_backup",
            "in": "query",
            "required": false,
            "description": "Pass final_backup=true (exact string) to take a final backup before destroying the VM. Any other value is treated as false.",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "job": {
                    "uid": "job_223344556677",
                    "type": "instance.delete",
                    "status": "queued"
                  }
                }
              }
            }
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/password/acknowledge": {
      "post": {
        "tags": [
          "Instances"
        ],
        "summary": "Acknowledge root password",
        "description": "Marks the one-time root password as seen by nulling the encrypted column so it is no longer returned by the detail endpoint.",
        "operationId": "postprojects_project_instances_instance_password_acknowledge",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/actions": {
      "post": {
        "tags": [
          "Instances"
        ],
        "summary": "Run a power action",
        "description": "Enqueues a power action (start, stop, shutdown, reboot, reset) after checking the instance is in an allowed state for that action.",
        "operationId": "postprojects_project_instances_instance_actions",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "type": {
                    "type": "string",
                    "description": "One of start|stop|shutdown|reboot|reset. Preconditions: start requires status 'stopped'; stop/shutdown/reboot/reset require status 'running' or 'installing'."
                  }
                },
                "required": [
                  "type"
                ]
              },
              "example": {
                "type": "reboot"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "job": {
                    "uid": "job_334455667788",
                    "type": "instance.power",
                    "status": "queued"
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — type is not one of the allowed power actions (zod enum)"
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          },
          "409": {
            "description": "invalid_state — Instance status does not satisfy the action's precondition (POWER_PRECONDITIONS)"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/ssh-key": {
      "post": {
        "tags": [
          "Instances"
        ],
        "summary": "Authorize an SSH key on the VM",
        "description": "Adds an SSH public key to the default user's authorized_keys on the running VM via the guest agent (idempotent). Powers the `suble vm ssh` CLI flow.",
        "operationId": "postprojects_project_instances_instance_ssh_key",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "publicKey": {
                    "type": "string",
                    "description": "A single-line OpenSSH public key (ssh-ed25519/ssh-rsa/ssh-dss/ecdsa-sha2-*). Trimmed and validated against /^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-[a-z0-9-]+) [A-Za-z0-9+/=]+( \\S.*)?$/ before use."
                  }
                },
                "required": [
                  "publicKey"
                ]
              },
              "example": {
                "publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrGq... me@laptop"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "added": true,
                  "alreadyPresent": false,
                  "user": "ubuntu",
                  "ip": "185.234.12.7"
                }
              }
            }
          },
          "400": {
            "description": "invalid_key — publicKey is missing or not a well-formed single-line OpenSSH public key"
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          },
          "409": {
            "description": "invalid_state — Instance has no running VM yet (vmTarget: missing vmid or node_name)"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/containers": {
      "get": {
        "tags": [
          "Instances"
        ],
        "summary": "List Docker containers",
        "description": "Runs `docker ps -a` live on the VM via the guest agent and returns the parsed container list.",
        "operationId": "getprojects_project_instances_instance_containers",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "containers": [
                    {
                      "id": "3f9a2b1c4d5e6f70...",
                      "name": "gitea",
                      "image": "gitea/gitea:1.22",
                      "state": "running",
                      "status": "Up 3 days",
                      "ports": "0.0.0.0:3000->3000/tcp"
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          },
          "409": {
            "description": "invalid_state — Instance has no running VM yet (vmTarget: missing vmid or node_name)"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/containers/{containerId}/logs": {
      "get": {
        "tags": [
          "Instances"
        ],
        "summary": "Get Docker container logs",
        "description": "Runs `docker logs` for a container on the VM via the guest agent and returns the tail of combined stdout/stderr.",
        "operationId": "getprojects_project_instances_instance_containers_containerId_logs",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "containerId",
            "in": "path",
            "required": true,
            "description": "Container id or name; validated inside dockerLogsScript",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "tail",
            "in": "query",
            "required": false,
            "description": "Number of trailing log lines passed to dockerLogsScript. Defaults to 500 when omitted.",
            "schema": {
              "type": "number"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "logs": "2026-06-19T08:01:22.114Z Server listening on :3000\n2026-06-19T08:01:23.998Z Ready\n"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — dockerLogsScript throws (e.g."
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          },
          "409": {
            "description": "invalid_state — Instance has no running VM yet (vmTarget: missing vmid or node_name)"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/app/action": {
      "post": {
        "tags": [
          "Instances"
        ],
        "summary": "Run a Docker or managed-DB app action",
        "description": "Enqueues a mutating app action (Docker container create/start/stop/restart/remove, managed-DB create database/user/upgrade, or ingress set/remove). Validates and builds the shell script up-front so bad args fail fast with 400.",
        "operationId": "postprojects_project_instances_instance_app_action",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "action": {
                    "type": "string",
                    "description": "One of docker.start|docker.stop|docker.restart|docker.remove|docker.create|db.create_database|db.create_user|db.upgrade|ingress.set|ingress.remove (zod enum)."
                  },
                  "args": {
                    "type": "object",
                    "description": "Action arguments (zod record, defaults to {}). docker control: {id}. docker.create: {image, name?, ports?, env?, volumes?, restart?}. db.create_database: {name}. db.create_user: {username, password, database?}. db.upgrade: {}. ingress.set: {hostname, target, port}. ingress.remove: {hostname}. Shape is enforced by buildActionScript, not by zod."
                  }
                },
                "required": [
                  "action"
                ]
              },
              "example": {
                "action": "docker.create",
                "args": {
                  "image": "nginx:1.27",
                  "name": "proxy",
                  "ports": [
                    "8080:80"
                  ],
                  "restart": "unless-stopped"
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "job": {
                    "uid": "job_445566778899",
                    "type": "app.action",
                    "status": "queued"
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (unknown action enum value)"
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/resize": {
      "post": {
        "tags": [
          "Instances"
        ],
        "summary": "Resize an instance (change plan)",
        "description": "Switches the instance to a new plan (cores/RAM/disk) and enqueues an instance.resize job. Disk is grow-only — the target plan's disk must be greater than or equal to the current.",
        "operationId": "postprojects_project_instances_instance_resize",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "plan": {
                    "type": "string",
                    "description": "Target plan code (active plan). Must have disk_gb >= the current plan's disk_gb."
                  }
                },
                "required": [
                  "plan"
                ]
              },
              "example": {
                "plan": "bxs-4-8"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "job_556677889900",
                  "type": "instance.resize",
                  "status": "queued",
                  "createdAt": "2026-06-20T10:15:00.000Z"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (missing plan)"
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          },
          "409": {
            "description": "operation_in_progress — Instance status is not 'running' or 'stopped' (IDLE_FOR_OPS check, before enqueue)"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/rebuild": {
      "post": {
        "tags": [
          "Instances"
        ],
        "summary": "Rebuild an instance",
        "description": "Wipes and reinstalls the instance from a (possibly new) image or template while keeping its IP and vmid. Takes a pre-rebuild safety backup unless explicitly disabled.",
        "operationId": "postprojects_project_instances_instance_rebuild",
        "security": [
          {
            "bearerAuth": [
              "VPS_DELETE"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "source": {
                    "type": "object",
                    "description": "Exactly one of {image: <os_image uid>} or {template: <template uid>} to rebuild from (zod union)."
                  },
                  "sshKeys": {
                    "type": "array",
                    "description": "Replacement SSH public keys snapshot (written to instances.ssh_keys_snapshot only if provided)."
                  },
                  "backup": {
                    "type": "boolean",
                    "description": "Whether to take a pre-rebuild safety backup. Defaults to true; the job payload backup is set to (body.backup !== false), so only backup:false disables it."
                  }
                },
                "required": [
                  "source"
                ]
              },
              "example": {
                "source": {
                  "image": "img_debian12"
                },
                "sshKeys": [
                  "ssh-ed25519 AAAAC3... me@host"
                ],
                "backup": true
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "job_667788990011",
                  "type": "instance.rebuild",
                  "status": "queued",
                  "createdAt": "2026-06-20T10:20:00.000Z"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (missing/invalid source)"
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          },
          "409": {
            "description": "operation_in_progress — Instance status is not 'running' or 'stopped' (IDLE_FOR_OPS check, before enqueue)"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/password": {
      "post": {
        "tags": [
          "Instances"
        ],
        "summary": "Reset root/admin password",
        "description": "Sets a new root/admin password (supplied or generated), stores it encrypted, enqueues an instance.reset_password job, and returns the new password once.",
        "operationId": "postprojects_project_instances_instance_password",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "password": {
                    "type": "string",
                    "description": "New password, 8-72 chars. If omitted a random password is generated."
                  }
                }
              },
              "example": {
                "password": "S3cure-pass-2026"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "job_778899001122",
                  "type": "instance.reset_password",
                  "status": "queued",
                  "password": "a1b2c3d4e5f6!k9m2p1X",
                  "createdAt": "2026-06-20T10:25:00.000Z"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — password fails zod validation (length 8-72)"
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          },
          "409": {
            "description": "operation_in_progress — Instance status is not 'running' or 'stopped' (IDLE_FOR_OPS check, before enqueue)"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/events": {
      "get": {
        "tags": [
          "Instances"
        ],
        "summary": "List instance activity events",
        "description": "Returns the instance's lifecycle event feed (newest first) from the shared actions log, with opaque base64 cursor pagination.",
        "operationId": "getprojects_project_instances_instance_events",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Max events to return, capped at 200 (Math.min(limit,200)). Defaults to 50.",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "Opaque base64 cursor from a previous response's next_cursor; decoded to a numeric id and returns events older than it.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "events": [
                    {
                      "uid": "evt_aabbccddeeff",
                      "type": "create.requested",
                      "message": "Instance creation requested",
                      "meta": null,
                      "created_at": "2026-06-18T09:12:44.000Z",
                      "user_email": "alex@example.com"
                    }
                  ],
                  "next_cursor": "MTIz"
                }
              }
            }
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/app": {
      "get": {
        "tags": [
          "Instances"
        ],
        "summary": "Get installed app status",
        "description": "Returns the install status, progress, and manifest display info for the app installed on the instance, decrypting any secret display values for the authorized caller.",
        "operationId": "getprojects_project_instances_instance_app",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "app": {
                    "status": "installed",
                    "current_step": 7,
                    "total_steps": 7,
                    "step_name": "finalize",
                    "exit_code": 0,
                    "info": {
                      "display": [
                        {
                          "label": "Admin URL",
                          "value": "https://185.234.12.7:3000"
                        },
                        {
                          "label": "Admin password",
                          "value": "s3cr3t-generated-pw"
                        }
                      ]
                    },
                    "retry_count": 0,
                    "health_checked_at": "2026-06-19T08:10:00.000Z",
                    "started_at": "2026-06-18T09:13:00.000Z",
                    "finished_at": "2026-06-18T09:21:00.000Z",
                    "slug": "gitea",
                    "version": "1.22",
                    "name": "Gitea",
                    "logo_url": "https://assets.suble.io/apps/gitea.svg",
                    "display": "[...]"
                  }
                }
              }
            }
          },
          "404": {
            "description": "app_not_found — No instance_apps row exists for this instance"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/app/state": {
      "get": {
        "tags": [
          "Instances"
        ],
        "summary": "Get live app overview",
        "description": "Runs the installed app manifest's state probes on the VM via the guest agent and returns a live per-app overview. Returns running:false when the VM is not up.",
        "operationId": "getprojects_project_instances_instance_app_state",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "running": true,
                  "state": {
                    "version": "1.22.3",
                    "repos": 42,
                    "users": 8,
                    "service": "active"
                  }
                }
              }
            }
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/app/tables": {
      "get": {
        "tags": [
          "Instances"
        ],
        "summary": "List database tables",
        "description": "For SQL database apps, lists tables (name + on-disk size) in a given database via a least-privilege read-only system user, run live on the VM via the guest agent.",
        "operationId": "getprojects_project_instances_instance_app_tables",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "database",
            "in": "query",
            "required": true,
            "description": "Database name to enumerate. Validated as a SQL identifier inside dbListTablesScript. Defaults to empty string when omitted, which fails validation (but only after the VM-up/engine checks).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "tables": [
                    {
                      "name": "users",
                      "size": "12.50"
                    },
                    {
                      "name": "sessions",
                      "size": "3.20"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "unsupported_engine — Installed app slug/engine is not mysql, mariadb, or postgresql (SQL_TABLE_ENGINES)"
          },
          "404": {
            "description": "instance_not_found — No active instance with this uid in the project"
          },
          "502": {
            "description": "db_query_failed — The guest-agent table-listing script returned non-zero"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/app/retry": {
      "post": {
        "tags": [
          "Instances"
        ],
        "summary": "Retry a failed app install",
        "description": "Re-enqueues the app install job for an instance whose app install previously failed, resetting the app status to pending and incrementing retry_count.",
        "operationId": "postprojects_project_instances_instance_app_retry",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "job": {
                    "uid": "job_889900112233",
                    "type": "app.install",
                    "status": "queued"
                  }
                }
              }
            }
          },
          "404": {
            "description": "app_not_found — No instance_apps row exists for this instance"
          },
          "409": {
            "description": "invalid_state — The instance_apps.status is not 'failed' (only failed installs can be retried)"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/backups": {
      "get": {
        "tags": [
          "Backups, restore, resize"
        ],
        "summary": "List instance backups",
        "description": "Returns all non-deleted backups (manual snapshots and automatic-tier backups) for the given instance, newest first (ORDER BY id DESC). Each item reflects current snapshot status and size.",
        "operationId": "getprojects_project_instances_instance_backups",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid the instance belongs to (e.g. proj_...). Resolved to an internal id; must exist.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid (e.g. ins_...). Must belong to the project and not be deleted.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "uid": "bak_9fK2mQ7xR1",
                    "instanceUid": "ins_4Tb8vC2pL0",
                    "type": "manual",
                    "status": "success",
                    "sizeGb": 12,
                    "note": "before kernel upgrade",
                    "expiresAt": "2026-07-20T08:00:00.000Z",
                    "createdAt": "2026-06-20T08:00:00.000Z"
                  },
                  {
                    "uid": "bak_2aH5nP9wT4",
                    "instanceUid": "ins_4Tb8vC2pL0",
                    "type": "auto",
                    "status": "success",
                    "sizeGb": 11,
                    "createdAt": "2026-06-19T03:00:00.000Z"
                  }
                ]
              }
            }
          },
          "400": {
            "description": "project_lookup_failed — JWT path only: middleware resolves the project membership before the handler and returns 400 { error: \"Project not found.\" } when the :project uid does not resolve."
          },
          "401": {
            "description": "unauthorized — No Authorization header / bearer token, or invalid JWT / API key."
          },
          "403": {
            "description": "forbidden — API key lacks READ_VPS, member lacks READ_VPS, or caller is not authorized for the project."
          },
          "404": {
            "description": "project_not_found — Handler-level getProjectId() finds no project for the :project uid."
          }
        }
      },
      "post": {
        "tags": [
          "Backups, restore, resize"
        ],
        "summary": "Create a manual backup",
        "description": "Inserts a pending manual backup row immediately, then enqueues a backup.create job to perform the snapshot via PBS. Returns the queued job to poll, not the finished backup.",
        "operationId": "postprojects_project_instances_instance_backups",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (e.g. proj_...).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid (e.g. ins_...) to snapshot.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "note": {
                    "type": "string",
                    "description": "Optional free-text label for the backup, max 255 characters (zod: z.string().max(255).optional())."
                  }
                }
              },
              "example": {
                "note": "before kernel upgrade"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "job_7Qd3rZ8mK2",
                  "type": "backup.create",
                  "status": "queued",
                  "createdAt": "2026-06-20T08:00:00.000Z"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails the zod schema (e.g."
          },
          "401": {
            "description": "unauthorized — Missing/invalid auth token"
          },
          "403": {
            "description": "forbidden — Caller lacks VPS_CONTROL for the project"
          },
          "404": {
            "description": "project_not_found — Handler getProjectId() finds no project for the :project uid"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/backups/{backup}/restore": {
      "post": {
        "tags": [
          "Backups, restore, resize"
        ],
        "summary": "Restore instance from a backup",
        "description": "Enqueues an instance.restore job that rolls the instance back to a successful backup, claims the instance so no other operation can run concurrently, and flips the instance to status 'restoring'.",
        "operationId": "postprojects_project_instances_instance_backups_backup_restore",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (e.g. proj_...).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid (e.g. ins_...) to restore.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "backup",
            "in": "path",
            "required": true,
            "description": "Backup uid (e.g. bak_...) to restore from; must belong to the instance and have status 'success'.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "job_5Hb1tY4nQ7",
                  "type": "instance.restore",
                  "status": "queued",
                  "createdAt": "2026-06-20T08:00:00.000Z"
                }
              }
            }
          },
          "401": {
            "description": "unauthorized — Missing/invalid auth token"
          },
          "403": {
            "description": "forbidden — Caller lacks VPS_CONTROL for the project"
          },
          "404": {
            "description": "project_not_found — Project uid not found"
          },
          "409": {
            "description": "operation_in_progress — Instance status is not 'running' or 'stopped' (busy)."
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/backups/{backup}": {
      "delete": {
        "tags": [
          "Backups, restore, resize"
        ],
        "summary": "Delete a backup",
        "description": "Marks the backup row as 'deleting' and enqueues a backup.delete job to remove the underlying snapshot from PBS. Returns the queued job.",
        "operationId": "deleteprojects_project_instances_instance_backups_backup",
        "security": [
          {
            "bearerAuth": [
              "VPS_DELETE"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (e.g. proj_...).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid (e.g. ins_...) the backup belongs to.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "backup",
            "in": "path",
            "required": true,
            "description": "Backup uid (e.g. bak_...) to delete; must belong to the instance and not already have status 'deleted'.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "job_8Wc2pK6rL3",
                  "type": "backup.delete",
                  "status": "queued",
                  "createdAt": "2026-06-20T08:00:00.000Z"
                }
              }
            }
          },
          "401": {
            "description": "unauthorized — Missing/invalid auth token"
          },
          "403": {
            "description": "forbidden — Caller lacks VPS_DELETE for the project"
          },
          "404": {
            "description": "project_not_found — Project uid not found"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/backups/{backup}/template": {
      "post": {
        "tags": [
          "Backups, restore, resize"
        ],
        "summary": "Save a backup as a template",
        "description": "Creates a reusable image template from a successful backup by inserting a template row (status 'creating') and enqueuing a template.create job. The template captures the backup's PBS snapshot and size plus the source instance's disk, default user, and protocol.",
        "operationId": "postprojects_project_instances_instance_backups_backup_template",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (e.g. proj_...).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid (e.g. ins_...) that owns the backup; its plan disk_gb (as min_disk_gb), os image default_user, and protocol seed the template.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "backup",
            "in": "path",
            "required": true,
            "description": "Backup uid (e.g. bak_...) to base the template on; must belong to the instance and have status 'success'.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Display name for the new template, 1 to 64 characters (zod: z.string().min(1).max(64))."
                  }
                },
                "required": [
                  "name"
                ]
              },
              "example": {
                "name": "web-stack-base"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "job_3Rd9mT1xK8",
                  "type": "template.create",
                  "status": "queued",
                  "createdAt": "2026-06-20T08:00:00.000Z"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (name missing, empty, or longer than 64 chars)."
          },
          "401": {
            "description": "unauthorized — Missing/invalid auth token"
          },
          "403": {
            "description": "forbidden — Caller lacks VPS_CONTROL for the project"
          },
          "404": {
            "description": "project_not_found — Project uid not found"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/backup-tier": {
      "put": {
        "tags": [
          "Backups, restore, resize"
        ],
        "summary": "Set instance automatic backup tier",
        "description": "Updates the instance's automatic backup tier, which controls scheduled backup frequency/retention and associated billing. Applies synchronously to the instance row.",
        "operationId": "putprojects_project_instances_instance_backup_tier",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (e.g. proj_...).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid (e.g. ins_...) whose backup tier is being set.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "tier": {
                    "type": "string",
                    "description": "The automatic backup tier to set. One of 'none', 'basic', 'extended' (zod: z.enum(['none','basic','extended']))."
                  }
                },
                "required": [
                  "tier"
                ]
              },
              "example": {
                "tier": "extended"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Success"
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (tier missing or not one of none|basic|extended)."
          },
          "401": {
            "description": "unauthorized — Missing/invalid auth token"
          },
          "403": {
            "description": "forbidden — Caller lacks VPS_CONTROL for the project"
          },
          "404": {
            "description": "project_not_found — Project uid not found"
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/firewall": {
      "get": {
        "tags": [
          "Firewall"
        ],
        "summary": "List firewall rules",
        "description": "Returns all firewall rules for an instance, ordered by ascending position. The DB is the source of truth; this read reflects the persisted rule set, not necessarily what is live on Proxmox if a firewall.sync is still pending.",
        "operationId": "getprojects_project_instances_instance_firewall",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID (e.g. proj_…). With an API key it must equal the key's bound project (mismatch is rejected 403 before the route runs).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance UID (ins_…). Must belong to the project and have status != 'deleted'.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "uid": "fwr_8x2k9q4m1p",
                    "position": 1,
                    "direction": "in",
                    "action": "ACCEPT",
                    "proto": "tcp",
                    "source": "203.0.113.0/24",
                    "dport": "22",
                    "comment": "SSH from office",
                    "enabled": true
                  },
                  {
                    "uid": "fwr_3a7d2f9c0e",
                    "position": 2,
                    "direction": "in",
                    "action": "DROP",
                    "enabled": true
                  }
                ]
              }
            }
          },
          "401": {
            "description": "Invalid API key. — API key not found / secret hash mismatch (or for JWT callers: missing token -> 'Authentication missing1', bad token -> 'Invalid token.')."
          },
          "403": {
            "description": "API key is not valid for this project. — The :project param does not match the API key's bound project (checked in apiKeyAuth before the route)."
          },
          "404": {
            "description": "project_not_found — The :project UID does not resolve to a project (getProjectId)."
          }
        }
      },
      "post": {
        "tags": [
          "Firewall"
        ],
        "summary": "Create a firewall rule",
        "description": "Appends a new firewall rule to the instance, auto-assigning the next position (COALESCE(MAX(position),0)+1). Persists to the DB and best-effort enqueues a firewall.sync job to push the full rule set to Proxmox.",
        "operationId": "postprojects_project_instances_instance_firewall",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID. With an API key it must equal the key's bound project (mismatch -> 403).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance UID (ins_…) to attach the rule to. Must belong to the project and have status != 'deleted'.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "direction": {
                    "type": "string",
                    "description": "Traffic direction the rule matches."
                  },
                  "action": {
                    "type": "string",
                    "description": "What to do with matching traffic."
                  },
                  "proto": {
                    "type": "string",
                    "description": "Protocol, e.g. tcp, udp, icmp. Omit for any (stored NULL)."
                  },
                  "source": {
                    "type": "string",
                    "description": "Source address/CIDR/alias to match. Omit for any (stored NULL)."
                  },
                  "dest": {
                    "type": "string",
                    "description": "Destination address/CIDR/alias to match. Omit for any (stored NULL)."
                  },
                  "sport": {
                    "type": "string",
                    "description": "Source port or port range/list (Proxmox syntax). Stored NULL if omitted."
                  },
                  "dport": {
                    "type": "string",
                    "description": "Destination port or port range/list (Proxmox syntax). Stored NULL if omitted."
                  },
                  "comment": {
                    "type": "string",
                    "description": "Free-text label for the rule. Stored NULL if omitted."
                  },
                  "enabled": {
                    "type": "boolean",
                    "description": "Whether the rule is active. Defaults to true (zod .default(true))."
                  }
                },
                "required": [
                  "direction",
                  "action"
                ]
              },
              "example": {
                "direction": "in",
                "action": "ACCEPT",
                "proto": "tcp",
                "source": "203.0.113.0/24",
                "dport": "22",
                "comment": "SSH from office",
                "enabled": true
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "fwr_8x2k9q4m1p",
                  "position": 3,
                  "direction": "in",
                  "action": "ACCEPT",
                  "proto": "tcp",
                  "source": "203.0.113.0/24",
                  "dport": "22",
                  "comment": "SSH from office",
                  "enabled": true
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (bad enum value, field over max length, wrong type)."
          },
          "401": {
            "description": "Invalid API key. — Missing/invalid API key or JWT (flat {error:\"...\"} body)."
          },
          "403": {
            "description": "API key is not valid for this project. — The :project param does not match the API key's bound project."
          },
          "404": {
            "description": "project_not_found — The :project UID does not resolve (getProjectId)."
          }
        }
      }
    },
    "/projects/{project}/instances/{instance}/firewall/{rule}": {
      "patch": {
        "tags": [
          "Firewall"
        ],
        "summary": "Update a firewall rule",
        "description": "Partially updates an existing firewall rule by UID. Only provided fields are changed; position can be reassigned to reorder the rule. Persists changes and best-effort enqueues a firewall.sync job — but only when at least one field actually changes.",
        "operationId": "patchprojects_project_instances_instance_firewall_rule",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID. With an API key it must equal the key's bound project (mismatch -> 403).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance UID (ins_…). Must belong to the project and have status != 'deleted'.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "rule",
            "in": "path",
            "required": true,
            "description": "Firewall rule UID (fwr_…) to update; must belong to the instance (matched on uid AND instance_id).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "direction": {
                    "type": "string",
                    "description": "New traffic direction."
                  },
                  "action": {
                    "type": "string",
                    "description": "New action."
                  },
                  "proto": {
                    "type": "string",
                    "description": "New protocol; an empty string (falsy) clears it to NULL (any)."
                  },
                  "source": {
                    "type": "string",
                    "description": "New source; empty string clears to NULL."
                  },
                  "dest": {
                    "type": "string",
                    "description": "New destination; empty string clears to NULL."
                  },
                  "sport": {
                    "type": "string",
                    "description": "New source port spec; empty string clears to NULL."
                  },
                  "dport": {
                    "type": "string",
                    "description": "New destination port spec; empty string clears to NULL."
                  },
                  "comment": {
                    "type": "string",
                    "description": "New comment; empty string clears to NULL."
                  },
                  "enabled": {
                    "type": "boolean",
                    "description": "Enable/disable the rule (stored as 1/0)."
                  },
                  "position": {
                    "type": "integer",
                    "description": "New evaluation position; reorders the rule. Validated as z.number().int().positive()."
                  }
                }
              },
              "example": {
                "enabled": false,
                "comment": "Temporarily disabled",
                "position": 1
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "fwr_8x2k9q4m1p",
                  "position": 1,
                  "direction": "in",
                  "action": "ACCEPT",
                  "proto": "tcp",
                  "source": "203.0.113.0/24",
                  "dport": "22",
                  "comment": "Temporarily disabled",
                  "enabled": false
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (bad enum, field over max length, non-positive/non-integer position)."
          },
          "401": {
            "description": "Invalid API key. — Missing/invalid API key or JWT (flat {error:\"...\"} body)."
          },
          "403": {
            "description": "API key is not valid for this project. — The :project param does not match the API key's bound project."
          },
          "404": {
            "description": "project_not_found — The :project UID does not resolve (getProjectId)."
          }
        }
      },
      "delete": {
        "tags": [
          "Firewall"
        ],
        "summary": "Delete a firewall rule",
        "description": "Permanently removes a firewall rule from the instance by UID, then best-effort enqueues a firewall.sync job to update Proxmox.",
        "operationId": "deleteprojects_project_instances_instance_firewall_rule",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID. With an API key it must equal the key's bound project (mismatch -> 403).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance UID (ins_…). Must belong to the project and have status != 'deleted'.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "rule",
            "in": "path",
            "required": true,
            "description": "Firewall rule UID (fwr_…) to delete; must belong to the instance (matched on uid AND instance_id).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          },
          "401": {
            "description": "Invalid API key. — Missing/invalid API key or JWT (flat {error:\"...\"} body)."
          },
          "403": {
            "description": "API key is not valid for this project. — The :project param does not match the API key's bound project."
          },
          "404": {
            "description": "project_not_found — The :project UID does not resolve (getProjectId)."
          }
        }
      }
    },
    "/projects/{project}/networks": {
      "get": {
        "tags": [
          "Private networks"
        ],
        "summary": "List private networks",
        "description": "Lists all non-deleted private networks in the project, newest first. Each entry includes the deterministic /24 CIDR and a live count of attached instances.",
        "operationId": "getprojects_project_networks",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_…) that owns the networks.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "uid": "net_a1b2c3d4e5f6",
                    "projectUid": "prj_9z8y7x6w5v4u",
                    "name": "backend-lan",
                    "cidr": "10.64.3.0/24",
                    "status": "active",
                    "memberCount": 2,
                    "createdAt": "2026-06-20T10:14:52.000Z"
                  }
                ]
              }
            }
          },
          "404": {
            "description": "project_not_found — getProjectId finds no project with the given uid (route-layer error envelope with code)."
          }
        }
      },
      "post": {
        "tags": [
          "Private networks"
        ],
        "summary": "Create a private network",
        "description": "Creates a VLAN-tagged L2 private network. The server claims the lowest free VLAN tag in 2000–3999 and derives a deterministic /24 CIDR. The network is active immediately and billing starts at creation.",
        "operationId": "postprojects_project_networks",
        "security": [
          {
            "bearerAuth": [
              "ORDER_PRODUCT"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_…) to create the network in.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Display name. Zod: z.string().min(1).max(32) — 1 to 32 characters."
                  }
                },
                "required": [
                  "name"
                ]
              },
              "example": {
                "name": "backend-lan"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "net_a1b2c3d4e5f6",
                  "projectUid": "prj_9z8y7x6w5v4u",
                  "name": "backend-lan",
                  "cidr": "10.64.3.0/24",
                  "status": "active",
                  "memberCount": 0,
                  "createdAt": "2026-06-20T10:14:52.000Z"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — createSchema validation fails — name missing, empty, or longer than 32 chars."
          },
          "404": {
            "description": "project_not_found — No project exists with the given uid."
          }
        }
      }
    },
    "/projects/{project}/networks/{network}": {
      "get": {
        "tags": [
          "Private networks"
        ],
        "summary": "Get a private network",
        "description": "Fetches a single non-deleted private network by uid, including its CIDR and current member count.",
        "operationId": "getprojects_project_networks_network",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_…) that owns the network.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "network",
            "in": "path",
            "required": true,
            "description": "Network uid (net_…).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "net_a1b2c3d4e5f6",
                  "projectUid": "prj_9z8y7x6w5v4u",
                  "name": "backend-lan",
                  "cidr": "10.64.3.0/24",
                  "status": "active",
                  "memberCount": 2,
                  "createdAt": "2026-06-20T10:14:52.000Z"
                }
              }
            }
          },
          "404": {
            "description": "project_not_found — No project exists with the given uid."
          }
        }
      },
      "delete": {
        "tags": [
          "Private networks"
        ],
        "summary": "Delete a private network",
        "description": "Soft-deletes an empty private network (status='deleted'), which frees its VLAN tag and stops billing. The network must have no attached instances.",
        "operationId": "deleteprojects_project_networks_network",
        "security": [
          {
            "bearerAuth": [
              "VPS_DELETE"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_…) that owns the network.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "network",
            "in": "path",
            "required": true,
            "description": "Network uid (net_…) to delete.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          },
          "404": {
            "description": "project_not_found — No project exists with the given uid."
          },
          "409": {
            "description": "network_not_empty — The network's member_count is > 0;"
          }
        }
      }
    },
    "/projects/{project}/networks/{network}/members": {
      "get": {
        "tags": [
          "Private networks"
        ],
        "summary": "List network members",
        "description": "Lists the instances attached to a private network, with each member's allocated private IP and attachment status, ordered by attach time.",
        "operationId": "getprojects_project_networks_network_members",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_…) that owns the network.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "network",
            "in": "path",
            "required": true,
            "description": "Network uid (net_…).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "instanceUid": "ins_k3m9p2q7r5t1",
                    "instanceName": "web-01",
                    "ip": "10.64.3.10",
                    "status": "attached",
                    "attachedAt": "2026-06-20T10:20:03.000Z"
                  }
                ]
              }
            }
          },
          "404": {
            "description": "project_not_found — No project exists with the given uid."
          }
        }
      },
      "post": {
        "tags": [
          "Private networks"
        ],
        "summary": "Attach an instance to a network",
        "description": "Attaches an instance to the private network by allocating a private IP and hot-plugging a NIC. The work runs asynchronously: a network.attach job is enqueued and a job descriptor is returned to poll.",
        "operationId": "postprojects_project_networks_network_members",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_…) that owns the network.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "network",
            "in": "path",
            "required": true,
            "description": "Network uid (net_…) to attach the instance to.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "instanceUid": {
                    "type": "string",
                    "description": "Uid (ins_…) of the instance to attach. Zod: z.string().min(1). Must resolve to a non-deleted instance in the same project."
                  },
                  "ip": {
                    "type": "string",
                    "description": "Optional static private IPv4 to assign. Zod: z.string().ip({version:'v4'}).optional(). If omitted, the next free host from .10 to .249 in the network's /24 is chosen."
                  }
                },
                "required": [
                  "instanceUid"
                ]
              },
              "example": {
                "instanceUid": "ins_k3m9p2q7r5t1",
                "ip": "10.64.3.20"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "job_h8j4k2l6m9n3",
                  "type": "network.attach",
                  "status": "queued",
                  "createdAt": "2026-06-20T10:20:03.000Z"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — attachSchema validation fails — instanceUid empty/missing, or ip present but not a valid IPv4 address (ZodError → details.issues)."
          },
          "403": {
            "description": "unauthenticated — getUserId(res) cannot resolve a user — res.locals.user.userUID missing, or no users row for it."
          },
          "404": {
            "description": "project_not_found — No project exists with the given uid."
          },
          "409": {
            "description": "already_attached — A network_members row already exists for this network+instance."
          }
        }
      }
    },
    "/projects/{project}/networks/{network}/members/{instance}": {
      "delete": {
        "tags": [
          "Private networks"
        ],
        "summary": "Detach an instance from a network",
        "description": "Detaches an instance from the private network. Marks the member as detaching and enqueues an async network.detach job that removes the NIC; returns a job descriptor to poll.",
        "operationId": "deleteprojects_project_networks_network_members_instance",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_…) that owns the network.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "network",
            "in": "path",
            "required": true,
            "description": "Network uid (net_…).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "path",
            "required": true,
            "description": "Instance uid (ins_…) to detach from the network.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "job_p1q5r9s3t7u2",
                  "type": "network.detach",
                  "status": "queued",
                  "createdAt": "2026-06-20T10:25:41.000Z"
                }
              }
            }
          },
          "403": {
            "description": "unauthenticated — getUserId(res) cannot resolve a user — res.locals.user.userUID missing, or no users row for it (route-layer ApiError, code 'unauthenticated')."
          },
          "404": {
            "description": "project_not_found — No project exists with the given uid."
          },
          "409": {
            "description": "operation_in_progress — enqueueJob with serializeOnInstance:true cannot claim the instance because another job is already running on it."
          }
        }
      }
    },
    "/projects/{project}/templates": {
      "get": {
        "tags": [
          "Templates"
        ],
        "summary": "List project templates",
        "description": "Returns all non-deleted disk-image templates belonging to the project, newest first (ordered by internal id descending). Templates are reusable disk images created from instance backups via the backups router.",
        "operationId": "getprojects_project_templates",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (e.g. proj_...) the templates belong to.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "uid": "tpl_8sJk2Lq9",
                    "projectUid": "proj_4Xy7",
                    "name": "web-base-debian12",
                    "sourceInstanceName": "web-01",
                    "sizeGb": 20,
                    "minDiskGb": 20,
                    "monthlyPriceOre": 200,
                    "status": "ready",
                    "createdAt": "2026-06-18T09:14:02.000Z"
                  },
                  {
                    "uid": "tpl_2bQm1Zr4",
                    "projectUid": "proj_4Xy7",
                    "name": "db-snapshot",
                    "sizeGb": 40,
                    "minDiskGb": 40,
                    "monthlyPriceOre": 400,
                    "status": "ready",
                    "createdAt": "2026-06-10T11:02:55.000Z"
                  }
                ]
              }
            }
          },
          "404": {
            "description": "project_not_found — Route-level: project passes the auth middleware but getProjectId finds no projects row for the uid."
          }
        }
      }
    },
    "/projects/{project}/templates/{template}": {
      "patch": {
        "tags": [
          "Templates"
        ],
        "summary": "Rename a template",
        "description": "Updates the display name of a single template and returns the full updated, serialized template object. Only the name can be changed via this endpoint.",
        "operationId": "patchprojects_project_templates_template",
        "security": [
          {
            "bearerAuth": [
              "VPS_CONTROL"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid that owns the template.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "template",
            "in": "path",
            "required": true,
            "description": "Template uid (e.g. tpl_...) to rename.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "New template name. Validated by zod: 1-64 characters (z.string().min(1).max(64))."
                  }
                },
                "required": [
                  "name"
                ]
              },
              "example": {
                "name": "web-base-debian12-hardened"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "tpl_8sJk2Lq9",
                  "projectUid": "proj_4Xy7",
                  "name": "web-base-debian12-hardened",
                  "sourceInstanceName": "web-01",
                  "sizeGb": 20,
                  "minDiskGb": 20,
                  "monthlyPriceOre": 200,
                  "status": "ready",
                  "createdAt": "2026-06-18T09:14:02.000Z"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails the zod schema (name missing, empty, or longer than 64 chars)."
          },
          "404": {
            "description": "template_not_found — No template with that uid exists in the project, or its status is 'deleted'."
          }
        }
      },
      "delete": {
        "tags": [
          "Templates"
        ],
        "summary": "Delete a template",
        "description": "Marks the template row as 'deleting' and enqueues an asynchronous template.delete job that removes the underlying PBS snapshot. Returns the queued job descriptor to poll.",
        "operationId": "deleteprojects_project_templates_template",
        "security": [
          {
            "bearerAuth": [
              "VPS_DELETE"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid that owns the template.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "template",
            "in": "path",
            "required": true,
            "description": "Template uid (e.g. tpl_...) to delete.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "job_Qa91Lm3",
                  "type": "template.delete",
                  "status": "queued",
                  "createdAt": "2026-06-20T13:45:11.000Z"
                }
              }
            }
          },
          "404": {
            "description": "template_not_found — No template with that uid exists in the project, or its status is already 'deleted'/'deleting' filtered out (findTemplate filters status != 'deleted')."
          }
        }
      }
    },
    "/projects/{project}/jobs": {
      "get": {
        "tags": [
          "Jobs (async progress)"
        ],
        "summary": "List recent jobs for a project",
        "description": "Returns the 50 most recent jobs in the project, newest first. Optionally filter to a single instance via the `instance` query parameter. The dashboard polls this while a job is active.",
        "operationId": "getprojects_project_jobs",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID the jobs belong to. With an API key, must match the key's scoped project (mismatch returns 403). With a user JWT, the caller must be the project owner or an authorized member.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "instance",
            "in": "query",
            "required": false,
            "description": "Instance UID (ins_...) to filter jobs by. When present, the query adds `AND ins.uid = ?` so only jobs whose joined instance UID matches are returned. Coerced to a string via String(); a falsy value is treated as absent.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "jobs": [
                    {
                      "uid": "job_8x2kf0a9c1",
                      "type": "instance.create",
                      "status": "running",
                      "instance": "ins_3kf9d0a1b2",
                      "progress": {
                        "step": 2,
                        "total": 4,
                        "percent": 50,
                        "message": "Provisioning disk"
                      },
                      "error": null,
                      "created_at": "2026-06-20T09:14:02.000Z",
                      "started_at": "2026-06-20T09:14:03.000Z",
                      "finished_at": null
                    },
                    {
                      "uid": "job_7a1bd9f3e0",
                      "type": "backup.create",
                      "status": "succeeded",
                      "instance": "ins_3kf9d0a1b2",
                      "progress": null,
                      "error": null,
                      "created_at": "2026-06-20T08:02:00.000Z",
                      "started_at": "2026-06-20T08:02:01.000Z",
                      "finished_at": "2026-06-20T08:03:45.000Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Invalid API key. — Auth middleware (bare { error: \"...\" } string shape, not the v3 envelope)."
          },
          "403": {
            "description": "API key is not valid for this project. — API-key path only: the :project path param does not equal the project the API key is scoped to."
          },
          "404": {
            "description": "project_not_found — In-handler: getProjectId finds no projects row for the :project UID."
          }
        }
      }
    },
    "/projects/{project}/jobs/{job}": {
      "get": {
        "tags": [
          "Jobs (async progress)"
        ],
        "summary": "Get a single job by UID",
        "description": "Fetches one job by its UID within the project, including live progress and error detail. Used to poll an async operation (e.g. instance create, resize, backup) to completion.",
        "operationId": "getprojects_project_jobs_job",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID the job belongs to. With an API key, must match the key's scoped project; with a user JWT, the caller must own or be an authorized member of the project.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "job",
            "in": "path",
            "required": true,
            "description": "Job UID (job_...) to retrieve. Scoped to the project via `WHERE job.project_id = ? AND job.uid = ?`, so a job from another project is treated as not found.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "job": {
                    "uid": "job_8x2kf0a9c1",
                    "type": "instance.create",
                    "status": "succeeded",
                    "instance": "ins_3kf9d0a1b2",
                    "progress": {
                      "step": 4,
                      "total": 4,
                      "percent": 100,
                      "message": "Instance ready"
                    },
                    "error": null,
                    "created_at": "2026-06-20T09:14:02.000Z",
                    "started_at": "2026-06-20T09:14:03.000Z",
                    "finished_at": "2026-06-20T09:16:10.000Z"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Invalid API key. — Auth middleware (bare string shape)."
          },
          "403": {
            "description": "API key is not valid for this project. — API-key path: :project does not match the key's scoped project."
          },
          "404": {
            "description": "project_not_found — getProjectId finds no projects row for :project."
          }
        }
      }
    },
    "/projects/{project}/api-keys": {
      "get": {
        "tags": [
          "API keys"
        ],
        "summary": "List project API keys",
        "description": "Returns all non-revoked API keys for the project, ordered newest first. Secrets are never returned here; each key is shown only by its masked 20-character prefix.",
        "operationId": "getprojects_project_api_keys",
        "security": [
          {
            "bearerAuth": [
              "READ_API"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_...) the keys belong to.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "uid": "apk_a1b2c3d4e5f6",
                    "name": "CI deploy bot",
                    "prefix": "sk_proj_3f9a1c7e8b2d",
                    "permissions": 2147483647,
                    "lastUsedAt": "2026-06-19T14:22:05.000Z",
                    "createdAt": "2026-06-01T09:10:00.000Z"
                  }
                ]
              }
            }
          },
          "400": {
            "description": "— — Auth layer (Bearer JWT only): JWT valid but :project param missing"
          },
          "401": {
            "description": "— — Auth layer (middlewareV2): no Authorization header/token"
          },
          "403": {
            "description": "— — Auth layer: Bearer-JWT principal is a non-owner member without READ_API"
          },
          "404": {
            "description": "project_not_found — Handler-level: getProjectId() cannot resolve req.params.project to a projects row."
          }
        }
      },
      "post": {
        "tags": [
          "API keys"
        ],
        "summary": "Create a project API key",
        "description": "Generates a new project-scoped API key (sk_proj_...) for automation and returns the full secret exactly once. Only a sha256 hash and a 20-char prefix are persisted server-side.",
        "operationId": "postprojects_project_api_keys",
        "security": [
          {
            "bearerAuth": [
              "WRITE_API"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_...) the key is scoped to.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Human label for the key. 1-64 characters (zod min(1).max(64))."
                  },
                  "permissions": {
                    "type": "integer",
                    "description": "PermissionTypes bitmask granted to the key (non-negative integer; zod number().int().nonnegative().optional()). Omit to grant the default full-access mask 2147483647 (0x7fffffff)."
                  }
                },
                "required": [
                  "name"
                ]
              },
              "example": {
                "name": "CI deploy bot",
                "permissions": 192
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "apk_a1b2c3d4e5f6",
                  "name": "CI deploy bot",
                  "prefix": "sk_proj_3f9a1c7e8b2d",
                  "permissions": 192,
                  "key": "sk_proj_3f9a1c7e8b2d4f6a8c0e1b3d5f7a9c1e3b5d7f9a1c3e5b7d"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails the zod createSchema (missing/empty name, name longer than 64 chars, or permissions negative/non-integer)."
          },
          "401": {
            "description": "— — Auth layer: missing token, invalid JWT, or unknown/invalid sk_proj_ key."
          },
          "403": {
            "description": "— — Auth layer: principal (member or API key) lacks WRITE_API, or sk_proj_ key not valid for this project."
          },
          "404": {
            "description": "project_not_found — Handler-level getProjectId(): the :project uid does not resolve to a projects row."
          }
        }
      }
    },
    "/projects/{project}/api-keys/{key}": {
      "delete": {
        "tags": [
          "API keys"
        ],
        "summary": "Revoke a project API key",
        "description": "Revokes (soft-deletes) a single API key by setting its revoked_at timestamp. The key immediately stops authenticating requests. Revoking an already-revoked or unknown key returns 404.",
        "operationId": "deleteprojects_project_api_keys_key",
        "security": [
          {
            "bearerAuth": [
              "WRITE_API"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project uid (prj_...) that owns the key.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "API key uid (apk_...) to revoke.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          },
          "400": {
            "description": "— — Auth layer (Bearer JWT only): :project missing (\"Project ID required.\") or project not resolvable for the member (\"Project not found.\")."
          },
          "401": {
            "description": "— — Auth layer: missing token, invalid JWT, or unknown/invalid sk_proj_ key."
          },
          "403": {
            "description": "— — Auth layer: principal lacks WRITE_API, or sk_proj_ key not valid for this project."
          },
          "404": {
            "description": "api_key_not_found — UPDATE matched no rows (affectedRows = 0): no active (revoked_at IS NULL) key with that uid exists in the project — also returned if the key was already revoked or belongs to a different project."
          }
        }
      }
    },
    "/projects/{project}/ddos": {
      "get": {
        "tags": [
          "DDoS protection"
        ],
        "summary": "Get DDoS status and recent events",
        "description": "Returns the project's DDoS settings, the list of always-on-protected instance IPs and owned IP ranges with their current protection status, and active plus recent (last 30 days) attack alerts grouped by alertid. Edge scrubbing is always on; this endpoint surfaces GlobalConnect events (legacy ddos_events table, ingested via /v2/webhook/ddos) that hit the project's instance IPs or owned IP ranges.",
        "operationId": "getprojects_project_ddos",
        "security": [
          {
            "bearerAuth": [
              "READ_VPS"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID (e.g. proj_...). The :project path segment is resolved to an internal numeric id via getProjectId (SELECT id FROM projects WHERE uid = ?).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "settings": {
                    "businessEnabled": true,
                    "webhook": {
                      "url": "https://hooks.example.com/ddos",
                      "enabled": true
                    },
                    "emails": [
                      "ops@example.com",
                      "alerts@example.com"
                    ],
                    "emailsIncluded": 1,
                    "pricing": {
                      "businessMonthlyDkk": 550,
                      "webhookMonthlyDkk": 50,
                      "emailMonthlyDkk": 25
                    }
                  },
                  "protectedIps": [
                    {
                      "address": "185.221.10.42",
                      "instanceUid": "ins_8fK2a",
                      "instanceName": "web-1",
                      "status": "mitigating"
                    },
                    {
                      "address": "185.221.0.0/22",
                      "instanceUid": "",
                      "instanceName": "Colo transit prefix",
                      "status": "clear"
                    }
                  ],
                  "active": [
                    {
                      "uid": "ddos_90231",
                      "alertId": 90231,
                      "targetIp": "185.221.10.42",
                      "instanceUid": "ins_8fK2a",
                      "instanceName": "web-1",
                      "importance": "high",
                      "kind": "mitigation",
                      "active": true,
                      "peakTrafficGbps": 12.4,
                      "peakPacketsMpps": 3.1,
                      "signatures": [
                        "UDP_FLOOD",
                        "DNS_AMP"
                      ],
                      "startedAt": "2026-06-20T09:14:00.000Z",
                      "updatedAt": "2026-06-20T09:22:00.000Z",
                      "timeline": [
                        {
                          "state": "started",
                          "importance": "medium",
                          "peakTrafficGbps": 4.2,
                          "peakPacketsMpps": 1,
                          "at": "2026-06-20T09:14:00.000Z"
                        },
                        {
                          "state": "updated",
                          "importance": "high",
                          "peakTrafficGbps": 12.4,
                          "peakPacketsMpps": 3.1,
                          "at": "2026-06-20T09:22:00.000Z"
                        }
                      ]
                    }
                  ],
                  "recent": [
                    {
                      "uid": "ddos_90115",
                      "alertId": 90115,
                      "targetIp": "185.221.10.42",
                      "instanceUid": "ins_8fK2a",
                      "instanceName": "web-1",
                      "importance": "low",
                      "kind": "monitoring",
                      "active": false,
                      "peakTrafficGbps": 0.8,
                      "peakPacketsMpps": 0.2,
                      "signatures": [],
                      "startedAt": "2026-06-18T02:00:00.000Z",
                      "updatedAt": "2026-06-18T02:40:00.000Z",
                      "endedAt": "2026-06-18T02:40:00.000Z",
                      "timeline": [
                        {
                          "state": "started",
                          "importance": "low",
                          "peakTrafficGbps": 0.8,
                          "peakPacketsMpps": 0.2,
                          "at": "2026-06-18T02:00:00.000Z"
                        },
                        {
                          "state": "ended",
                          "importance": "low",
                          "peakTrafficGbps": 0,
                          "peakPacketsMpps": 0,
                          "at": "2026-06-18T02:40:00.000Z"
                        }
                      ]
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "project_not_found — API-key auth only: the auth middleware does not resolve the project for API keys, so getProjectId throws the v3 envelope when the :project UID does not resolve."
          }
        }
      }
    },
    "/projects/{project}/ddos/business": {
      "put": {
        "tags": [
          "DDoS protection"
        ],
        "summary": "Enable or disable DDoS Business addon",
        "description": "Toggles the paid 'DDoS Business' addon for the project. Enabling starts billing and stamps business_started_at; disabling stops billing (business_stopped_at) and also disables the dependent alert webhook addon.",
        "operationId": "putprojects_project_ddos_business",
        "security": [
          {
            "bearerAuth": [
              "ORDER_PRODUCT"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID. Resolved to an internal numeric id via getProjectId.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "enabled": {
                    "type": "boolean",
                    "description": "true to enable the DDoS Business addon, false to disable it. Validated by zod (z.boolean())."
                  }
                },
                "required": [
                  "enabled"
                ]
              },
              "example": {
                "enabled": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "businessEnabled": true,
                  "webhook": {
                    "url": "https://hooks.example.com/ddos",
                    "enabled": true
                  },
                  "emails": [
                    "ops@example.com"
                  ],
                  "emailsIncluded": 1,
                  "pricing": {
                    "businessMonthlyDkk": 550,
                    "webhookMonthlyDkk": 50,
                    "emailMonthlyDkk": 25
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (enabled missing or not a boolean) — thrown by .parse via asyncHandler."
          },
          "403": {
            "description": "unauthenticated — v3 envelope: the authenticated principal cannot be resolved to a numeric user id (getUserId — e.g."
          },
          "404": {
            "description": "project_not_found — API-key auth only: getProjectId fails to resolve the :project UID."
          }
        }
      }
    },
    "/projects/{project}/ddos/webhook": {
      "put": {
        "tags": [
          "DDoS protection"
        ],
        "summary": "Set or clear the alert webhook",
        "description": "Sets or clears the single HTTPS alert webhook that receives DDoS event notifications. Requires DDoS Business to be enabled before a non-null URL can be set; passing null clears the webhook.",
        "operationId": "putprojects_project_ddos_webhook",
        "security": [
          {
            "bearerAuth": [
              "ORDER_PRODUCT"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID. Resolved to an internal numeric id via getProjectId.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "Webhook URL. zod requires a valid URL of at most 512 chars, or null. Field must be present (it is nullable, not optional). A non-null URL must additionally be https:// (checked after zod). Pass null to remove the webhook."
                  }
                },
                "required": [
                  "url"
                ]
              },
              "example": {
                "url": "https://hooks.example.com/ddos"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "businessEnabled": true,
                  "webhook": {
                    "url": "https://hooks.example.com/ddos",
                    "enabled": true
                  },
                  "emails": [],
                  "emailsIncluded": 1,
                  "pricing": {
                    "businessMonthlyDkk": 550,
                    "webhookMonthlyDkk": 50,
                    "emailMonthlyDkk": 25
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (url is not a valid URL, exceeds 512 chars, or the field is missing — it is required, though nullable)."
          },
          "403": {
            "description": "unauthenticated — v3 envelope: getUserId cannot resolve the principal to a numeric user id (e.g."
          },
          "404": {
            "description": "project_not_found — API-key auth only: getProjectId fails to resolve the :project UID."
          },
          "409": {
            "description": "business_required — A non-null url is provided while DDoS Business is not enabled."
          }
        }
      }
    },
    "/projects/{project}/ddos/emails": {
      "post": {
        "tags": [
          "DDoS protection"
        ],
        "summary": "Add an email alert recipient",
        "description": "Adds an email address to the DDoS alert notification recipients for the project. Requires DDoS Business; the first recipient is included, each additional one is billed.",
        "operationId": "postprojects_project_ddos_emails",
        "security": [
          {
            "bearerAuth": [
              "ORDER_PRODUCT"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID. Resolved to an internal numeric id via getProjectId.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "description": "Recipient email address. zod requires a valid email of at most 255 chars. Stored lowercased; duplicates are ignored (INSERT IGNORE)."
                  }
                },
                "required": [
                  "email"
                ]
              },
              "example": {
                "email": "alerts@example.com"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "businessEnabled": true,
                  "webhook": {
                    "url": "https://hooks.example.com/ddos",
                    "enabled": true
                  },
                  "emails": [
                    "ops@example.com",
                    "alerts@example.com"
                  ],
                  "emailsIncluded": 1,
                  "pricing": {
                    "businessMonthlyDkk": 550,
                    "webhookMonthlyDkk": 50,
                    "emailMonthlyDkk": 25
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (email missing, not a valid email, or over 255 chars)."
          },
          "404": {
            "description": "project_not_found — API-key auth only: getProjectId fails to resolve the :project UID."
          },
          "409": {
            "description": "business_required — DDoS Business is not enabled for the project (loadSettings business_enabled is falsy)."
          }
        }
      },
      "delete": {
        "tags": [
          "DDoS protection"
        ],
        "summary": "Remove an email alert recipient",
        "description": "Removes an email address from the project's DDoS alert recipients. The address to remove is supplied in the JSON request body (lowercased before matching).",
        "operationId": "deleteprojects_project_ddos_emails",
        "security": [
          {
            "bearerAuth": [
              "ORDER_PRODUCT"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID. Resolved to an internal numeric id via getProjectId.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "description": "Recipient email to remove. zod requires a valid email of at most 255 chars. Lowercased before matching the DELETE."
                  }
                },
                "required": [
                  "email"
                ]
              },
              "example": {
                "email": "alerts@example.com"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "businessEnabled": true,
                  "webhook": {
                    "url": "https://hooks.example.com/ddos",
                    "enabled": true
                  },
                  "emails": [
                    "ops@example.com"
                  ],
                  "emailsIncluded": 1,
                  "pricing": {
                    "businessMonthlyDkk": 550,
                    "webhookMonthlyDkk": 50,
                    "emailMonthlyDkk": 25
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (email missing, not a valid email, or over 255 chars)."
          },
          "404": {
            "description": "project_not_found — API-key auth only: getProjectId fails to resolve the :project UID."
          }
        }
      }
    },
    "/projects/{project}/colocation": {
      "get": {
        "tags": [
          "Colocation"
        ],
        "summary": "Get project colocation details",
        "description": "Returns the project's colocation product including rack location, power commitment, and its smart plugs with each plug's latest power reading. Returns null when the project has no colocation product (or the legacy colocation tables do not exist on a v3-only database).",
        "operationId": "getprojects_project_colocation",
        "security": [
          {
            "bearerAuth": [
              "READ_COLOCATION"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID the colocation belongs to (e.g. prj_...). Resolved to a numeric project id via getProjectId.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "prod_8fa21c",
                  "name": "Rack A — Taastrup",
                  "datacenter": "Hørskætten 12, 2630 Taastrup",
                  "hall": "21",
                  "rackU": 1,
                  "access": "Escorted 24/7 access",
                  "powerCommitWatts": 1600,
                  "createdAt": "2025-11-03T09:14:22.000Z",
                  "smartPlugs": [
                    {
                      "uid": "plug_a1b2c3",
                      "name": "PDU-1 Left",
                      "coolingWatts": 800,
                      "currentWatts": 432
                    },
                    {
                      "uid": "plug_d4e5f6",
                      "name": "PDU-2 Right",
                      "coolingWatts": 800,
                      "currentWatts": 510
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "bad_request — User-bearer path only: requireProject set but no :project → { error: \"Project ID required.\" };"
          },
          "401": {
            "description": "unauthorized — No Authorization header / no token → { error: \"Authentication missing1\" }."
          },
          "403": {
            "description": "forbidden — API-key path: key lacks READ_COLOCATION → { error: \"API key lacks the required permission.\" };"
          },
          "404": {
            "description": "project_not_found — API-key path: getProjectId cannot resolve the :project UID."
          }
        }
      }
    },
    "/projects/{project}/colocation/usage": {
      "get": {
        "tags": [
          "Colocation"
        ],
        "summary": "Get hourly power usage for a plug",
        "description": "Returns the hourly power-usage history (watts) for a single smart plug within the project's colocation, over a selected lookback window. Returns an empty array when the project has no colocation or no matching history rows.",
        "operationId": "getprojects_project_colocation_usage",
        "security": [
          {
            "bearerAuth": [
              "READ_COLOCATION"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID the colocation belongs to (e.g. prj_...). Resolved to a numeric project id via getProjectId.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "plug",
            "in": "query",
            "required": true,
            "description": "Smart plug UID (coloPlugs.uid, e.g. plug_a1b2c3) to fetch usage for. Coerced via String(req.query.plug ?? \"\"), so if omitted it defaults to an empty string that matches no plug and yields [].",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Lookback window: one of 1h, 6h, 24h, 7d (mapped to 1/6/24/168 hours). Unknown or omitted values fall back to 24h. Not validated or echoed in the response.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "ts": "2026-06-20T08:00:00.000Z",
                    "watts": 418
                  },
                  {
                    "ts": "2026-06-20T09:00:00.000Z",
                    "watts": 437
                  },
                  {
                    "ts": "2026-06-20T10:00:00.000Z",
                    "watts": 402
                  }
                ]
              }
            }
          },
          "400": {
            "description": "bad_request — User-bearer path only: no :project with requireProject → { error: \"Project ID required.\" };"
          },
          "401": {
            "description": "unauthorized — Missing token → { error: \"Authentication missing1\" };"
          },
          "403": {
            "description": "forbidden — API-key path: lacks READ_COLOCATION → { error: \"API key lacks the required permission.\" } or key not valid for project → { error: \"API key is not valid for this project.\" }."
          },
          "404": {
            "description": "project_not_found — API-key path: getProjectId cannot resolve the :project UID."
          }
        }
      }
    },
    "/projects/{project}/transit": {
      "get": {
        "tags": [
          "IP transit"
        ],
        "summary": "Get the project IP transit",
        "description": "Returns the single IP transit configuration for the project, including the linked private network (VLAN), Suble's ASN, port speed, prefix limits, and the configured BGP sessions with their MD5 passwords. Transit is staff-provisioned in the DB (transits + transit_bgp_sessions tables); this endpoint is read-only.",
        "operationId": "getprojects_project_transit",
        "security": [
          {
            "bearerAuth": [
              "READ_COLOCATION"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID (e.g. proj_…). For project API keys the key's own project is enforced by the auth layer; for user JWTs the caller must own/staff/have READ_COLOCATION on this project.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "trn_8mK2pQ",
                  "name": "Primary IP transit",
                  "location": "dk-cph-1",
                  "asn": "AS64512",
                  "portMbps": 10000,
                  "ipv4Prefixes": 4,
                  "ipv6Prefixes": 2,
                  "createdAt": "2026-05-14T09:21:00.000Z",
                  "network": {
                    "uid": "net_3Tx9Lp",
                    "name": "transit-uplink",
                    "cidr": "10.20.0.0/24",
                    "vlanId": 412
                  },
                  "bgpSessions": [
                    {
                      "family": 4,
                      "ourIp": "185.205.0.1",
                      "theirIp": "185.205.0.2",
                      "ourAsn": "AS199545",
                      "theirAsn": "AS64512",
                      "md5Password": "s3cr3t-md5"
                    },
                    {
                      "family": 6,
                      "ourIp": "2a0e:b107::1",
                      "theirIp": "2a0e:b107::2",
                      "ourAsn": "AS199545",
                      "theirAsn": "AS64512",
                      "md5Password": "s3cr3t-md5"
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "project_not_found — The :project UID does not resolve to a project (getProjectId throws → v3 error envelope)."
          }
        }
      }
    },
    "/projects/{project}/transit/stats": {
      "get": {
        "tags": [
          "IP transit"
        ],
        "summary": "Get live transit stats",
        "description": "Returns current in/out throughput, 30-day 95th-percentile in/out, and current in/out packet rates for the project's transit, computed live from Prometheus counters on the VyOS border router (device eth4.<vlan>) for the transit's VLAN.",
        "operationId": "getprojects_project_transit_stats",
        "security": [
          {
            "bearerAuth": [
              "READ_COLOCATION"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID. For project API keys the key's own project is enforced; for user JWTs the caller must own/staff/have READ_COLOCATION on this project.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "inMbps": 842.3,
                  "outMbps": 1190.7,
                  "p95InMbps": 640,
                  "p95OutMbps": 905.2,
                  "inPps": 118540,
                  "outPps": 96231
                }
              }
            }
          },
          "404": {
            "description": "project_not_found — The :project UID does not resolve to a project (getProjectId)."
          }
        }
      }
    },
    "/projects/{project}/transit/usage": {
      "get": {
        "tags": [
          "IP transit"
        ],
        "summary": "Get transit traffic time series",
        "description": "Returns a time series of in/out throughput (Mbit/s) for the project's transit over the requested range, sampled from Prometheus query_range against the VyOS counters (device eth4.<vlan>) for the transit's VLAN.",
        "operationId": "getprojects_project_transit_usage",
        "security": [
          {
            "bearerAuth": [
              "READ_COLOCATION"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID. For project API keys the key's own project is enforced; for user JWTs the caller must own/staff/have READ_COLOCATION on this project.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "range",
            "in": "query",
            "required": false,
            "description": "Time window. One of 1h, 6h, 24h, 7d. Any other/missing value falls back to 24h. Controls both span (1h=3600s, 6h=21600s, 24h=86400s, 7d=604800s) and step (1h→60s, 6h→300s, 24h→900s, 7d→3600s).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "ts": "2026-06-19T12:00:00.000Z",
                    "inMbps": 803.4,
                    "outMbps": 1102.9
                  },
                  {
                    "ts": "2026-06-19T12:15:00.000Z",
                    "inMbps": 815.1,
                    "outMbps": 1150.3
                  },
                  {
                    "ts": "2026-06-19T12:30:00.000Z",
                    "inMbps": 790.7,
                    "outMbps": 1088
                  }
                ]
              }
            }
          },
          "404": {
            "description": "project_not_found — The :project UID does not resolve to a project (getProjectId)."
          }
        }
      }
    },
    "/projects/{project}/ip-ranges": {
      "get": {
        "tags": [
          "IP ranges"
        ],
        "summary": "List project IP ranges",
        "description": "Returns the IP prefixes (transit / colocation space) that a project owns, ordered by address family then internal id. Ranges are read-only here — they are allocated by Suble staff directly in the ip_ranges table after ownership verification — and are consumed by the DDoS attribution logic so any attack on an IP inside a range maps to this project.",
        "operationId": "getprojects_project_ip_ranges",
        "security": [
          {
            "bearerAuth": [
              "READ_COLOCATION"
            ]
          }
        ],
        "parameters": [
          {
            "name": "project",
            "in": "path",
            "required": true,
            "description": "Project UID (uid column of projects), e.g. proj_a1b2c3. Resolved to the internal numeric project id via getProjectId; an unknown uid yields 404 project_not_found (on the API-key path the auth layer first checks that the key's own project matches :project).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "uid": "ipr_7f3a9c2e1b",
                    "cidr": "185.246.88.0/24",
                    "family": 4,
                    "description": "Primary transit allocation (DK1)",
                    "source": "colocation",
                    "createdAt": "2026-02-11T09:42:13.000Z"
                  },
                  {
                    "uid": "ipr_2c8d4f6a90",
                    "cidr": "2a0e:97c0:df0::/48",
                    "family": 6,
                    "description": "IPv6 PI block",
                    "source": "transit",
                    "createdAt": "2026-03-04T14:10:55.000Z"
                  }
                ]
              }
            }
          },
          "400": {
            "description": "Project ID required. — User-JWT path: requireProject is true but the request has no :project param."
          },
          "401": {
            "description": "Authentication missing1 — No Authorization header / no Bearer token present."
          },
          "403": {
            "description": "API key lacks the required permission. — The project API key (sk_proj_…) is valid but its permissions bitmask does not include READ_COLOCATION (the key is the cap — no owner-bypass)."
          },
          "404": {
            "description": "project_not_found — The :project uid does not resolve to a project row (thrown by getProjectId via the v3 error envelope { error: { code, message, details, request_id } })."
          }
        }
      }
    },
    "/account/sshkeys": {
      "get": {
        "tags": [
          "Account (SSH keys, security, limits)"
        ],
        "summary": "List account SSH keys",
        "description": "Returns all SSH public keys belonging to the authenticated user, newest first (ordered by descending row id). v3 SSH keys are user-scoped (not project-scoped).",
        "operationId": "getaccount_sshkeys",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "ssh_keys": [
                    {
                      "uid": "key_8f2a1c9d4e",
                      "name": "laptop",
                      "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH... alex@laptop",
                      "fingerprint": "SHA256:abcd1234efgh5678ijkl9012mnop3456qrst7890uvwx",
                      "created_at": "2026-06-18T09:14:22.000Z"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "unauthenticated — Missing or invalid Authorization bearer token (middlewareV2 rejects before the handler)."
          },
          "403": {
            "description": "unauthenticated — getUserId throws 403 when res.locals.user.userUID is absent ('Authentication required') or when the UID does not resolve to a user row ('User not found')."
          }
        }
      },
      "post": {
        "tags": [
          "Account (SSH keys, security, limits)"
        ],
        "summary": "Add an SSH key",
        "description": "Adds a new OpenSSH public key to the user's account. The SHA256 fingerprint is computed server-side from the base64 key body and used to reject duplicates.",
        "operationId": "postaccount_sshkeys",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Human label for the key."
                  },
                  "public_key": {
                    "type": "string",
                    "description": "OpenSSH public key. Must match ^(ssh-(rsa|ed25519)|ecdsa-sha2-nistp(256|384|521))\\s+<base64-body>(\\s+comment)?$."
                  }
                },
                "required": [
                  "name",
                  "public_key"
                ]
              },
              "example": {
                "name": "laptop",
                "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHk... alex@laptop"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "ssh_key": {
                    "uid": "key_8f2a1c9d4e",
                    "name": "laptop",
                    "fingerprint": "SHA256:abcd1234efgh5678ijkl9012mnop3456qrst7890uvwx"
                  }
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails the zod schema (name empty or >64 chars, or public_key does not match the OpenSSH key regex — the regex issue carries the message 'invalid OpenSSH public key')."
          },
          "401": {
            "description": "unauthenticated — Missing/invalid bearer token (middlewareV2)."
          },
          "403": {
            "description": "unauthenticated — getUserId cannot resolve the authenticated user."
          },
          "409": {
            "description": "key_exists — A key with the same computed SHA256 fingerprint already exists on this account (message 'This SSH key is already added')."
          }
        }
      }
    },
    "/account/sshkeys/{key}": {
      "delete": {
        "tags": [
          "Account (SSH keys, security, limits)"
        ],
        "summary": "Delete an SSH key",
        "description": "Removes an SSH key from the user's account by its uid. Only deletes keys owned by the authenticated user.",
        "operationId": "deleteaccount_sshkeys_key",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The uid of the SSH key to delete (e.g. key_8f2a1c9d4e).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          },
          "401": {
            "description": "unauthenticated — Missing/invalid bearer token (middlewareV2)."
          },
          "403": {
            "description": "unauthenticated — getUserId cannot resolve the authenticated user."
          },
          "404": {
            "description": "key_not_found — No SSH key with that uid is owned by the authenticated user (DELETE affected zero rows)."
          }
        }
      }
    },
    "/account/profile": {
      "patch": {
        "tags": [
          "Account (SSH keys, security, limits)"
        ],
        "summary": "Update profile",
        "description": "Partially updates the authenticated user's profile (full name and/or UI language) and returns the full current profile DTO.",
        "operationId": "patchaccount_profile",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "fullname": {
                    "type": "string",
                    "description": "User's display name. Omit to leave unchanged."
                  },
                  "language": {
                    "type": "string",
                    "description": "Preferred UI language. Omit to leave unchanged."
                  }
                }
              },
              "example": {
                "fullname": "Alexander Moeller",
                "language": "en"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "usr_3b9c0a71f2",
                  "email": "alex@example.com",
                  "fullname": "Alexander Moeller",
                  "country": "DK",
                  "language": "en",
                  "emailVerified": true,
                  "mfaEnabled": false,
                  "createdAt": "2025-11-02T08:30:00.000Z"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails the zod schema (fullname empty/>128 chars, or language not 'da'/'en')."
          },
          "401": {
            "description": "unauthenticated — Missing/invalid bearer token (middlewareV2)."
          },
          "403": {
            "description": "unauthenticated — getUserId cannot resolve the authenticated user."
          },
          "404": {
            "description": "user_not_found — The user row cannot be re-read after the update."
          }
        }
      }
    },
    "/account/mfa/enable": {
      "post": {
        "tags": [
          "Account (SSH keys, security, limits)"
        ],
        "summary": "Begin TOTP enrollment",
        "description": "Starts two-factor (TOTP) enrollment: generates a secret stored encrypted but not yet enabled, and returns the base32 secret plus an otpauth:// URI for the authenticator QR code.",
        "operationId": "postaccount_mfa_enable",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "secret": "JBSWY3DPEHPK3PXP",
                  "otpauthUrl": "otpauth://totp/Suble:alex@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Suble"
                }
              }
            }
          },
          "401": {
            "description": "unauthenticated — Missing/invalid bearer token (middlewareV2)."
          },
          "403": {
            "description": "unauthenticated — getUserId cannot resolve the authenticated user."
          },
          "404": {
            "description": "user_not_found — The authenticated user id does not resolve to a user row."
          },
          "409": {
            "description": "mfa_already_enabled — Two-factor is already enabled (users.mfa_enabled is truthy);"
          }
        }
      }
    },
    "/account/mfa/confirm": {
      "post": {
        "tags": [
          "Account (SSH keys, security, limits)"
        ],
        "summary": "Confirm TOTP enrollment",
        "description": "Completes two-factor enrollment by verifying a 6-digit code against the pending encrypted secret, then enabling MFA on the account.",
        "operationId": "postaccount_mfa_confirm",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "code": {
                    "type": "object",
                    "description": "Current 6-digit TOTP code from the authenticator app."
                  }
                },
                "required": [
                  "code"
                ]
              },
              "example": {
                "code": "123456"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Success"
          },
          "400": {
            "description": "invalid_request — code does not match the /^\\d{6}$/ pattern (zod message 'Enter the 6-digit code')."
          },
          "401": {
            "description": "unauthenticated — Missing/invalid bearer token (middlewareV2)."
          },
          "403": {
            "description": "unauthenticated — getUserId cannot resolve the authenticated user."
          }
        }
      }
    },
    "/account/mfa/disable": {
      "post": {
        "tags": [
          "Account (SSH keys, security, limits)"
        ],
        "summary": "Disable TOTP",
        "description": "Disables two-factor by requiring a current 6-digit code, then clearing the stored secret and the enabled flag.",
        "operationId": "postaccount_mfa_disable",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "code": {
                    "type": "object",
                    "description": "Current 6-digit TOTP code from the authenticator app."
                  }
                },
                "required": [
                  "code"
                ]
              },
              "example": {
                "code": "123456"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Success"
          },
          "400": {
            "description": "invalid_request — code does not match the /^\\d{6}$/ pattern (zod message 'Enter the 6-digit code')."
          },
          "401": {
            "description": "unauthenticated — Missing/invalid bearer token (middlewareV2)."
          },
          "403": {
            "description": "unauthenticated — getUserId cannot resolve the authenticated user."
          }
        }
      }
    },
    "/account/limits": {
      "get": {
        "tags": [
          "Account (SSH keys, security, limits)"
        ],
        "summary": "Get account quotas and usage",
        "description": "Returns each account quota with its live usage and effective limit (per-account overrides fall back to platform defaults). Covers instances, total memory, template storage, and monthly restores.",
        "operationId": "getaccount_limits",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "key": "instances",
                    "used": 3,
                    "limit": 10
                  },
                  {
                    "key": "memoryMb",
                    "used": 12288,
                    "limit": 65536
                  },
                  {
                    "key": "templateStorageGb",
                    "used": 22,
                    "limit": 100
                  },
                  {
                    "key": "restoresPerMonth",
                    "used": 1,
                    "limit": 3
                  }
                ]
              }
            }
          },
          "401": {
            "description": "unauthenticated — Missing/invalid bearer token (middlewareV2)."
          },
          "403": {
            "description": "unauthenticated — getUserId cannot resolve the authenticated user."
          }
        }
      }
    },
    "/account/limit-requests": {
      "get": {
        "tags": [
          "Account (SSH keys, security, limits)"
        ],
        "summary": "List quota-increase requests",
        "description": "Returns the authenticated user's quota-increase requests, newest first (descending row id), with their current review status.",
        "operationId": "getaccount_limit_requests",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "uid": "req_5a1f2b3c4d",
                    "quotaKey": "instances",
                    "requested": 25,
                    "useCase": "Scaling out a CI fleet for nightly integration tests",
                    "status": "pending",
                    "createdAt": "2026-06-15T11:42:09.000Z"
                  }
                ]
              }
            }
          },
          "401": {
            "description": "unauthenticated — Missing/invalid bearer token (middlewareV2)."
          },
          "403": {
            "description": "unauthenticated — getUserId cannot resolve the authenticated user."
          }
        }
      },
      "post": {
        "tags": [
          "Account (SSH keys, security, limits)"
        ],
        "summary": "Request a quota increase",
        "description": "Submits a request to raise a specific account quota, including a justification. Created in 'pending' status for staff review.",
        "operationId": "postaccount_limit_requests",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "quotaKey": {
                    "type": "string",
                    "description": "Which quota to increase."
                  },
                  "requested": {
                    "type": "integer",
                    "description": "Desired new limit value for that quota."
                  },
                  "useCase": {
                    "type": "string",
                    "description": "Justification describing the intended use."
                  }
                },
                "required": [
                  "quotaKey",
                  "requested",
                  "useCase"
                ]
              },
              "example": {
                "quotaKey": "instances",
                "requested": 25,
                "useCase": "Scaling out a CI fleet for nightly integration tests"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "req_5a1f2b3c4d",
                  "quotaKey": "instances",
                  "requested": 25,
                  "useCase": "Scaling out a CI fleet for nightly integration tests",
                  "status": "pending",
                  "createdAt": "2026-06-15T11:42:09.123Z"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod: quotaKey not in the allowed enum, requested not a positive integer, or useCase shorter than 10 / longer than 512 chars."
          },
          "401": {
            "description": "unauthenticated — Missing/invalid bearer token (middlewareV2)."
          },
          "403": {
            "description": "unauthenticated — getUserId cannot resolve the authenticated user."
          }
        }
      }
    },
    "/me": {
      "get": {
        "tags": [
          "Current user"
        ],
        "summary": "Get the current authenticated user",
        "description": "Returns the profile of the user that owns the credential used for the request. With a project API key (sk_proj_...) it resolves to the key owner; with a user JWT it resolves to the token subject. Reads the shared (v2-owned) users table.",
        "operationId": "getme",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "uid": "usr_8x2k9d",
                  "email": "dev@example.com",
                  "fullname": "Ada Lovelace",
                  "country": "DK",
                  "language": "da",
                  "emailVerified": true,
                  "mfaEnabled": false,
                  "role": "user",
                  "createdAt": "2026-01-14T09:22:31.000Z"
                }
              }
            }
          },
          "403": {
            "description": "unauthenticated — Auth passed but no userUID could be resolved from res.locals.user (message: \"Authentication required\")."
          },
          "404": {
            "description": "user_not_found — No row exists in the users table for the resolved userUID (message: \"User not found\")."
          }
        }
      }
    },
    "/notifications": {
      "get": {
        "tags": [
          "Notifications"
        ],
        "summary": "List account notifications",
        "description": "Returns the authenticated account's notifications (newest first), populated by other subsystems such as billing and background jobs. Capped at the 100 most recent.",
        "operationId": "getnotifications",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "uid": "ntf_8sK2mQ",
                    "category": "billing",
                    "title": "Invoice paid",
                    "body": "Your invoice for May 2026 (250000 ore ex. VAT) was paid.",
                    "href": "/dashboard/billing/invoices/inv_4Tn9aZ",
                    "read": false,
                    "createdAt": "2026-06-18T09:14:22.000Z"
                  },
                  {
                    "uid": "ntf_7rJ1pP",
                    "category": "job",
                    "title": "Instance ready",
                    "body": "Instance ins_2Vb8cD finished provisioning.",
                    "read": true,
                    "createdAt": "2026-06-17T11:02:05.000Z"
                  }
                ]
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token is valid but carries no userUID, or the resolved user no longer exists in the users table (thrown by getUserId)."
          }
        }
      }
    },
    "/notifications/read": {
      "post": {
        "tags": [
          "Notifications"
        ],
        "summary": "Mark specific notifications read",
        "description": "Marks the given notifications (by uid) as read for the authenticated account by setting their read_at timestamp to NOW(). Only currently-unread notifications owned by the caller are affected.",
        "operationId": "postnotifications_read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "uids": {
                    "type": "array",
                    "description": "Notification uids to mark as read. Validated by zod as z.array(z.string()).min(1), so it must be a non-empty array of strings. Uids not owned by the caller, already-read, or non-existent are silently ignored."
                  }
                },
                "required": [
                  "uids"
                ]
              },
              "example": {
                "uids": [
                  "ntf_8sK2mQ",
                  "ntf_7rJ1pP"
                ]
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Success"
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (ZodError), e.g."
          },
          "403": {
            "description": "unauthenticated — Authenticated principal has no resolvable user (no userUID, or user row not found)."
          }
        }
      }
    },
    "/notifications/read-all": {
      "post": {
        "tags": [
          "Notifications"
        ],
        "summary": "Mark all notifications read",
        "description": "Marks every currently-unread notification for the authenticated account as read by setting read_at to NOW(). Takes no request body.",
        "operationId": "postnotifications_read_all",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          },
          "403": {
            "description": "unauthenticated — Authenticated principal has no resolvable user (no userUID, or user row not found)."
          }
        }
      }
    },
    "/billing/overview": {
      "get": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Get billing overview snapshot",
        "description": "Returns the account's billing rail, prepaid credit balance, current burn rate, accrued usage for the month, default payment method, upcoming credit expiry, and a provisioning-readiness block that drives the instance wizard gate.",
        "operationId": "getbilling_overview",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "rail": "mobilepay",
                  "balanceOre": 125000,
                  "burnRatePerDayOre": 21600,
                  "currentPeriodAccruedOre": 48300,
                  "defaultPaymentMethodUid": "pm_a1b2c3d4e5f6",
                  "creditExpiringSoonOre": 5000,
                  "creditNextExpiryAt": "2026-07-15T00:00:00.000Z",
                  "readiness": {
                    "canProvision": true,
                    "reason": "ok",
                    "isCompany": false,
                    "country": "DK",
                    "minCreditOre": 5000
                  }
                }
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found (v3 error envelope: {error:{code,message,details,request_id}})."
          }
        }
      }
    },
    "/billing/usage": {
      "get": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "List current-month usage by resource",
        "description": "Returns per-resource usage records for the current UTC month, grouped by resource, type, SKU and project, ordered by spend descending. Optionally filtered to a single project.",
        "operationId": "getbilling_usage",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "projectUid",
            "in": "query",
            "required": false,
            "description": "Restrict usage to a single project (matches projects.uid, e.g. prj_...). Ignored unless it is a string.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "resourceUid": "ins_9f8e7d6c5b4a",
                    "resourceName": "web-1",
                    "resourceType": "instance",
                    "projectUid": "prj_1a2b3c4d5e6f",
                    "sku": "bxs-2-4",
                    "hours": 336,
                    "amountOre": 40320
                  }
                ]
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          }
        }
      }
    },
    "/billing/invoices": {
      "get": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "List invoices",
        "description": "Returns all invoices for the account, newest first, with provider, kind, billing period, status, and the øre subtotal/credit/VAT/total breakdown.",
        "operationId": "getbilling_invoices",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "uid": "inv_a1b2c3d4e5f6",
                    "provider": "dinero",
                    "kind": "monthly",
                    "periodYear": 2026,
                    "periodMonth": 5,
                    "status": "paid",
                    "subtotalOre": 48300,
                    "creditAppliedOre": 10000,
                    "vatOre": 9575,
                    "totalDueOre": 47875,
                    "createdAt": "2026-06-01T00:05:00.000Z",
                    "paidAt": "2026-06-01T00:06:12.000Z"
                  }
                ]
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          }
        }
      }
    },
    "/billing/invoices/{uid}/pdf": {
      "get": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Download invoice PDF",
        "description": "Returns the invoice as a PDF. For the DK (Dinero) rail it streams Dinero's own PDF; for the Stripe rail it 302-redirects to Stripe's hosted invoice URL; otherwise it renders a self-generated PDF from the stored lines and customer details.",
        "operationId": "getbilling_invoices_uid_pdf",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "uid",
            "in": "path",
            "required": true,
            "description": "Invoice uid (inv_...). Must belong to the authenticated user.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": "%PDF-1.7 ... (binary application/pdf body)"
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          },
          "404": {
            "description": "invoice_not_found — No invoice with that uid owned by the user."
          }
        }
      }
    },
    "/billing/credits": {
      "get": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Get credit balance and ledger",
        "description": "Returns the account's current prepaid credit balance plus the 100 most recent append-only credit ledger entries.",
        "operationId": "getbilling_credits",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "balanceOre": 125000,
                  "ledger": [
                    {
                      "uid": "crd_a1b2c3d4e5f6",
                      "type": "topup",
                      "amountOre": 100000,
                      "balanceAfterOre": 125000,
                      "note": "MobilePay top-up",
                      "createdAt": "2026-06-18T10:22:00.000Z"
                    }
                  ]
                }
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          }
        }
      }
    },
    "/billing/payment-methods": {
      "get": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "List payment methods",
        "description": "Returns the account's non-revoked payment methods. As a side effect it reconciles any pending MobilePay agreements against Vipps, activating or failing them so the dashboard can poll this route while a method is being approved.",
        "operationId": "getbilling_payment_methods",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "uid": "pm_a1b2c3d4e5f6",
                    "provider": "mobilepay",
                    "status": "active",
                    "isDefault": true,
                    "label": "MobilePay",
                    "createdAt": "2026-06-10T09:00:00.000Z"
                  }
                ]
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          }
        }
      }
    },
    "/billing/payment-methods/{method}/default": {
      "put": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Set default payment method",
        "description": "Marks the given active payment method as the account default, clearing the default flag on all others.",
        "operationId": "putbilling_payment_methods_method_default",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "method",
            "in": "path",
            "required": true,
            "description": "Payment method uid (pm_...). Must be an active method owned by the user.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          },
          "404": {
            "description": "payment_method_not_found — No active payment method with that uid owned by the user."
          }
        }
      }
    },
    "/billing/payment-methods/{method}": {
      "delete": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Remove a payment method",
        "description": "Revokes the given payment method (soft delete) and clears its default flag.",
        "operationId": "deletebilling_payment_methods_method",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "method",
            "in": "path",
            "required": true,
            "description": "Payment method uid (pm_...) owned by the user.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          },
          "404": {
            "description": "payment_method_not_found — No matching payment method with that uid owned by the user (zero rows affected)."
          }
        }
      }
    },
    "/billing/details": {
      "get": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Get invoice/customer details",
        "description": "Returns the account's saved invoice address and company details (name, email, address, country, VAT/EAN), or null if no billing_customers row exists yet.",
        "operationId": "getbilling_details",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "isCompany": false,
                  "name": "Jane Hansen",
                  "email": "jane@example.com",
                  "street": "Vestergade 12",
                  "zipCode": "8000",
                  "city": "Aarhus",
                  "country": "DK",
                  "phone": "+4512345678",
                  "vatNumber": "DK12345678",
                  "eanNumber": "5790000000000"
                }
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          }
        }
      },
      "put": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Save invoice/customer details",
        "description": "Upserts the account's invoice address and company details and re-derives the billing rail from the country (DK -> MobilePay+Dinero, otherwise Stripe), unless a manual rail override is locked.",
        "operationId": "putbilling_details",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "isCompany": {
                    "type": "boolean",
                    "description": "Whether this is a business account."
                  },
                  "name": {
                    "type": "string",
                    "description": "Legal/billing name, 1-128 chars."
                  },
                  "email": {
                    "type": "string",
                    "description": "Billing email (validated as an email)."
                  },
                  "street": {
                    "type": "string",
                    "description": "Street address, 1-160 chars."
                  },
                  "zipCode": {
                    "type": "string",
                    "description": "Postal code, 1-16 chars."
                  },
                  "city": {
                    "type": "string",
                    "description": "City, 1-96 chars."
                  },
                  "country": {
                    "type": "string",
                    "description": "ISO 3166-1 alpha-2 country code, exactly 2 chars (uppercased server-side); drives rail routing."
                  },
                  "phone": {
                    "type": "string",
                    "description": "Phone number, max 32 chars."
                  },
                  "vatNumber": {
                    "type": "string",
                    "description": "VAT registration number, max 32 chars."
                  },
                  "eanNumber": {
                    "type": "string",
                    "description": "EAN/GLN number for public-sector invoicing, max 32 chars."
                  }
                },
                "required": [
                  "isCompany",
                  "name",
                  "email",
                  "street",
                  "zipCode",
                  "city",
                  "country"
                ]
              },
              "example": {
                "isCompany": false,
                "name": "Jane Hansen",
                "email": "jane@example.com",
                "street": "Vestergade 12",
                "zipCode": "8000",
                "city": "Aarhus",
                "country": "DK",
                "phone": "+4512345678"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Success"
          },
          "400": {
            "description": "invalid_request — Body fails the zod schema (e.g."
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          }
        }
      }
    },
    "/billing/top-up": {
      "post": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Start a credit top-up",
        "description": "Initiates a one-time prepaid credit top-up. On the Stripe rail it creates a Checkout payment session; on the MobilePay rail it records a pending payment attempt and creates a Vipps ePayment. Returns a redirect URL the customer must complete.",
        "operationId": "postbilling_top_up",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "amountOre": {
                    "type": "integer",
                    "description": "Top-up amount in øre, integer, min 5000 (50 DKK), max 10000000 (100,000 DKK)."
                  }
                },
                "required": [
                  "amountOre"
                ]
              },
              "example": {
                "amountOre": 100000
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "redirectUrl": "https://checkout.stripe.com/c/pay/cs_test_...",
                  "status": "pending"
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — amountOre missing, non-integer, below 5000, or above 10000000."
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          },
          "503": {
            "description": "provider_not_configured — Stripe rail with no STRIPE_SECRET_KEY, or MobilePay rail with ePayment not configured."
          }
        }
      }
    },
    "/billing/payment-methods/stripe": {
      "post": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Start adding a Stripe card",
        "description": "Creates a Stripe Checkout setup session (get-or-create the Stripe customer first) so the user can save a card. Returns the hosted redirect URL.",
        "operationId": "postbilling_payment_methods_stripe",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "redirectUrl": "https://checkout.stripe.com/c/pay/cs_test_..."
                }
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          },
          "503": {
            "description": "provider_not_configured — STRIPE_SECRET_KEY is not set."
          }
        }
      }
    },
    "/billing/payment-methods/mobilepay": {
      "post": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Start a MobilePay agreement",
        "description": "Creates a recurring MobilePay (Vipps) agreement and records a pending payment method. Returns the approval URL the customer must open plus the new method uid.",
        "operationId": "postbilling_payment_methods_mobilepay",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "approvalUrl": "https://api.vipps.no/dwo-api-application/v1/deeplink/...",
                  "uid": "pm_a1b2c3d4e5f6"
                }
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          },
          "502": {
            "description": "mobilepay_agreement_failed — Creating the agreement at Vipps threw;"
          },
          "503": {
            "description": "provider_not_configured — Vipps/MobilePay is not configured."
          }
        }
      }
    },
    "/billing/promo": {
      "post": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Redeem a promo code",
        "description": "Atomically redeems a promo code, granting the configured credit to the account's ledger. Enforces one redemption per customer and the code's usage cap, active flag, and expiry.",
        "operationId": "postbilling_promo",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "code": {
                    "type": "string",
                    "description": "Promo code, 1-64 chars. Trimmed and uppercased before lookup."
                  }
                },
                "required": [
                  "code"
                ]
              },
              "example": {
                "code": "WELCOME50"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "creditedOre": 5000,
                  "balanceAfterOre": 130000
                }
              }
            }
          },
          "400": {
            "description": "promo_inactive — Code is not active."
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          },
          "404": {
            "description": "promo_not_found — Code does not exist."
          },
          "409": {
            "description": "promo_already_used — This user already redeemed the code (duplicate insert into promo_redemptions)."
          }
        }
      }
    },
    "/billing/alerts": {
      "get": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "List low-balance alert thresholds",
        "description": "Returns the account's configured low-balance alert thresholds and whether each is currently armed, highest threshold first.",
        "operationId": "getbilling_alerts",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": [
                  {
                    "thresholdOre": 10000,
                    "armed": true
                  },
                  {
                    "thresholdOre": 5000,
                    "armed": false
                  }
                ]
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          }
        }
      },
      "post": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Create/upsert an alert threshold",
        "description": "Adds a low-balance alert threshold for the account. Upsert: posting an existing threshold is a no-op.",
        "operationId": "postbilling_alerts",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "thresholdOre": {
                    "type": "integer",
                    "description": "Balance threshold in øre, integer, min 0, max 100000000 (1,000,000 DKK)."
                  }
                },
                "required": [
                  "thresholdOre"
                ]
              },
              "example": {
                "thresholdOre": 10000
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Success"
          },
          "400": {
            "description": "invalid_request — thresholdOre missing, non-integer, negative, or above 100000000."
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          }
        }
      }
    },
    "/billing/alerts/{thresholdOre}": {
      "delete": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Delete an alert threshold",
        "description": "Removes the low-balance alert threshold matching the given øre value.",
        "operationId": "deletebilling_alerts_thresholdOre",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "thresholdOre",
            "in": "path",
            "required": true,
            "description": "The threshold value in øre to delete (coerced from the path with Number()).",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Success"
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          }
        }
      }
    },
    "/billing/settings": {
      "get": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Get billing settings",
        "description": "Returns the account's auto-recharge configuration (enabled flag, trigger and top-up amount) and whether the suspend warning has been acknowledged.",
        "operationId": "getbilling_settings",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "autorechargeEnabled": true,
                  "autorechargeTriggerOre": 5000,
                  "autorechargeAmountOre": 100000,
                  "suspendAcknowledged": false
                }
              }
            }
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          }
        }
      },
      "put": {
        "tags": [
          "Billing & credits"
        ],
        "summary": "Update billing settings",
        "description": "Upserts the account's auto-recharge configuration and optionally records a standing suspend acknowledgment. Enabling auto-recharge requires both a trigger and an amount.",
        "operationId": "putbilling_settings",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "autorechargeEnabled": {
                    "type": "boolean",
                    "description": "Whether auto-recharge is enabled."
                  },
                  "autorechargeTriggerOre": {
                    "type": "integer",
                    "description": "Balance at/below which auto-recharge fires, in øre; 5000-10000000 or null. Required (non-null) when enabling."
                  },
                  "autorechargeAmountOre": {
                    "type": "integer",
                    "description": "Amount to recharge in øre; 5000-10000000 or null. Required (non-null) when enabling."
                  },
                  "suspendAcknowledged": {
                    "type": "boolean",
                    "description": "Set true to record a standing acknowledgment of suspend-on-empty-balance. Write-once: never cleared once set."
                  }
                },
                "required": [
                  "autorechargeEnabled"
                ]
              },
              "example": {
                "autorechargeEnabled": true,
                "autorechargeTriggerOre": 5000,
                "autorechargeAmountOre": 100000,
                "suspendAcknowledged": true
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Success"
          },
          "400": {
            "description": "autorecharge_incomplete — autorechargeEnabled is true but trigger and/or amount is missing/null."
          },
          "403": {
            "description": "unauthenticated — Token resolves but has no userUID, or the user row is not found."
          }
        }
      }
    },
    "/auth/login": {
      "post": {
        "tags": [
          "Auth (email verification)"
        ],
        "summary": "Authenticate with email and password",
        "description": "Verifies email/password and returns RS256 JWT access and refresh tokens. If the account has 2FA enabled, the first call without a code returns { mfa_required: true } and the client must re-submit with a 6-digit TOTP code.",
        "operationId": "postauth_login",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "description": "Account email; matched case-insensitively (lowercased server-side)."
                  },
                  "password": {
                    "type": "string",
                    "description": "Account password (min length 1)."
                  },
                  "code": {
                    "type": "string",
                    "description": "6-digit TOTP code, supplied on the second step only when 2FA is enabled on the account."
                  }
                },
                "required": [
                  "email",
                  "password"
                ]
              },
              "example": {
                "email": "dev@example.com",
                "password": "correct horse battery staple",
                "code": "123456"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
                  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (missing/invalid email or empty password);"
          },
          "401": {
            "description": "invalid_credentials — Email not found, the user has no password set, or the password does not match."
          }
        }
      }
    },
    "/auth/signup": {
      "post": {
        "tags": [
          "Auth (email verification)"
        ],
        "summary": "Create account, default project, and log in",
        "description": "Creates a new user (role 'user', emailVerified=0), seeds a default project ('My Project'), seeds a billing_customers row with the payment rail derived from country (DK -> MobilePay, else Stripe), sends a verification email, and returns access/refresh tokens.",
        "operationId": "postauth_signup",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "description": "New account email; must be unique, stored lowercased."
                  },
                  "password": {
                    "type": "string",
                    "description": "Password, minimum 8 characters; hashed with bcrypt (rounds 10)."
                  },
                  "fullname": {
                    "type": "string",
                    "description": "Display name, 1-120 characters."
                  },
                  "country": {
                    "type": "string",
                    "description": "2-letter country code; defaults to 'DK', uppercased server-side. Determines the billing rail and purchase-gate."
                  },
                  "isCompany": {
                    "type": "boolean",
                    "description": "Whether the account is a business; stored as is_company (0/1) on the billing_customers row (defaults to false)."
                  },
                  "language": {
                    "type": "string",
                    "description": "UI language; defaults to 'da'."
                  }
                },
                "required": [
                  "email",
                  "password",
                  "fullname"
                ]
              },
              "example": {
                "email": "dev@example.com",
                "password": "hunter2hunter2",
                "fullname": "Jane Developer",
                "country": "DK",
                "isCompany": false,
                "language": "en"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
                  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body fails zod validation (invalid email, password < 8 chars, empty/oversized fullname >120, country not exactly 2 chars, or language not 'da'/'en');"
          },
          "409": {
            "description": "email_taken — An account with the given email already exists."
          }
        }
      }
    },
    "/auth/refresh": {
      "post": {
        "tags": [
          "Auth (email verification)"
        ],
        "summary": "Exchange a refresh token for new tokens",
        "description": "Validates a refresh-type JWT and issues a fresh access/refresh token pair for the same user. The refresh token itself is the credential; no other auth header is required.",
        "operationId": "postauth_refresh",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "refresh_token": {
                    "type": "string",
                    "description": "A previously issued refresh JWT (tokenType 'refresh'); min length 1."
                  }
                },
                "required": [
                  "refresh_token"
                ]
              },
              "example": {
                "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "example": {
                  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
                  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
                }
              }
            }
          },
          "400": {
            "description": "invalid_request — Body is missing refresh_token or it is an empty string;"
          },
          "401": {
            "description": "invalid_refresh — Token fails signature/expiry verification, is not tokenType 'refresh', or carries no userUID."
          }
        }
      }
    },
    "/auth/password/forgot": {
      "post": {
        "tags": [
          "Auth (email verification)"
        ],
        "summary": "Request a password-reset email",
        "description": "If the email belongs to an account, generates a one-hour reset token and emails a reset link. Always returns 204 regardless of whether the email exists, to avoid revealing account existence.",
        "operationId": "postauth_password_forgot",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "description": "Email to send the reset link to; matched case-insensitively."
                  }
                },
                "required": [
                  "email"
                ]
              },
              "example": {
                "email": "dev@example.com"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Success"
          },
          "400": {
            "description": "invalid_request — Body is missing email or contains an invalid email;"
          }
        }
      }
    },
    "/auth/password/reset": {
      "post": {
        "tags": [
          "Auth (email verification)"
        ],
        "summary": "Set a new password using a reset token",
        "description": "Consumes a valid, unused, unexpired reset token, updates the user's password (bcrypt rounds 10), and marks the token used. The token is the credential; no session is required.",
        "operationId": "postauth_password_reset",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "Reset token from the email link; must be exactly 64 characters."
                  },
                  "password": {
                    "type": "string",
                    "description": "New password, minimum 8 characters."
                  }
                },
                "required": [
                  "token",
                  "password"
                ]
              },
              "example": {
                "token": "3f9c1a2b4d5e6f70819273645566778899aabbccddeeff00112233445566778",
                "password": "my-new-strong-password"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Success"
          },
          "400": {
            "description": "invalid_token — Token does not exist, was already used (used_at set), or has expired (expires_at <= NOW())."
          }
        }
      }
    }
  }
}