From edc19ab92e23935ad80116fdc3405fb92e6d6d55 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 15 Jul 2026 13:15:56 -0400 Subject: [PATCH 1/3] docs(openapi): update openapi schema with missing information --- create-a-container/openapi.v1.yaml | 1088 +++++++++++++++++++++++++--- 1 file changed, 998 insertions(+), 90 deletions(-) diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index e44204a6..5ee5dac5 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -23,7 +23,19 @@ info: Success: `{ "data": , "meta"?: {...} }`. Error: `{ "error": { "code": "...", "message": "...", "fields"?: {...} } }`. - version: 1.0.0 + + ## Common errors (any endpoint) + + Not repeated on every operation below: + + - `401 unauthorized` — missing/invalid session or API key (any authenticated endpoint). + - `403 forbidden` — admin-only endpoint called by a non-admin. + - `403 csrf_invalid` — missing/invalid `X-CSRF-Token` on a state-changing request under session auth. + - `409 conflict` — unique-constraint violation (`fields` names the offending columns). + - `422 validation_failed` — model-level validation failure (`fields` maps column → message). + - `429` — rate limited (repeated 4xx/5xx responses trigger a temporary block). + - `500 internal_error` — unhandled server error. + version: 1.1.0 servers: - url: /api/v1 @@ -39,6 +51,7 @@ tags: - name: Groups - name: API Keys - name: Settings + - name: Resource Requests - name: Meta components: @@ -54,6 +67,16 @@ components: XCSRFToken: description: CSRF token returned by GET /csrf-token. Required for state-changing requests under session auth. schema: { type: string } + responses: + NotFound: + description: Resource not found + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + Forbidden: + description: Forbidden + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + BadRequest: + description: 'Invalid request (code: invalid_request)' + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } schemas: Error: type: object @@ -84,13 +107,30 @@ components: gateway: { type: string } dnsForwarders: { type: string } externalIp: { type: string, nullable: true } + nodeCount: + type: integer + description: Number of nodes in the site. Present on list/show responses; absent from create/update responses. + SiteInput: + type: object + properties: + name: { type: string } + internalDomain: { type: string } + dhcpRange: { type: string } + subnetMask: { type: string } + gateway: { type: string } + dnsForwarders: { type: string } + externalIp: { type: string, nullable: true } Container: type: object properties: id: { type: integer } hostname: { type: string } owner: { type: string, description: Username of the user who created the container } - containerId: { type: string, nullable: true } + collaborators: + type: array + items: { type: string } + description: Usernames the container is shared with + containerId: { type: string, nullable: true, description: Provider container id — Proxmox VMID or Docker container id (null until provisioned) } status: { $ref: '#/components/schemas/ContainerStatus' } template: { type: string } ipv4Address: { type: string, nullable: true } @@ -101,11 +141,52 @@ components: type: object additionalProperties: { type: string } nvidiaRequested: { type: boolean } - sshPort: { type: integer, nullable: true } + sshPort: { type: integer, nullable: true, description: 'External port of the TCP service on internal port 22, if any' } + sshHost: { type: string, nullable: true, description: 'Hostname of the primary HTTP service, falling back to the site external IP' } + httpEntries: + type: array + description: One entry per HTTP service + items: + type: object + properties: + port: { type: integer } + externalUrl: { type: string, nullable: true } nodeName: { type: string, nullable: true } + nodeApiUrl: { type: string, nullable: true } services: type: array - items: { type: object } + items: { $ref: '#/components/schemas/ContainerService' } + createdAt: { type: string, format: date-time } + ContainerService: + type: object + properties: + id: { type: integer } + type: { type: string, enum: [http, transport, dns] } + internalPort: { type: integer } + httpService: + type: object + nullable: true + properties: + id: { type: integer } + externalHostname: { type: string } + externalDomainId: { type: integer } + backendProtocol: { type: string, enum: [http, https] } + authRequired: { type: boolean } + domain: { type: string, nullable: true } + transportService: + type: object + nullable: true + properties: + id: { type: integer } + protocol: { type: string, enum: [tcp, udp] } + externalPort: { type: integer } + dnsService: + type: object + nullable: true + properties: + id: { type: integer } + recordType: { type: string, enum: [SRV] } + dnsName: { type: string } ContainerStatus: type: string description: >- @@ -124,6 +205,34 @@ components: - failed - missing - unknown + ServiceInput: + type: object + required: [type, internalPort] + properties: + type: + type: string + enum: [http, https, tcp, udp, srv] + description: >- + http/https create an HTTP service (https sets backendProtocol to + https); srv creates a DNS SRV service; tcp/udp create a transport + service with an auto-assigned external port. + internalPort: { type: integer } + externalHostname: { type: string, description: Required for http/https } + externalDomainId: { type: integer, description: Required for http/https } + dnsName: { type: string, description: Required for srv } + authRequired: { type: boolean } + ServiceUpdate: + allOf: + - $ref: '#/components/schemas/ServiceInput' + - type: object + properties: + id: { type: integer, description: Existing service id. Omit to create a new service. } + deleted: { type: boolean, description: 'true deletes the service identified by `id`' } + EnvVar: + type: object + properties: + key: { type: string } + value: { type: string } Node: type: object properties: @@ -153,31 +262,163 @@ components: apiUrl: type: string nullable: true - description: Proxmox API URL or Docker host, e.g. unix:///var/run/docker.sock, tcp://host:2375, http://host:2375, https://host:2376 + description: Proxmox API URL or Docker host, e.g. unix:///var/run/docker.sock, tcp://host:2375, http://host:2375, https://host:2376. Required (and validated) when nodeType is docker. tokenId: { type: string, nullable: true } - secret: { type: string, nullable: true, format: password } + secret: + type: string + writeOnly: true + description: Proxmox API token secret. Never returned (see `hasSecret`). On update, blank/omitted keeps the existing secret. tlsVerify: { type: boolean, nullable: true } - imageStorage: { type: string } - volumeStorage: { type: string } - networkBridge: { type: string } - nvidiaAvailable: { type: boolean } + imageStorage: { type: string, default: local } + volumeStorage: { type: string, default: local-lvm } + networkBridge: { type: string, default: vmbr0 } + nvidiaAvailable: { type: boolean, default: false } Job: type: object properties: id: { type: integer } command: { type: string } - status: { type: string, enum: [pending, running, completed, failed] } + status: { type: string, enum: [pending, running, success, failure, cancelled] } createdBy: { type: string } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } - ApiKey: + JobStatusRow: type: object properties: id: { type: integer } + jobId: { type: integer } + output: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + ApiKey: + type: object + properties: + id: { type: string, format: uuid } keyPrefix: { type: string } description: { type: string, nullable: true } lastUsedAt: { type: string, format: date-time, nullable: true } createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + User: + type: object + properties: + uidNumber: { type: integer } + uid: { type: string, description: Username } + givenName: { type: string } + sn: { type: string } + cn: { type: string, description: Full name (derived from givenName + sn) } + mail: { type: string, format: email } + status: { type: string, description: 'Account status: `pending` until approved, `active` allows login' } + groups: + type: array + description: Present when group memberships are loaded + items: + type: object + properties: + gidNumber: { type: integer } + cn: { type: string } + isAdmin: { type: boolean } + isAdmin: { type: boolean } + UserInput: + type: object + properties: + uid: { type: string } + givenName: { type: string } + sn: { type: string } + mail: { type: string, format: email } + userPassword: + type: string + format: password + writeOnly: true + description: On update, blank/omitted keeps the existing password. + status: { type: string, description: Defaults to `pending` on create } + groupIds: + type: array + items: { type: integer } + description: gidNumbers. When provided, replaces the user's group memberships. + Group: + type: object + properties: + gidNumber: { type: integer } + cn: { type: string } + isAdmin: { type: boolean } + userCount: + type: integer + description: Present on list/show responses; absent from create/update responses. + ExternalDomain: + type: object + properties: + id: { type: integer } + name: { type: string } + acmeEmail: { type: string, nullable: true } + acmeDirectoryUrl: { type: string, nullable: true } + cloudflareApiEmail: { type: string, nullable: true } + siteId: { type: integer, nullable: true } + site: + type: object + nullable: true + properties: + id: { type: integer } + name: { type: string } + authServer: { type: string, nullable: true } + hasCloudflareApiKey: { type: boolean } + ExternalDomainInput: + type: object + properties: + name: { type: string } + acmeEmail: { type: string, nullable: true } + acmeDirectoryUrl: { type: string, nullable: true } + cloudflareApiEmail: { type: string, nullable: true } + cloudflareApiKey: + type: string + writeOnly: true + description: Never returned (see `hasCloudflareApiKey`). On update, blank/omitted keeps the existing key. + siteId: { type: integer, nullable: true } + authServer: { type: string, nullable: true } + Settings: + type: object + properties: + smtpUrl: { type: string } + smtpNoreplyAddress: { type: string } + defaultContainerEnvVars: + type: array + items: + type: object + properties: + key: { type: string } + value: { type: string } + description: { type: string } + netboxUrl: { type: string } + netboxToken: { type: string } + bannerMessage: { type: string, description: 'Announcement banner shown at the top of the app (supports [text](url) links); blank disables it. Also surfaced unauthenticated via GET /health as `banner`.' } + ResourceRequest: + type: object + properties: + id: { type: integer } + siteId: { type: integer } + hostname: { type: string } + username: { type: string } + resourceType: { type: string, enum: [memory, swap, cpus, rootfs] } + value: { type: integer, description: 'memory/swap in MB, rootfs in GB, cpus as a count' } + status: { type: string, enum: [pending, approved, denied] } + comment: { type: string, nullable: true } + adminComment: { type: string, nullable: true } + reviewedBy: { type: string, nullable: true, description: 'Reviewer username, or `system` for auto-approvals' } + reviewedAt: { type: string, format: date-time, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + site: + type: object + nullable: true + properties: + id: { type: integer } + name: { type: string } + CollaboratorList: + type: object + properties: + collaborators: + type: array + items: { type: string } security: - BearerAuth: [] @@ -192,22 +433,49 @@ paths: responses: '200': description: Token returned + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { csrfToken: { type: string } } } } } /health: get: tags: [Meta] summary: Health check security: [] responses: - '200': { description: OK } + '200': + description: OK + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { status: { type: string }, isDev: { type: boolean }, oidcEnabled: { type: boolean }, banner: { type: string, nullable: true, description: Admin-configured announcement banner (null when unset) } } } } } + /openapi.json: + get: + tags: [Meta] + summary: This OpenAPI specification (JSON) + security: [] + responses: + '200': { description: OpenAPI document, content: { application/json: {} } } + /openapi.yaml: + get: + tags: [Meta] + summary: This OpenAPI specification (YAML) + security: [] + responses: + '200': { description: OpenAPI document, content: { text/yaml: {} } } /session: get: tags: [Auth] summary: Current session responses: - '200': { description: Session payload } + '200': + description: Session payload + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { user: { type: string }, isAdmin: { type: boolean } } } } } '401': { description: Not authenticated, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + # POST /auth/dev (one-click dev login) exists only when NODE_ENV !== 'production' + # and is intentionally undocumented here. /auth/login: post: tags: [Auth] @@ -225,9 +493,14 @@ paths: password: { type: string, format: password } redirect: { type: string } responses: - '200': { description: Logged in } - '401': { description: Invalid credentials } - '403': { description: 'OIDC enabled (code: oidc_enabled) — use SSO instead' } + '200': + description: Logged in + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { user: { type: string }, isAdmin: { type: boolean }, redirect: { type: string } } } } } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { description: 'Invalid credentials (code: invalid_credentials)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '403': { description: 'OIDC enabled (code: oidc_enabled) — use SSO instead; or account not active (code: account_inactive)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /auth/oidc/login: get: tags: [Auth] @@ -252,10 +525,18 @@ paths: /auth/logout: post: tags: [Auth] - responses: { '200': { description: Logged out } } + summary: Log out (clears the session; returns an IdP end-session URL when OIDC is enabled) + security: [] + responses: + '200': + description: Logged out + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { loggedOut: { type: boolean }, logoutUrl: { type: string, nullable: true } } } } } /auth/register: post: tags: [Auth] + summary: Self-register (disabled when OIDC SSO is enabled) security: [] requestBody: required: true @@ -272,13 +553,27 @@ paths: userPassword: { type: string, format: password } inviteToken: { type: string } responses: - '201': { description: Account created } + '201': + description: Account created (`status` is `active` for the first user or invited users, otherwise `pending`) + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { uid: { type: string }, status: { type: string }, message: { type: string } } } } } + '400': { description: 'invalid_request, invalid_invite, or email_mismatch', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '403': { description: 'OIDC enabled (code: oidc_enabled) — self-registration disabled', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /auth/register/invite/{token}: get: tags: [Auth] + summary: Validate an invitation token security: [] parameters: [{ in: path, name: token, required: true, schema: { type: string } }] - responses: { '200': { description: Invite valid, returns email } } + responses: + '200': + description: Invite valid, returns email + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { email: { type: string } } } } } + '403': { description: 'OIDC enabled (code: oidc_enabled)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { description: 'Invalid or expired invitation (code: invalid_invite)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /auth/password-reset/request: post: tags: [Auth] @@ -292,15 +587,21 @@ paths: required: [usernameOrEmail] properties: usernameOrEmail: { type: string } - responses: { '200': { description: Generic OK (does not reveal account existence) } } + responses: + '200': { description: Generic OK (does not reveal account existence) } + '400': { $ref: '#/components/responses/BadRequest' } /auth/password-reset/{token}: get: tags: [Auth] security: [] parameters: [{ in: path, name: token, required: true, schema: { type: string } }] responses: - '200': { description: Token valid } - '404': { description: Invalid or expired } + '200': + description: Token valid, returns username + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { username: { type: string } } } } } + '404': { description: 'Invalid or expired (code: invalid_token)' } post: tags: [Auth] security: [] @@ -313,20 +614,71 @@ paths: type: object required: [password, confirmPassword] properties: - password: { type: string } + password: { type: string, description: Minimum 8 characters } confirmPassword: { type: string } - responses: { '200': { description: Password reset } } + responses: + '200': { description: Password reset } + '400': { description: 'invalid_request, mismatch, or weak_password', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { description: 'Invalid or expired (code: invalid_token)' } /sites: - get: { tags: [Sites], summary: List sites, responses: { '200': { description: Array of sites } } } + get: + tags: [Sites] + summary: List sites + responses: + '200': + description: Array of sites + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Site' } } } } post: tags: [Sites] summary: Create a site (admin) - responses: { '201': { description: Created } } + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/SiteInput' } + responses: + '201': + description: Created + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Site' } } } + '403': { $ref: '#/components/responses/Forbidden' } /sites/{id}: - get: { tags: [Sites], parameters: [{ in: path, name: id, required: true, schema: { type: integer } }], responses: { '200': { description: Site } } } - put: { tags: [Sites], parameters: [{ in: path, name: id, required: true, schema: { type: integer } }], responses: { '200': { description: Updated } } } - delete: { tags: [Sites], parameters: [{ in: path, name: id, required: true, schema: { type: integer } }], responses: { '204': { description: Deleted } } } + parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] + get: + tags: [Sites] + responses: + '200': + description: Site + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Site' } } } + '404': { $ref: '#/components/responses/NotFound' } + put: + tags: [Sites] + summary: Update a site (admin). Full update — omitted optional fields are cleared. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/SiteInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Site' } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [Sites] + summary: Delete a site (admin) + responses: + '204': { description: Deleted } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '409': { description: 'Site still has nodes (code: has_nodes)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers: get: @@ -349,8 +701,12 @@ paths: is intersected with what they may already see (own plus shared), so it can only narrow visibility. responses: - '200': { description: Array of containers } - '403': { description: 'Admin access required', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '200': + description: Array of containers + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Container' } } } } + '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } post: tags: [Containers] parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }] @@ -362,8 +718,8 @@ paths: required: [hostname, template] properties: hostname: { type: string } - template: { type: string } - customTemplate: { type: string } + template: { type: string, description: 'Docker image reference, or `custom` to use `customTemplate`' } + customTemplate: { type: string, description: Image reference used when `template` is `custom` } entrypoint: { type: string } nvidiaRequested: { type: boolean } username: @@ -375,19 +731,42 @@ paths: description: Usernames to share the new container with (must exist) environmentVars: type: array - items: { type: object, properties: { key: { type: string }, value: { type: string } } } + items: { $ref: '#/components/schemas/EnvVar' } services: type: object - additionalProperties: { type: object } + description: Keyed map of services to create (keys are arbitrary) + additionalProperties: { $ref: '#/components/schemas/ServiceInput' } responses: - '201': { description: Creation job enqueued } + '201': + description: Creation job enqueued + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + containerId: { type: integer, description: Database id of the new container } + jobId: { type: integer } + hostname: { type: string } + status: { $ref: '#/components/schemas/ContainerStatus' } + '400': { description: 'invalid_request or invalid_service', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '403': { description: 'forbidden — non-admins may not create containers for other users', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { description: 'site_not_found, or user_not_found when a collaborator does not exist', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '409': { description: 'No provisionable node available (code: no_node or no_nvidia_node)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers/new: get: tags: [Containers] summary: Form bootstrap data (domains + NVIDIA flag) parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }] - responses: { '200': { description: Bootstrap payload } } + responses: + '200': + description: Bootstrap payload + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { siteId: { type: integer }, externalDomains: { type: array, items: { type: object } }, nvidiaAvailable: { type: boolean } } } } } + '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers/metadata: get: tags: [Containers] @@ -397,14 +776,25 @@ paths: - { in: query, name: image, required: true, schema: { type: string } } responses: '200': { description: Image metadata } - '404': { description: Image not found } + '400': { $ref: '#/components/responses/BadRequest' } + '404': { description: 'Image not found (code: image_not_found)' } + '502': { description: 'Registry lookup failed (code: registry_error)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers/{id}: parameters: - { in: path, name: siteId, required: true, schema: { type: integer } } - { in: path, name: id, required: true, schema: { type: integer } } - get: { tags: [Containers], responses: { '200': { description: Container } } } + get: + tags: [Containers] + responses: + '200': + description: Container + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Container' } } } + '404': { $ref: '#/components/responses/NotFound' } put: tags: [Containers] + summary: Update services/env/entrypoint; enqueues a restart job when needed (owner/admin) requestBody: content: application/json: @@ -414,20 +804,49 @@ paths: username: type: string description: '(Admin only) Reassign ownership of this container to the specified existing user' - services: { type: object, additionalProperties: { type: object } } + services: + type: object + description: >- + Keyed map of service changes (keys are arbitrary). Entries + with `deleted: true` remove the service `id`; entries with an + `id` may toggle `authRequired`; entries without an `id` + create a new service. + additionalProperties: { $ref: '#/components/schemas/ServiceUpdate' } environmentVars: type: array - items: { type: object, properties: { key: { type: string }, value: { type: string } } } - entrypoint: { type: string } - restart: { type: boolean } + items: { $ref: '#/components/schemas/EnvVar' } + description: Full replacement set. Omitting it clears all user env vars (unless the request is restart-only). + entrypoint: { type: string, nullable: true, description: Omitting/blank clears the entrypoint (unless the request is restart-only) } + restart: { type: boolean, description: 'true forces a restart even with no config changes; alone, it performs a restart-only request' } responses: - '200': { description: Updated, optional restart job } + '200': + description: Updated, optional restart job + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + containerId: { type: integer } + jobId: { type: integer, nullable: true, description: 'Restart job id, when a restart was enqueued' } + dnsWarnings: { type: array, items: { type: string } } + message: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } '403': { description: 'forbidden — only the owner/admin may edit (collaborators have a read-only view); non-admins may not reassign ownership', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { $ref: '#/components/responses/NotFound' } delete: tags: [Containers] responses: - '200': { description: Deleted, with DNS cleanup warnings } + '200': + description: Deleted, with DNS cleanup warnings + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { deleted: { type: boolean }, dnsWarnings: { type: array, items: { type: string } } } } } } '403': { description: 'forbidden — only the owner/admin may delete (collaborators can view but not manage)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { $ref: '#/components/responses/NotFound' } + '409': { description: 'DB/Proxmox hostname mismatch — delete aborted (code: hostname_mismatch)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers/{id}/collaborators: parameters: - { in: path, name: siteId, required: true, schema: { type: integer } } @@ -435,7 +854,13 @@ paths: get: tags: [Containers] summary: List users a container is shared with (owner/collaborator/admin) - responses: { '200': { description: '{ collaborators: string[] }' } } + responses: + '200': + description: Collaborator list + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/CollaboratorList' } } } + '404': { $ref: '#/components/responses/NotFound' } post: tags: [Containers] summary: Share a container with another user (owner/admin). Idempotent — sharing with an existing collaborator is a no-op. @@ -448,7 +873,12 @@ paths: required: [username] properties: { username: { type: string } } responses: - '201': { description: '{ collaborators: string[] }' } + '201': + description: Collaborator list + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/CollaboratorList' } } } + '400': { $ref: '#/components/responses/BadRequest' } '403': { description: 'forbidden — only the owner/admin may share (collaborators can view but not manage)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '404': { description: 'user_not_found when the username does not exist', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '409': { description: 'already_owner', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } @@ -461,20 +891,41 @@ paths: - { in: path, name: id, required: true, schema: { type: integer } } - { in: path, name: username, required: true, schema: { type: string } } responses: - '200': { description: '{ collaborators: string[] }' } + '200': + description: Collaborator list + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/CollaboratorList' } } } '403': { description: 'forbidden — only the owner/admin may unshare (collaborators can view but not manage)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { description: 'Container or collaborator not found', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/nodes: parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }] - get: { tags: [Nodes], responses: { '200': { description: Array of nodes } } } + get: + tags: [Nodes] + responses: + '200': + description: Array of nodes + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Node' } } } } + '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } post: tags: [Nodes] + summary: Create a node (admin) requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/NodeInput' } - responses: { '201': { description: Created (admin) } } + responses: + '201': + description: Created + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Node' } } } + '400': { description: 'invalid_node_type, or invalid_docker_host when nodeType is docker and apiUrl is not a valid Docker host', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '403': { $ref: '#/components/responses/Forbidden' } /sites/{siteId}/nodes/import: post: tags: [Nodes] @@ -492,40 +943,94 @@ paths: username: { type: string } password: { type: string } tlsVerify: { type: boolean } - responses: { '201': { description: Imported } } + responses: + '201': + description: Imported + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { nodes: { type: array, items: { $ref: '#/components/schemas/Node' } }, importedContainerCount: { type: integer } } } } } + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + '502': { description: 'Proxmox import failed (code: import_failed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/nodes/{id}: parameters: - { in: path, name: siteId, required: true, schema: { type: integer } } - { in: path, name: id, required: true, schema: { type: integer } } - get: { tags: [Nodes], responses: { '200': { description: Node } } } + get: + tags: [Nodes] + responses: + '200': + description: Node + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Node' } } } + '404': { $ref: '#/components/responses/NotFound' } put: tags: [Nodes] + summary: Update a node (admin). Full update — omitted fields are reset to defaults/null (except `secret`, which is kept when blank/omitted, and `nodeType`, which keeps its current value). requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/NodeInput' } - responses: { '200': { description: Updated (admin) } } - delete: { tags: [Nodes], responses: { '204': { description: Deleted (admin) } } } + responses: + '200': + description: Updated + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Node' } } } + '400': { description: 'invalid_node_type, or invalid_docker_host when nodeType is docker and apiUrl is not a valid Docker host', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [Nodes] + summary: Delete a node (admin) + responses: + '204': { description: Deleted } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '409': { description: 'Node still has containers (code: has_containers)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/nodes/{id}/storages: get: tags: [Nodes] + summary: List Proxmox CT template storages (empty array when the node has no API credentials or the query fails) parameters: - { in: path, name: siteId, required: true, schema: { type: integer } } - { in: path, name: id, required: true, schema: { type: integer } } - responses: { '200': { description: Array of Proxmox CT template storages } } + responses: + '200': + description: Array of Proxmox CT template storages + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { type: object, properties: { name: { type: string }, total: { type: integer }, available: { type: integer } } } } } } + '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /jobs/{id}: parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] - get: { tags: [Jobs], responses: { '200': { description: Job metadata } } } + get: + tags: [Jobs] + summary: Job metadata (creator or admin; others receive 404) + responses: + '200': + description: Job metadata + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Job' } } } + '404': { $ref: '#/components/responses/NotFound' } /jobs/{id}/status: get: tags: [Jobs] parameters: - { in: path, name: id, required: true, schema: { type: integer } } - - { in: query, name: offset, schema: { type: integer } } - - { in: query, name: limit, schema: { type: integer } } - responses: { '200': { description: Status rows } } + - { in: query, name: offset, schema: { type: integer, minimum: 0, default: 0 } } + - { in: query, name: limit, schema: { type: integer, maximum: 1000, default: 1000 } } + responses: + '200': + description: Status rows + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/JobStatusRow' } } } } + '404': { $ref: '#/components/responses/NotFound' } /jobs/{id}/stream: get: tags: [Jobs] @@ -535,8 +1040,11 @@ paths: - { in: query, name: lastId, schema: { type: integer } } responses: '200': - description: SSE stream — events `log` and `status` + description: >- + SSE stream — `log` events (`{id, output, timestamp}`) and a final + `status` event (`{status}`) when the job leaves pending/running. content: { text/event-stream: {} } + '404': { $ref: '#/components/responses/NotFound' } /agents: post: @@ -547,7 +1055,8 @@ paths: seconds. The response carries the site's config snapshot with a strong `ETag`; send it back via `If-None-Match` to receive `304 Not Modified` when nothing changed. Allowed from localhost without credentials - (manager bootstrap) or with an admin API key. + (manager bootstrap) or with an admin API key. Exempt from the CSRF + guard. parameters: - in: header name: If-None-Match @@ -563,7 +1072,7 @@ paths: properties: siteId: { type: integer } hostname: { type: string } - currentTime: { type: integer, description: Agent time, epoch seconds UTC } + currentTime: { type: integer, description: 'Agent time, epoch seconds UTC' } ipv4Address: { type: string, nullable: true } services: type: object @@ -574,26 +1083,124 @@ paths: lastApply: { type: string, enum: [success, failure, unknown] } responses: '200': - description: Config snapshot (`{ data: { site, nginx } }`) with `ETag` header + description: 'Config snapshot (`{ data: { site, nginx } }`) with `ETag` header' '304': { description: Config unchanged since If-None-Match } - '422': { description: Missing siteId/hostname } + '422': { description: 'Missing siteId/hostname (code: validation_failed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } get: tags: [Agents] summary: Current status of all agents (admin) - responses: { '200': { description: 'List of agents with lastCheckinAt, services and server-computed secondsSinceCheckin' } } + responses: + '200': + description: List of agents + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: { type: integer } + siteId: { type: integer } + siteName: { type: string, nullable: true } + hostname: { type: string } + ipv4Address: { type: string, nullable: true } + services: { type: object, nullable: true } + lastCheckinAt: { type: string, format: date-time, nullable: true } + secondsSinceCheckin: { type: integer, nullable: true, description: Computed server-side at response time } + '403': { $ref: '#/components/responses/Forbidden' } /external-domains: - get: { tags: [External Domains], responses: { '200': { description: List } } } - post: { tags: [External Domains], responses: { '201': { description: Created (admin) } } } + get: + tags: [External Domains] + summary: List external domains (admin) + responses: + '200': + description: List + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/ExternalDomain' } } } } + '403': { $ref: '#/components/responses/Forbidden' } + post: + tags: [External Domains] + summary: Create an external domain (admin) + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/ExternalDomainInput' } + responses: + '201': + description: Created + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/ExternalDomain' } } } + '403': { $ref: '#/components/responses/Forbidden' } /external-domains/{id}: parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] - get: { tags: [External Domains], responses: { '200': { description: Item } } } - put: { tags: [External Domains], responses: { '200': { description: Updated (admin) } } } - delete: { tags: [External Domains], responses: { '204': { description: Deleted (admin) } } } + get: + tags: [External Domains] + responses: + '200': + description: Item + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/ExternalDomain' } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + put: + tags: [External Domains] + summary: Update an external domain (admin). Full update — omitted optional fields are cleared (except `cloudflareApiKey`, which is kept when blank/omitted). + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/ExternalDomainInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/ExternalDomain' } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [External Domains] + summary: Delete an external domain (admin) + responses: + '204': { description: Deleted } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } /users: - get: { tags: [Users], responses: { '200': { description: List (admin) } } } - post: { tags: [Users], responses: { '201': { description: Created (admin) } } } + get: + tags: [Users] + summary: List users (admin) + responses: + '200': + description: List + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/User' } } } } + '403': { $ref: '#/components/responses/Forbidden' } + post: + tags: [Users] + summary: Create a user (admin) + requestBody: + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/UserInput' + - required: [uid, givenName, sn, mail, userPassword] + responses: + '201': + description: Created + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/User' } } } + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } /users/invite: post: tags: [Users] @@ -606,33 +1213,334 @@ paths: type: object required: [email] properties: { email: { type: string, format: email } } - responses: { '200': { description: Invitation sent } } + responses: + '200': + description: Invitation sent + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { email: { type: string }, message: { type: string } } } } } + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + '409': { description: 'smtp_not_configured or duplicate_email', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '502': { description: 'Email delivery failed (code: email_failed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /users/email-all: + post: + tags: [Users] + summary: Broadcast an email to every user with an address (admin) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [subject, message] + properties: + subject: { type: string } + message: { type: string } + responses: + '200': + description: Broadcast result + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { sent: { type: integer }, failed: { type: integer }, recipients: { type: integer } } } } } + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + '409': { description: 'smtp_not_configured or no_recipients', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /users/{uidNumber}: parameters: [{ in: path, name: uidNumber, required: true, schema: { type: integer } }] - get: { tags: [Users], responses: { '200': { description: User } } } - put: { tags: [Users], responses: { '200': { description: Updated (admin) } } } - delete: { tags: [Users], responses: { '204': { description: Deleted (admin) } } } + get: + tags: [Users] + responses: + '200': + description: User + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/User' } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + put: + tags: [Users] + summary: Update a user (admin). Partial — omitted/blank fields keep their current values. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/UserInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/User' } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [Users] + summary: Delete a user (admin) + responses: + '204': { description: Deleted } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } /groups: - get: { tags: [Groups], responses: { '200': { description: List (admin) } } } - post: { tags: [Groups], responses: { '201': { description: Created (admin) } } } + get: + tags: [Groups] + summary: List groups (admin) + responses: + '200': + description: List + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Group' } } } } + '403': { $ref: '#/components/responses/Forbidden' } + post: + tags: [Groups] + summary: Create a group (admin) + requestBody: + content: + application/json: + schema: + type: object + required: [gidNumber, cn] + properties: + gidNumber: { type: integer } + cn: { type: string } + isAdmin: { type: boolean } + responses: + '201': + description: Created + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Group' } } } + '403': { $ref: '#/components/responses/Forbidden' } /groups/{id}: - parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] - get: { tags: [Groups], responses: { '200': { description: Group } } } - put: { tags: [Groups], responses: { '200': { description: Updated (admin) } } } - delete: { tags: [Groups], responses: { '204': { description: Deleted (admin) } } } + parameters: [{ in: path, name: id, required: true, schema: { type: integer }, description: gidNumber }] + get: + tags: [Groups] + responses: + '200': + description: Group + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Group' } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + put: + tags: [Groups] + summary: Update a group (admin) + requestBody: + content: + application/json: + schema: + type: object + properties: + cn: { type: string } + isAdmin: { type: boolean } + responses: + '200': + description: Updated + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Group' } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [Groups] + summary: Delete a group (admin) + responses: + '204': { description: Deleted } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } /apikeys: - get: { tags: [API Keys], responses: { '200': { description: List of current user's keys } } } + get: + tags: [API Keys] + summary: List the current user's API keys + responses: + '200': + description: List of current user's keys + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/ApiKey' } } } } post: tags: [API Keys] summary: Mint a new API key (plaintext returned ONCE) - responses: { '201': { description: Created with plaintext `key` } } + requestBody: + content: + application/json: + schema: + type: object + properties: + description: { type: string, maxLength: 255, nullable: true } + responses: + '201': + description: Created with plaintext `key` + content: + application/json: + schema: + type: object + properties: + data: + allOf: + - $ref: '#/components/schemas/ApiKey' + - type: object + properties: + key: { type: string, description: Plaintext API key — this is the only time it is returned } + warning: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } /apikeys/{id}: - parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] - get: { tags: [API Keys], responses: { '200': { description: Key metadata } } } - delete: { tags: [API Keys], responses: { '204': { description: Revoked } } } + parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }] + get: + tags: [API Keys] + responses: + '200': + description: Key metadata + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/ApiKey' } } } + '400': { description: 'Malformed id — must be a UUID (code: invalid_request)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [API Keys] + responses: + '204': { description: Revoked } + '400': { description: 'Malformed id — must be a UUID (code: invalid_request)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { $ref: '#/components/responses/NotFound' } /settings: - get: { tags: [Settings], responses: { '200': { description: System settings (admin) } } } - put: { tags: [Settings], responses: { '200': { description: Saved (admin) } } } + get: + tags: [Settings] + summary: Read system settings (admin) + responses: + '200': + description: System settings + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/Settings' } } } + '403': { $ref: '#/components/responses/Forbidden' } + put: + tags: [Settings] + summary: Save system settings (admin). Full update — omitted fields are cleared. + requestBody: + content: + application/json: + schema: { $ref: '#/components/schemas/Settings' } + responses: + '200': + description: Saved + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { saved: { type: boolean } } } } } + '403': { $ref: '#/components/responses/Forbidden' } + + /resource-requests: + get: + tags: [Resource Requests] + summary: List resource requests (admins see all; users see only their own) + parameters: + - in: query + name: status + schema: { type: string, enum: [pending, approved, denied, closed] } + description: '`closed` matches approved + denied' + - { in: query, name: siteId, schema: { type: integer } } + responses: + '200': + description: Array of resource requests + content: + application/json: + schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/ResourceRequest' } } } } + post: + tags: [Resource Requests] + summary: >- + Request a resource adjustment for a container. Auto-approved (and + applied to matching provisioned containers) when the caller is an admin + or the value is at or below the default (memory 4096 MB, swap 0 MB, + cpus 4, rootfs 50 GB); otherwise left pending for admin review. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [siteId, hostname, resourceType, value] + properties: + siteId: { type: integer } + hostname: { type: string } + resourceType: { type: string, enum: [memory, swap, cpus, rootfs] } + value: { type: integer, minimum: 0, description: 'memory/swap in MB, rootfs in GB, cpus as a count' } + comment: { type: string } + responses: + '201': + description: Request created (`status` reflects any auto-approval) + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/ResourceRequest' } } } + '400': { $ref: '#/components/responses/BadRequest' } + '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /resource-requests/count: + get: + tags: [Resource Requests] + summary: Count pending resource requests (for badge display) + responses: + '200': + description: Pending count + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { count: { type: integer } } } } } + /resource-requests/effective/{siteId}/{hostname}/{username}: + get: + tags: [Resource Requests] + summary: Effective approved resources for a container identity (defaults merged with the most recent approval per type) + parameters: + - { in: path, name: siteId, required: true, schema: { type: integer } } + - { in: path, name: hostname, required: true, schema: { type: string } } + - { in: path, name: username, required: true, schema: { type: string } } + responses: + '200': + description: Effective resource values + content: + application/json: + schema: { type: object, properties: { data: { type: object, properties: { memory: { type: integer }, swap: { type: integer }, cpus: { type: integer }, rootfs: { type: integer } } } } } + '403': { description: 'forbidden — non-admins may only query their own username', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /resource-requests/{id}/approve: + put: + tags: [Resource Requests] + summary: Approve a pending request and apply it to matching provisioned containers (admin) + parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] + requestBody: + content: + application/json: + schema: + type: object + properties: + adminComment: { type: string } + responses: + '200': + description: Approved + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/ResourceRequest' } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '409': { description: 'Request already reviewed (code: already_reviewed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '422': { description: 'No provisioned container matches the request (code: container_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /resource-requests/{id}/deny: + put: + tags: [Resource Requests] + summary: Deny a pending request (admin) + parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] + requestBody: + content: + application/json: + schema: + type: object + properties: + adminComment: { type: string } + responses: + '200': + description: Denied + content: + application/json: + schema: { type: object, properties: { data: { $ref: '#/components/schemas/ResourceRequest' } } } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '409': { description: 'Request already reviewed (code: already_reviewed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } From f1af21884377a066a7c12aa0875d7f2e459e0d1e Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Wed, 15 Jul 2026 13:27:28 -0400 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- create-a-container/openapi.v1.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index 5ee5dac5..ccfbe95b 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -142,7 +142,7 @@ components: additionalProperties: { type: string } nvidiaRequested: { type: boolean } sshPort: { type: integer, nullable: true, description: 'External port of the TCP service on internal port 22, if any' } - sshHost: { type: string, nullable: true, description: 'Hostname of the primary HTTP service, falling back to the site external IP' } + sshHost: { type: string, nullable: true, description: 'Hostname to use with sshPort when connecting over SSH, falling back to the site external IP' } httpEntries: type: array description: One entry per HTTP service @@ -601,7 +601,7 @@ paths: content: application/json: schema: { type: object, properties: { data: { type: object, properties: { username: { type: string } } } } } - '404': { description: 'Invalid or expired (code: invalid_token)' } + '404': { description: 'Invalid or expired (code: invalid_token)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } post: tags: [Auth] security: [] @@ -619,7 +619,7 @@ paths: responses: '200': { description: Password reset } '400': { description: 'invalid_request, mismatch, or weak_password', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } - '404': { description: 'Invalid or expired (code: invalid_token)' } + '404': { description: 'Invalid or expired (code: invalid_token)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites: get: @@ -777,7 +777,7 @@ paths: responses: '200': { description: Image metadata } '400': { $ref: '#/components/responses/BadRequest' } - '404': { description: 'Image not found (code: image_not_found)' } + '404': { description: 'Image not found (code: image_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '502': { description: 'Registry lookup failed (code: registry_error)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers/{id}: parameters: From 59cd82991b6728d2ad0246e0a0c738a0a520adf5 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Fri, 24 Jul 2026 16:23:43 -0400 Subject: [PATCH 3/3] docs(openapi): add operationIds so MCP tools get stable, readable names The MCP server (manager-control-program, built on awslabs openapi-mcp-server / fastmcp) names each generated tool after the operation's operationId. Without one it falls back to munging the summary or METHOD+path and truncating at 56 chars, producing names like GET_sitessiteIdcontainersid and Usernamepassword_login_disabled_when_OIDC_SSO_is_enabled. Give all 70 operations a unique snake_case verb_noun operationId (list_sites, create_container, agent_check_in, ...). --- create-a-container/openapi.v1.yaml | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index ccfbe95b..87239337 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -427,6 +427,7 @@ security: paths: /csrf-token: get: + operationId: get_csrf_token tags: [Meta] summary: Get a CSRF token security: [] @@ -438,6 +439,7 @@ paths: schema: { type: object, properties: { data: { type: object, properties: { csrfToken: { type: string } } } } } /health: get: + operationId: get_health tags: [Meta] summary: Health check security: [] @@ -449,6 +451,7 @@ paths: schema: { type: object, properties: { data: { type: object, properties: { status: { type: string }, isDev: { type: boolean }, oidcEnabled: { type: boolean }, banner: { type: string, nullable: true, description: Admin-configured announcement banner (null when unset) } } } } } /openapi.json: get: + operationId: get_openapi_json tags: [Meta] summary: This OpenAPI specification (JSON) security: [] @@ -456,6 +459,7 @@ paths: '200': { description: OpenAPI document, content: { application/json: {} } } /openapi.yaml: get: + operationId: get_openapi_yaml tags: [Meta] summary: This OpenAPI specification (YAML) security: [] @@ -464,6 +468,7 @@ paths: /session: get: + operationId: get_session tags: [Auth] summary: Current session responses: @@ -478,6 +483,7 @@ paths: # and is intentionally undocumented here. /auth/login: post: + operationId: login tags: [Auth] summary: Username/password login (disabled when OIDC SSO is enabled) security: [] @@ -503,6 +509,7 @@ paths: '403': { description: 'OIDC enabled (code: oidc_enabled) — use SSO instead; or account not active (code: account_inactive)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /auth/oidc/login: get: + operationId: oidc_login tags: [Auth] summary: Begin OIDC single sign-on (redirects to the identity provider) security: [] @@ -516,6 +523,7 @@ paths: '404': { description: 'OIDC not configured (code: oidc_disabled)' } /auth/oidc/callback: get: + operationId: oidc_callback tags: [Auth] summary: OIDC authorization-code callback (completes SSO and starts a session) security: [] @@ -524,6 +532,7 @@ paths: '404': { description: 'OIDC not configured (code: oidc_disabled)' } /auth/logout: post: + operationId: logout tags: [Auth] summary: Log out (clears the session; returns an IdP end-session URL when OIDC is enabled) security: [] @@ -535,6 +544,7 @@ paths: schema: { type: object, properties: { data: { type: object, properties: { loggedOut: { type: boolean }, logoutUrl: { type: string, nullable: true } } } } } /auth/register: post: + operationId: register tags: [Auth] summary: Self-register (disabled when OIDC SSO is enabled) security: [] @@ -562,6 +572,7 @@ paths: '403': { description: 'OIDC enabled (code: oidc_enabled) — self-registration disabled', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /auth/register/invite/{token}: get: + operationId: validate_invite tags: [Auth] summary: Validate an invitation token security: [] @@ -576,6 +587,7 @@ paths: '404': { description: 'Invalid or expired invitation (code: invalid_invite)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /auth/password-reset/request: post: + operationId: request_password_reset tags: [Auth] security: [] requestBody: @@ -592,6 +604,7 @@ paths: '400': { $ref: '#/components/responses/BadRequest' } /auth/password-reset/{token}: get: + operationId: validate_password_reset_token tags: [Auth] security: [] parameters: [{ in: path, name: token, required: true, schema: { type: string } }] @@ -603,6 +616,7 @@ paths: schema: { type: object, properties: { data: { type: object, properties: { username: { type: string } } } } } '404': { description: 'Invalid or expired (code: invalid_token)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } post: + operationId: reset_password tags: [Auth] security: [] parameters: [{ in: path, name: token, required: true, schema: { type: string } }] @@ -623,6 +637,7 @@ paths: /sites: get: + operationId: list_sites tags: [Sites] summary: List sites responses: @@ -632,6 +647,7 @@ paths: application/json: schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Site' } } } } post: + operationId: create_site tags: [Sites] summary: Create a site (admin) requestBody: @@ -648,6 +664,7 @@ paths: /sites/{id}: parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] get: + operationId: get_site tags: [Sites] responses: '200': @@ -657,6 +674,7 @@ paths: schema: { type: object, properties: { data: { $ref: '#/components/schemas/Site' } } } '404': { $ref: '#/components/responses/NotFound' } put: + operationId: update_site tags: [Sites] summary: Update a site (admin). Full update — omitted optional fields are cleared. requestBody: @@ -672,6 +690,7 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } delete: + operationId: delete_site tags: [Sites] summary: Delete a site (admin) responses: @@ -682,6 +701,7 @@ paths: /sites/{siteId}/containers: get: + operationId: list_containers tags: [Containers] parameters: - { in: path, name: siteId, required: true, schema: { type: integer } } @@ -708,6 +728,7 @@ paths: schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Container' } } } } '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } post: + operationId: create_container tags: [Containers] parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }] requestBody: @@ -757,6 +778,7 @@ paths: '409': { description: 'No provisionable node available (code: no_node or no_nvidia_node)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers/new: get: + operationId: get_new_container_form tags: [Containers] summary: Form bootstrap data (domains + NVIDIA flag) parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }] @@ -769,6 +791,7 @@ paths: '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers/metadata: get: + operationId: get_image_metadata tags: [Containers] summary: Fetch Docker image metadata parameters: @@ -784,6 +807,7 @@ paths: - { in: path, name: siteId, required: true, schema: { type: integer } } - { in: path, name: id, required: true, schema: { type: integer } } get: + operationId: get_container tags: [Containers] responses: '200': @@ -793,6 +817,7 @@ paths: schema: { type: object, properties: { data: { $ref: '#/components/schemas/Container' } } } '404': { $ref: '#/components/responses/NotFound' } put: + operationId: update_container tags: [Containers] summary: Update services/env/entrypoint; enqueues a restart job when needed (owner/admin) requestBody: @@ -837,6 +862,7 @@ paths: '403': { description: 'forbidden — only the owner/admin may edit (collaborators have a read-only view); non-admins may not reassign ownership', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '404': { $ref: '#/components/responses/NotFound' } delete: + operationId: delete_container tags: [Containers] responses: '200': @@ -852,6 +878,7 @@ paths: - { in: path, name: siteId, required: true, schema: { type: integer } } - { in: path, name: id, required: true, schema: { type: integer } } get: + operationId: list_container_collaborators tags: [Containers] summary: List users a container is shared with (owner/collaborator/admin) responses: @@ -862,6 +889,7 @@ paths: schema: { type: object, properties: { data: { $ref: '#/components/schemas/CollaboratorList' } } } '404': { $ref: '#/components/responses/NotFound' } post: + operationId: add_container_collaborator tags: [Containers] summary: Share a container with another user (owner/admin). Idempotent — sharing with an existing collaborator is a no-op. requestBody: @@ -884,6 +912,7 @@ paths: '409': { description: 'already_owner', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/containers/{id}/collaborators/{username}: delete: + operationId: remove_container_collaborator tags: [Containers] summary: Stop sharing a container with a user (owner/admin) parameters: @@ -902,6 +931,7 @@ paths: /sites/{siteId}/nodes: parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }] get: + operationId: list_nodes tags: [Nodes] responses: '200': @@ -911,6 +941,7 @@ paths: schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Node' } } } } '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } post: + operationId: create_node tags: [Nodes] summary: Create a node (admin) requestBody: @@ -928,6 +959,7 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } /sites/{siteId}/nodes/import: post: + operationId: import_nodes tags: [Nodes] summary: Bulk-import nodes + containers from a Proxmox cluster (admin) parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }] @@ -957,6 +989,7 @@ paths: - { in: path, name: siteId, required: true, schema: { type: integer } } - { in: path, name: id, required: true, schema: { type: integer } } get: + operationId: get_node tags: [Nodes] responses: '200': @@ -966,6 +999,7 @@ paths: schema: { type: object, properties: { data: { $ref: '#/components/schemas/Node' } } } '404': { $ref: '#/components/responses/NotFound' } put: + operationId: update_node tags: [Nodes] summary: Update a node (admin). Full update — omitted fields are reset to defaults/null (except `secret`, which is kept when blank/omitted, and `nodeType`, which keeps its current value). requestBody: @@ -983,6 +1017,7 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } delete: + operationId: delete_node tags: [Nodes] summary: Delete a node (admin) responses: @@ -992,6 +1027,7 @@ paths: '409': { description: 'Node still has containers (code: has_containers)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /sites/{siteId}/nodes/{id}/storages: get: + operationId: list_node_storages tags: [Nodes] summary: List Proxmox CT template storages (empty array when the node has no API credentials or the query fails) parameters: @@ -1008,6 +1044,7 @@ paths: /jobs/{id}: parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] get: + operationId: get_job tags: [Jobs] summary: Job metadata (creator or admin; others receive 404) responses: @@ -1019,6 +1056,7 @@ paths: '404': { $ref: '#/components/responses/NotFound' } /jobs/{id}/status: get: + operationId: get_job_status tags: [Jobs] parameters: - { in: path, name: id, required: true, schema: { type: integer } } @@ -1033,6 +1071,7 @@ paths: '404': { $ref: '#/components/responses/NotFound' } /jobs/{id}/stream: get: + operationId: stream_job_output tags: [Jobs] summary: Server-sent events stream of job output parameters: @@ -1048,6 +1087,7 @@ paths: /agents: post: + operationId: agent_check_in tags: [Agents] summary: Agent check-in description: | @@ -1087,6 +1127,7 @@ paths: '304': { description: Config unchanged since If-None-Match } '422': { description: 'Missing siteId/hostname (code: validation_failed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } get: + operationId: list_agents tags: [Agents] summary: Current status of all agents (admin) responses: @@ -1114,6 +1155,7 @@ paths: /external-domains: get: + operationId: list_external_domains tags: [External Domains] summary: List external domains (admin) responses: @@ -1124,6 +1166,7 @@ paths: schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/ExternalDomain' } } } } '403': { $ref: '#/components/responses/Forbidden' } post: + operationId: create_external_domain tags: [External Domains] summary: Create an external domain (admin) requestBody: @@ -1140,6 +1183,7 @@ paths: /external-domains/{id}: parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] get: + operationId: get_external_domain tags: [External Domains] responses: '200': @@ -1150,6 +1194,7 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } put: + operationId: update_external_domain tags: [External Domains] summary: Update an external domain (admin). Full update — omitted optional fields are cleared (except `cloudflareApiKey`, which is kept when blank/omitted). requestBody: @@ -1165,6 +1210,7 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } delete: + operationId: delete_external_domain tags: [External Domains] summary: Delete an external domain (admin) responses: @@ -1174,6 +1220,7 @@ paths: /users: get: + operationId: list_users tags: [Users] summary: List users (admin) responses: @@ -1184,6 +1231,7 @@ paths: schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/User' } } } } '403': { $ref: '#/components/responses/Forbidden' } post: + operationId: create_user tags: [Users] summary: Create a user (admin) requestBody: @@ -1203,6 +1251,7 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } /users/invite: post: + operationId: invite_user tags: [Users] summary: Send an email invitation (admin) requestBody: @@ -1225,6 +1274,7 @@ paths: '502': { description: 'Email delivery failed (code: email_failed)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /users/email-all: post: + operationId: email_all_users tags: [Users] summary: Broadcast an email to every user with an address (admin) requestBody: @@ -1249,6 +1299,7 @@ paths: /users/{uidNumber}: parameters: [{ in: path, name: uidNumber, required: true, schema: { type: integer } }] get: + operationId: get_user tags: [Users] responses: '200': @@ -1259,6 +1310,7 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } put: + operationId: update_user tags: [Users] summary: Update a user (admin). Partial — omitted/blank fields keep their current values. requestBody: @@ -1274,6 +1326,7 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } delete: + operationId: delete_user tags: [Users] summary: Delete a user (admin) responses: @@ -1283,6 +1336,7 @@ paths: /groups: get: + operationId: list_groups tags: [Groups] summary: List groups (admin) responses: @@ -1293,6 +1347,7 @@ paths: schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Group' } } } } '403': { $ref: '#/components/responses/Forbidden' } post: + operationId: create_group tags: [Groups] summary: Create a group (admin) requestBody: @@ -1315,6 +1370,7 @@ paths: /groups/{id}: parameters: [{ in: path, name: id, required: true, schema: { type: integer }, description: gidNumber }] get: + operationId: get_group tags: [Groups] responses: '200': @@ -1325,6 +1381,7 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } put: + operationId: update_group tags: [Groups] summary: Update a group (admin) requestBody: @@ -1344,6 +1401,7 @@ paths: '403': { $ref: '#/components/responses/Forbidden' } '404': { $ref: '#/components/responses/NotFound' } delete: + operationId: delete_group tags: [Groups] summary: Delete a group (admin) responses: @@ -1353,6 +1411,7 @@ paths: /apikeys: get: + operationId: list_api_keys tags: [API Keys] summary: List the current user's API keys responses: @@ -1362,6 +1421,7 @@ paths: application/json: schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/ApiKey' } } } } post: + operationId: create_api_key tags: [API Keys] summary: Mint a new API key (plaintext returned ONCE) requestBody: @@ -1390,6 +1450,7 @@ paths: /apikeys/{id}: parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }] get: + operationId: get_api_key tags: [API Keys] responses: '200': @@ -1400,6 +1461,7 @@ paths: '400': { description: 'Malformed id — must be a UUID (code: invalid_request)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '404': { $ref: '#/components/responses/NotFound' } delete: + operationId: delete_api_key tags: [API Keys] responses: '204': { description: Revoked } @@ -1408,6 +1470,7 @@ paths: /settings: get: + operationId: get_settings tags: [Settings] summary: Read system settings (admin) responses: @@ -1418,6 +1481,7 @@ paths: schema: { type: object, properties: { data: { $ref: '#/components/schemas/Settings' } } } '403': { $ref: '#/components/responses/Forbidden' } put: + operationId: update_settings tags: [Settings] summary: Save system settings (admin). Full update — omitted fields are cleared. requestBody: @@ -1434,6 +1498,7 @@ paths: /resource-requests: get: + operationId: list_resource_requests tags: [Resource Requests] summary: List resource requests (admins see all; users see only their own) parameters: @@ -1449,6 +1514,7 @@ paths: application/json: schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/ResourceRequest' } } } } post: + operationId: create_resource_request tags: [Resource Requests] summary: >- Request a resource adjustment for a container. Auto-approved (and @@ -1478,6 +1544,7 @@ paths: '404': { description: 'Site not found (code: site_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /resource-requests/count: get: + operationId: count_pending_resource_requests tags: [Resource Requests] summary: Count pending resource requests (for badge display) responses: @@ -1488,6 +1555,7 @@ paths: schema: { type: object, properties: { data: { type: object, properties: { count: { type: integer } } } } } /resource-requests/effective/{siteId}/{hostname}/{username}: get: + operationId: get_effective_resources tags: [Resource Requests] summary: Effective approved resources for a container identity (defaults merged with the most recent approval per type) parameters: @@ -1503,6 +1571,7 @@ paths: '403': { description: 'forbidden — non-admins may only query their own username', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /resource-requests/{id}/approve: put: + operationId: approve_resource_request tags: [Resource Requests] summary: Approve a pending request and apply it to matching provisioned containers (admin) parameters: [{ in: path, name: id, required: true, schema: { type: integer } }] @@ -1525,6 +1594,7 @@ paths: '422': { description: 'No provisioned container matches the request (code: container_not_found)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } /resource-requests/{id}/deny: put: + operationId: deny_resource_request tags: [Resource Requests] summary: Deny a pending request (admin) parameters: [{ in: path, name: id, required: true, schema: { type: integer } }]