From da26f7f764563a35dacc970cc0196e6aa7828384 Mon Sep 17 00:00:00 2001 From: jarek Date: Mon, 17 Aug 2026 09:50:34 +0200 Subject: [PATCH] OpenAPI spec at /api/docs with a Scalar viewer, opt-in via FEAT_API_DOCS --- package.json | 10 +- src/hooks.server.ts | 16 +- src/lib/openapi.generated.json | 28712 ++++++++++++++++ src/lib/server/features.ts | 7 + src/routes/+layout.server.ts | 7 +- src/routes/+layout.svelte | 8 +- src/routes/api/activity/+server.ts | 24 + src/routes/api/activity/containers/+server.ts | 9 + src/routes/api/activity/events/+server.ts | 6 + src/routes/api/activity/stats/+server.ts | 9 + src/routes/api/audit/+server.ts | 19 + src/routes/api/audit/events/+server.ts | 6 + src/routes/api/audit/export/+server.ts | 14 + src/routes/api/audit/users/+server.ts | 8 + src/routes/api/auth/ldap/+server.ts | 21 + src/routes/api/auth/ldap/[id]/+server.ts | 35 + src/routes/api/auth/ldap/[id]/test/+server.ts | 11 + src/routes/api/auth/login/+server.ts | 15 +- src/routes/api/auth/logout/+server.ts | 8 +- src/routes/api/auth/oidc/+server.ts | 21 + src/routes/api/auth/oidc/[id]/+server.ts | 35 + .../api/auth/oidc/[id]/initiate/+server.ts | 21 + src/routes/api/auth/oidc/[id]/test/+server.ts | 9 + src/routes/api/auth/oidc/callback/+server.ts | 9 + src/routes/api/auth/providers/+server.ts | 6 + src/routes/api/auth/session/+server.ts | 9 +- src/routes/api/auth/settings/+server.ts | 20 + src/routes/api/auth/tokens/+server.ts | 20 + src/routes/api/auth/tokens/[id]/+server.ts | 10 + src/routes/api/auto-update/+server.ts | 7 + .../auto-update/[containerName]/+server.ts | 29 + src/routes/api/backup/configs/+server.ts | 24 + src/routes/api/backup/configs/[id]/+server.ts | 34 + .../api/backup/configs/[id]/run/+server.ts | 9 + .../api/backup/configs/[id]/stop/+server.ts | 11 + src/routes/api/backup/destinations/+server.ts | 21 + .../api/backup/destinations/[id]/+server.ts | 39 + .../backup/destinations/[id]/init/+server.ts | 13 + .../destinations/[id]/rotate-key/+server.ts | 20 +- .../backup/destinations/[id]/task/+server.ts | 14 + .../backup/destinations/[id]/test/+server.ts | 12 + .../destinations/[id]/verify/+server.ts | 12 + .../api/backup/destinations/test/+server.ts | 10 + src/routes/api/backup/instance/+server.ts | 6 + src/routes/api/backup/restore/+server.ts | 12 + .../api/backup/restore/preview/+server.ts | 10 + src/routes/api/backup/restore/stop/+server.ts | 11 + src/routes/api/backup/snapshots/+server.ts | 11 + .../api/backup/snapshots/[id]/+server.ts | 15 + .../backup/snapshots/[id]/browse/+server.ts | 11 + .../api/backup/snapshots/[id]/dump/+server.ts | 13 + .../backup/snapshots/[id]/metadata/+server.ts | 8 + .../api/backup/snapshots/diff/+server.ts | 9 + .../api/backup/stack-dir-listing/+server.ts | 9 + src/routes/api/backup/stack-path/+server.ts | 9 + src/routes/api/batch/+server.ts | 14 +- src/routes/api/changelog/+server.ts | 8 + src/routes/api/config-sets/+server.ts | 20 + src/routes/api/config-sets/[id]/+server.ts | 35 + src/routes/api/containers/+server.ts | 20 + src/routes/api/containers/[id]/+server.ts | 26 + .../api/containers/[id]/exec/+server.ts | 14 + .../api/containers/[id]/files/+server.ts | 14 + .../containers/[id]/files/chmod/+server.ts | 16 + .../containers/[id]/files/content/+server.ts | 34 + .../containers/[id]/files/create/+server.ts | 17 + .../containers/[id]/files/delete/+server.ts | 15 + .../containers/[id]/files/download/+server.ts | 15 + .../containers/[id]/files/rename/+server.ts | 17 + .../containers/[id]/files/upload/+server.ts | 16 + .../api/containers/[id]/inspect/+server.ts | 11 + .../api/containers/[id]/logs/+server.ts | 14 + .../containers/[id]/logs/stream/+server.ts | 14 + .../api/containers/[id]/pause/+server.ts | 12 + .../api/containers/[id]/rename/+server.ts | 16 + .../api/containers/[id]/restart/+server.ts | 13 + .../api/containers/[id]/shells/+server.ts | 12 + .../api/containers/[id]/start/+server.ts | 13 + .../api/containers/[id]/stats/+server.ts | 12 + .../api/containers/[id]/stop/+server.ts | 13 + src/routes/api/containers/[id]/top/+server.ts | 12 + .../api/containers/[id]/unpause/+server.ts | 12 + .../containers/[id]/update-runtime/+server.ts | 13 + .../api/containers/[id]/update/+server.ts | 16 + .../containers/batch-update-stream/+server.ts | 10 + .../api/containers/batch-update/+server.ts | 11 + src/routes/api/containers/sizes/+server.ts | 10 + src/routes/api/containers/stats/+server.ts | 12 + .../api/containers/stats/stream/+server.ts | 7 + .../api/dashboard/preferences/+server.ts | 15 + src/routes/api/dashboard/stats/+server.ts | 10 + .../api/dashboard/stats/stream/+server.ts | 7 + src/routes/api/debug/memory/+server.ts | 11 + src/routes/api/dependencies/+server.ts | 8 + src/routes/api/docs/+server.ts | 45 + src/routes/api/docs/ui/+page.server.ts | 9 + src/routes/api/docs/ui/+page.svelte | 49 + src/routes/api/environments/+server.ts | 21 + src/routes/api/environments/[id]/+server.ts | 32 + .../environments/[id]/disk-warning/+server.ts | 21 + .../api/environments/[id]/icon/+server.ts | 27 + .../environments/[id]/image-prune/+server.ts | 28 + .../[id]/notifications/+server.ts | 24 + .../notifications/[notificationId]/+server.ts | 35 + .../[id]/remote-stacks-dir/+server.ts | 21 + .../api/environments/[id]/test/+server.ts | 8 + .../api/environments/[id]/timezone/+server.ts | 20 + .../api/environments/detect-socket/+server.ts | 5 + src/routes/api/environments/test/+server.ts | 8 + src/routes/api/events/+server.ts | 6 + src/routes/api/git/credentials/+server.ts | 18 + .../api/git/credentials/[id]/+server.ts | 33 + src/routes/api/git/preview-env/+server.ts | 12 + src/routes/api/git/repositories/+server.ts | 19 + .../api/git/repositories/[id]/+server.ts | 34 + .../git/repositories/[id]/deploy/+server.ts | 10 + .../api/git/repositories/[id]/sync/+server.ts | 20 + .../api/git/repositories/[id]/test/+server.ts | 10 + .../api/git/repositories/test/+server.ts | 12 + src/routes/api/git/stacks/+server.ts | 16 + src/routes/api/git/stacks/[id]/+server.ts | 26 + .../git/stacks/[id]/deploy-stream/+server.ts | 11 + .../api/git/stacks/[id]/deploy/+server.ts | 10 + .../api/git/stacks/[id]/env-files/+server.ts | 26 + .../api/git/stacks/[id]/sync/+server.ts | 10 + .../api/git/stacks/[id]/test/+server.ts | 10 + .../api/git/stacks/[id]/webhook/+server.ts | 26 + src/routes/api/git/webhook/[id]/+server.ts | 26 + src/routes/api/hawser/connect/+server.ts | 12 + src/routes/api/hawser/tokens/+server.ts | 34 + src/routes/api/health/+server.ts | 6 + src/routes/api/health/database/+server.ts | 12 + src/routes/api/host/+server.ts | 11 + src/routes/api/images/+server.ts | 12 + src/routes/api/images/[id]/+server.ts | 14 + src/routes/api/images/[id]/export/+server.ts | 12 + src/routes/api/images/[id]/history/+server.ts | 12 + src/routes/api/images/[id]/tag/+server.ts | 15 + src/routes/api/images/load/+server.ts | 10 + src/routes/api/images/pull/+server.ts | 11 + src/routes/api/images/push/+server.ts | 16 + src/routes/api/images/scan/+server.ts | 29 +- src/routes/api/images/scan/export/+server.ts | 11 + src/routes/api/jobs/[id]/+server.ts | 12 + src/routes/api/labels/+server.ts | 28 +- src/routes/api/legal/license/+server.ts | 8 + src/routes/api/legal/privacy/+server.ts | 8 + src/routes/api/license/+server.ts | 24 + src/routes/api/logs/merged/+server.ts | 12 + src/routes/api/networks/+server.ts | 21 + src/routes/api/networks/[id]/+server.ts | 19 + .../api/networks/[id]/connect/+server.ts | 14 + .../api/networks/[id]/disconnect/+server.ts | 14 + .../api/networks/[id]/inspect/+server.ts | 9 + src/routes/api/notifications/+server.ts | 17 + src/routes/api/notifications/[id]/+server.ts | 33 + .../api/notifications/[id]/test/+server.ts | 10 + src/routes/api/notifications/test/+server.ts | 11 + .../api/notifications/trigger-test/+server.ts | 19 +- .../preferences/favorite-groups/+server.ts | 20 + .../api/preferences/favorites/+server.ts | 19 + src/routes/api/preferences/grid/+server.ts | 23 + src/routes/api/preferences/sidebar/+server.ts | 22 + src/routes/api/profile/+server.ts | 20 + src/routes/api/profile/avatar/+server.ts | 18 + src/routes/api/profile/preferences/+server.ts | 15 + src/routes/api/prune/all/+server.ts | 11 + src/routes/api/prune/containers/+server.ts | 11 + src/routes/api/prune/images/+server.ts | 11 + src/routes/api/prune/networks/+server.ts | 11 + src/routes/api/prune/volumes/+server.ts | 11 + src/routes/api/registries/+server.ts | 19 + src/routes/api/registries/[id]/+server.ts | 33 + .../api/registries/[id]/default/+server.ts | 11 + src/routes/api/registries/test/+server.ts | 12 + src/routes/api/registry/catalog/+server.ts | 13 + src/routes/api/registry/image/+server.ts | 17 + src/routes/api/registry/search/+server.ts | 14 + src/routes/api/registry/tag-info/+server.ts | 11 + src/routes/api/registry/tags/+server.ts | 15 + src/routes/api/roles/+server.ts | 19 + src/routes/api/roles/[id]/+server.ts | 34 + src/routes/api/schedules/+server.ts | 6 + .../api/schedules/[type]/[id]/+server.ts | 10 + .../api/schedules/[type]/[id]/run/+server.ts | 10 + .../schedules/[type]/[id]/toggle/+server.ts | 10 + .../api/schedules/executions/+server.ts | 16 + .../api/schedules/executions/[id]/+server.ts | 18 + src/routes/api/schedules/settings/+server.ts | 15 + src/routes/api/schedules/stream/+server.ts | 6 + .../schedules/system/[id]/toggle/+server.ts | 9 + src/routes/api/secret-providers/+server.ts | 16 + .../api/secret-providers/[id]/+server.ts | 30 + .../secret-providers/[id]/probe/+server.ts | 11 + .../api/secret-providers/[id]/test/+server.ts | 11 + .../api/secret-providers/test/+server.ts | 13 +- src/routes/api/self-update/+server.ts | 10 + src/routes/api/self-update/check/+server.ts | 6 + .../api/self-update/progress/+server.ts | 9 + src/routes/api/settings/general/+server.ts | 14 + src/routes/api/settings/navigation/+server.ts | 17 + src/routes/api/settings/scanner/+server.ts | 32 + .../api/settings/scanner/cache/+server.ts | 7 + src/routes/api/settings/theme/+server.ts | 6 + src/routes/api/stacks/+server.ts | 17 + src/routes/api/stacks/[name]/+server.ts | 15 + .../[name]/check-path-change/+server.ts | 12 +- .../api/stacks/[name]/compose/+server.ts | 20 + .../stacks/[name]/delete-preview/+server.ts | 10 + .../api/stacks/[name]/deploy/+server.ts | 10 + src/routes/api/stacks/[name]/down/+server.ts | 12 + src/routes/api/stacks/[name]/env/+server.ts | 19 + .../api/stacks/[name]/env/raw/+server.ts | 24 +- .../api/stacks/[name]/env/validate/+server.ts | 15 +- .../api/stacks/[name]/relocate/+server.ts | 13 +- .../api/stacks/[name]/restart/+server.ts | 9 + src/routes/api/stacks/[name]/start/+server.ts | 10 + src/routes/api/stacks/[name]/stop/+server.ts | 10 + src/routes/api/stacks/adopt/+server.ts | 12 + src/routes/api/stacks/base-path/+server.ts | 6 +- src/routes/api/stacks/default-path/+server.ts | 20 +- src/routes/api/stacks/path-hints/+server.ts | 11 +- src/routes/api/stacks/scan/+server.ts | 10 + src/routes/api/stacks/sources/+server.ts | 7 + .../api/stacks/validate-path/+server.ts | 9 + src/routes/api/system/+server.ts | 11 + src/routes/api/system/disk/+server.ts | 12 + src/routes/api/system/files/+server.ts | 22 +- .../api/system/files/content/+server.ts | 11 +- src/routes/api/templates/+server.ts | 9 + src/routes/api/templates/compose/+server.ts | 13 + src/routes/api/templates/sources/+server.ts | 44 + src/routes/api/users/+server.ts | 21 + src/routes/api/users/[id]/+server.ts | 35 + src/routes/api/users/[id]/mfa/+server.ts | 20 + src/routes/api/users/[id]/roles/+server.ts | 36 + src/routes/api/volumes/+server.ts | 22 + src/routes/api/volumes/[name]/+server.ts | 19 + .../api/volumes/[name]/browse/+server.ts | 12 + .../volumes/[name]/browse/content/+server.ts | 14 + .../volumes/[name]/browse/release/+server.ts | 13 +- .../api/volumes/[name]/clone/+server.ts | 13 + .../api/volumes/[name]/export/+server.ts | 12 + .../api/volumes/[name]/inspect/+server.ts | 10 + src/routes/api/vulnerabilities/+server.ts | 7 + .../api/vulnerabilities/count/+server.ts | 9 + .../api/vulnerabilities/export/+server.ts | 6 + .../api/vulnerabilities/scan-all/+server.ts | 6 + src/routes/audit/+server.ts | 15 + src/routes/audit/users/+server.ts | 7 + src/routes/metrics/+server.ts | 8 + 251 files changed, 32497 insertions(+), 66 deletions(-) create mode 100644 src/lib/openapi.generated.json create mode 100644 src/routes/api/docs/+server.ts create mode 100644 src/routes/api/docs/ui/+page.server.ts create mode 100644 src/routes/api/docs/ui/+page.svelte diff --git a/package.json b/package.json index b9f211a..65218ec 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "npx vite dev", - "prebuild": "npx license-checker --json --production | jq 'to_entries | map({name: (.key | split(\"@\")[0:-1] | join(\"@\")), version: (.key | split(\"@\")[-1]), license: .value.licenses, repository: .value.repository}) | sort_by(.name)' > src/lib/data/dependencies.json.tmp && mv src/lib/data/dependencies.json.tmp src/lib/data/dependencies.json || true", + "prebuild": "npx license-checker --json --production | jq 'to_entries | map({name: (.key | split(\"@\")[0:-1] | join(\"@\")), version: (.key | split(\"@\")[-1]), license: .value.licenses, repository: .value.repository}) | sort_by(.name)' > src/lib/data/dependencies.json.tmp && mv src/lib/data/dependencies.json.tmp src/lib/data/dependencies.json || true && npm run generate:openapi", "build": "npx vite build", "start": "node ./server.js", "preview": "node ./build/index.js", @@ -50,7 +50,9 @@ "test:quick": "bun test tests/api-smoke.test.ts tests/notifications.test.ts", "test:integration": "bun test tests/api-smoke.test.ts tests/crud-operations.test.ts tests/scheduling.test.ts tests/hawser-connection.test.ts", "test:e2e": "npx playwright test tests/e2e/", - "generate:legal": "node scripts/generate-legal-pages.ts" + "generate:legal": "node scripts/generate-legal-pages.ts", + "generate:openapi": "npx tsx scripts/generate-openapi.ts", + "generate:openapi:check": "npx tsx scripts/generate-openapi.ts --check" }, "dependencies": { "@1password/sdk": "0.4.1-beta.1", @@ -82,8 +84,10 @@ "devalue": "5.8.1", "drizzle-orm": "0.45.2", "fast-xml-parser": "5.7.3", + "isomorphic-dompurify": "3.22.0", "js-yaml": "4.3.1", "ldapts": "8.1.3", + "marked": "18.0.9", "modern-tar": "0.8.0", "nodemailer": "9.0.1", "otpauth": "9.4.1", @@ -100,6 +104,7 @@ "@layerstack/tailwind": "^1.0.1", "@lucide/svelte": "^0.562.0", "@playwright/test": "1.57.0", + "@scalar/api-reference": "1.64.0", "@sveltejs/adapter-node": "^5.2.0", "@sveltejs/kit": "2.50.0", "@sveltejs/vite-plugin-svelte": "6.2.4", @@ -131,6 +136,7 @@ "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.2.2", "tailwindcss": "^4.1.18", + "tsx": "4.23.1", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", "vite": "^7.3.1" diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 126234a..17b92fb 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -19,7 +19,7 @@ import type { HandleServerError, Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { startRssTracker, stopRssTracker, rssBeforeOp, rssAfterOp } from '$lib/server/rss-tracker'; import { getClientIp } from '$lib/server/client-ip'; -import { BACKUPS_ENABLED } from '$lib/server/features'; +import { BACKUPS_ENABLED, API_DOCS_ENABLED } from '$lib/server/features'; // Side-effect import: installs globalThis.__authenticateWsUpgrade and // globalThis.__canAccessEnvForUser used by the raw WS upgrade handlers in // server.js / vite.config.ts to authenticate /api/containers/*/exec. @@ -268,7 +268,8 @@ const PUBLIC_PATHS = [ '/api/changelog', '/api/dependencies', '/api/health', - '/api/settings/theme' + '/api/settings/theme', + '/api/docs' ]; // Check if path is public @@ -310,6 +311,17 @@ export const handle: Handle = async ({ event, resolve }) => { } } + // API docs gate (see $lib/server/features.ts). Enforced HERE, not in the + // docs +page.server load, because the app runs ssr=false so a page load + // never fires on a cold server render. Single chokepoint covers the spec + // endpoint and the Scalar viewer. + if (!API_DOCS_ENABLED) { + const p = event.url.pathname.replace(/\/$/, ''); + if (p === '/api/docs' || p === '/api/docs/ui') { + return new Response('Not found', { status: 404 }); + } + } + const httpBefore = rssBeforeOp(); try { // Check if auth is enabled diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json new file mode 100644 index 0000000..376974f --- /dev/null +++ b/src/lib/openapi.generated.json @@ -0,0 +1,28712 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Dockhand API", + "version": "1.0.42", + "description": "Auto-generated from src/routes/**/+server.ts by scripts/generate-openapi.ts. Path, HTTP method, tag, and auth requirement are derived automatically from the route tree and hooks.server.ts PUBLIC_PATHS. Parameters, response status codes, and request body fields are additionally auto-detected from each handler’s own source (query params via url.searchParams, status codes via status:/error(), body fields via destructuring) — adding a new endpoint therefore requires ZERO manual spec edits to show up with real params/responses. An optional, additive `@openapi` JSDoc annotation on a handler replaces the generic auto-descriptions with hand-written summaries, exact types, and examples." + }, + "servers": [ + { + "url": "/" + } + ], + "tags": [ + { + "name": "activity" + }, + { + "name": "audit" + }, + { + "name": "auth" + }, + { + "name": "auto-update" + }, + { + "name": "backup" + }, + { + "name": "batch" + }, + { + "name": "changelog" + }, + { + "name": "config-sets" + }, + { + "name": "containers" + }, + { + "name": "dashboard" + }, + { + "name": "debug" + }, + { + "name": "dependencies" + }, + { + "name": "docs" + }, + { + "name": "environments" + }, + { + "name": "events" + }, + { + "name": "git" + }, + { + "name": "hawser" + }, + { + "name": "health" + }, + { + "name": "host" + }, + { + "name": "images" + }, + { + "name": "jobs" + }, + { + "name": "labels" + }, + { + "name": "legal" + }, + { + "name": "license" + }, + { + "name": "logs" + }, + { + "name": "metrics" + }, + { + "name": "networks" + }, + { + "name": "notifications" + }, + { + "name": "preferences" + }, + { + "name": "profile" + }, + { + "name": "prune" + }, + { + "name": "registries" + }, + { + "name": "registry" + }, + { + "name": "roles" + }, + { + "name": "schedules" + }, + { + "name": "secret-providers" + }, + { + "name": "self-update" + }, + { + "name": "settings" + }, + { + "name": "stacks" + }, + { + "name": "system" + }, + { + "name": "templates" + }, + { + "name": "users" + }, + { + "name": "volumes" + }, + { + "name": "vulnerabilities" + } + ], + "components": { + "securitySchemes": { + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "dockhand_session", + "description": "Session cookie set on login (src/lib/server/auth.ts validateSession)." + }, + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "dh_<43-char base64url>", + "description": "User-scoped API token (src/lib/server/api-tokens.ts). Only evaluated on /api/* and /metrics when no session cookie is present (src/hooks.server.ts). Rate-limited: 10 failures/IP -> 429 for 5 minutes." + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "paths": { + "/api/activity": { + "get": { + "operationId": "get_api_activity", + "tags": [ + "activity" + ], + "summary": "Query container activity events with filters and pagination", + "parameters": [ + { + "name": "environmentId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment (from GET /api/environments)" + }, + { + "name": "containerId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by container ID (from GET /api/containers)" + }, + { + "name": "containerName", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by container name" + }, + { + "name": "actions", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated event actions to filter by" + }, + { + "name": "labels", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated labels to filter by" + }, + { + "name": "fromDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of the date range (ISO 8601)" + }, + { + "name": "toDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of the date range (ISO 8601)" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Maximum number of events to return" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Number of events to skip (pagination)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "containerName": { + "type": "string" + }, + "action": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "total", + "limit", + "offset" + ] + }, + "example": { + "events": [ + { + "id": 0, + "containerName": "string", + "action": "string", + "timestamp": "string" + } + ], + "total": 0, + "limit": 0, + "offset": 0 + } + } + } + }, + "403": { + "description": "Permission denied (requires the activity:view permission)" + }, + "500": { + "description": "Failed to fetch container events" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_activity", + "tags": [ + "activity" + ], + "summary": "Clear all stored container activity events", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (requires the activity:delete permission)" + }, + "500": { + "description": "Failed to clear container events" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/activity/containers": { + "get": { + "operationId": "get_api_activity_containers", + "tags": [ + "activity" + ], + "summary": "List distinct container names that appear in the activity log, for filter dropdowns", + "parameters": [ + { + "name": "environment_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": [ + "web-1", + "db-1", + "cache-1" + ] + } + } + }, + "403": { + "description": "Permission denied (requires the activity:view permission)" + }, + "500": { + "description": "Failed to fetch container names" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/activity/events": { + "get": { + "operationId": "get_api_activity_events", + "tags": [ + "activity" + ], + "summary": "Stream live container activity and environment-status events over Server-Sent Events", + "parameters": [], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream) emitting connected, heartbeat, activity and env_status events" + }, + "403": { + "description": "Permission denied (requires the activity:view permission)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/activity/stats": { + "get": { + "operationId": "get_api_activity_stats", + "tags": [ + "activity" + ], + "summary": "Get aggregate container activity statistics (totals and counts by action)", + "parameters": [ + { + "name": "environment_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "today": { + "type": "integer" + }, + "byAction": { + "type": "object", + "properties": {} + } + }, + "required": [ + "total", + "today" + ] + }, + "example": { + "total": 128, + "today": 7, + "byAction": { + "start": 40, + "stop": 30 + } + } + } + } + }, + "403": { + "description": "Permission denied (requires the activity:view permission)" + }, + "500": { + "description": "Failed to fetch stats" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/audit": { + "get": { + "operationId": "get_api_audit", + "tags": [ + "audit" + ], + "summary": "Query the audit log with filters and pagination (Enterprise only)", + "parameters": [ + { + "name": "usernames", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated usernames to filter by" + }, + { + "name": "entityTypes", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated entity types to filter by" + }, + { + "name": "actions", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated actions to filter by" + }, + { + "name": "username", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Legacy single-username filter" + }, + { + "name": "entityType", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Legacy single entity-type filter" + }, + { + "name": "action", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Legacy single-action filter" + }, + { + "name": "environmentId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment (from GET /api/environments)" + }, + { + "name": "labels", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated labels to filter by" + }, + { + "name": "fromDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of the date range (ISO 8601)" + }, + { + "name": "toDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of the date range (ISO 8601)" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Maximum number of entries to return" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Number of entries to skip (pagination)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "action": { + "type": "string" + }, + "entityType": { + "type": "string" + }, + "entityName": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "total": { + "type": "integer" + } + } + }, + "example": { + "logs": [ + { + "id": 0, + "username": "string", + "action": "string", + "entityType": "string", + "entityName": "string", + "createdAt": "string" + } + ], + "total": 0 + } + } + } + }, + "403": { + "description": "Enterprise required, or permission denied" + }, + "500": { + "description": "Failed to fetch audit logs" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/audit/events": { + "get": { + "operationId": "get_api_audit_events", + "tags": [ + "audit" + ], + "summary": "Stream live audit-log events over Server-Sent Events (Enterprise only)", + "parameters": [], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream) emitting connected, heartbeat and audit events" + }, + "403": { + "description": "Enterprise required, or permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/audit/export": { + "get": { + "operationId": "get_api_audit_export", + "tags": [ + "audit" + ], + "summary": "Export the audit log as a downloadable file in JSON, CSV or Markdown (Enterprise only)", + "parameters": [ + { + "name": "username", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by username" + }, + { + "name": "entityType", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by entity type" + }, + { + "name": "action", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by action" + }, + { + "name": "environmentId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment (from GET /api/environments)" + }, + { + "name": "fromDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of the date range (ISO 8601)" + }, + { + "name": "toDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of the date range (ISO 8601)" + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Output format — json (default), csv or md" + } + ], + "responses": { + "200": { + "description": "Downloadable audit-log export (Content-Disposition attachment; JSON, CSV or Markdown)" + }, + "403": { + "description": "Enterprise required, or permission denied" + }, + "500": { + "description": "Failed to export audit logs" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/audit/users": { + "get": { + "operationId": "get_api_audit_users", + "tags": [ + "audit" + ], + "summary": "List the distinct usernames that appear in the audit log, for filter dropdowns (Enterprise only)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": [ + "admin", + "ci-bot", + "alice" + ] + } + } + }, + "403": { + "description": "Enterprise required, or permission denied" + }, + "500": { + "description": "Failed to fetch audit log users" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/auth/ldap": { + "get": { + "operationId": "get_api_auth_ldap", + "tags": [ + "auth" + ], + "summary": "List all configured LDAP providers (enterprise only; bind passwords are masked)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "serverUrl": { + "type": "string" + }, + "baseDn": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "serverUrl", + "baseDn" + ] + } + }, + "example": [ + { + "id": 1, + "name": "Corporate LDAP", + "enabled": true, + "serverUrl": "ldaps://ldap.example.com:636", + "baseDn": "dc=example,dc=com" + } + ] + } + } + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "500": { + "description": "Failed to read the LDAP configurations" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_auth_ldap", + "tags": [ + "auth" + ], + "summary": "Create a new LDAP provider configuration (enterprise only)", + "parameters": [], + "responses": { + "201": { + "description": "LDAP configuration created (bindPassword is masked in the response)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "serverUrl": { + "type": "string" + }, + "baseDn": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "serverUrl", + "baseDn" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "serverUrl": "string", + "baseDn": "string" + } + } + } + }, + "400": { + "description": "Name, server URL, and base DN are required" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "500": { + "description": "Failed to create the LDAP configuration" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "serverUrl": { + "type": "string" + }, + "baseDn": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "bindDn": { + "type": "string" + }, + "bindPassword": { + "type": "string" + }, + "userFilter": { + "type": "string" + }, + "usernameAttribute": { + "type": "string" + }, + "emailAttribute": { + "type": "string" + }, + "displayNameAttribute": { + "type": "string" + }, + "groupBaseDn": { + "type": "string" + }, + "groupFilter": { + "type": "string" + }, + "adminGroup": { + "type": "string" + }, + "roleMappings": { + "type": "object", + "properties": {} + }, + "tlsEnabled": { + "type": "boolean" + }, + "tlsCa": { + "type": "string" + } + }, + "required": [ + "name", + "serverUrl", + "baseDn" + ] + }, + "example": { + "name": "Corporate LDAP", + "serverUrl": "ldaps://ldap.example.com:636", + "baseDn": "dc=example,dc=com", + "bindDn": "cn=admin,dc=example,dc=com", + "bindPassword": "***", + "enabled": true, + "tlsEnabled": true + } + } + } + } + } + }, + "/api/auth/ldap/{id}": { + "get": { + "operationId": "get_api_auth_ldap_id", + "tags": [ + "auth" + ], + "summary": "Get a single LDAP provider configuration by id (enterprise only; bind password is masked)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the LDAP configuration (from GET /api/auth/ldap)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "serverUrl": { + "type": "string" + }, + "baseDn": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "serverUrl", + "baseDn" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "serverUrl": "string", + "baseDn": "string" + } + } + } + }, + "400": { + "description": "Invalid id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "LDAP configuration not found" + }, + "500": { + "description": "Failed to read the LDAP configuration" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_auth_ldap_id", + "tags": [ + "auth" + ], + "summary": "Update an existing LDAP provider configuration (only supplied fields change; a masked bindPassword is ignored)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the LDAP configuration (from GET /api/auth/ldap)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "serverUrl": { + "type": "string" + }, + "baseDn": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "serverUrl", + "baseDn" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "serverUrl": "string", + "baseDn": "string" + } + } + } + }, + "400": { + "description": "Invalid id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "LDAP configuration not found" + }, + "500": { + "description": "Failed to update the LDAP configuration" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "serverUrl": { + "type": "string" + }, + "bindDn": { + "type": "string" + }, + "bindPassword": { + "type": "string" + }, + "baseDn": { + "type": "string" + }, + "userFilter": { + "type": "string" + }, + "usernameAttribute": { + "type": "string" + }, + "emailAttribute": { + "type": "string" + }, + "displayNameAttribute": { + "type": "string" + }, + "groupBaseDn": { + "type": "string" + }, + "groupFilter": { + "type": "string" + }, + "adminGroup": { + "type": "string" + }, + "roleMappings": { + "type": "object", + "properties": {} + }, + "tlsEnabled": { + "type": "boolean" + }, + "tlsCa": { + "type": "string" + } + } + }, + "example": { + "enabled": false, + "userFilter": "(sAMAccountName={{username}})", + "bindPassword": "***" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_auth_ldap_id", + "tags": [ + "auth" + ], + "summary": "Delete an LDAP provider configuration by id (enterprise only)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the LDAP configuration (from GET /api/auth/ldap)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "LDAP configuration not found" + }, + "500": { + "description": "Failed to delete the LDAP configuration" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/auth/ldap/{id}/test": { + "post": { + "operationId": "post_api_auth_ldap_id_test", + "tags": [ + "auth" + ], + "summary": "Test connectivity of a stored LDAP configuration by id (enterprise only)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the LDAP configuration to test (from GET /api/auth/ldap)" + } + ], + "responses": { + "200": { + "description": "Connection test result (success flag plus diagnostic detail from the LDAP server)" + }, + "400": { + "description": "Invalid id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "LDAP configuration not found" + }, + "500": { + "description": "Failed to test the LDAP connection" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/auth/login": { + "post": { + "operationId": "post_api_auth_login", + "tags": [ + "auth" + ], + "summary": "Authenticate with username/password (local or LDAP) and set the session cookie", + "parameters": [], + "responses": { + "200": { + "description": "Login succeeded and dockhand_session cookie was set — OR requiresMfa:true if a second factor is needed first", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "user": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "isAdmin": { + "type": "boolean" + } + }, + "required": [ + "id", + "username", + "isAdmin" + ] + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "user": { + "id": 1, + "username": "admin", + "email": "admin@example.com", + "displayName": "Admin", + "isAdmin": true + } + } + } + } + }, + "400": { + "description": "Authentication disabled, or username/password missing" + }, + "401": { + "description": "Invalid credentials or invalid MFA code" + }, + "403": { + "description": "Local login disabled via DISABLE_LOCAL_LOGIN" + }, + "429": { + "description": "Rate-limited (too many attempts for this IP+username)" + }, + "500": { + "description": "Unexpected error during authentication" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "mfaToken": { + "type": "string" + }, + "provider": { + "type": "string" + } + }, + "required": [ + "username", + "password" + ] + }, + "example": { + "username": "admin", + "password": "correct horse battery staple", + "provider": "local" + } + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "operationId": "post_api_auth_logout", + "tags": [ + "auth" + ], + "summary": "Destroy the current session (clears the dockhand_session cookie)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "500": { + "description": "Unexpected error while destroying the session" + } + }, + "security": [] + } + }, + "/api/auth/oidc": { + "get": { + "operationId": "get_api_auth_oidc", + "tags": [ + "auth" + ], + "summary": "List all configured OIDC providers (client secrets are masked)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "issuerUrl", + "clientId" + ] + } + }, + "example": [ + { + "id": 1, + "name": "Authentik", + "enabled": true, + "issuerUrl": "https://auth.example.com/application/o/dockhand/", + "clientId": "dockhand" + } + ] + } + } + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:view)" + }, + "500": { + "description": "Failed to read the OIDC configurations" + } + }, + "security": [] + }, + "post": { + "operationId": "post_api_auth_oidc", + "tags": [ + "auth" + ], + "summary": "Create a new OIDC provider configuration", + "parameters": [], + "responses": { + "201": { + "description": "OIDC configuration created (clientSecret is masked in the response)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "issuerUrl", + "clientId" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "issuerUrl": "string", + "clientId": "string" + } + } + } + }, + "400": { + "description": "A required field is missing (name, issuerUrl, clientId, clientSecret, redirectUri)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:edit)" + }, + "500": { + "description": "Failed to create the OIDC configuration" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "redirectUri": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "scopes": { + "type": "string" + }, + "usernameClaim": { + "type": "string" + }, + "emailClaim": { + "type": "string" + }, + "displayNameClaim": { + "type": "string" + }, + "adminClaim": { + "type": "string" + }, + "adminValue": { + "type": "string" + }, + "roleMappingsClaim": { + "type": "string" + }, + "roleMappings": { + "type": "object", + "properties": {} + } + }, + "required": [ + "name", + "issuerUrl", + "clientId", + "clientSecret", + "redirectUri" + ] + }, + "example": { + "name": "Authentik", + "issuerUrl": "https://auth.example.com/application/o/dockhand/", + "clientId": "dockhand", + "clientSecret": "***", + "redirectUri": "https://dockhand.example.com/api/auth/oidc/callback", + "enabled": true, + "scopes": "openid profile email" + } + } + } + } + } + }, + "/api/auth/oidc/{id}": { + "get": { + "operationId": "get_api_auth_oidc_id", + "tags": [ + "auth" + ], + "summary": "Get a single OIDC provider configuration by id (client secret is masked)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC configuration (from GET /api/auth/oidc)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "issuerUrl", + "clientId" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "issuerUrl": "string", + "clientId": "string" + } + } + } + }, + "400": { + "description": "Invalid configuration id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:view)" + }, + "404": { + "description": "OIDC configuration not found" + }, + "500": { + "description": "Failed to read the OIDC configuration" + } + }, + "security": [] + }, + "put": { + "operationId": "put_api_auth_oidc_id", + "tags": [ + "auth" + ], + "summary": "Update an existing OIDC provider configuration (only supplied fields change; a masked clientSecret is ignored)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC configuration (from GET /api/auth/oidc)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "issuerUrl", + "clientId" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "issuerUrl": "string", + "clientId": "string" + } + } + } + }, + "400": { + "description": "Invalid configuration id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:edit)" + }, + "404": { + "description": "OIDC configuration not found" + }, + "500": { + "description": "Failed to update the OIDC configuration" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "redirectUri": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "usernameClaim": { + "type": "string" + }, + "emailClaim": { + "type": "string" + }, + "displayNameClaim": { + "type": "string" + }, + "adminClaim": { + "type": "string" + }, + "adminValue": { + "type": "string" + }, + "roleMappingsClaim": { + "type": "string" + }, + "roleMappings": { + "type": "object", + "properties": {} + } + } + }, + "example": { + "enabled": true, + "scopes": "openid profile email groups", + "clientSecret": "***" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_auth_oidc_id", + "tags": [ + "auth" + ], + "summary": "Delete an OIDC provider configuration by id", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC configuration (from GET /api/auth/oidc)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid configuration id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:edit)" + }, + "404": { + "description": "OIDC configuration not found" + }, + "500": { + "description": "Failed to delete the OIDC configuration" + } + }, + "security": [] + } + }, + "/api/auth/oidc/{id}/initiate": { + "get": { + "operationId": "get_api_auth_oidc_id_initiate", + "tags": [ + "auth" + ], + "summary": "Start the OIDC login flow for a provider — on success throws a 302 redirect to the IdP authorization URL", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC provider (from GET /api/auth/oidc)" + }, + { + "name": "redirect", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Post-login destination path to return to (defaults to /)" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "302": { + "description": "Redirect to the IdP's authorization URL" + }, + "400": { + "description": "Authentication is not enabled, or the configuration id is invalid" + }, + "404": { + "description": "OIDC provider not found or disabled" + }, + "500": { + "description": "Failed to build the authorization URL / initiate SSO" + } + }, + "security": [] + }, + "post": { + "operationId": "post_api_auth_oidc_id_initiate", + "tags": [ + "auth" + ], + "summary": "Return the OIDC authorization URL for a provider without redirecting (JSON response)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC provider (from GET /api/auth/oidc)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "example": { + "url": "string" + } + } + } + }, + "400": { + "description": "Authentication is not enabled, or the configuration id is invalid" + }, + "404": { + "description": "OIDC provider not found or disabled" + }, + "500": { + "description": "Failed to build the authorization URL / initiate SSO" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "redirect": { + "type": "string" + } + } + }, + "example": { + "redirect": "/dashboard" + } + } + } + } + } + }, + "/api/auth/oidc/{id}/test": { + "post": { + "operationId": "post_api_auth_oidc_id_test", + "tags": [ + "auth" + ], + "summary": "Test the discovery/connection of a stored OIDC configuration by id", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC configuration to test (from GET /api/auth/oidc)" + } + ], + "responses": { + "200": { + "description": "Connection test result (success flag plus diagnostic detail from the provider)" + }, + "400": { + "description": "Invalid configuration id (not a number)" + }, + "403": { + "description": "Admin access required (auth is enabled and the caller is not an admin)" + }, + "500": { + "description": "Failed to test the OIDC connection" + } + }, + "security": [] + } + }, + "/api/auth/oidc/callback": { + "get": { + "operationId": "get_api_auth_oidc_callback", + "tags": [ + "auth" + ], + "summary": "Handle the OIDC redirect callback from the IdP — always responds with a 302 redirect (to the original destination on success, or to /login with an error query param on failure)", + "parameters": [ + { + "name": "code", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Authorization code returned by the IdP" + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Opaque state value used to correlate the request and carry the post-login redirect" + }, + { + "name": "error", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Error code returned by the IdP when authentication failed" + }, + { + "name": "error_description", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Human-readable error detail returned by the IdP" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "302": { + "description": "Always a redirect — on success a session cookie is set and the caller is redirected to the original destination; on any error the caller is redirected to /login with an error query param" + } + }, + "security": [] + } + }, + "/api/auth/providers": { + "get": { + "operationId": "get_api_auth_providers", + "tags": [ + "auth" + ], + "summary": "List the authentication providers offered on the login page (local, LDAP, OIDC), plus the default provider", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "initiateUrl": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ] + } + }, + "defaultProvider": { + "type": "string" + } + } + }, + "example": { + "providers": [ + { + "id": "local", + "name": "Local", + "type": "local" + }, + { + "id": "oidc:1", + "name": "Authentik", + "type": "oidc", + "initiateUrl": "/api/auth/oidc/1/initiate" + } + ], + "defaultProvider": "local" + } + } + } + } + }, + "security": [] + } + }, + "/api/auth/session": { + "get": { + "operationId": "get_api_auth_session", + "tags": [ + "auth" + ], + "summary": "Get the current session (public — used by the frontend to bootstrap auth state)", + "parameters": [], + "responses": { + "200": { + "description": "user is present only when authenticated:true", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authenticated": { + "type": "boolean" + }, + "authEnabled": { + "type": "boolean" + }, + "user": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "isAdmin": { + "type": "boolean" + }, + "provider": { + "type": "string" + } + } + } + }, + "required": [ + "authenticated", + "authEnabled" + ] + }, + "example": { + "authenticated": true, + "authEnabled": true, + "user": { + "id": 1, + "username": "admin", + "email": "admin@example.com", + "displayName": "Admin", + "avatar": null, + "isAdmin": true, + "provider": "local" + } + } + } + } + }, + "500": { + "description": "Unexpected error while validating the session" + } + }, + "security": [] + } + }, + "/api/auth/settings": { + "get": { + "operationId": "get_api_auth_settings", + "tags": [ + "auth" + ], + "summary": "Get the global authentication settings (whether auth is enabled and the default provider)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authEnabled": { + "type": "boolean" + }, + "defaultProvider": { + "type": "string" + } + }, + "required": [ + "authEnabled" + ] + }, + "example": { + "authEnabled": true, + "defaultProvider": "local" + } + } + } + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:view)" + }, + "500": { + "description": "Failed to read the auth settings" + } + }, + "security": [] + }, + "put": { + "operationId": "put_api_auth_settings", + "tags": [ + "auth" + ], + "summary": "Update the global authentication settings (enabling auth requires at least one admin/user to exist)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authEnabled": { + "type": "boolean" + }, + "defaultProvider": { + "type": "string" + } + }, + "required": [ + "authEnabled" + ] + }, + "example": { + "authEnabled": true, + "defaultProvider": "string" + } + } + } + }, + "400": { + "description": "Cannot enable authentication without an existing user/admin (response includes requiresUser:true)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:edit)" + }, + "500": { + "description": "Failed to update the auth settings" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authEnabled": { + "type": "boolean" + }, + "defaultProvider": { + "type": "string" + } + } + }, + "example": { + "authEnabled": true, + "defaultProvider": "local" + } + } + } + } + } + }, + "/api/auth/tokens": { + "get": { + "operationId": "get_api_auth_tokens", + "tags": [ + "auth" + ], + "summary": "List the authenticated user's API tokens (never returns the hash/secret)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "tokenPrefix": { + "type": "string" + }, + "lastUsed": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "tokenPrefix", + "createdAt" + ] + } + }, + "example": [ + { + "id": 1, + "name": "CI/CD Pipeline", + "tokenPrefix": "a1b2c3d4", + "lastUsed": "2027-01-02T08:00:00Z", + "expiresAt": "2027-01-01T00:00:00Z", + "createdAt": "2026-06-01T10:00:00Z" + } + ] + } + } + }, + "400": { + "description": "Authentication is not enabled" + }, + "401": { + "description": "Not authenticated" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_auth_tokens", + "tags": [ + "auth" + ], + "summary": "Create a new API token for the authenticated user (requires a session, not a Bearer token)", + "parameters": [], + "responses": { + "201": { + "description": "Token created — the plaintext value is shown ONLY this once and cannot be retrieved again", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "tokenPrefix": { + "type": "string" + } + }, + "required": [ + "token", + "id", + "tokenPrefix" + ] + }, + "example": { + "token": "dh_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "id": 42, + "tokenPrefix": "a1b2c3d4" + } + } + } + }, + "400": { + "description": "Invalid body, missing password for local users, or MAX_TOKENS_PER_USER (25) reached" + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Token creation attempted via Bearer auth (session login required) or wrong password" + }, + "404": { + "description": "User record not found while confirming the password (local provider)" + }, + "429": { + "description": "Too many failed password confirmation attempts (rate limited)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "CI/CD Pipeline", + "expiresAt": "2027-01-01T00:00:00Z", + "password": "correct horse battery staple" + } + } + } + } + } + }, + "/api/auth/tokens/{id}": { + "delete": { + "operationId": "delete_api_auth_tokens_id", + "tags": [ + "auth" + ], + "summary": "Revoke (permanently delete) one of the authenticated user's API tokens", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Token id (from GET /api/auth/tokens)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Authentication not enabled, or id is not a valid integer" + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Attempted via Bearer auth (a leaked token cannot revoke other tokens — session required)" + }, + "404": { + "description": "Token not found, or belongs to a different (non-admin) user" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/auto-update": { + "get": { + "operationId": "get_api_auto-update", + "tags": [ + "auto-update" + ], + "summary": "Get all enabled auto-update settings for an environment, keyed by container name", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID to read settings for (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + }, + "example": { + "web-1": { + "enabled": true, + "scheduleType": "daily", + "cronExpression": "0 3 * * *", + "vulnerabilityCriteria": "never" + } + } + } + } + }, + "500": { + "description": "Failed to get auto-update settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/auto-update/{containerName}": { + "get": { + "operationId": "get_api_auto-update_containerName", + "tags": [ + "auto-update" + ], + "summary": "Get the auto-update setting for a container (returns sensible defaults when none is stored)", + "parameters": [ + { + "name": "containerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container name (URL-encoded)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the container belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "scheduleType": { + "type": "string" + }, + "cronExpression": { + "type": "string" + }, + "vulnerabilityCriteria": { + "type": "string" + } + }, + "required": [ + "enabled", + "scheduleType", + "vulnerabilityCriteria" + ] + }, + "example": { + "enabled": false, + "scheduleType": "daily", + "cronExpression": "0 3 * * *", + "vulnerabilityCriteria": "never" + } + } + } + }, + "500": { + "description": "Failed to get auto-update setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_auto-update_containerName", + "tags": [ + "auto-update" + ], + "summary": "Create or update a container's auto-update setting; enabled=false deletes it and its schedule", + "parameters": [ + { + "name": "containerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container name (URL-encoded)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the container belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "scheduleType": { + "type": "string" + }, + "cronExpression": { + "type": "string" + }, + "vulnerabilityCriteria": { + "type": "string" + }, + "deleted": { + "type": "boolean" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "id", + "enabled", + "scheduleType", + "vulnerabilityCriteria" + ] + }, + "example": { + "success": true, + "deleted": true + } + } + } + }, + "500": { + "description": "Failed to save auto-update setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cronExpression": { + "type": "string" + }, + "cron_expression": { + "type": "string" + }, + "vulnerabilityCriteria": { + "type": "string" + }, + "vulnerability_criteria": { + "type": "string" + } + } + }, + "example": { + "enabled": true, + "cronExpression": "0 3 * * *", + "vulnerabilityCriteria": "never" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_auto-update_containerName", + "tags": [ + "auto-update" + ], + "summary": "Delete a container's auto-update setting and unregister its schedule", + "parameters": [ + { + "name": "containerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container name (URL-encoded)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the container belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "500": { + "description": "Failed to delete auto-update setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/backup/configs": { + "get": { + "operationId": "get_api_backup_configs", + "tags": [ + "backup" + ], + "summary": "List backup configurations, optionally filtered by type, target and environment (enterprise callers only see configs for environments they can access)", + "parameters": [ + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by backup config type (e.g. \"container\", \"stack\")" + }, + { + "name": "target", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by the backed-up target name (container or stack name)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter by environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Array of backup configuration objects" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:view\") is produced by the shared requireBackups route guard." + }, + "post": { + "operationId": "post_api_backup_configs", + "tags": [ + "backup" + ], + "summary": "Create a backup configuration for a container or stack, optionally scheduled, and register its cron schedule when enabled", + "parameters": [], + "responses": { + "201": { + "description": "The created backup configuration object" + }, + "400": { + "description": "Invalid input — missing destinationId/targetName, invalid targetName, invalid cron expression, invalid retention, or a local repository paired with a remote environment / non-backupable stack" + }, + "403": { + "description": "Access denied to the requested environment (enterprise)" + }, + "500": { + "description": "Failed to create the backup configuration (persistence error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard. A requested environmentId the caller can't access also 403s (enterprise). destinationId from GET /api/backup/destinations. environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationId": { + "type": "integer" + }, + "targetName": { + "type": "string" + }, + "type": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "allVolumes": { + "type": "boolean" + }, + "selectedVolumes": { + "type": "array", + "items": { + "type": "string" + } + }, + "stopBeforeBackup": { + "type": "boolean" + }, + "schedule": { + "type": "string" + }, + "retention": { + "type": "object", + "properties": { + "keepLast": { + "type": "integer" + }, + "keepDaily": { + "type": "integer" + }, + "keepWeekly": { + "type": "integer" + }, + "keepMonthly": { + "type": "integer" + }, + "keepYearly": { + "type": "integer" + } + } + }, + "options": { + "type": "object", + "properties": {} + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "destinationId", + "targetName" + ] + }, + "example": { + "destinationId": 3, + "targetName": "nextcloud", + "type": "container", + "environmentId": 1, + "enabled": true, + "allVolumes": true, + "stopBeforeBackup": false, + "schedule": "0 3 * * *", + "retention": { + "keepDaily": 7, + "keepWeekly": 4 + } + } + } + } + } + } + }, + "/api/backup/configs/{id}": { + "get": { + "operationId": "get_api_backup_configs_id", + "tags": [ + "backup" + ], + "summary": "Fetch a single backup configuration by id, enforcing environment-scoped access", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup configuration id (from GET /api/backup/configs)" + } + ], + "responses": { + "200": { + "description": "The backup configuration object" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission (\"backups:view\") and environment-access denials (403) and not-found (404) are produced by the shared route guards." + }, + "put": { + "operationId": "put_api_backup_configs_id", + "tags": [ + "backup" + ], + "summary": "Update a backup configuration and re-register or remove its cron schedule accordingly", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup configuration id (from GET /api/backup/configs)" + } + ], + "responses": { + "200": { + "description": "The updated backup configuration object" + }, + "400": { + "description": "Invalid input — invalid cron expression, invalid retention, or a local repository paired with a remote environment" + }, + "409": { + "description": "Cannot change the destination while a backup is running for this config" + }, + "500": { + "description": "Update failed (persistence error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards. The environment is fixed at creation and cannot be changed here. destinationId from GET /api/backup/destinations.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "allVolumes": { + "type": "boolean" + }, + "selectedVolumes": { + "type": "array", + "items": { + "type": "string" + } + }, + "stopBeforeBackup": { + "type": "boolean" + }, + "schedule": { + "type": "string" + }, + "retention": { + "type": "object", + "properties": { + "keepLast": { + "type": "integer" + }, + "keepDaily": { + "type": "integer" + }, + "keepWeekly": { + "type": "integer" + }, + "keepMonthly": { + "type": "integer" + }, + "keepYearly": { + "type": "integer" + } + } + }, + "options": { + "type": "object", + "properties": {} + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "schedule": "0 4 * * *", + "enabled": true, + "stopBeforeBackup": true, + "retention": { + "keepDaily": 14 + } + } + } + } + } + }, + "delete": { + "operationId": "delete_api_backup_configs_id", + "tags": [ + "backup" + ], + "summary": "Delete a backup configuration and unregister its schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup configuration id (from GET /api/backup/configs)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true } once the configuration is deleted" + }, + "409": { + "description": "A backup is currently running for this config — stop it before deleting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards." + } + }, + "/api/backup/configs/{id}/run": { + "post": { + "operationId": "post_api_backup_configs_id_run", + "tags": [ + "backup" + ], + "summary": "Trigger a manual backup run for a configuration, streaming progress as a Server-Sent Events job", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup configuration id (from GET /api/backup/configs)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream of progress and a final result event (status \"success\", \"warning\", \"skipped\" or \"error\")" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a text/event-stream that emits `progress` events during the run and a final `result` event. Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards." + } + }, + "/api/backup/configs/{id}/stop": { + "post": { + "operationId": "post_api_backup_configs_id_stop", + "tags": [ + "backup" + ], + "summary": "Cancel the in-flight backup for a configuration", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup configuration id (from GET /api/backup/configs)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, stopped } where \"stopped\" indicates whether a running backup helper was actually killed" + }, + "500": { + "description": "Failed to cancel the backup (internal error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards." + } + }, + "/api/backup/destinations": { + "get": { + "operationId": "get_api_backup_destinations", + "tags": [ + "backup" + ], + "summary": "List all backup destinations (restic repositories); the password is stripped and cloud-credential env vars are omitted from the list view", + "parameters": [], + "responses": { + "200": { + "description": "Array of backup destination objects without secrets (no password, no envVars)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:view\") is produced by the shared requireBackups route guard." + }, + "post": { + "operationId": "post_api_backup_destinations", + "tags": [ + "backup" + ], + "summary": "Create a restic backup destination, auto-initialize and test the repository, and register its default maintenance schedules", + "parameters": [], + "responses": { + "201": { + "description": "The created backup destination object (includes decrypted envVars since the caller just supplied them; password is stripped)" + }, + "400": { + "description": "Invalid input — missing name/repository/password, unsupported/SSRF-blocked repository, invalid restic flags, or an invalid cron schedule in the policies" + }, + "409": { + "description": "A destination with this name already exists" + }, + "500": { + "description": "Failed to create the destination (persistence error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "password": { + "type": "string" + }, + "envVars": { + "type": "object", + "properties": {} + }, + "flags": { + "type": "string" + }, + "hostPath": { + "type": "string" + }, + "policies": { + "type": "string" + } + }, + "required": [ + "name", + "repository", + "password" + ] + }, + "example": { + "name": "S3 Offsite", + "repository": "s3:s3.amazonaws.com/my-bucket/restic", + "password": "***", + "envVars": { + "AWS_ACCESS_KEY_ID": "***", + "AWS_SECRET_ACCESS_KEY": "***" + } + } + } + } + } + } + }, + "/api/backup/destinations/{id}": { + "get": { + "operationId": "get_api_backup_destinations_id", + "tags": [ + "backup" + ], + "summary": "Fetch a single backup destination; decrypted cloud-credential env vars are only included for callers who can manage backups, and the password is always stripped", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "The backup destination object (envVars included only for \"backups:manage\" callers; password always stripped)" + }, + "400": { + "description": "Invalid id (not a number)" + }, + "404": { + "description": "Destination not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:view\") is produced by the shared requireBackups route guard." + }, + "put": { + "operationId": "put_api_backup_destinations_id", + "tags": [ + "backup" + ], + "summary": "Update a backup destination, re-validating repository and flags when supplied and re-registering maintenance schedules when policies change", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "The updated backup destination object (password stripped, envVars echoed back to the managing caller)" + }, + "400": { + "description": "Invalid input — invalid id, unsupported/SSRF-blocked repository, invalid restic flags, invalid policy cron, or switching to a local repository used by a remote-environment config" + }, + "404": { + "description": "Destination not found" + }, + "409": { + "description": "A backup using this destination is currently running, or a destination with the new name already exists" + }, + "500": { + "description": "Update failed (persistence error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "password": { + "type": "string" + }, + "envVars": { + "type": "object", + "properties": {} + }, + "flags": { + "type": "string" + }, + "hostPath": { + "type": "string" + }, + "policies": { + "type": "string" + } + } + }, + "example": { + "name": "S3 Offsite (renamed)", + "policies": "{\"pruneEnabled\":true,\"pruneSchedule\":\"0 0 1 * *\"}" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_backup_destinations_id", + "tags": [ + "backup" + ], + "summary": "Delete a backup destination and unregister all its maintenance and dependent backup-config schedules", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true } once the destination is deleted" + }, + "400": { + "description": "Invalid id (not a number)" + }, + "404": { + "description": "Destination not found" + }, + "409": { + "description": "A backup using this destination is currently running — try again once it finishes" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard." + } + }, + "/api/backup/destinations/{id}/init": { + "post": { + "operationId": "post_api_backup_destinations_id_init", + "tags": [ + "backup" + ], + "summary": "Initialize the restic repository for a destination and record the resulting test status", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, message } when the repository is initialized" + }, + "400": { + "description": "Invalid id (not a number)" + }, + "404": { + "description": "Destination not found" + }, + "500": { + "description": "Repository initialization failed; returns { success: false, error }" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard." + } + }, + "/api/backup/destinations/{id}/rotate-key": { + "post": { + "operationId": "post_api_backup_destinations_id_rotate-key", + "tags": [ + "backup" + ], + "summary": "Rotate the restic repository password for a destination and persist the new password", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true } once the password is rotated and the database is updated" + }, + "400": { + "description": "Invalid input — invalid id, missing passwords, or the current password is incorrect" + }, + "404": { + "description": "Destination not found" + }, + "409": { + "description": "restic rotated the key but the database write failed (manual recovery needed; the response includes dbOutOfSync:true)" + }, + "500": { + "description": "restic call failed for an unrelated reason" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + }, + "required": [ + "currentPassword", + "newPassword" + ] + }, + "example": { + "currentPassword": "***", + "newPassword": "***" + } + } + } + } + } + }, + "/api/backup/destinations/{id}/task": { + "post": { + "operationId": "post_api_backup_destinations_id_task", + "tags": [ + "backup" + ], + "summary": "Run a restic repository maintenance task (unlock, check, prune, stats, repair-index or repair-snapshots) against a destination", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "The task result object — { success: true, ... } on success, or { success: false, error } when the task itself fails" + }, + "400": { + "description": "Invalid input — invalid destination id, or an unknown task name (must be one of unlock, check, prune, stats, repair-index, repair-snapshots)" + }, + "403": { + "description": "No access to an environment whose configs use this destination (enterprise)" + }, + "404": { + "description": "Destination not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard. Streamed via createJobResponse — a task failure never surfaces as an HTTP 500; it comes back as a 200 with a failed job result (JSON-preferring callers) or a job/SSE 'result' event with success:false (streaming callers).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "task": { + "type": "string" + } + }, + "required": [ + "task" + ] + }, + "example": { + "task": "check" + } + } + } + } + } + }, + "/api/backup/destinations/{id}/test": { + "post": { + "operationId": "post_api_backup_destinations_id_test", + "tags": [ + "backup" + ], + "summary": "Test connectivity to a saved backup destination's repository and update its stored test status", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "Test result — { success: true, status: \"success\" } when reachable, or { success: false, status: \"needs_init\" | \"failed\", error } otherwise" + }, + "400": { + "description": "Invalid id (not a number)" + }, + "404": { + "description": "Destination not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard." + } + }, + "/api/backup/destinations/{id}/verify": { + "post": { + "operationId": "post_api_backup_destinations_id_verify", + "tags": [ + "backup" + ], + "summary": "Verify the integrity of a destination's backups by reading a data subset, streaming progress as a Server-Sent Events job", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream of progress and a final result event ({ success, ... })" + }, + "400": { + "description": "Invalid destination id (not a number)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a text/event-stream that emits `progress` events and a final `result` event. Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "dataSubset": { + "type": "string" + } + } + }, + "example": { + "dataSubset": "5%" + } + } + } + } + } + }, + "/api/backup/destinations/test": { + "post": { + "operationId": "post_api_backup_destinations_test", + "tags": [ + "backup" + ], + "summary": "Test a backup repository — either a saved destination (by destinationId) or inline credentials supplied for a not-yet-saved destination", + "parameters": [], + "responses": { + "200": { + "description": "Test result — { success: true } when the repository is reachable, or { success: false, needsInit?, error } otherwise" + }, + "400": { + "description": "Invalid input — missing repository/password for an inline test, or an unsupported/SSRF-blocked repository" + }, + "404": { + "description": "Destination not found (when testing a saved destinationId)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Provide `destinationId` to test a saved destination using its stored credentials, or omit it and supply `repository`/`password`/`envVars` to test before saving. Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard. destinationId from GET /api/backup/destinations.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationId": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "password": { + "type": "string" + }, + "envVars": { + "type": "object", + "properties": {} + } + } + }, + "example": { + "repository": "s3:s3.amazonaws.com/my-bucket/restic", + "password": "***", + "envVars": { + "AWS_ACCESS_KEY_ID": "***", + "AWS_SECRET_ACCESS_KEY": "***" + } + } + } + } + } + } + }, + "/api/backup/instance": { + "get": { + "operationId": "get_api_backup_instance", + "tags": [ + "backup" + ], + "summary": "This install's stable backup instance id, used to tell own snapshots from foreign orphans", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + } + }, + "required": [ + "instanceId" + ] + }, + "example": { + "instanceId": "string" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Requires backups:view; 403 (no permission) and 404 (backup feature disabled) come from the shared requireBackups guard." + } + }, + "/api/backup/restore": { + "post": { + "operationId": "post_api_backup_restore", + "tags": [ + "backup" + ], + "summary": "Restore a backup snapshot in-place or to a new location, streaming progress as a Server-Sent Events job", + "parameters": [], + "responses": { + "200": { + "description": "Server-Sent Events stream of progress and a final result event (status \"success\" or \"error\")" + }, + "400": { + "description": "Invalid restore request; the response \"issues\" array lists the validation problems" + }, + "403": { + "description": "Permission denied — requires \"backups:manage\", or no access to the target or snapshot-owning environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a text/event-stream that emits `progress` events and a final `result` event. An in-place restore is destructive and requires confirmOverwrite:true. Authorization and snapshot-environment access are checked before the request shape is validated. destinationId from GET /api/backup/destinations. snapshotId from GET /api/backup/snapshots. environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationId": { + "type": "integer" + }, + "snapshotId": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "targetType": { + "type": "string" + }, + "volumes": { + "type": "array", + "items": { + "type": "string" + } + }, + "environmentId": { + "type": "integer" + }, + "confirmOverwrite": { + "type": "boolean" + }, + "targetPath": { + "type": "string" + }, + "targetName": { + "type": "string" + }, + "postRestore": { + "type": "string" + }, + "volumeDestinations": { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + } + }, + "required": [ + "destinationId", + "snapshotId" + ] + }, + "example": { + "destinationId": 3, + "snapshotId": "a1b2c3d4", + "mode": "new-location", + "targetType": "container", + "targetName": "nextcloud-restored", + "environmentId": 1, + "confirmOverwrite": false + } + } + } + } + } + }, + "/api/backup/restore/preview": { + "post": { + "operationId": "post_api_backup_restore_preview", + "tags": [ + "backup" + ], + "summary": "Preview what a restore would write (resolved targets + volume/stack contents)", + "parameters": [], + "responses": { + "200": { + "description": "The snapshot preview, optionally with a resolved targets list" + }, + "400": { + "description": "Missing required fields (destinationId, snapshotId)" + }, + "403": { + "description": "Permission denied (needs backups:view) or access denied to the target environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "With includeTargets it also resolves the concrete restore targets (host paths / volumes); without it, returns the metadata-only preview the initial modal load relies on.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationId": { + "type": "integer" + }, + "snapshotId": { + "type": "string" + }, + "includeTargets": { + "type": "boolean" + }, + "targetEnvId": { + "type": "integer" + } + }, + "required": [ + "destinationId", + "snapshotId" + ] + }, + "example": { + "destinationId": 0, + "snapshotId": "string", + "includeTargets": true, + "targetEnvId": 0 + } + } + } + } + } + }, + "/api/backup/restore/stop": { + "post": { + "operationId": "post_api_backup_restore_stop", + "tags": [ + "backup" + ], + "summary": "Cancel a running restore — kills the restore helper for a given snapshotId, or all restore helpers when snapshotId is omitted", + "parameters": [], + "responses": { + "200": { + "description": "Returns { success: true, stopped } where \"stopped\" indicates whether a running restore helper was actually killed" + }, + "400": { + "description": "Invalid snapshotId" + }, + "403": { + "description": "Permission denied — requires \"backups:manage\", or no access to the targeted environment" + }, + "500": { + "description": "Failed to cancel the restore (internal error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "snapshotId from GET /api/backup/snapshots. environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "snapshotId": { + "type": "string" + }, + "environmentId": { + "type": "integer" + } + } + }, + "example": { + "snapshotId": "a1b2c3d4" + } + } + } + } + } + }, + "/api/backup/snapshots": { + "get": { + "operationId": "get_api_backup_snapshots", + "tags": [ + "backup" + ], + "summary": "List restic snapshots for a backup config or a single destination (job-polled)", + "parameters": [ + { + "name": "configId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Backup config id (mutually exclusive with destinationId)" + }, + { + "name": "destinationId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Single destination id (mutually exclusive with configId)" + }, + { + "name": "allDestinations", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "With configId, include every destination of the config" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid or missing configId/destinationId" + }, + "403": { + "description": "Permission denied (needs backups:view) or environment access denied" + }, + "404": { + "description": "Backup config not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Pass configId to list across the config's destinations, or destinationId for one. Job-polled so a reverse proxy can't abort the sync restic snapshots read at ~15s; match each snapshot's instance tag against GET /api/backup/instance to tell own snapshots from foreign orphans." + } + }, + "/api/backup/snapshots/{id}": { + "delete": { + "operationId": "delete_api_backup_snapshots_id", + "tags": [ + "backup" + ], + "summary": "Forget and prune a single snapshot from a destination (destructive restic forget --prune), with a server-authoritative environment access gate", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Restic snapshot id to forget (from GET /api/backup/snapshots)" + }, + { + "name": "destinationId", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Destination holding the snapshot (required) (from GET /api/backup/destinations)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Optional environment id for an early enterprise access check; the authoritative gate resolves the snapshot's owning environment server-side (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true } once the snapshot is forgotten and pruned" + }, + "400": { + "description": "Missing/invalid destinationId or an invalid snapshot id" + }, + "403": { + "description": "Permission denied — requires \"backups:manage\", or no access to the snapshot's owning environment" + }, + "404": { + "description": "Snapshot not found for this installation (not owned)" + }, + "500": { + "description": "Failed to forget the snapshot (restic error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/backup/snapshots/{id}/browse": { + "get": { + "operationId": "get_api_backup_snapshots_id_browse", + "tags": [ + "backup" + ], + "summary": "List directory entries at a path inside a snapshot (job-polled)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The restic snapshot id" + }, + { + "name": "destinationId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Destination the snapshot lives in" + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Directory path inside the snapshot to list" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment context for access checks" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing/invalid destinationId" + }, + "403": { + "description": "Permission denied (needs backups:view) or environment access denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Job-polled so a proxy can't abort the restic read at ~15s." + } + }, + "/api/backup/snapshots/{id}/dump": { + "get": { + "operationId": "get_api_backup_snapshots_id_dump", + "tags": [ + "backup" + ], + "summary": "Read a single file (or archive) out of a snapshot (job-polled restic dump)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The restic snapshot id" + }, + { + "name": "destinationId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Destination the snapshot lives in" + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Path inside the snapshot (must resolve under /volumes or /metadata)" + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Payload kind (e.g. inline preview vs archive)" + }, + { + "name": "download", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "When set, stream the file/directory as a tar" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing/invalid destinationId or path" + }, + "403": { + "description": "Permission denied (needs backups:view), or a raw metadata.json download was refused" + }, + "500": { + "description": "The restic dump failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Job-polled so a proxy can't abort the restic dump at ~15s. type selects the payload (inline preview vs archive); download requests a raw file/directory tar. A raw metadata.json download is refused (403) - it would bypass redaction." + } + }, + "/api/backup/snapshots/{id}/metadata": { + "get": { + "operationId": "get_api_backup_snapshots_id_metadata", + "tags": [ + "backup" + ], + "summary": "The snapshot's redacted metadata layout (job-polled restic dump)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The restic snapshot id" + }, + { + "name": "destinationId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Destination the snapshot lives in" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing/invalid destinationId" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Job-polled so a proxy can't abort the restic dump at ~15s. The layout is redacted (stack secrets and container Config.Env/Labels stripped) before it leaves the server." + } + }, + "/api/backup/snapshots/diff": { + "get": { + "operationId": "get_api_backup_snapshots_diff", + "tags": [ + "backup" + ], + "summary": "Diff two snapshots' file trees (added/changed/removed) - job-polled", + "parameters": [ + { + "name": "destinationId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Destination both snapshots live in" + }, + { + "name": "snapshotA", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "The baseline snapshot id" + }, + { + "name": "snapshotB", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "The snapshot to compare against the baseline" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing destinationId, snapshotA, or snapshotB" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Job-polled so a proxy can't abort the restic read at ~15s." + } + }, + "/api/backup/stack-dir-listing": { + "get": { + "operationId": "get_api_backup_stack-dir-listing", + "tags": [ + "backup" + ], + "summary": "Probe the target host and list the actual contents of a stack's directory, for the backup create/edit dialog's file picker", + "parameters": [ + { + "name": "target", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Target stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "The stack directory probe result — kind \"listed\"/\"tar\" with hostPath/localStackDir and entries, or kind \"helper-failed\"/\"unknown\" with a reason (never throws for an operational failure)" + }, + "400": { + "description": "target is required" + }, + "403": { + "description": "Permission denied (requires backups:view), or no access to the target's environment (enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/backup/stack-path": { + "get": { + "operationId": "get_api_backup_stack-path", + "tags": [ + "backup" + ], + "summary": "Preview where a stack's directory will be captured from on the host, without running a backup", + "parameters": [ + { + "name": "target", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Target stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "The resolved capture path preview — kind \"candidate\" with hostPath, kind \"tar\" with composeFile only, or kind \"unknown\" with a reason" + }, + "400": { + "description": "target is required" + }, + "403": { + "description": "Permission denied (requires backups:view), or no access to the target's environment (enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/batch": { + "post": { + "operationId": "post_api_batch", + "tags": [ + "batch" + ], + "summary": "Run a bulk operation (start/stop/restart/pause/unpause/remove/down, depending on entity type) across many containers, images, volumes, networks, or stacks; clients that send Accept application/json get a synchronous summary, others get a jobId and progress over Server-Sent Events", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the entities belong to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Either a synchronous completion summary ({type:\"complete\", summary:{total, success, failed}}) or an async job handle ({jobId}) whose progress streams over SSE" + }, + "400": { + "description": "Invalid JSON body, invalid entity type, invalid operation for the entity type, or empty items array" + }, + "403": { + "description": "Permission denied for the requested operation, or environment access denied on enterprise" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "operation": { + "type": "string" + }, + "entityType": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "options": { + "type": "object", + "properties": { + "force": { + "type": "boolean" + }, + "removeVolumes": { + "type": "boolean" + } + } + } + }, + "required": [ + "operation", + "entityType", + "items" + ] + }, + "example": { + "operation": "restart", + "entityType": "containers", + "items": [ + { + "id": "abc123", + "name": "web" + } + ], + "options": { + "force": false + } + } + } + } + } + } + }, + "/api/changelog": { + "get": { + "operationId": "get_api_changelog", + "tags": [ + "changelog" + ], + "summary": "Return the bundled Dockhand changelog data", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "date": { + "type": "string" + }, + "changes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "version" + ] + } + }, + "example": [ + { + "version": "1.0.39", + "date": "2026-06-01", + "changes": [ + "Fixed image export streaming" + ] + } + ] + } + } + } + }, + "security": [] + } + }, + "/api/config-sets": { + "get": { + "operationId": "get_api_config-sets", + "tags": [ + "config-sets" + ], + "summary": "List all config sets (reusable bundles of env vars, labels, ports, volumes, and runtime defaults)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "networkMode", + "restartPolicy", + "createdAt", + "updatedAt" + ] + } + }, + "example": [ + { + "id": 1, + "name": "web-defaults", + "description": "Defaults for web stacks", + "envVars": [ + { + "key": "TZ", + "value": "UTC" + } + ], + "labels": [], + "ports": [], + "volumes": [], + "networkMode": "bridge", + "restartPolicy": "unless-stopped", + "createdAt": "2026-06-01T10:00:00Z", + "updatedAt": "2026-06-01T10:00:00Z" + } + ] + } + } + }, + "403": { + "description": "Permission denied (requires configsets:view)" + }, + "500": { + "description": "Failed to fetch config sets" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_config-sets", + "tags": [ + "config-sets" + ], + "summary": "Create a new config set", + "parameters": [], + "responses": { + "201": { + "description": "Config set created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "networkMode", + "restartPolicy", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 1, + "name": "web-defaults", + "description": "Defaults for web stacks", + "envVars": [ + { + "key": "TZ", + "value": "UTC" + } + ], + "labels": [], + "ports": [], + "volumes": [], + "networkMode": "bridge", + "restartPolicy": "unless-stopped", + "createdAt": "2026-06-01T10:00:00Z", + "updatedAt": "2026-06-01T10:00:00Z" + } + } + } + }, + "400": { + "description": "Name is required, or a config set with this name already exists" + }, + "403": { + "description": "Permission denied (requires configsets:create)" + }, + "500": { + "description": "Failed to create config set" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "web-defaults", + "description": "Defaults for web stacks", + "envVars": [ + { + "key": "TZ", + "value": "UTC" + } + ], + "networkMode": "bridge", + "restartPolicy": "unless-stopped" + } + } + } + } + } + }, + "/api/config-sets/{id}": { + "get": { + "operationId": "get_api_config-sets_id", + "tags": [ + "config-sets" + ], + "summary": "Fetch a single config set by its numeric ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Config set ID (from GET /api/config-sets)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "networkMode", + "restartPolicy", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 1, + "name": "web-defaults", + "description": "Defaults for web stacks", + "envVars": [ + { + "key": "TZ", + "value": "UTC" + } + ], + "labels": [], + "ports": [], + "volumes": [], + "networkMode": "bridge", + "restartPolicy": "unless-stopped", + "createdAt": "2026-06-01T10:00:00Z", + "updatedAt": "2026-06-01T10:00:00Z" + } + } + } + }, + "400": { + "description": "Invalid ID" + }, + "403": { + "description": "Permission denied (requires configsets:view)" + }, + "404": { + "description": "Config set not found" + }, + "500": { + "description": "Failed to fetch config set" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_config-sets_id", + "tags": [ + "config-sets" + ], + "summary": "Update an existing config set by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Config set ID (from GET /api/config-sets)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "networkMode", + "restartPolicy", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 1, + "name": "web-defaults", + "description": "Defaults for web stacks", + "envVars": [], + "labels": [], + "ports": [], + "volumes": [], + "networkMode": "host", + "restartPolicy": "always", + "createdAt": "2026-06-01T10:00:00Z", + "updatedAt": "2026-06-02T12:00:00Z" + } + } + } + }, + "400": { + "description": "Invalid ID, or a config set with this name already exists" + }, + "403": { + "description": "Permission denied (requires configsets:edit)" + }, + "404": { + "description": "Config set not found" + }, + "500": { + "description": "Failed to update config set" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + } + } + }, + "example": { + "name": "web-defaults", + "networkMode": "host", + "restartPolicy": "always" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_config-sets_id", + "tags": [ + "config-sets" + ], + "summary": "Delete a config set by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Config set ID (from GET /api/config-sets)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid ID" + }, + "403": { + "description": "Permission denied (requires configsets:delete)" + }, + "404": { + "description": "Config set not found" + }, + "500": { + "description": "Failed to delete config set" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers": { + "get": { + "operationId": "get_api_containers", + "tags": [ + "containers" + ], + "summary": "List containers for one environment (dockhand.hidden=true label is filtered out)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id — an empty array is returned if omitted (from GET /api/environments)" + }, + { + "name": "all", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Include stopped containers (default true; pass \"false\" to only show running ones)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "image", + "state", + "status" + ] + } + }, + "example": [ + { + "id": "a1b2c3d4e5f6", + "name": "immich_server", + "image": "ghcr.io/immich-app/immich-server:latest", + "state": "running", + "status": "Up 2 hours (healthy)" + } + ] + } + } + }, + "403": { + "description": "Permission denied, or access denied to this environment" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_containers", + "tags": [ + "containers" + ], + "summary": "Create a new container in an environment (auto-pulls the image first if it isn't present locally), optionally starting it right away", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "imagePulled": { + "type": "boolean" + } + }, + "required": [ + "success", + "id" + ] + }, + "example": { + "success": true, + "id": "string", + "imagePulled": true + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to this environment" + }, + "500": { + "description": "Container creation failed, or the image could not be pulled" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "ports": { + "type": "object", + "properties": {} + }, + "volumes": { + "type": "object", + "properties": {} + }, + "volumeBinds": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "labels": { + "type": "object", + "properties": {} + }, + "cmd": { + "type": "array", + "items": { + "type": "string" + } + }, + "entrypoint": { + "type": "array", + "items": { + "type": "string" + } + }, + "workingDir": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + }, + "restartMaxRetries": { + "type": "integer" + }, + "networkMode": { + "type": "string" + }, + "additionalNetworks": { + "type": "array", + "items": { + "type": "string" + } + }, + "networkAliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "networkIpv4Address": { + "type": "string" + }, + "networkIpv6Address": { + "type": "string" + }, + "startAfterCreate": { + "type": "boolean" + } + }, + "required": [ + "name", + "image" + ] + }, + "example": { + "name": "my-app", + "image": "nginx:latest", + "startAfterCreate": true + } + } + } + } + } + }, + "/api/containers/{id}": { + "get": { + "operationId": "get_api_containers_id", + "tags": [ + "containers" + ], + "summary": "Return the full Docker inspect payload for a single container", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "The raw Docker inspect object for the container" + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to inspect the container (e.g. it no longer exists or the daemon is unreachable)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_containers_id", + "tags": [ + "containers" + ], + "summary": "Remove a container and clean up its auto-update schedule and pending-update record", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Force removal of a running container (Docker force flag)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to remove the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/exec": { + "post": { + "operationId": "post_api_containers_id_exec", + "tags": [ + "containers" + ], + "summary": "Create a Docker exec instance in a container and return its ID plus the Docker connection info for a terminal WebSocket", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "envId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "execId": { + "type": "string" + }, + "connectionInfo": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "execId" + ] + }, + "example": { + "execId": "string", + "connectionInfo": { + "type": "string", + "host": "string", + "port": 0 + } + } + } + } + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to create the exec instance" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "shell": { + "type": "string" + }, + "user": { + "type": "string" + } + } + }, + "example": { + "shell": "/bin/sh", + "user": "root" + } + } + } + } + } + }, + "/api/containers/{id}/files": { + "get": { + "operationId": "get_api_containers_id_files", + "tags": [ + "containers" + ], + "summary": "List the contents of a directory inside a container's filesystem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Absolute directory path inside the container (default \"/\")" + }, + { + "name": "simpleLs", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Use a lightweight `ls` listing instead of a full stat of each entry" + } + ], + "responses": { + "200": { + "description": "Directory listing (entries with name, type, size and permission metadata)" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to list the directory" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/files/chmod": { + "post": { + "operationId": "post_api_containers_id_files_chmod", + "tags": [ + "containers" + ], + "summary": "Change the mode (permissions) of a file or directory inside a container (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "recursive": { + "type": "boolean" + } + }, + "required": [ + "success", + "path", + "mode", + "recursive" + ] + }, + "example": { + "success": true, + "path": "/app/entrypoint.sh", + "mode": "755", + "recursive": false + } + } + } + }, + "400": { + "description": "Path or mode missing, an invalid chmod mode, or the container is not running" + }, + "403": { + "description": "Permission denied, read-only file system, or operation not permitted" + }, + "404": { + "description": "Path not found" + }, + "500": { + "description": "Failed to change permissions" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "recursive": { + "type": "boolean" + } + }, + "required": [ + "path", + "mode" + ] + }, + "example": { + "path": "/app/entrypoint.sh", + "mode": "755", + "recursive": false + } + } + } + } + } + }, + "/api/containers/{id}/files/content": { + "get": { + "operationId": "get_api_containers_id_files_content", + "tags": [ + "containers" + ], + "summary": "Read the content of a single file inside a container (max 1 MB)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute file path inside the container" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "content", + "path" + ] + }, + "example": { + "content": "string", + "path": "string" + } + } + } + }, + "400": { + "description": "Path is missing, the target is a directory, or the container is not running" + }, + "403": { + "description": "Permission denied to read the file" + }, + "404": { + "description": "File not found" + }, + "413": { + "description": "File is larger than the 1 MB read limit" + }, + "500": { + "description": "Failed to read the file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_containers_id_files_content", + "tags": [ + "containers" + ], + "summary": "Overwrite the content of a file inside a container (max 1 MB, requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute file path inside the container" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "required": [ + "success", + "path" + ] + }, + "example": { + "success": true, + "path": "/etc/nginx/nginx.conf" + } + } + } + }, + "400": { + "description": "Path is missing, content is missing/not a string, or the container is not running" + }, + "403": { + "description": "Permission denied, or the target file system is read-only" + }, + "404": { + "description": "Target directory not found" + }, + "413": { + "description": "Content is larger than the 1 MB write limit" + }, + "500": { + "description": "Failed to write the file" + }, + "507": { + "description": "No space left on device" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "server {\n listen 80;\n}\n" + } + } + } + } + } + }, + "/api/containers/{id}/files/create": { + "post": { + "operationId": "post_api_containers_id_files_create", + "tags": [ + "containers" + ], + "summary": "Create an empty file or a directory inside a container (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "success", + "path", + "type" + ] + }, + "example": { + "success": true, + "path": "/app/data", + "type": "directory" + } + } + } + }, + "400": { + "description": "Path missing, type not \"file\" or \"directory\", or the container is not running" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Parent directory not found" + }, + "409": { + "description": "Path already exists" + }, + "500": { + "description": "Failed to create the path" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "path", + "type" + ] + }, + "example": { + "path": "/app/data", + "type": "directory" + } + } + } + } + } + }, + "/api/containers/{id}/files/delete": { + "delete": { + "operationId": "delete_api_containers_id_files_delete", + "tags": [ + "containers" + ], + "summary": "Delete a file or directory inside a container (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute path inside the container to delete" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "required": [ + "success", + "path" + ] + }, + "example": { + "success": true, + "path": "/app/tmp/old.log" + } + } + } + }, + "400": { + "description": "Path missing, a refused critical-path delete, a non-empty directory, or the container is not running" + }, + "403": { + "description": "Permission denied, or read-only file system" + }, + "404": { + "description": "Path not found" + }, + "500": { + "description": "Failed to delete the path" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/files/download": { + "get": { + "operationId": "get_api_containers_id_files_download", + "tags": [ + "containers" + ], + "summary": "Download a file or directory from a container as a tar (optionally gzip-compressed) archive attachment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute path inside the container to archive and download" + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Archive format, \"tar\" (default) or \"tar.gz\"" + } + ], + "responses": { + "200": { + "description": "Archive stream (application/x-tar or application/gzip) as a file attachment" + }, + "400": { + "description": "Path is missing" + }, + "403": { + "description": "Permission denied to access the path" + }, + "404": { + "description": "File not found" + }, + "500": { + "description": "Failed to download the file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/files/rename": { + "post": { + "operationId": "post_api_containers_id_files_rename", + "tags": [ + "containers" + ], + "summary": "Rename or move a file or directory inside a container (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "oldPath": { + "type": "string" + }, + "newPath": { + "type": "string" + } + }, + "required": [ + "success", + "oldPath", + "newPath" + ] + }, + "example": { + "success": true, + "oldPath": "/app/config.yaml", + "newPath": "/app/config.old.yaml" + } + } + } + }, + "400": { + "description": "oldPath or newPath missing, or the container is not running" + }, + "403": { + "description": "Permission denied, or read-only file system" + }, + "404": { + "description": "Source path not found" + }, + "409": { + "description": "Destination already exists" + }, + "500": { + "description": "Failed to rename the path" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "oldPath": { + "type": "string" + }, + "newPath": { + "type": "string" + } + }, + "required": [ + "oldPath", + "newPath" + ] + }, + "example": { + "oldPath": "/app/config.yaml", + "newPath": "/app/config.old.yaml" + } + } + } + } + } + }, + "/api/containers/{id}/files/upload": { + "post": { + "operationId": "post_api_containers_id_files_upload", + "tags": [ + "containers" + ], + "summary": "Upload one or more files into a container directory (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute target directory inside the container" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "uploaded": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "success", + "uploaded" + ] + }, + "example": { + "success": true, + "uploaded": [ + "app.conf" + ] + } + } + } + }, + "400": { + "description": "Target path missing, or no files provided" + }, + "403": { + "description": "Permission denied to write to the path" + }, + "404": { + "description": "Target directory not found" + }, + "500": { + "description": "Failed to upload the files" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Send `multipart/form-data` with one or more `files` parts; each file is tar-wrapped and written into the target directory. Partial success is reported per file in `uploaded`/`errors`." + } + }, + "/api/containers/{id}/inspect": { + "get": { + "operationId": "get_api_containers_id_inspect", + "tags": [ + "containers" + ], + "summary": "Return the full Docker inspect payload for a container (requires the 'inspect' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "The raw Docker inspect object for the container" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to inspect the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/logs": { + "get": { + "operationId": "get_api_containers_id_logs", + "tags": [ + "containers" + ], + "summary": "Return the last N lines of a container's combined stdout/stderr logs as a single string", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "tail", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Number of trailing log lines to return (default 100)" + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only return logs since this time (Unix timestamp or Docker duration, e.g. 10m)" + }, + { + "name": "until", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only return logs before this time (Unix timestamp or Docker duration)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "string" + } + }, + "required": [ + "logs" + ] + }, + "example": { + "logs": "string" + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to read the container logs" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/logs/stream": { + "get": { + "operationId": "get_api_containers_id_logs_stream", + "tags": [ + "containers" + ], + "summary": "Stream a container's logs live as Server-Sent Events (connected/log/end/error events, with keepalive heartbeats)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "tail", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Number of trailing log lines to prime the stream with (default \"100\")" + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only stream logs since this time (Unix timestamp or Docker duration, e.g. 10m)" + }, + { + "name": "until", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only stream logs before this time (Unix timestamp or Docker duration)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream) of log lines" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a `text/event-stream`. For Hawser Edge environments whose agent is not connected the stream body carries an error event. Docker stdout/stderr frames are demultiplexed server-side; TTY containers stream raw text." + } + }, + "/api/containers/{id}/pause": { + "post": { + "operationId": "post_api_containers_id_pause", + "tags": [ + "containers" + ], + "summary": "Pause all processes in a running container (requires the container 'stop' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to pause the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/rename": { + "post": { + "operationId": "post_api_containers_id_rename", + "tags": [ + "containers" + ], + "summary": "Rename a container and update any associated auto-update schedule (requires the 'create' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The new name is missing or not a string" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to rename the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "my-renamed-container" + } + } + } + } + } + }, + "/api/containers/{id}/restart": { + "post": { + "operationId": "post_api_containers_id_restart", + "tags": [ + "containers" + ], + "summary": "Restart a container", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to restart the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/shells": { + "get": { + "operationId": "get_api_containers_id_shells", + "tags": [ + "containers" + ], + "summary": "Probe a container for available shells (bash/sh/zsh/ash) and report the preferred default (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "shells": { + "type": "array", + "items": { + "type": "string" + } + }, + "defaultShell": { + "type": "string" + }, + "allShells": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "label": { + "type": "string" + }, + "available": { + "type": "boolean" + } + }, + "required": [ + "path", + "label", + "available" + ] + } + } + }, + "required": [ + "shells", + "allShells" + ] + }, + "example": { + "shells": [ + "/bin/sh" + ], + "defaultShell": "/bin/sh", + "allShells": [ + { + "path": "/bin/bash", + "label": "Bash", + "available": false + }, + { + "path": "/bin/sh", + "label": "Shell (sh)", + "available": true + } + ] + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "On probe failure the endpoint still returns 200 with empty results rather than an error, so callers always get a usable shell list." + } + }, + "/api/containers/{id}/start": { + "post": { + "operationId": "post_api_containers_id_start", + "tags": [ + "containers" + ], + "summary": "Start a stopped container", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to start the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/stats": { + "get": { + "operationId": "get_api_containers_id_stats", + "tags": [ + "containers" + ], + "summary": "Return a one-shot CPU/memory/network/block-IO stats snapshot for a container (Docker-CLI-equivalent memory accounting)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cpuPercent": { + "type": "number" + }, + "memoryUsage": { + "type": "integer" + }, + "memoryRaw": { + "type": "integer" + }, + "memoryCache": { + "type": "integer" + }, + "memoryLimit": { + "type": "integer" + }, + "memoryPercent": { + "type": "number" + }, + "networkRx": { + "type": "integer" + }, + "networkTx": { + "type": "integer" + }, + "blockRead": { + "type": "integer" + }, + "blockWrite": { + "type": "integer" + }, + "timestamp": { + "type": "integer" + } + }, + "required": [ + "cpuPercent", + "memoryUsage", + "memoryRaw", + "memoryCache", + "memoryLimit", + "memoryPercent", + "networkRx", + "networkTx", + "blockRead", + "blockWrite", + "timestamp" + ] + }, + "example": { + "cpuPercent": 0, + "memoryUsage": 0, + "memoryRaw": 0, + "memoryCache": 0, + "memoryLimit": 0, + "memoryPercent": 0, + "networkRx": 0, + "networkTx": 0, + "blockRead": 0, + "blockWrite": 0, + "timestamp": 0 + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "No environment configured, the environment was not found, or the container was not found" + }, + "500": { + "description": "Failed to read the container stats" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/stop": { + "post": { + "operationId": "post_api_containers_id_stop", + "tags": [ + "containers" + ], + "summary": "Stop a running container", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to stop the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/top": { + "get": { + "operationId": "get_api_containers_id_top", + "tags": [ + "containers" + ], + "summary": "List the processes running inside a container (via `ps` in the container, falling back to the Docker top API)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Titles": { + "type": "array", + "items": { + "type": "string" + } + }, + "Processes": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "source": { + "type": "string" + } + }, + "required": [ + "Titles", + "Processes", + "source" + ] + }, + "example": { + "Titles": [ + "string" + ], + "Processes": [ + [ + "string" + ] + ], + "source": "string" + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to read the container process list" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Uses the container 'inspect' permission. The `source` field indicates whether the result came from an in-container `ps` (`\"ps\"`) or the Docker top API fallback (`\"top\"`)." + } + }, + "/api/containers/{id}/unpause": { + "post": { + "operationId": "post_api_containers_id_unpause", + "tags": [ + "containers" + ], + "summary": "Resume all processes in a paused container (requires the container 'start' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to unpause the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/update": { + "post": { + "operationId": "post_api_containers_id_update", + "tags": [ + "containers" + ], + "summary": "Recreate a container with updated create-options (optionally re-pulling the image first), preserving its configuration (requires the 'create' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": [ + "success", + "id" + ] + }, + "example": { + "success": true, + "id": "3f4a1c2b9d8e" + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to update the container (e.g. the image pull or recreation failed)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "The body carries the container create-options (image, name, env, ports, volumes, …) alongside the two control flags below; `repullImage` pulls the image before recreation and `startAfterUpdate` starts the new container once created.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string" + }, + "name": { + "type": "string" + }, + "repullImage": { + "type": "boolean" + }, + "startAfterUpdate": { + "type": "boolean" + } + } + }, + "example": { + "image": "nginx:latest", + "name": "web", + "repullImage": true, + "startAfterUpdate": true + } + } + } + } + } + }, + "/api/containers/{id}/update-runtime": { + "post": { + "operationId": "post_api_containers_id_update-runtime", + "tags": [ + "containers" + ], + "summary": "Update a running container's restart policy and/or CPU/memory/blkio/pids limits in-place (no recreate)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container id (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "warnings": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid JSON body, or no supported fields were provided" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Update failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "RestartPolicy": { + "type": "object", + "properties": {} + }, + "CpuShares": { + "type": "integer" + }, + "CpuPeriod": { + "type": "integer" + }, + "CpuQuota": { + "type": "integer" + }, + "CpuRealtimePeriod": { + "type": "integer" + }, + "CpuRealtimeRuntime": { + "type": "integer" + }, + "CpusetCpus": { + "type": "string" + }, + "CpusetMems": { + "type": "string" + }, + "NanoCpus": { + "type": "integer" + }, + "Memory": { + "type": "integer" + }, + "MemorySwap": { + "type": "integer" + }, + "MemoryReservation": { + "type": "integer" + }, + "MemorySwappiness": { + "type": "integer" + }, + "KernelMemory": { + "type": "integer" + }, + "BlkioWeight": { + "type": "integer" + }, + "BlkioWeightDevice": { + "type": "array", + "items": { + "type": "string" + } + }, + "BlkioDeviceReadBps": { + "type": "array", + "items": { + "type": "string" + } + }, + "BlkioDeviceWriteBps": { + "type": "array", + "items": { + "type": "string" + } + }, + "BlkioDeviceReadIOps": { + "type": "array", + "items": { + "type": "string" + } + }, + "BlkioDeviceWriteIOps": { + "type": "array", + "items": { + "type": "string" + } + }, + "PidsLimit": { + "type": "integer" + } + } + }, + "example": { + "Memory": 536870912, + "PidsLimit": 512 + } + } + } + } + } + }, + "/api/containers/{id}/version-notes": { + "get": { + "operationId": "get_api_containers_id_version-notes", + "tags": [ + "containers" + ], + "summary": "Resolve GitHub release notes for the versions a semver update-check surfaced (requires the 'view' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "versions", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Comma-separated version tags to fetch notes for (target + skipped), e.g. \"16.3-alpine,16.4-alpine\"" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "changelogUrl": { + "type": "string" + }, + "source": { + "type": "string" + }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "name": { + "type": "string" + }, + "githubTag": { + "type": "string" + }, + "body": { + "type": "string" + }, + "publishedAt": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "version", + "url" + ] + } + } + }, + "required": [ + "notes" + ] + }, + "example": { + "changelogUrl": "https://github.com/go-gitea/gitea/releases", + "source": "go-gitea/gitea", + "notes": [ + { + "version": "1.22.0", + "name": "v1.22.0", + "githubTag": "v1.22.0", + "body": "## Changes...", + "publishedAt": "2024-05-01T00:00:00Z", + "url": "https://github.com/go-gitea/gitea/releases/tag/v1.22.0" + } + ] + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to resolve release notes" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/batch-update": { + "post": { + "operationId": "post_api_containers_batch-update", + "tags": [ + "containers" + ], + "summary": "Recreate a set of containers with their latest images, preserving all settings (requires the 'create' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "containerId", + "containerName", + "success" + ] + } + }, + "summary": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "success": { + "type": "integer" + }, + "failed": { + "type": "integer" + } + }, + "required": [ + "total", + "success", + "failed" + ] + } + }, + "required": [ + "success", + "results", + "summary" + ] + }, + "example": { + "success": true, + "results": [ + { + "containerId": "string", + "containerName": "string", + "success": true, + "error": "string" + } + ], + "summary": { + "total": 0, + "success": 0, + "failed": 0 + } + } + } + } + }, + "400": { + "description": "The containerIds array is missing or empty" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to run the batch update" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Containers are processed sequentially; the response reports per-container success/failure plus a summary. Use the streaming variant for live progress. containerIds from GET /api/containers.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "containerIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "containerIds" + ] + }, + "example": { + "containerIds": [ + "3f4a1c2b9d8e", + "a1b2c3d4e5f6" + ] + } + } + } + } + } + }, + "/api/containers/batch-update-stream": { + "post": { + "operationId": "post_api_containers_batch-update-stream", + "tags": [ + "containers" + ], + "summary": "Recreate a set of containers with live streaming progress (Server-Sent Events), optionally blocking on vulnerability criteria (requires the 'create' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream) of per-container update progress" + }, + "400": { + "description": "Invalid JSON body, or the containerIds array is missing or empty" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a `text/event-stream` reporting per-container progress. `vulnerabilityCriteria` (default \"never\") can block an update when a container's image scan exceeds the configured severity threshold. containerIds from GET /api/containers.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "containerIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "vulnerabilityCriteria": { + "type": "string" + } + }, + "required": [ + "containerIds" + ] + }, + "example": { + "containerIds": [ + "3f4a1c2b9d8e" + ], + "vulnerabilityCriteria": "never" + } + } + } + } + } + }, + "/api/containers/check-updates": { + "get": { + "operationId": "get_api_containers_check-updates", + "tags": [ + "containers" + ], + "summary": "Read the cached pending image-update records for an environment (no fresh check; requires the 'view' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentId": { + "type": "integer" + }, + "pendingUpdates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "currentImage": { + "type": "string" + }, + "checkedAt": { + "type": "string" + } + }, + "required": [ + "containerId", + "containerName", + "currentImage" + ] + } + } + }, + "required": [ + "environmentId", + "pendingUpdates" + ] + }, + "example": { + "environmentId": 0, + "pendingUpdates": [ + { + "containerId": "string", + "containerName": "string", + "currentImage": "string", + "checkedAt": "string" + } + ] + } + } + } + }, + "400": { + "description": "Environment ID required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to get pending updates" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns the containers currently flagged as having a pending image update from the last check. Does not trigger a new check — use POST to run a fresh check." + }, + "post": { + "operationId": "post_api_containers_check-updates", + "tags": [ + "containers" + ], + "summary": "Trigger a fresh image-update check across all (non-hidden, non-podman-infra) containers in an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "text/event-stream job stream (\"progress\" events with {checked,total}, final \"result\" event with {total,updatesFound,results}) — or, with \"Accept: application/json\", the final result as plain JSON" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/pending-updates": { + "get": { + "operationId": "get_api_containers_pending-updates", + "tags": [ + "containers" + ], + "summary": "List the containers in an environment that have a pending image update recorded (requires the 'view' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (required) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentId": { + "type": "integer" + }, + "pendingUpdates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "currentImage": { + "type": "string" + }, + "checkedAt": { + "type": "string" + }, + "hasImageUpdate": { + "type": "boolean" + }, + "newerVersion": { + "type": "string" + } + }, + "required": [ + "containerId", + "containerName", + "currentImage", + "checkedAt", + "hasImageUpdate" + ] + } + } + }, + "required": [ + "environmentId", + "pendingUpdates" + ] + }, + "example": { + "environmentId": 0, + "pendingUpdates": [ + { + "containerId": "string", + "containerName": "string", + "currentImage": "string", + "checkedAt": "string", + "hasImageUpdate": true, + "newerVersion": "string" + } + ] + } + } + } + }, + "400": { + "description": "Environment ID is required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to get the pending updates" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_containers_pending-updates", + "tags": [ + "containers" + ], + "summary": "Clear pending-update records for an environment, or for a single container when containerId is given", + "parameters": [ + { + "name": "env", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (required) (from GET /api/environments)" + }, + { + "name": "containerId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "The container to clear; omit to clear ALL pending updates in the environment (from GET /api/containers)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Environment ID is required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to remove the pending update" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/sizes": { + "get": { + "operationId": "get_api_containers_sizes", + "tags": [ + "containers" + ], + "summary": "List all containers in an environment together with their writable-layer and root-filesystem sizes (requires the 'view' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Array of containers with size metadata (SizeRw / SizeRootFs)" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to get container sizes (returns an empty object)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/stats": { + "get": { + "operationId": "get_api_containers_stats", + "tags": [ + "containers" + ], + "summary": "Return a CPU/memory/network/block-IO stats snapshot for every running container in an environment (requires the 'view' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "debug", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Return raw Docker stats for the single container with this name instead of the aggregate list" + } + ], + "responses": { + "200": { + "description": "Array of per-container stats snapshots (or, with `debug`, the raw stats for one container)" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "The requested debug container was not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns an empty array when no environment is configured or specified. With `debug=` it returns the raw memory_stats for a single container instead. On internal error it returns an empty array with status 200." + } + }, + "/api/containers/stats/stream": { + "get": { + "operationId": "get_api_containers_stats_stream", + "tags": [ + "containers" + ], + "summary": "Stream live CPU/memory/network/block-IO stats for all running containers in an environment (one snapshot round, then closes)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id — an immediate \"done\" event is sent if omitted or no environments are configured (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "text/event-stream SSE stream (\"stat\" events per container with {id,name,cpuPercent,memoryUsage,memoryRaw,memoryCache,memoryLimit,memoryPercent,networkRx,networkTx,blockRead,blockWrite}, an \"error\" event on environment-not-found, then a final \"done\" event)" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/dashboard/preferences": { + "get": { + "operationId": "get_api_dashboard_preferences", + "tags": [ + "dashboard" + ], + "summary": "Get the current user's dashboard preferences (grid layout, lock state, view mode)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "gridLayout": { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + }, + "locked": { + "type": "boolean" + }, + "viewMode": { + "type": "string" + } + }, + "required": [ + "locked", + "viewMode" + ] + }, + "example": { + "gridLayout": [], + "locked": false, + "viewMode": "grid" + } + } + } + }, + "500": { + "description": "Failed to get dashboard preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_dashboard_preferences", + "tags": [ + "dashboard" + ], + "summary": "Update the current user's dashboard preferences (only provided fields are merged)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "gridLayout": { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + }, + "locked": { + "type": "boolean" + }, + "viewMode": { + "type": "string" + } + }, + "required": [ + "locked", + "viewMode" + ] + }, + "example": { + "gridLayout": [ + {} + ], + "locked": true, + "viewMode": "string" + } + } + } + }, + "500": { + "description": "Failed to save dashboard preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "gridLayout": { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + }, + "locked": { + "type": "boolean" + }, + "viewMode": { + "type": "string" + } + } + }, + "example": { + "locked": true, + "viewMode": "list" + } + } + } + } + } + }, + "/api/dashboard/stats": { + "get": { + "operationId": "get_api_dashboard_stats", + "tags": [ + "dashboard" + ], + "summary": "Get aggregated dashboard statistics per environment (containers, images, volumes, stacks, metrics)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Restrict the stats to a single environment (returns one object) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "online": { + "type": "boolean" + }, + "containers": { + "type": "object", + "properties": {} + }, + "images": { + "type": "object", + "properties": {} + }, + "volumes": { + "type": "object", + "properties": {} + }, + "networks": { + "type": "object", + "properties": {} + }, + "stacks": { + "type": "object", + "properties": {} + }, + "metrics": { + "type": "object", + "properties": {} + }, + "events": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "name" + ] + } + }, + "example": [ + { + "id": 0, + "name": "string", + "online": true, + "containers": {}, + "images": {}, + "volumes": {}, + "networks": {}, + "stacks": {}, + "metrics": {}, + "events": {} + } + ] + } + } + }, + "403": { + "description": "Permission denied (requires the environments:view permission)" + }, + "404": { + "description": "Environment not found (when env is supplied)" + }, + "500": { + "description": "Failed to get dashboard stats" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns an array of per-environment stats. When env is supplied and matches, a single object is returned instead of an array. An empty array is returned on a fresh install with no environments." + } + }, + "/api/dashboard/stats/stream": { + "get": { + "operationId": "get_api_dashboard_stats_stream", + "tags": [ + "dashboard" + ], + "summary": "Stream per-environment dashboard stats progressively over Server-Sent Events", + "parameters": [], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream), or a JSON payload when Accept application/json is requested" + }, + "403": { + "description": "Permission denied (requires the environments:view permission)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Emits environments, partial, complete, error and done events as each environment's stats resolve. When the client sends Accept application/json, the stream is collected and returned as a single JSON payload instead." + } + }, + "/api/debug/memory": { + "get": { + "operationId": "get_api_debug_memory", + "tags": [ + "debug" + ], + "summary": "Return Node.js/V8 memory diagnostics; only enabled when MEMORY_MONITOR=true. Also supports ?snapshot and ?snapshots to dump/list heap snapshots.", + "parameters": [ + { + "name": "gc", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Force garbage collection before reporting when true (requires Node started with --expose-gc)" + } + ], + "responses": { + "200": { + "description": "With ?snapshot returns {snapshot}, with ?snapshots returns {snapshots}; otherwise the full memory report", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timestamp": { + "type": "string" + }, + "uptime": { + "type": "string" + }, + "gcForced": { + "type": "boolean" + }, + "gcAvailable": { + "type": "boolean" + }, + "process": { + "type": "string" + }, + "growth": { + "type": "string" + }, + "v8Heap": { + "type": "string" + }, + "system": { + "type": "string" + }, + "rssTracker": { + "type": "string" + } + }, + "required": [ + "timestamp", + "uptime", + "gcForced", + "gcAvailable", + "process", + "growth", + "v8Heap", + "system" + ] + }, + "example": { + "timestamp": "2026-07-01T10:00:00.000Z", + "uptime": { + "ms": 3600000, + "hours": 1, + "human": "1h 0m" + }, + "gcForced": false, + "gcAvailable": false, + "process": { + "rss": "180 MB" + }, + "growth": { + "rssPerHour": "2 MB" + }, + "v8Heap": { + "usedHeapSize": "90 MB" + }, + "system": { + "cpus": 8, + "platform": "linux" + } + } + } + } + }, + "403": { + "description": "Memory monitor not enabled (set MEMORY_MONITOR=true)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/dependencies": { + "get": { + "operationId": "get_api_dependencies", + "tags": [ + "dependencies" + ], + "summary": "Return the combined list of npm dependencies and external tool images (grype, trivy), sorted by name, excluding Dockhand itself", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "license": { + "type": "string" + }, + "repository": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ] + } + }, + "example": [ + { + "name": "anchore/grype", + "version": "v0.110.0", + "license": "Apache-2.0", + "repository": "https://github.com/anchore/grype" + } + ] + } + } + } + }, + "security": [] + } + }, + "/api/docs": { + "get": { + "operationId": "get_api_docs", + "tags": [ + "docs" + ], + "summary": "The full OpenAPI 3.0 specification for the Dockhand REST API (unauthenticated)", + "parameters": [], + "responses": { + "200": { + "description": "The generated OpenAPI document — see GET /api/docs/ui for an interactive viewer" + }, + "304": { + "description": "Not modified — the client's If-None-Match matches the current spec ETag" + }, + "404": { + "description": "Not found — API docs are disabled (set FEAT_API_DOCS=true to enable)" + } + }, + "security": [] + } + }, + "/api/environments": { + "get": { + "operationId": "get_api_environments", + "tags": [ + "environments" + ], + "summary": "List all Docker environments (hosts) known to Dockhand", + "parameters": [], + "responses": { + "200": { + "description": "Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "connectionType": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "protocol": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "publicIp": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "connectionType" + ] + } + }, + "example": [ + { + "id": 1, + "name": "hhdocker01", + "connectionType": "socket", + "host": null, + "port": 2375, + "protocol": "http", + "icon": "server", + "publicIp": "203.0.113.10", + "timezone": "Europe/Berlin" + }, + { + "id": 2, + "name": "hhdocker02", + "connectionType": "hawser-edge", + "host": null, + "port": 2375, + "protocol": "http", + "icon": "server", + "publicIp": null, + "timezone": "Europe/Berlin" + } + ] + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "500": { + "description": "Unexpected error while loading environments" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments", + "tags": [ + "environments" + ], + "summary": "Register a new Docker environment (host)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "connectionType": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "connectionType" + ] + }, + "example": { + "id": 3, + "name": "hhdocker03", + "connectionType": "socket" + } + } + } + }, + "400": { + "description": "Invalid name, invalid connectionType, or host missing for direct/hawser-standard" + }, + "403": { + "description": "Permission denied (RBAC 'environments:create' missing)" + }, + "409": { + "description": "An environment with this name already exists" + }, + "500": { + "description": "Unexpected error while creating the environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "connectionType": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "protocol": { + "type": "string" + }, + "socketPath": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "publicIp": { + "type": "string" + }, + "labels": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "hhdocker03", + "connectionType": "socket", + "socketPath": "/var/run/docker.sock", + "icon": "server" + } + } + } + } + } + }, + "/api/environments/{id}": { + "get": { + "operationId": "get_api_environments_id", + "tags": [ + "environments" + ], + "summary": "Get a single environment by id, including its parsed labels and public IP", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "connectionType": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "publicIp": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "connectionType" + ] + }, + "example": { + "id": 0, + "name": "string", + "connectionType": "string", + "labels": [ + "string" + ], + "publicIp": "string" + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading the environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_environments_id", + "tags": [ + "environments" + ], + "summary": "Update an environment; renaming also renames its on-disk stacks/git-repos directories", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "connectionType": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "publicIp": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "connectionType" + ] + }, + "example": { + "id": 0, + "name": "string", + "connectionType": "string", + "labels": [ + "string" + ], + "publicIp": "string" + } + } + } + }, + "400": { + "description": "Invalid new name (rename validation)" + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "409": { + "description": "Rename target directory already exists, or the on-disk rename failed (e.g. EXDEV across filesystems)" + }, + "500": { + "description": "Unexpected error while updating the environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "protocol": { + "type": "string" + }, + "tlsCa": { + "type": "string" + }, + "tlsCert": { + "type": "string" + }, + "tlsKey": { + "type": "string" + }, + "tlsSkipVerify": { + "type": "boolean" + }, + "icon": { + "type": "string" + }, + "socketPath": { + "type": "string" + }, + "collectActivity": { + "type": "boolean" + }, + "collectMetrics": { + "type": "boolean" + }, + "highlightChanges": { + "type": "boolean" + }, + "labels": { + "type": "string" + }, + "connectionType": { + "type": "string" + }, + "hawserToken": { + "type": "string" + }, + "publicIp": { + "type": "string" + } + } + }, + "example": { + "name": "hhdocker03", + "collectMetrics": true + } + } + } + } + }, + "delete": { + "operationId": "delete_api_environments_id", + "tags": [ + "environments" + ], + "summary": "Delete an environment and all its associated git stacks, schedules, icons, and on-disk directories", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid environment id, or the environment could not be deleted" + }, + "403": { + "description": "Permission denied (RBAC 'environments:delete' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Environment name is empty/whitespace (refuses to delete to avoid an unsafe directory cleanup), or an unexpected error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/{id}/disk-warning": { + "get": { + "operationId": "get_api_environments_id_disk-warning", + "tags": [ + "environments" + ], + "summary": "Get the disk-space warning thresholds for an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "threshold": { + "type": "integer" + }, + "thresholdGb": { + "type": "integer" + } + }, + "required": [ + "enabled", + "mode", + "threshold", + "thresholdGb" + ] + }, + "example": { + "enabled": true, + "mode": "percentage", + "threshold": 80, + "thresholdGb": 50 + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_disk-warning", + "tags": [ + "environments" + ], + "summary": "Save the disk-space warning thresholds for an environment (each field optional/independent)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while saving the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "threshold": { + "type": "integer" + }, + "thresholdGb": { + "type": "integer" + } + } + }, + "example": { + "enabled": true, + "mode": "percentage", + "threshold": 85 + } + } + } + } + } + }, + "/api/environments/{id}/icon": { + "get": { + "operationId": "get_api_environments_id_icon", + "tags": [ + "environments" + ], + "summary": "Get the custom icon image for an environment (raw image/webp bytes, not JSON)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Binary image/webp response body, Cache-Control public max-age=3600" + }, + "404": { + "description": "No custom icon set for this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_icon", + "tags": [ + "environments" + ], + "summary": "Upload a custom icon for an environment (base64-encoded image, ~200KB limit)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "icon": { + "type": "string" + } + }, + "required": [ + "success", + "icon" + ] + }, + "example": { + "success": true, + "icon": "custom:env-3.webp" + } + } + } + }, + "400": { + "description": "Missing image data, or image exceeds the ~300000-char base64 size limit" + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string" + } + }, + "required": [ + "image" + ] + }, + "example": { + "image": "string" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_environments_id_icon", + "tags": [ + "environments" + ], + "summary": "Remove an environment's custom icon and reset it to the default \"globe\" icon", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "icon": { + "type": "string" + } + }, + "required": [ + "success", + "icon" + ] + }, + "example": { + "success": true, + "icon": "globe" + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/{id}/image-prune": { + "get": { + "operationId": "get_api_environments_id_image-prune", + "tags": [ + "environments" + ], + "summary": "Get the automatic image-prune schedule settings for an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cronExpression": { + "type": "string" + }, + "pruneMode": { + "type": "string" + } + }, + "required": [ + "enabled", + "cronExpression", + "pruneMode" + ] + } + }, + "required": [ + "settings" + ] + }, + "example": { + "settings": { + "enabled": false, + "cronExpression": "0 3 * * 0", + "pruneMode": "dangling" + } + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_image-prune", + "tags": [ + "environments" + ], + "summary": "Save the automatic image-prune schedule for an environment (registers/unregisters the croner job)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cronExpression": { + "type": "string" + }, + "pruneMode": { + "type": "string" + } + }, + "required": [ + "enabled", + "cronExpression", + "pruneMode" + ] + } + }, + "required": [ + "success", + "settings" + ] + }, + "example": { + "success": true, + "settings": { + "enabled": true, + "cronExpression": "string", + "pruneMode": "string" + } + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while saving the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cronExpression": { + "type": "string" + }, + "pruneMode": { + "type": "string" + } + } + }, + "example": { + "enabled": true, + "cronExpression": "0 3 * * 0", + "pruneMode": "dangling" + } + } + } + } + }, + "put": { + "operationId": "put_api_environments_id_image-prune", + "tags": [ + "environments" + ], + "summary": "Immediately run an image prune for an environment (outside its schedule)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The prune operation itself failed (Docker error)" + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while triggering the prune" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/{id}/notifications": { + "get": { + "operationId": "get_api_environments_id_notifications", + "tags": [ + "environments" + ], + "summary": "List notification channel configurations attached to an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "notificationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "notificationId", + "enabled" + ] + } + }, + "example": [ + { + "id": 0, + "notificationId": 0, + "enabled": true, + "eventTypes": [ + "string" + ] + } + ] + } + } + }, + "400": { + "description": "Invalid environment id" + }, + "403": { + "description": "Permission denied (RBAC 'notifications:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading notifications" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_notifications", + "tags": [ + "environments" + ], + "summary": "Attach a notification channel to an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "notificationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "notificationId", + "enabled" + ] + }, + "example": { + "id": 0, + "notificationId": 0, + "enabled": true + } + } + } + }, + "400": { + "description": "Invalid environment id, or notificationId missing" + }, + "403": { + "description": "Permission denied (RBAC 'notifications:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "409": { + "description": "This notification channel is already configured for this environment" + }, + "500": { + "description": "Unexpected error while creating the notification link" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "notificationId from GET /api/notifications.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "notificationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "notificationId" + ] + }, + "example": { + "notificationId": 1, + "enabled": true, + "eventTypes": [ + "stack_deploy_failed" + ] + } + } + } + } + } + }, + "/api/environments/{id}/notifications/{notificationId}": { + "get": { + "operationId": "get_api_environments_id_notifications_notificationId", + "tags": [ + "environments" + ], + "summary": "Get one notification-channel link for an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + }, + { + "name": "notificationId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification channel id (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "notificationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "notificationId", + "enabled" + ] + }, + "example": { + "id": 0, + "notificationId": 0, + "enabled": true, + "eventTypes": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid id or notificationId" + }, + "403": { + "description": "Permission denied (RBAC 'notifications:view' missing)" + }, + "404": { + "description": "Environment not found, or no such notification link for this environment" + }, + "500": { + "description": "Unexpected error while loading the notification link" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_environments_id_notifications_notificationId", + "tags": [ + "environments" + ], + "summary": "Update a notification-channel link (enabled state and/or event type filter)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + }, + { + "name": "notificationId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification channel id (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "notificationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "notificationId", + "enabled" + ] + }, + "example": { + "id": 0, + "notificationId": 0, + "enabled": true, + "eventTypes": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid id or notificationId" + }, + "403": { + "description": "Permission denied (RBAC 'notifications:edit' missing)" + }, + "404": { + "description": "Environment not found, or no such notification link for this environment" + }, + "500": { + "description": "Unexpected error while updating the notification link" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "enabled": false + } + } + } + } + }, + "delete": { + "operationId": "delete_api_environments_id_notifications_notificationId", + "tags": [ + "environments" + ], + "summary": "Remove a notification-channel link from an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + }, + { + "name": "notificationId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification channel id (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid id or notificationId" + }, + "403": { + "description": "Permission denied (RBAC 'notifications:delete' missing)" + }, + "404": { + "description": "No such notification link for this environment" + }, + "500": { + "description": "Unexpected error while deleting the notification link" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/{id}/remote-stacks-dir": { + "get": { + "operationId": "get_api_environments_id_remote-stacks-dir", + "tags": [ + "environments" + ], + "summary": "Get the per-environment remote stacks directory used to stage stack files onto a direct (agentless) daemon", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment ID (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "remoteStacksDir": { + "type": "string" + } + } + }, + "example": { + "remoteStacksDir": "/mnt/dockhand/stacks" + } + } + } + }, + "403": { + "description": "Permission denied (requires environments:view)" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_remote-stacks-dir", + "tags": [ + "environments" + ], + "summary": "Set (or clear) the per-environment remote stacks directory for a direct (agentless) daemon", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment ID (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "remoteStacksDir": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "remoteStacksDir": "/mnt/dockhand/stacks" + } + } + } + }, + "400": { + "description": "Invalid input — remoteStacksDir must be a string or null, or must be an absolute path with no \"..\"" + }, + "403": { + "description": "Permission denied (requires environments:edit)" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "remoteStacksDir": { + "type": "string" + } + } + }, + "example": { + "remoteStacksDir": "/mnt/dockhand/stacks" + } + } + } + } + } + }, + "/api/environments/{id}/test": { + "post": { + "operationId": "post_api_environments_id_test", + "tags": [ + "environments" + ], + "summary": "Test connectivity to a saved environment's Docker/Hawser endpoint", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "success:false with a human-readable error message is also returned as HTTP 200 (connection/agent-not-connected states are not transport errors)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "info": { + "type": "object", + "properties": { + "serverVersion": { + "type": "string" + }, + "containers": { + "type": "integer" + }, + "images": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "engine": { + "type": "string" + } + } + }, + "isEdgeMode": { + "type": "boolean" + }, + "hawser": { + "type": "object", + "properties": {} + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "info": { + "serverVersion": "string", + "containers": 0, + "images": 0, + "name": "string", + "engine": "string" + }, + "isEdgeMode": true, + "hawser": {} + } + } + } + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/{id}/timezone": { + "get": { + "operationId": "get_api_environments_id_timezone", + "tags": [ + "environments" + ], + "summary": "Get the IANA timezone used for scheduling on an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timezone": { + "type": "string" + } + }, + "required": [ + "timezone" + ] + }, + "example": { + "timezone": "Europe/Berlin" + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading the timezone" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_timezone", + "tags": [ + "environments" + ], + "summary": "Set the IANA timezone for an environment and refresh its schedules to use it", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "success", + "timezone" + ] + }, + "example": { + "success": true, + "timezone": "string" + } + } + } + }, + "400": { + "description": "Not a recognized IANA timezone (Intl.supportedValuesOf('timeZone'))" + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while saving the timezone" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timezone": { + "type": "string" + } + }, + "required": [ + "timezone" + ] + }, + "example": { + "timezone": "Europe/Berlin" + } + } + } + } + } + }, + "/api/environments/{id}/update-check": { + "get": { + "operationId": "get_api_environments_id_update-check", + "tags": [ + "environments" + ], + "summary": "Get the automatic container-image update-check schedule for an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cron": { + "type": "string" + }, + "autoUpdate": { + "type": "boolean" + }, + "vulnerabilityCriteria": { + "type": "string" + } + }, + "required": [ + "enabled", + "cron", + "autoUpdate", + "vulnerabilityCriteria" + ] + } + }, + "required": [ + "settings" + ] + }, + "example": { + "settings": { + "enabled": false, + "cron": "0 4 * * *", + "autoUpdate": false, + "vulnerabilityCriteria": "never" + } + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_update-check", + "tags": [ + "environments" + ], + "summary": "Save the automatic image update-check schedule for an environment (registers/unregisters the croner job)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cron": { + "type": "string" + }, + "autoUpdate": { + "type": "boolean" + }, + "vulnerabilityCriteria": { + "type": "string" + } + }, + "required": [ + "enabled", + "cron", + "autoUpdate", + "vulnerabilityCriteria" + ] + } + }, + "required": [ + "success", + "settings" + ] + }, + "example": { + "success": true, + "settings": { + "enabled": true, + "cron": "string", + "autoUpdate": true, + "vulnerabilityCriteria": "string" + } + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while saving the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cron": { + "type": "string" + }, + "autoUpdate": { + "type": "boolean" + }, + "vulnerabilityCriteria": { + "type": "string" + }, + "checkPinnedVersions": { + "type": "boolean" + }, + "semverMaxBump": { + "type": "string" + }, + "semverMatchFlavor": { + "type": "boolean" + }, + "semverIncludePrerelease": { + "type": "boolean" + } + } + }, + "example": { + "enabled": true, + "cron": "0 4 * * *", + "autoUpdate": false, + "vulnerabilityCriteria": "never", + "checkPinnedVersions": true, + "semverMaxBump": "minor", + "semverMatchFlavor": true, + "semverIncludePrerelease": false + } + } + } + } + } + }, + "/api/environments/detect-socket": { + "get": { + "operationId": "get_api_environments_detect-socket", + "tags": [ + "environments" + ], + "summary": "Detect common Docker/Podman socket paths that exist on the Dockhand host", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sockets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "exists": { + "type": "boolean" + } + }, + "required": [ + "path", + "name", + "exists" + ] + } + }, + "homedir": { + "type": "string" + } + }, + "required": [ + "sockets", + "homedir" + ] + }, + "example": { + "sockets": [ + { + "path": "/var/run/docker.sock", + "name": "Docker (default)", + "exists": true + } + ], + "homedir": "/home/dockhand" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/test": { + "post": { + "operationId": "post_api_environments_test", + "tags": [ + "environments" + ], + "summary": "Test a Docker/Hawser connection configuration WITHOUT saving it as an environment", + "parameters": [], + "responses": { + "200": { + "description": "success:false with a human-readable error message is also returned as HTTP 200 (connection failures are not transport errors)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "info": { + "type": "object", + "properties": { + "serverVersion": { + "type": "string" + }, + "containers": { + "type": "integer" + }, + "images": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "hawser": { + "type": "object", + "properties": {} + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "info": { + "serverVersion": "string", + "containers": 0, + "images": 0, + "name": "string" + }, + "hawser": {} + } + } + } + }, + "400": { + "description": "Host is required for direct/hawser-standard connection types" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connectionType": { + "type": "string" + }, + "socketPath": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "protocol": { + "type": "string" + }, + "tlsCa": { + "type": "string" + }, + "tlsCert": { + "type": "string" + }, + "tlsKey": { + "type": "string" + }, + "tlsSkipVerify": { + "type": "boolean" + }, + "hawserToken": { + "type": "string" + } + }, + "required": [ + "connectionType" + ] + }, + "example": { + "connectionType": "socket", + "socketPath": "/var/run/docker.sock" + } + } + } + } + } + }, + "/api/events": { + "get": { + "operationId": "get_api_events", + "tags": [ + "events" + ], + "summary": "Stream live Docker events (container/image/volume/network) for an environment via SSE, with periodic heartbeats", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id — without it, an \"info\" SSE message is sent and the stream ends (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "text/event-stream SSE stream (\"connected\", \"heartbeat\" every 5s, \"docker\" events with {type,action,actor,time,timeNano}, or an \"error\"/\"info\" event for edge environments, missing/unknown environment, or a lost Docker connection)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/credentials": { + "get": { + "operationId": "get_api_git_credentials", + "tags": [ + "git" + ], + "summary": "List all stored git credentials with secrets stripped (only hasPassword/hasSshKey flags returned)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "hasSshKey": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "authType", + "hasPassword", + "hasSshKey" + ] + } + }, + "example": [ + { + "id": 1, + "name": "github-deploy", + "authType": "ssh", + "username": "git", + "hasPassword": false, + "hasSshKey": true, + "createdAt": "2026-06-01T10:00:00Z", + "updatedAt": "2026-06-01T10:00:00Z" + } + ] + } + } + }, + "403": { + "description": "Caller lacks the git:view permission" + }, + "500": { + "description": "Failed to read git credentials from the database" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_git_credentials", + "tags": [ + "git" + ], + "summary": "Create a new git credential (none/password/ssh auth) and return it with secrets stripped", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "hasSshKey": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "authType", + "hasPassword", + "hasSshKey" + ] + }, + "example": { + "id": 0, + "name": "string", + "authType": "string", + "username": "string", + "hasPassword": true, + "hasSshKey": true, + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "Missing name, invalid authType, a missing secret for the chosen authType, or a duplicate credential name" + }, + "403": { + "description": "Caller lacks the git:create permission" + }, + "500": { + "description": "Failed to create the git credential" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "sshPrivateKey": { + "type": "string" + }, + "sshPassphrase": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "github-deploy", + "authType": "ssh", + "username": "git", + "sshPrivateKey": "***", + "sshPassphrase": "***" + } + } + } + } + } + }, + "/api/git/credentials/{id}": { + "get": { + "operationId": "get_api_git_credentials_id", + "tags": [ + "git" + ], + "summary": "Get a single git credential by ID with secrets stripped (only hasPassword/hasSshKey flags returned)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git credential ID (from GET /api/git/credentials)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "hasSshKey": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "authType", + "hasPassword", + "hasSshKey" + ] + }, + "example": { + "id": 0, + "name": "string", + "authType": "string", + "username": "string", + "hasPassword": true, + "hasSshKey": true, + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the git:view permission" + }, + "404": { + "description": "No credential exists with that ID" + }, + "500": { + "description": "Failed to read the git credential" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_git_credentials_id", + "tags": [ + "git" + ], + "summary": "Update a git credential and return it with secrets stripped", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git credential ID (from GET /api/git/credentials)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "hasSshKey": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "authType", + "hasPassword", + "hasSshKey" + ] + }, + "example": { + "id": 0, + "name": "string", + "authType": "string", + "username": "string", + "hasPassword": true, + "hasSshKey": true, + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "Invalid id, invalid authType, or a duplicate credential name" + }, + "403": { + "description": "Caller lacks the git:edit permission" + }, + "404": { + "description": "No credential exists with that ID" + }, + "500": { + "description": "The update failed or the credential could not be persisted" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "sshPrivateKey": { + "type": "string" + }, + "sshPassphrase": { + "type": "string" + } + } + }, + "example": { + "name": "github-deploy", + "authType": "password", + "username": "git", + "password": "***" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_git_credentials_id", + "tags": [ + "git" + ], + "summary": "Delete a git credential by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git credential ID (from GET /api/git/credentials)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the git:delete permission" + }, + "404": { + "description": "No credential exists with that ID" + }, + "500": { + "description": "The deletion failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/preview-env": { + "post": { + "operationId": "post_api_git_preview-env", + "tags": [ + "git" + ], + "summary": "Clone a repo to a temp dir and preview its merged env-file variables for the git-stack env editor", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vars": { + "type": "string" + }, + "sources": { + "type": "string" + } + }, + "required": [ + "vars", + "sources" + ] + }, + "example": { + "vars": "string", + "sources": "string" + } + } + } + }, + "400": { + "description": "composePath missing, neither repositoryId nor url supplied, or the repo/env-file preview reported an error" + }, + "401": { + "description": "Authentication required" + }, + "404": { + "description": "The referenced repository does not exist" + }, + "500": { + "description": "Failed to preview the env files (clone or read error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "repositoryId from GET /api/git/repositories. credentialId from GET /api/git/credentials.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repositoryId": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + }, + "composePath": { + "type": "string" + }, + "envFilePath": { + "type": "string" + } + }, + "required": [ + "composePath" + ] + }, + "example": { + "repositoryId": 3, + "composePath": "docker-compose.yml", + "envFilePath": ".env.prod" + } + } + } + } + } + }, + "/api/git/repositories": { + "get": { + "operationId": "get_api_git_repositories", + "tags": [ + "git" + ], + "summary": "List all git repositories (repositories are global, not scoped to an environment)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "url", + "branch" + ] + } + }, + "example": [ + { + "id": 1, + "name": "homelab", + "url": "https://github.com/example/homelab.git", + "branch": "main", + "credentialId": 2 + } + ] + } + } + }, + "403": { + "description": "Caller lacks the git:view permission" + }, + "500": { + "description": "Failed to read git repositories" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_git_repositories", + "tags": [ + "git" + ], + "summary": "Create a git repository (branch defaults to main); deployment config lives on git stacks, not here", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "url", + "branch" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "branch": "string", + "credentialId": 0 + } + } + } + }, + "400": { + "description": "Missing name/url, an invalid credentialId, or a duplicate repository name" + }, + "403": { + "description": "Caller lacks the git:create permission" + }, + "500": { + "description": "Failed to create the git repository" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "credentialId from GET /api/git/credentials.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "name", + "url" + ] + }, + "example": { + "name": "homelab", + "url": "https://github.com/example/homelab.git", + "branch": "main", + "credentialId": 2 + } + } + } + } + } + }, + "/api/git/repositories/{id}": { + "get": { + "operationId": "get_api_git_repositories_id", + "tags": [ + "git" + ], + "summary": "Get a single git repository by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "url", + "branch" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "branch": "string", + "credentialId": 0 + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the git:view permission" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "Failed to read the git repository" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_git_repositories_id", + "tags": [ + "git" + ], + "summary": "Update a git repository's basic fields (name/url/branch/credential)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "url", + "branch" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "branch": "string", + "credentialId": 0 + } + } + } + }, + "400": { + "description": "Invalid id, an invalid credentialId, or a duplicate repository name" + }, + "403": { + "description": "Caller lacks the git:edit permission" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The update failed or the repository could not be persisted" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "credentialId from GET /api/git/credentials.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + } + }, + "example": { + "name": "homelab", + "url": "https://github.com/example/homelab.git", + "branch": "production", + "credentialId": 2 + } + } + } + } + }, + "delete": { + "operationId": "delete_api_git_repositories_id", + "tags": [ + "git" + ], + "summary": "Delete a git repository, first removing the clone directories of every git stack it backs", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the git:delete permission" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The deletion failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/repositories/{id}/deploy": { + "post": { + "operationId": "post_api_git_repositories_id_deploy", + "tags": [ + "git" + ], + "summary": "Deploy the compose stack(s) defined in a git repository (clones/pulls, then runs docker compose)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The deployment failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/repositories/{id}/sync": { + "get": { + "operationId": "get_api_git_repositories_id_sync", + "tags": [ + "git" + ], + "summary": "Check whether the tracked branch has new commits upstream without pulling them", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hasUpdates": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "hasUpdates" + ] + }, + "example": { + "hasUpdates": false + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The update check failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_git_repositories_id_sync", + "tags": [ + "git" + ], + "summary": "Sync (git pull) the local clone of a repository to the latest commit on its tracked branch", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The sync failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/repositories/{id}/test": { + "post": { + "operationId": "post_api_git_repositories_id_test", + "tags": [ + "git" + ], + "summary": "Test connectivity/authentication to a saved repository using its stored credential", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The connectivity test failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/repositories/test": { + "post": { + "operationId": "post_api_git_repositories_test", + "tags": [ + "git" + ], + "summary": "Test an unsaved repository configuration (url/branch/credentialId) before creating it", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The url field is missing" + }, + "403": { + "description": "Caller lacks the settings:manage permission" + }, + "500": { + "description": "The connectivity test failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "credentialId from GET /api/git/credentials.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "url" + ] + }, + "example": { + "url": "https://github.com/example/homelab.git", + "branch": "main", + "credentialId": 2 + } + } + } + } + } + }, + "/api/git/stacks": { + "get": { + "operationId": "get_api_git_stacks", + "tags": [ + "git" + ], + "summary": "List git-deployed stacks (optionally scoped to one environment)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "500": { + "description": "Failed to list git stacks" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_git_stacks", + "tags": [ + "git" + ], + "summary": "Create a git-deployed stack (from an existing repo or new repo url/branch)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid stack name, or secretProviderId is not a number/null" + }, + "403": { + "description": "Permission denied (needs stacks:create; binding a secret provider also needs secrets:view)" + }, + "409": { + "description": "A git stack with this name already exists in the environment" + }, + "500": { + "description": "Failed to create the git stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stackName": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "repositoryId": { + "type": "integer" + }, + "secretProviderId": { + "type": "integer" + }, + "webhookEnabled": { + "type": "boolean" + }, + "webhookSecret": { + "type": "string" + } + }, + "required": [ + "stackName" + ] + }, + "example": { + "stackName": "string", + "environmentId": 0, + "repositoryId": 0, + "secretProviderId": 0, + "webhookEnabled": true, + "webhookSecret": "string" + } + } + } + } + } + }, + "/api/git/stacks/{id}": { + "get": { + "operationId": "get_api_git_stacks_id", + "tags": [ + "git" + ], + "summary": "Get one git stack", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The git stack id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "404": { + "description": "Git stack not found" + }, + "500": { + "description": "Failed to load the git stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_git_stacks_id", + "tags": [ + "git" + ], + "summary": "Update a git stack (rename, schedule, webhook, secret-provider binding)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The git stack id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid stack name, or secretProviderId is not a number/null" + }, + "403": { + "description": "Permission denied (needs stacks:edit; binding a secret provider also needs secrets:view)" + }, + "404": { + "description": "Git stack not found" + }, + "500": { + "description": "Failed to update the git stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stackName": { + "type": "string" + }, + "secretProviderId": { + "type": "integer" + }, + "webhookEnabled": { + "type": "boolean" + }, + "webhookSecret": { + "type": "string" + } + } + }, + "example": { + "stackName": "string", + "secretProviderId": 0, + "webhookEnabled": true, + "webhookSecret": "string" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_git_stacks_id", + "tags": [ + "git" + ], + "summary": "Delete a git stack (removes its deploy files and stack source)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The git stack id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:delete)" + }, + "404": { + "description": "Git stack not found" + }, + "500": { + "description": "Failed to delete the git stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/stacks/{id}/deploy": { + "post": { + "operationId": "post_api_git_stacks_id_deploy", + "tags": [ + "git" + ], + "summary": "Deploy a git stack, streaming the deploy log as SSE result events via the job-response channel", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "An SSE stream whose final `result` event carries {success, error}", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + }, + "example": {} + } + } + }, + "403": { + "description": "Caller lacks the stacks:start permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "Failed to start the deployment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/stacks/{id}/deploy-stream": { + "post": { + "operationId": "post_api_git_stacks_id_deploy-stream", + "tags": [ + "git" + ], + "summary": "Deploy a git stack with live progress; streams SSE, or returns a jobId to poll (Accept negotiated)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Deployment started — either a jobId to poll or a streamed SSE deploy log", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + } + }, + "example": { + "jobId": "a1b2c3d4" + } + } + } + }, + "403": { + "description": "Caller lacks the stacks:start permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Clients sending `Accept: application/json` get a synchronous, buffered SSE-as-JSON result; otherwise a jobId is returned immediately and progress is delivered out-of-band." + } + }, + "/api/git/stacks/{id}/env-files": { + "get": { + "operationId": "get_api_git_stacks_id_env-files", + "tags": [ + "git" + ], + "summary": "List the .env files present in a git stack's cloned repository", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "files" + ] + }, + "example": { + "files": [ + ".env", + ".env.prod" + ] + } + } + } + }, + "400": { + "description": "The repository could not be read (e.g. not yet cloned)" + }, + "403": { + "description": "Caller lacks the stacks:view permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "Failed to list the env files" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Lists the `.env*` files in the git stack's synced repository checkout (read-only). Use this to discover which file to configure as the stack's `envFilePath`. This endpoint does not write — to choose the env file, PUT /api/git/stacks/{id} with `envFilePath`; to set env values, use the stack env endpoints." + }, + "post": { + "operationId": "post_api_git_stacks_id_env-files", + "tags": [ + "git" + ], + "summary": "Read and parse one .env file from a git stack's repository into a key/value map", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vars": { + "type": "string" + } + }, + "required": [ + "vars" + ] + }, + "example": { + "vars": { + "TZ": "Europe/Berlin" + } + } + } + } + }, + "400": { + "description": "The path field is missing or the env file could not be read" + }, + "403": { + "description": "Caller lacks the stacks:view permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "Failed to read the env file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Reads and parses a single `.env` file (by `path`) from the git stack's synced repository checkout into a key/value map (read-only). There is no write counterpart — git-stack env is set via `envFilePath` (PUT /api/git/stacks/{id}) or the stack env endpoints.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "example": { + "path": ".env.prod" + } + } + } + } + } + }, + "/api/git/stacks/{id}/sync": { + "post": { + "operationId": "post_api_git_stacks_id_sync", + "tags": [ + "git" + ], + "summary": "Sync (git pull) a git stack's repository clone to the latest tracked commit", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Caller lacks the stacks:edit permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "The sync failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/stacks/{id}/test": { + "post": { + "operationId": "post_api_git_stacks_id_test", + "tags": [ + "git" + ], + "summary": "Test a git stack's repository access and compose configuration without deploying", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Caller lacks the stacks:view permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "The test failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/stacks/{id}/webhook": { + "get": { + "operationId": "get_api_git_stacks_id_webhook", + "tags": [ + "git" + ], + "summary": "GET webhook trigger for a git stack, with the secret passed as the `secret` query parameter", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + }, + { + "name": "secret", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Webhook secret; required only if the stack has a webhook secret configured" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "skipped": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + }, + "example": { + "success": true, + "skipped": false + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "401": { + "description": "The provided secret did not match the stack's webhook secret" + }, + "403": { + "description": "Webhooks are not enabled for this stack" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "The deployment triggered by the webhook failed" + } + }, + "security": [] + }, + "post": { + "operationId": "post_api_git_stacks_id_webhook", + "tags": [ + "git" + ], + "summary": "Webhook trigger (GitHub/GitLab) that deploys a git stack when its signature/token verifies", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "skipped": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + }, + "example": { + "success": true, + "skipped": false + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "401": { + "description": "The webhook signature or token did not verify" + }, + "403": { + "description": "Webhooks are not enabled for this stack" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "The deployment triggered by the webhook failed" + } + }, + "security": [], + "description": "Public endpoint authenticated by the stack's webhook secret via `X-Hub-Signature-256` (GitHub) or `X-Gitlab-Token` (GitLab); the raw request body is used for HMAC verification." + } + }, + "/api/git/webhook/{id}": { + "get": { + "operationId": "get_api_git_webhook_id", + "tags": [ + "git" + ], + "summary": "GET webhook trigger for a git repository, with the secret passed as the `secret` query parameter", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + }, + { + "name": "secret", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Webhook secret; required only if the repository has a webhook secret configured" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "401": { + "description": "The provided secret did not match the repository's webhook secret" + }, + "403": { + "description": "Webhooks are not enabled for this repository" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The deployment triggered by the webhook failed" + } + }, + "security": [] + }, + "post": { + "operationId": "post_api_git_webhook_id", + "tags": [ + "git" + ], + "summary": "Webhook trigger (GitHub/GitLab) that deploys from a git repository when its signature/token verifies", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "401": { + "description": "The webhook signature or token did not verify" + }, + "403": { + "description": "Webhooks are not enabled for this repository" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The deployment triggered by the webhook failed" + } + }, + "security": [], + "description": "Public endpoint authenticated by the repository's webhook secret via `X-Hub-Signature-256` (GitHub) or `X-Gitlab-Token` (GitLab); the raw request body is used for HMAC verification." + } + }, + "/api/hawser/connect": { + "get": { + "operationId": "get_api_hawser_connect", + "tags": [ + "hawser" + ], + "summary": "Report the Hawser Edge WebSocket endpoint status and list currently connected agents", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "message": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "activeConnections": { + "type": "integer" + }, + "connections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "environmentId": { + "type": "integer" + }, + "agentId": { + "type": "string" + }, + "agentName": { + "type": "string" + }, + "agentVersion": { + "type": "string" + }, + "dockerVersion": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "connectedAt": { + "type": "string" + }, + "lastHeartbeat": { + "type": "string" + } + }, + "required": [ + "environmentId" + ] + } + } + }, + "required": [ + "status", + "message", + "protocol", + "activeConnections", + "connections" + ] + }, + "example": { + "status": "ready", + "message": "Hawser Edge WebSocket endpoint. Connect via WebSocket.", + "protocol": "wss:///api/hawser/connect", + "activeConnections": 0, + "connections": [] + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_hawser_connect", + "tags": [ + "hawser" + ], + "summary": "Fallback for non-WebSocket clients; always advises upgrading to a WebSocket connection", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "426": { + "description": "This endpoint requires a WebSocket upgrade (ws:// or wss://)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/hawser/tokens": { + "get": { + "operationId": "get_api_hawser_tokens", + "tags": [ + "hawser" + ], + "summary": "List all Hawser agent tokens (only the prefix is returned, never the full token)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "tokenPrefix": { + "type": "string" + }, + "name": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "isActive": { + "type": "boolean" + }, + "lastUsed": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "expiresAt": { + "type": "string" + } + }, + "required": [ + "id", + "tokenPrefix", + "name", + "environmentId", + "isActive", + "createdAt" + ] + } + }, + "example": [ + { + "id": 1, + "tokenPrefix": "hw_abc123", + "name": "edge-01", + "environmentId": 1, + "isActive": true, + "lastUsed": null, + "createdAt": "2026-06-01T10:00:00Z", + "expiresAt": null + } + ] + } + } + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Admin access required" + }, + "500": { + "description": "Failed to read the tokens" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_hawser_tokens", + "tags": [ + "hawser" + ], + "summary": "Generate a new Hawser agent token for an environment (the plaintext token is returned only once)", + "parameters": [], + "responses": { + "200": { + "description": "Token generated — the plaintext token is shown only once and cannot be retrieved again", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "tokenId": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "token", + "tokenId", + "message" + ] + }, + "example": { + "token": "***", + "tokenId": 42, + "message": "Token generated successfully. Save this token - it will not be shown again." + } + } + } + }, + "400": { + "description": "The name or environmentId field is missing or of the wrong type" + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Admin access required" + }, + "500": { + "description": "Failed to generate the token" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "expiresAt": { + "type": "string" + }, + "rawToken": { + "type": "string" + } + }, + "required": [ + "name", + "environmentId" + ] + }, + "example": { + "name": "edge-01", + "environmentId": 1, + "expiresAt": "2027-01-01T00:00:00Z" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_hawser_tokens", + "tags": [ + "hawser" + ], + "summary": "Revoke a Hawser agent token by ID", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the token to revoke (from GET /api/hawser/tokens)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ] + }, + "example": { + "success": true, + "message": "Token revoked" + } + } + } + }, + "400": { + "description": "The id query parameter is missing" + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Admin access required" + }, + "500": { + "description": "Failed to revoke the token" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/health": { + "get": { + "operationId": "get_api_health", + "tags": [ + "health" + ], + "summary": "Liveness probe — always returns 200 when the SvelteKit process is up", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + }, + "required": [ + "status", + "timestamp" + ] + }, + "example": { + "status": "ok", + "timestamp": "2027-01-01T12:00:00.000Z" + } + } + } + } + }, + "security": [] + } + }, + "/api/health/database": { + "get": { + "operationId": "get_api_health_database", + "tags": [ + "health" + ], + "summary": "Public database health check reporting schema/migration/table status; authenticated settings:view callers also get connection details", + "parameters": [], + "responses": { + "200": { + "description": "Schema health; an unhealthy database returns the same shape with HTTP 503", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean" + }, + "database": { + "type": "string" + }, + "migrationsTable": { + "type": "boolean" + }, + "appliedMigrations": { + "type": "integer" + }, + "pendingMigrations": { + "type": "integer" + }, + "tables": { + "type": "integer" + }, + "timestamp": { + "type": "string" + } + }, + "required": [ + "healthy", + "database", + "migrationsTable", + "appliedMigrations", + "pendingMigrations", + "tables", + "timestamp" + ] + }, + "example": { + "healthy": true, + "database": "sqlite", + "migrationsTable": true, + "appliedMigrations": 42, + "pendingMigrations": 0, + "tables": 25, + "timestamp": "2026-07-01T10:00:00.000Z" + } + } + } + }, + "500": { + "description": "Unexpected error while checking database health" + } + }, + "security": [], + "description": "Returns HTTP 200 when healthy and 503 when unhealthy (schema drift or table loss); 500 on an unexpected error. Connection details and the migration tag are only included for authenticated callers with settings:view." + } + }, + "/api/host": { + "get": { + "operationId": "get_api_host", + "tags": [ + "host" + ], + "summary": "Return host info (hostname, IP, CPU, memory, uptime, Docker counts) for the daemon behind an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment to describe (basic local info is returned when omitted) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hostname": { + "type": "string" + }, + "ipAddress": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "arch": { + "type": "string" + }, + "cpus": { + "type": "integer" + }, + "totalMemory": { + "type": "integer" + }, + "freeMemory": { + "type": "integer" + }, + "uptime": { + "type": "integer" + }, + "dockerVersion": { + "type": "string" + }, + "dockerContainers": { + "type": "integer" + }, + "dockerContainersRunning": { + "type": "integer" + }, + "dockerImages": { + "type": "integer" + }, + "environment": { + "type": "string" + } + }, + "required": [ + "hostname", + "ipAddress", + "platform", + "arch", + "cpus", + "totalMemory", + "freeMemory", + "uptime", + "dockerContainers", + "dockerContainersRunning", + "dockerImages" + ] + }, + "example": { + "hostname": "docker-host", + "ipAddress": "192.168.1.10", + "platform": "linux", + "arch": "x64", + "cpus": 8, + "totalMemory": 16777216000, + "freeMemory": 8388608000, + "uptime": 123456, + "dockerVersion": "27.0.3", + "dockerContainers": 10, + "dockerContainersRunning": 8, + "dockerImages": 25, + "environment": { + "id": 1, + "name": "local", + "connectionType": "socket" + } + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + }, + "500": { + "description": "Failed to get host info" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/images": { + "get": { + "operationId": "get_api_images", + "tags": [ + "images" + ], + "summary": "List the Docker images of an environment (returns an empty array when no env is given or Docker is unreachable)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment whose images to list (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Array of images (empty if no env is specified or the Docker connection fails)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "RepoTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "Size": { + "type": "integer" + }, + "Created": { + "type": "integer" + } + }, + "required": [ + "Id" + ] + } + }, + "example": [ + { + "Id": "sha256:abc123", + "RepoTags": [ + "nginx:latest" + ], + "Size": 142000000, + "Created": 1719830400 + } + ] + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/images/{id}": { + "delete": { + "operationId": "delete_api_images_id", + "tags": [ + "images" + ], + "summary": "Remove a Docker image by ID or name (optionally forced), scoped to an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image ID or name to remove (from GET /api/images)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Force removal even if the image is tagged or referenced (default false)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + }, + "409": { + "description": "Image is in use by a running container or has dependent child images" + }, + "500": { + "description": "Failed to remove image" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/images/{id}/export": { + "get": { + "operationId": "get_api_images_id_export", + "tags": [ + "images" + ], + "summary": "Export a Docker image as a downloadable tar (or tar.gz) stream", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image ID or name to export (from GET /api/images)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + }, + { + "name": "compress", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Gzip the tar stream and serve it as .tar.gz (default false)" + } + ], + "responses": { + "200": { + "description": "The image tar (application/x-tar) or gzipped tar (application/gzip) as an attachment" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Docker returned no response body, or the export failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/images/{id}/history": { + "get": { + "operationId": "get_api_images_id_history", + "tags": [ + "images" + ], + "summary": "Return the layer build history of a Docker image", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image ID or name whose history to return (from GET /api/images)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Created": { + "type": "integer" + }, + "CreatedBy": { + "type": "string" + }, + "Size": { + "type": "integer" + }, + "Comment": { + "type": "string" + } + } + } + }, + "example": [ + { + "Id": "sha256:abc123", + "Created": 1719830400, + "CreatedBy": "/bin/sh -c #(nop) CMD", + "Size": 0, + "Comment": "" + } + ] + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to get image history" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/images/{id}/tag": { + "post": { + "operationId": "post_api_images_id_tag", + "tags": [ + "images" + ], + "summary": "Tag a Docker image into a repository (defaults the tag to \"latest\")", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image ID or name to tag (from GET /api/images)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Repository name is missing or not a string" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to tag image" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repo": { + "type": "string" + }, + "tag": { + "type": "string" + } + }, + "required": [ + "repo" + ] + }, + "example": { + "repo": "registry.example.com/myapp", + "tag": "v1.2.3" + } + } + } + } + } + }, + "/api/images/load": { + "post": { + "operationId": "post_api_images_load", + "tags": [ + "images" + ], + "summary": "Load a Docker image from an uploaded tar (docker load) for air-gapped hosts", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id" + } + ], + "responses": { + "200": { + "description": "loaded echoes the daemon's final line, e.g. \"Loaded image: alpine:3.20\"", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "loaded": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "loaded": "string" + } + } + } + }, + "400": { + "description": "Request body (an image tar) is required" + }, + "403": { + "description": "Permission denied (needs images:load), or access denied to this environment" + }, + "500": { + "description": "The daemon rejected the tar or the load failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "The request body is the raw image tar (Content-Type application/x-tar), streamed straight to the daemon without buffering. Local/socket or direct TCP only; Hawser is rejected." + } + }, + "/api/images/pull": { + "post": { + "operationId": "post_api_images_pull", + "tags": [ + "images" + ], + "summary": "Pull a Docker image and stream pull (and optional scan-on-pull) progress as Server-Sent Events", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment to pull into (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "A Server-Sent Events stream of pull progress, ending with a \"result\" event" + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string" + }, + "scanAfterPull": { + "type": "boolean" + } + }, + "required": [ + "image" + ] + }, + "example": { + "image": "nginx:latest", + "scanAfterPull": false + } + } + } + } + } + }, + "/api/images/push": { + "post": { + "operationId": "post_api_images_push", + "tags": [ + "images" + ], + "summary": "Tag a local image and push it to a configured registry (streams progress, or runs synchronously for Accept: application/json)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the source image belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + } + }, + "example": { + "jobId": "a1b2c3d4" + } + } + } + }, + "400": { + "description": "Image ID or registry ID missing, or the image has no usable tag" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Registry not found" + }, + "500": { + "description": "Failed to push image" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "imageId from GET /api/images. registryId from GET /api/registries.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "imageId": { + "type": "string" + }, + "registryId": { + "type": "integer" + }, + "imageName": { + "type": "string" + }, + "newTag": { + "type": "string" + } + }, + "required": [ + "imageId", + "registryId" + ] + }, + "example": { + "imageId": "sha256:abc123", + "registryId": 2, + "imageName": "myapp:latest", + "newTag": "myapp:v1.2.3" + } + } + } + } + } + }, + "/api/images/scan": { + "get": { + "operationId": "get_api_images_scan", + "tags": [ + "images" + ], + "summary": "Return the latest cached vulnerability scan for an image, resolving its tag to a SHA256 ID", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + }, + { + "name": "image", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image name or ID to look up cached results for" + }, + { + "name": "scanner", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict the lookup to a specific scanner (e.g. grype or trivy)" + } + ], + "responses": { + "200": { + "description": "found=false when no cached scan exists; otherwise result holds the stored scan", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "found": { + "type": "boolean" + }, + "result": { + "type": "string" + } + }, + "required": [ + "found" + ] + }, + "example": { + "found": true, + "result": { + "imageId": "sha256:abc123", + "scanner": "grype", + "summary": { + "critical": 0, + "high": 2 + } + } + } + } + } + }, + "400": { + "description": "Image name is required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to get scan results" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_images_scan", + "tags": [ + "images" + ], + "summary": "Start a vulnerability scan of an image and stream scan progress as Server-Sent Events, persisting the results", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "A Server-Sent Events stream of scan progress, ending with a \"result\" event" + }, + "400": { + "description": "Image name is required" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "imageName": { + "type": "string" + }, + "scanner": { + "type": "string" + } + }, + "required": [ + "imageName" + ] + }, + "example": { + "imageName": "nginx:latest", + "scanner": "grype" + } + } + } + } + } + }, + "/api/images/scan/export": { + "get": { + "operationId": "get_api_images_scan_export", + "tags": [ + "images" + ], + "summary": "Export the cached vulnerability findings for a single image as json, csv, or sarif", + "parameters": [ + { + "name": "imageId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image SHA/ID to export (falls back to the \"image\" param when omitted) (from GET /api/images)" + }, + { + "name": "image", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Image name/ID used as a fallback when imageId is not given" + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Output format — json (default), csv, or sarif" + } + ], + "responses": { + "200": { + "description": "A 400 is returned when imageId is missing or (auth enabled) env is not specified, and 500 on failure" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/jobs/{id}": { + "get": { + "operationId": "get_api_jobs_id", + "tags": [ + "jobs" + ], + "summary": "Poll a background job's status and accumulated output lines (no auth — job ids are unguessable UUIDs)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Job id (UUID)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "event": { + "type": "string" + }, + "data": { + "type": "object", + "properties": {} + } + } + } + }, + "result": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "status", + "lines" + ] + }, + "example": { + "id": "string", + "status": "string", + "lines": [ + { + "event": "string", + "data": {} + } + ], + "result": {} + } + } + } + }, + "404": { + "description": "Job not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_jobs_id", + "tags": [ + "jobs" + ], + "summary": "Request cancellation of a running background job (no auth — job ids are unguessable UUIDs)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Job id (UUID)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ] + }, + "example": { + "cancelled": true + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/labels": { + "get": { + "operationId": "get_api_labels", + "tags": [ + "labels" + ], + "summary": "List all unique environment labels with their usage counts, the environments carrying each label, and any custom color", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "environments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "envId": { + "type": "integer" + }, + "envName": { + "type": "string" + } + }, + "required": [ + "envId", + "envName" + ] + } + }, + "count": { + "type": "integer" + }, + "color": { + "type": "string" + } + }, + "required": [ + "label", + "environments", + "count" + ] + } + }, + "colors": { + "type": "object", + "properties": {} + } + }, + "required": [ + "labels" + ] + }, + "example": { + "labels": [ + { + "label": "prod", + "environments": [ + { + "envId": 1, + "envName": "Production" + } + ], + "count": 1, + "color": "#ff0000" + } + ], + "colors": { + "prod": "#ff0000" + } + } + } + } + }, + "403": { + "description": "Permission denied (requires environments:view)" + }, + "500": { + "description": "Failed to get labels" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_labels", + "tags": [ + "labels" + ], + "summary": "Perform a bulk label operation on environments — rename, delete, add to environments, or set a custom color — selected via the action field", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "affected": { + "type": "integer" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "affected": 2 + } + } + } + }, + "400": { + "description": "Invalid action or missing/empty required fields for the chosen action" + }, + "403": { + "description": "Permission denied (requires environments:edit)" + }, + "500": { + "description": "Failed to manage labels" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentIds from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "oldLabel": { + "type": "string" + }, + "newLabel": { + "type": "string" + }, + "label": { + "type": "string" + }, + "environmentIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "color": { + "type": "string" + } + }, + "required": [ + "action" + ] + }, + "example": { + "action": "rename", + "oldLabel": "staging", + "newLabel": "stg" + } + } + } + } + } + }, + "/api/legal/license": { + "get": { + "operationId": "get_api_legal_license", + "tags": [ + "legal" + ], + "summary": "Return the bundled LICENSE.txt — as JSON by default, or as raw text/plain when format=text", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Set to \"text\" to return the raw license as text/plain instead of JSON" + } + ], + "responses": { + "200": { + "description": "The license text (as {content} JSON, or raw text/plain when format=text)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "string" + } + } + } + }, + "404": { + "description": "LICENSE.txt could not be found/read" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/legal/privacy": { + "get": { + "operationId": "get_api_legal_privacy", + "tags": [ + "legal" + ], + "summary": "Return the bundled PRIVACY.txt — as JSON by default, or as raw text/plain when format=text", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Set to \"text\" to return the raw privacy policy as text/plain instead of JSON" + } + ], + "responses": { + "200": { + "description": "The privacy policy text (as {content} JSON, or raw text/plain when format=text)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "string" + } + } + } + }, + "404": { + "description": "PRIVACY.txt could not be found/read" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/license": { + "get": { + "operationId": "get_api_license", + "tags": [ + "license" + ], + "summary": "Get the current license status plus the server hostname the license is bound to", + "parameters": [], + "responses": { + "200": { + "description": "The license status object merged with the current hostname" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "500": { + "description": "Failed to read the license status" + } + }, + "security": [] + }, + "post": { + "operationId": "post_api_license", + "tags": [ + "license" + ], + "summary": "Activate a license by name and key", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "license": { + "type": "object", + "properties": {} + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "license": {} + } + } + } + }, + "400": { + "description": "Name and key are required, or activation was rejected (invalid key)" + }, + "403": { + "description": "Permission denied (missing license:manage)" + }, + "500": { + "description": "Failed to activate the license" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "name", + "key" + ] + }, + "example": { + "name": "ACME Corp", + "key": "***" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_license", + "tags": [ + "license" + ], + "summary": "Deactivate the currently active license", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (missing license:manage)" + }, + "500": { + "description": "Failed to deactivate the license" + } + }, + "security": [] + } + }, + "/api/logs/merged": { + "get": { + "operationId": "get_api_logs_merged", + "tags": [ + "logs" + ], + "summary": "Stream merged, color-tagged logs from multiple containers over Server-Sent Events", + "parameters": [ + { + "name": "containers", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Comma-separated container IDs to merge logs from" + }, + { + "name": "tail", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Number of lines to tail per container (default 100)" + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only return logs since this timestamp (Docker since format)" + }, + { + "name": "until", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only return logs until this timestamp (Docker until format)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the containers belong to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream) emitting heartbeat and per-container log events" + }, + "400": { + "description": "No containers specified" + }, + "403": { + "description": "Permission denied (requires the containers:logs permission)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/networks": { + "get": { + "operationId": "get_api_networks", + "tags": [ + "networks" + ], + "summary": "List Docker networks for an environment (returns an empty array when no env is given)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID to list networks from (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "Internal": { + "type": "boolean" + }, + "Attachable": { + "type": "boolean" + } + }, + "required": [ + "Id", + "Name" + ] + } + }, + "example": [ + { + "Id": "string", + "Name": "string", + "Driver": "string", + "Scope": "string", + "Internal": true, + "Attachable": true + } + ] + } + } + }, + "403": { + "description": "Permission denied, or access denied to the requested environment (enterprise)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Failed to list networks" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_networks", + "tags": [ + "networks" + ], + "summary": "Create a Docker network in the given environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID to create the network in (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": [ + "success", + "id" + ] + }, + "example": { + "success": true, + "id": "3f1c...e9" + } + } + } + }, + "400": { + "description": "Network name is required" + }, + "403": { + "description": "Permission denied, or access denied to the requested environment (enterprise)" + }, + "500": { + "description": "Failed to create network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "driver": { + "type": "string" + }, + "internal": { + "type": "boolean" + }, + "attachable": { + "type": "boolean" + }, + "ingress": { + "type": "boolean" + }, + "enableIPv6": { + "type": "boolean" + }, + "options": { + "type": "object", + "properties": {} + }, + "labels": { + "type": "object", + "properties": {} + }, + "ipam": { + "type": "object", + "properties": { + "driver": { + "type": "string" + }, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + }, + "options": { + "type": "object", + "properties": {} + } + } + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "app-net", + "driver": "bridge", + "attachable": true, + "labels": { + "project": "dockhand" + } + } + } + } + } + } + }, + "/api/networks/{id}": { + "get": { + "operationId": "get_api_networks_id", + "tags": [ + "networks" + ], + "summary": "Inspect a Docker network by ID (a malformed ID is rejected with 400 by input validation)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker network ID (from GET /api/networks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the network belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "IPAM": { + "type": "object", + "properties": {} + }, + "Containers": { + "type": "object", + "properties": {} + } + }, + "required": [ + "Id", + "Name" + ] + }, + "example": { + "Id": "string", + "Name": "string", + "Driver": "string", + "Scope": "string", + "IPAM": {}, + "Containers": {} + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to the requested environment (enterprise)" + }, + "500": { + "description": "Failed to inspect network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_networks_id", + "tags": [ + "networks" + ], + "summary": "Remove a Docker network by ID (a malformed ID is rejected with 400 by input validation)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker network ID (from GET /api/networks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the network belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to the requested environment (enterprise)" + }, + "500": { + "description": "Failed to remove network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/networks/{id}/connect": { + "post": { + "operationId": "post_api_networks_id_connect", + "tags": [ + "networks" + ], + "summary": "Connect a container to a Docker network", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker network ID (from GET /api/networks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the network belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Container ID is required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to connect container to network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "containerId from GET /api/containers.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + } + }, + "required": [ + "containerId" + ] + }, + "example": { + "containerId": "a1b2c3d4e5f6", + "containerName": "web-1" + } + } + } + } + } + }, + "/api/networks/{id}/disconnect": { + "post": { + "operationId": "post_api_networks_id_disconnect", + "tags": [ + "networks" + ], + "summary": "Disconnect a container from a Docker network (optionally forcing the disconnect)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker network ID (from GET /api/networks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the network belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Container ID is required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to disconnect container from network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "containerId from GET /api/containers.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "force": { + "type": "boolean" + } + }, + "required": [ + "containerId" + ] + }, + "example": { + "containerId": "a1b2c3d4e5f6", + "containerName": "web-1", + "force": false + } + } + } + } + } + }, + "/api/networks/{id}/inspect": { + "get": { + "operationId": "get_api_networks_id_inspect", + "tags": [ + "networks" + ], + "summary": "Inspect a Docker network by ID (a malformed ID is rejected with 400 by input validation)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker network ID (from GET /api/networks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the network belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "IPAM": { + "type": "object", + "properties": {} + }, + "Containers": { + "type": "object", + "properties": {} + } + }, + "required": [ + "Id", + "Name" + ] + }, + "example": { + "Id": "string", + "Name": "string", + "Driver": "string", + "Scope": "string", + "IPAM": {}, + "Containers": {} + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to inspect network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/notifications": { + "get": { + "operationId": "get_api_notifications", + "tags": [ + "notifications" + ], + "summary": "List all notification settings (SMTP and Apprise) with SMTP passwords masked", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "from_email": { + "type": "string" + }, + "to_emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + }, + "password": { + "type": "string" + } + } + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "type", + "name", + "enabled" + ] + } + }, + "example": [ + { + "id": 0, + "type": "string", + "name": "string", + "enabled": true, + "config": { + "host": "string", + "port": 0, + "from_email": "string", + "to_emails": [ + "string" + ], + "urls": [ + "string" + ], + "password": "string" + }, + "eventTypes": [ + "string" + ] + } + ] + } + } + }, + "403": { + "description": "Permission denied (requires the notifications:view permission when auth is enabled)" + }, + "500": { + "description": "Failed to fetch notification settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_notifications", + "tags": [ + "notifications" + ], + "summary": "Create a notification setting (SMTP or Apprise); the response masks any SMTP password", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": {} + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "type", + "name", + "enabled" + ] + }, + "example": { + "id": 0, + "type": "string", + "name": "string", + "enabled": true, + "config": {}, + "eventTypes": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Missing or invalid fields — type/name/config required, type must be smtp or apprise, SMTP needs host/port/from_email/to_emails, Apprise needs at least one URL" + }, + "403": { + "description": "Permission denied (requires the notifications:create permission)" + }, + "500": { + "description": "Failed to create notification setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "secure": { + "type": "boolean" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "from_email": { + "type": "string" + }, + "from_name": { + "type": "string" + }, + "to_emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "event_types": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "name" + ] + }, + "example": { + "type": "smtp", + "name": "Ops Alerts", + "enabled": true, + "config": { + "host": "smtp.example.com", + "port": 587, + "secure": false, + "from_email": "dockhand@example.com", + "to_emails": [ + "ops@example.com" + ], + "password": "***" + }, + "eventTypes": [ + "container_unhealthy" + ] + } + } + } + } + } + }, + "/api/notifications/{id}": { + "get": { + "operationId": "get_api_notifications_id", + "tags": [ + "notifications" + ], + "summary": "Get a single notification setting by ID, with any SMTP password masked", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification setting ID (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "from_email": { + "type": "string" + }, + "to_emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + }, + "password": { + "type": "string" + } + } + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "type", + "name", + "enabled" + ] + }, + "example": { + "id": 0, + "type": "string", + "name": "string", + "enabled": true, + "config": { + "host": "string", + "port": 0, + "from_email": "string", + "to_emails": [ + "string" + ], + "urls": [ + "string" + ], + "password": "string" + }, + "eventTypes": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid ID (not a number)" + }, + "403": { + "description": "Permission denied (requires the notifications:view permission)" + }, + "404": { + "description": "Notification setting not found" + }, + "500": { + "description": "Failed to fetch notification setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_notifications_id", + "tags": [ + "notifications" + ], + "summary": "Update a notification setting; a masked SMTP password (\"********\") keeps the stored value", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification setting ID (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": {} + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "type", + "name", + "enabled" + ] + }, + "example": { + "id": 0, + "type": "string", + "name": "string", + "enabled": true, + "config": {}, + "eventTypes": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid ID, or config validation failed (SMTP needs host/port/from_email/to_emails, Apprise needs at least one URL)" + }, + "403": { + "description": "Permission denied (requires the notifications:edit permission)" + }, + "404": { + "description": "Notification setting not found" + }, + "500": { + "description": "Failed to update notification setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "secure": { + "type": "boolean" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "from_email": { + "type": "string" + }, + "from_name": { + "type": "string" + }, + "to_emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "event_types": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "name": "Ops Alerts", + "enabled": false, + "config": { + "host": "smtp.example.com", + "port": 587, + "from_email": "dockhand@example.com", + "to_emails": [ + "ops@example.com" + ], + "password": "********" + }, + "eventTypes": [ + "container_unhealthy", + "container_oom" + ] + } + } + } + } + }, + "delete": { + "operationId": "delete_api_notifications_id", + "tags": [ + "notifications" + ], + "summary": "Delete a notification setting by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification setting ID (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid ID (not a number)" + }, + "403": { + "description": "Permission denied (requires the notifications:delete permission)" + }, + "404": { + "description": "Notification setting not found" + }, + "500": { + "description": "Failed to delete notification setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/notifications/{id}/test": { + "post": { + "operationId": "post_api_notifications_id_test", + "tags": [ + "notifications" + ], + "summary": "Send a test notification through an already-saved notification setting", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification setting ID (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "message": "Test notification sent successfully" + } + } + } + }, + "400": { + "description": "Invalid ID (not a number)" + }, + "404": { + "description": "Notification setting not found" + }, + "500": { + "description": "Failed to test notification" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/notifications/test": { + "post": { + "operationId": "post_api_notifications_test", + "tags": [ + "notifications" + ], + "summary": "Send a test notification using an ad-hoc config supplied in the body (nothing is saved)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "message": "Test notification sent successfully" + } + } + } + }, + "400": { + "description": "Missing/invalid fields — type and config required; SMTP needs host/from_email/to_emails; Apprise needs at least one URL" + }, + "403": { + "description": "Permission denied (requires the settings:edit permission)" + }, + "500": { + "description": "Failed to test notification" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "secure": { + "type": "boolean" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "from_email": { + "type": "string" + }, + "from_name": { + "type": "string" + }, + "to_emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "required": [ + "type" + ] + }, + "example": { + "type": "smtp", + "name": "Test", + "config": { + "host": "smtp.example.com", + "port": 587, + "from_email": "dockhand@example.com", + "to_emails": [ + "ops@example.com" + ], + "password": "***" + } + } + } + } + } + } + }, + "/api/notifications/trigger-test": { + "get": { + "operationId": "get_api_notifications_trigger-test", + "tags": [ + "notifications" + ], + "summary": "List all available notification event types, grouped into categories", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "eventTypes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "categories": { + "type": "object", + "properties": { + "container": { + "type": "array", + "items": { + "type": "string" + } + }, + "autoUpdate": { + "type": "array", + "items": { + "type": "string" + } + }, + "gitStack": { + "type": "array", + "items": { + "type": "string" + } + }, + "stack": { + "type": "array", + "items": { + "type": "string" + } + }, + "security": { + "type": "array", + "items": { + "type": "string" + } + }, + "system": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "example": { + "eventTypes": [ + { + "id": "string", + "label": "string" + } + ], + "categories": { + "container": [ + "string" + ], + "autoUpdate": [ + "string" + ], + "gitStack": [ + "string" + ], + "stack": [ + "string" + ], + "security": [ + "string" + ], + "system": [ + "string" + ] + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_notifications_trigger-test", + "tags": [ + "notifications" + ], + "summary": "Trigger a real notification for a given event type (development/testing helper)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "sent": { + "type": "integer" + }, + "eventType": { + "type": "string" + }, + "environmentId": { + "type": "integer" + } + }, + "required": [ + "success", + "eventType" + ] + }, + "example": { + "success": true, + "sent": 1, + "eventType": "container_unhealthy", + "environmentId": 1 + } + } + } + }, + "400": { + "description": "eventType required, payload with title and message required, unknown event type, or environmentId missing for a non-system event" + }, + "500": { + "description": "Unknown error while sending the notification" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "eventType": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "payload": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "title", + "message" + ] + } + }, + "required": [ + "eventType" + ] + }, + "example": { + "eventType": "container_unhealthy", + "environmentId": 1, + "payload": { + "title": "Container unhealthy", + "message": "web-1 is unhealthy", + "type": "warning" + } + } + } + } + } + } + }, + "/api/preferences/favorite-groups": { + "get": { + "operationId": "get_api_preferences_favorite-groups", + "tags": [ + "preferences" + ], + "summary": "Get the saved log favorite-groups for an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment ID (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] + } + } + } + }, + "example": { + "groups": [ + { + "name": "frontend", + "containers": [ + "web-1", + "web-2" + ] + } + ] + } + } + } + }, + "400": { + "description": "Environment ID is required, or invalid (not a number)" + }, + "500": { + "description": "Failed to get favorite groups" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_preferences_favorite-groups", + "tags": [ + "preferences" + ], + "summary": "Add, remove, update or reorder log favorite-groups for an environment", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] + } + } + } + }, + "example": { + "groups": [ + { + "name": "string", + "containers": [ + "string" + ] + } + ] + } + } + } + }, + "400": { + "description": "Invalid environmentId, unknown action, missing group name, empty/missing containers, duplicate group name, or missing groups array for reorder" + }, + "404": { + "description": "Group not found (update action)" + }, + "500": { + "description": "Failed to update favorite groups" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentId": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "name": { + "type": "string" + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + }, + "newName": { + "type": "string" + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] + } + } + }, + "required": [ + "environmentId", + "action" + ] + }, + "example": { + "environmentId": 1, + "action": "add", + "name": "frontend", + "containers": [ + "web-1", + "web-2" + ] + } + } + } + } + } + }, + "/api/preferences/favorites": { + "get": { + "operationId": "get_api_preferences_favorites", + "tags": [ + "preferences" + ], + "summary": "Get the saved log favorites (container names) for an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment ID (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "favorites": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "favorites": [ + "web-1", + "db-1" + ] + } + } + } + }, + "400": { + "description": "Environment ID is required, or invalid (not a number)" + }, + "500": { + "description": "Failed to get favorites" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_preferences_favorites", + "tags": [ + "preferences" + ], + "summary": "Add, remove or reorder log favorites (container names) for an environment", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "favorites": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "favorites": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid environmentId, unknown action, missing favorites array for reorder, or missing containerName for add/remove" + }, + "500": { + "description": "Failed to update favorites" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentId": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "favorites": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "environmentId", + "action" + ] + }, + "example": { + "environmentId": 1, + "action": "add", + "containerName": "web-1" + } + } + } + } + } + }, + "/api/preferences/grid": { + "get": { + "operationId": "get_api_preferences_grid", + "tags": [ + "preferences" + ], + "summary": "Retrieve all saved data-grid column preferences (per-user when auth is enabled)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": {} + } + } + }, + "example": { + "preferences": { + "containers": { + "columns": [ + { + "id": "name", + "visible": true + } + ] + } + } + } + } + } + }, + "500": { + "description": "Failed to get grid preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_preferences_grid", + "tags": [ + "preferences" + ], + "summary": "Save column preferences for one data grid and return all grid preferences", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": {} + } + } + }, + "example": { + "preferences": {} + } + } + } + }, + "400": { + "description": "gridId is required, columns array is required, or a column is missing id (string) / visible (boolean)" + }, + "500": { + "description": "Failed to save grid preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "gridId": { + "type": "string" + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "visible" + ] + } + } + }, + "required": [ + "gridId" + ] + }, + "example": { + "gridId": "containers", + "columns": [ + { + "id": "name", + "visible": true + }, + { + "id": "image", + "visible": false + } + ] + } + } + } + } + }, + "delete": { + "operationId": "delete_api_preferences_grid", + "tags": [ + "preferences" + ], + "summary": "Reset grid preferences — a single grid when gridId is given, otherwise all grids", + "parameters": [ + { + "name": "gridId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Grid ID to reset; omit to reset every grid" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": {} + } + } + }, + "example": { + "preferences": {} + } + } + } + }, + "500": { + "description": "Failed to reset grid preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/preferences/sidebar": { + "get": { + "operationId": "get_api_preferences_sidebar", + "tags": [ + "preferences" + ], + "summary": "Retrieve the saved sidebar menu preferences (order and hidden items)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": { + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "hidden": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "example": { + "preferences": { + "order": [ + "dashboard", + "containers" + ], + "hidden": [ + "volumes" + ] + } + } + } + } + }, + "500": { + "description": "Failed to get sidebar preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_preferences_sidebar", + "tags": [ + "preferences" + ], + "summary": "Save the sidebar menu preferences (order and hidden items)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": { + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "hidden": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "example": { + "preferences": { + "order": [ + "string" + ], + "hidden": [ + "string" + ] + } + } + } + } + }, + "400": { + "description": "order must be an array of strings, or hidden must be an array of strings" + }, + "500": { + "description": "Failed to save sidebar preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "hidden": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "order", + "hidden" + ] + }, + "example": { + "order": [ + "dashboard", + "containers", + "stacks" + ], + "hidden": [ + "volumes" + ] + } + } + } + } + }, + "delete": { + "operationId": "delete_api_preferences_sidebar", + "tags": [ + "preferences" + ], + "summary": "Reset the sidebar menu preferences to their default order/visibility", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": { + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "hidden": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "example": { + "preferences": { + "order": [ + "string" + ], + "hidden": [ + "string" + ] + } + } + } + } + }, + "500": { + "description": "Failed to reset sidebar preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/profile": { + "get": { + "operationId": "get_api_profile", + "tags": [ + "profile" + ], + "summary": "Get the authenticated user's own profile", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "mfaEnabled": { + "type": "boolean" + }, + "isAdmin": { + "type": "boolean" + }, + "provider": { + "type": "string" + }, + "lastLogin": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "mfaEnabled", + "isAdmin", + "provider", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 0, + "username": "string", + "email": "string", + "displayName": "string", + "avatar": "string", + "mfaEnabled": true, + "isAdmin": true, + "provider": "string", + "lastLogin": "string", + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "Authentication is not enabled" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to read the profile" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_profile", + "tags": [ + "profile" + ], + "summary": "Update the authenticated user's own profile (email/display name, and optionally the password with current-password confirmation)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "mfaEnabled": { + "type": "boolean" + }, + "isAdmin": { + "type": "boolean" + }, + "lastLogin": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "mfaEnabled", + "isAdmin", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 0, + "username": "string", + "email": "string", + "displayName": "string", + "avatar": "string", + "mfaEnabled": true, + "isAdmin": true, + "lastLogin": "string", + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "Authentication is not enabled, current password missing, new password too short (<8), or current password incorrect" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to update the profile" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "example": { + "displayName": "Jane Doe", + "currentPassword": "***", + "newPassword": "***" + } + } + } + } + } + }, + "/api/profile/avatar": { + "post": { + "operationId": "post_api_profile_avatar", + "tags": [ + "profile" + ], + "summary": "Upload the authenticated user's avatar as a base64 image data URL (max ~500KB)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "avatar": { + "type": "string" + } + }, + "required": [ + "success", + "avatar" + ] + }, + "example": { + "success": true, + "avatar": "string" + } + } + } + }, + "400": { + "description": "Authentication is not enabled, avatar data missing, not an image data URL, or image too large (>500KB)" + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Failed to upload the avatar" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + } + }, + "required": [ + "avatar" + ] + }, + "example": { + "avatar": "data:image/png;base64,iVBORw0KGgo..." + } + } + } + } + }, + "delete": { + "operationId": "delete_api_profile_avatar", + "tags": [ + "profile" + ], + "summary": "Remove the authenticated user's avatar", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Authentication is not enabled" + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Failed to remove the avatar" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/profile/preferences": { + "get": { + "operationId": "get_api_profile_preferences", + "tags": [ + "profile" + ], + "summary": "Get the current user's UI preferences (theme, fonts, editor options)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Not authenticated / no user in context" + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Failed to load preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_profile_preferences", + "tags": [ + "profile" + ], + "summary": "Update the current user's UI preferences (each field is optional)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "A supplied field has the wrong type (e.g. editorIndentGuides not a boolean)" + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Failed to save preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lightTheme": { + "type": "string" + }, + "darkTheme": { + "type": "string" + }, + "font": { + "type": "string" + }, + "fontSize": { + "type": "string" + }, + "gridFontSize": { + "type": "string" + }, + "terminalFont": { + "type": "string" + }, + "editorFont": { + "type": "string" + }, + "animateIcons": { + "type": "boolean" + }, + "coloredActionButtons": { + "type": "boolean" + }, + "actionIconSize": { + "type": "string" + }, + "editorIndentGuides": { + "type": "boolean" + } + } + }, + "example": { + "lightTheme": "string", + "darkTheme": "string", + "font": "string", + "fontSize": "string", + "gridFontSize": "string", + "terminalFont": "string", + "editorFont": "string", + "animateIcons": true, + "coloredActionButtons": true, + "actionIconSize": "string", + "editorIndentGuides": true + } + } + } + } + } + }, + "/api/prune/all": { + "post": { + "operationId": "post_api_prune_all", + "tags": [ + "prune" + ], + "summary": "Prune all unused Docker resources (containers, images, volumes and networks) in a single operation", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, result } where result is the aggregated Docker prune report (space reclaimed, items deleted)" + }, + "403": { + "description": "Permission denied — requires the \"remove\" permission on containers, images, volumes AND networks for the target environment" + }, + "500": { + "description": "Failed to prune the system (Docker error); the message is returned in \"details\"" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/prune/containers": { + "post": { + "operationId": "post_api_prune_containers", + "tags": [ + "prune" + ], + "summary": "Prune (delete) all stopped containers in the target environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, result } where result is the Docker container-prune report (deleted container ids, space reclaimed)" + }, + "403": { + "description": "Permission denied — requires the \"remove\" permission on containers for the target environment" + }, + "500": { + "description": "Failed to prune containers (Docker error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/prune/images": { + "post": { + "operationId": "post_api_prune_images", + "tags": [ + "prune" + ], + "summary": "Prune unused Docker images, streaming progress as a Server-Sent Events job", + "parameters": [ + { + "name": "dangling", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When not \"false\", prune only dangling images; set \"dangling=false\" to prune all unused images (defaults to dangling-only)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream; the final `result` event contains { success, result } or { success:false, error }" + }, + "403": { + "description": "Permission denied — requires the \"remove\" permission on images for the target environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a text/event-stream. On completion a `result` event carries { success, result } on success or { success:false, error } on failure — the operation itself never returns a non-200 HTTP status once the permission check passes." + } + }, + "/api/prune/networks": { + "post": { + "operationId": "post_api_prune_networks", + "tags": [ + "prune" + ], + "summary": "Prune (delete) all unused Docker networks in the target environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, result } where result is the Docker network-prune report (deleted network names)" + }, + "403": { + "description": "Permission denied — requires the \"remove\" permission on networks for the target environment" + }, + "500": { + "description": "Failed to prune networks (Docker error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/prune/volumes": { + "post": { + "operationId": "post_api_prune_volumes", + "tags": [ + "prune" + ], + "summary": "Prune (delete) all unused Docker volumes in the target environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, result } where result is the Docker volume-prune report (deleted volume names, space reclaimed)" + }, + "403": { + "description": "Permission denied — requires the \"remove\" permission on volumes for the target environment" + }, + "500": { + "description": "Failed to prune volumes (Docker error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/registries": { + "get": { + "operationId": "get_api_registries", + "tags": [ + "registries" + ], + "summary": "List all configured container registries with passwords stripped (only a hasCredentials flag)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "hasCredentials": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "hasCredentials" + ] + } + }, + "example": [ + { + "id": 1, + "name": "Docker Hub", + "url": "https://docker.io", + "isDefault": true, + "hasCredentials": false + } + ] + } + } + }, + "403": { + "description": "Caller lacks the registries:view permission" + }, + "500": { + "description": "Failed to read registries" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_registries", + "tags": [ + "registries" + ], + "summary": "Create a container registry (optionally set it as default); credentials are trimmed and stored encrypted", + "parameters": [], + "responses": { + "201": { + "description": "Registry created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "hasCredentials": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "hasCredentials" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "isDefault": true, + "hasCredentials": true + } + } + } + }, + "400": { + "description": "Missing name or url, or a duplicate registry name" + }, + "403": { + "description": "Caller lacks the registries:create permission" + }, + "500": { + "description": "Failed to create the registry" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + }, + "required": [ + "name", + "url" + ] + }, + "example": { + "name": "GHCR", + "url": "https://ghcr.io", + "username": "deploy", + "password": "***", + "isDefault": false + } + } + } + } + } + }, + "/api/registries/{id}": { + "get": { + "operationId": "get_api_registries_id", + "tags": [ + "registries" + ], + "summary": "Get a single registry by ID with the password stripped (only a hasCredentials flag)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Registry ID (from GET /api/registries)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "hasCredentials": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "hasCredentials" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "isDefault": true, + "hasCredentials": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the registries:view permission" + }, + "404": { + "description": "No registry exists with that ID" + }, + "500": { + "description": "Failed to read the registry" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_registries_id", + "tags": [ + "registries" + ], + "summary": "Update a registry (optionally set as default); credentials are trimmed and the response strips the password", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Registry ID (from GET /api/registries)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "hasCredentials": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "hasCredentials" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "isDefault": true, + "hasCredentials": true + } + } + } + }, + "400": { + "description": "Invalid id, or a duplicate registry name" + }, + "403": { + "description": "Caller lacks the registries:edit permission" + }, + "404": { + "description": "No registry exists with that ID" + }, + "500": { + "description": "The update failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + } + }, + "example": { + "name": "GHCR", + "url": "https://ghcr.io", + "username": "deploy", + "password": "***", + "isDefault": true + } + } + } + } + }, + "delete": { + "operationId": "delete_api_registries_id", + "tags": [ + "registries" + ], + "summary": "Delete a registry by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Registry ID (from GET /api/registries)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid id, or the registry cannot be deleted" + }, + "403": { + "description": "Caller lacks the registries:delete permission" + }, + "404": { + "description": "No registry exists with that ID" + }, + "500": { + "description": "The deletion failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/registries/{id}/default": { + "post": { + "operationId": "post_api_registries_id_default", + "tags": [ + "registries" + ], + "summary": "Mark a registry as the default registry", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Registry ID (from GET /api/registries)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the settings:edit permission" + }, + "404": { + "description": "No registry exists with that ID" + }, + "500": { + "description": "Failed to set the default registry" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/registries/test": { + "post": { + "operationId": "post_api_registries_test", + "tags": [ + "registries" + ], + "summary": "Test registry connectivity and (if credentials are given) authentication against the V2 endpoint", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "connectivity": { + "type": "boolean" + }, + "authenticated": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "connectivity", + "message" + ] + }, + "example": { + "success": true, + "connectivity": true, + "authenticated": true, + "message": "Connected and authenticated as deploy" + } + } + } + }, + "400": { + "description": "The url field is missing (and no registryId was supplied)" + }, + "403": { + "description": "Caller lacks the registries:view permission" + }, + "404": { + "description": "The referenced registryId does not exist" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Pass either a saved registryId or inline url/username/password. The outcome is always reported in a 200 body via the success/connectivity/authenticated fields, never as an HTTP error. registryId from GET /api/registries.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "registryId": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "example": { + "url": "https://ghcr.io", + "username": "deploy", + "password": "***" + } + } + } + } + } + }, + "/api/registry/catalog": { + "get": { + "operationId": "get_api_registry_catalog", + "tags": [ + "registry" + ], + "summary": "List repositories in a registry's V2 catalog (with Harbor project-API fallback), paginated", + "parameters": [ + { + "name": "registry", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the configured registry to query (from GET /api/registries)" + }, + { + "name": "last", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Opaque pagination cursor from a previous page's nextLast" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repositories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "star_count": { + "type": "integer" + }, + "is_official": { + "type": "boolean" + }, + "is_automated": { + "type": "boolean" + } + }, + "required": [ + "name" + ] + } + }, + "pagination": { + "type": "object", + "properties": { + "pageSize": { + "type": "integer" + }, + "hasMore": { + "type": "boolean" + }, + "nextLast": { + "type": "string" + } + }, + "required": [ + "pageSize", + "hasMore" + ] + } + }, + "required": [ + "repositories", + "pagination" + ] + }, + "example": { + "repositories": [ + { + "name": "library/nginx", + "description": "", + "star_count": 0, + "is_official": false, + "is_automated": false + } + ], + "pagination": { + "pageSize": 100, + "hasMore": false, + "nextLast": null + } + } + } + } + }, + "400": { + "description": "Missing registry parameter, or Docker Hub was targeted (catalog listing unsupported)" + }, + "404": { + "description": "Registry not found, or the registry does not implement the V2 catalog API" + }, + "500": { + "description": "Failed to fetch the catalog" + }, + "503": { + "description": "Could not connect to the registry (connection refused, host not found, or a TLS error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Docker Hub is rejected (no catalog API). For 401/403/404 from the upstream registry the same status is proxied back to the caller." + } + }, + "/api/registry/image": { + "delete": { + "operationId": "delete_api_registry_image", + "tags": [ + "registry" + ], + "summary": "Delete a tagged image from a registry by resolving its manifest digest, then deleting by digest", + "parameters": [ + { + "name": "registry", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the configured registry (from GET /api/registries)" + }, + { + "name": "image", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Repository/image name (may include an org path)" + }, + { + "name": "tag", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Tag to delete" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "message": "Deleted library/nginx:1.27" + } + } + } + }, + "400": { + "description": "A required query parameter is missing, or Docker Hub was targeted (deletion unsupported)" + }, + "401": { + "description": "The registry rejected authentication while resolving or deleting the manifest" + }, + "403": { + "description": "Caller lacks the settings:edit permission" + }, + "404": { + "description": "Registry not found, or the image/tag/manifest does not exist" + }, + "405": { + "description": "The registry has manifest deletion disabled (REGISTRY_STORAGE_DELETE_ENABLED)" + }, + "500": { + "description": "Failed to delete the image" + }, + "503": { + "description": "Could not connect to the registry (connection refused or host not found)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Docker Hub deletion is rejected. Upstream 401 responses during manifest resolution/deletion are proxied back as 401." + } + }, + "/api/registry/search": { + "get": { + "operationId": "get_api_registry_search", + "tags": [ + "registry" + ], + "summary": "Search for images by name in Docker Hub or a configured private registry (direct + catalog fallback)", + "parameters": [ + { + "name": "term", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Search term / image name" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Maximum number of results (default 25)" + }, + { + "name": "registry", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the configured registry to search; omit to search Docker Hub (from GET /api/registries)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "star_count": { + "type": "integer" + }, + "is_official": { + "type": "boolean" + }, + "is_automated": { + "type": "boolean" + } + }, + "required": [ + "name" + ] + } + }, + "example": [ + { + "name": "nginx", + "description": "Official build of Nginx", + "star_count": 20000, + "is_official": true, + "is_automated": false + } + ] + } + } + }, + "400": { + "description": "The term query parameter is missing" + }, + "404": { + "description": "The referenced registry does not exist" + }, + "500": { + "description": "Failed to search images" + }, + "503": { + "description": "Could not connect to the registry (connection refused or host not found)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "With no registry parameter the search runs against Docker Hub; otherwise against the given registry, falling back to Docker Hub search when the registry is a Docker Hub mirror." + } + }, + "/api/registry/tag-info": { + "get": { + "operationId": "get_api_registry_tag-info", + "tags": [ + "registry" + ], + "summary": "Resolve the compressed size and creation date of a single registry tag from its manifest (lazy per-tag fetch for the registry browser)", + "parameters": [ + { + "name": "registry", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Registry ID; omitted or Docker Hub registries return a graceful \"not supported\" result instead of a manifest fetch (from GET /api/registries)" + }, + { + "name": "image", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image repository/name" + }, + { + "name": "tag", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Tag name or digest" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "size": { + "type": "number" + }, + "lastUpdated": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "example": { + "size": 104857600, + "lastUpdated": "2026-06-01T12:00:00Z" + } + } + } + }, + "400": { + "description": "image and tag are required" + }, + "404": { + "description": "Registry not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/registry/tags": { + "get": { + "operationId": "get_api_registry_tags", + "tags": [ + "registry" + ], + "summary": "List the tags of an image from Docker Hub or a configured registry (paginated for Docker Hub)", + "parameters": [ + { + "name": "registry", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the configured registry; omit to query Docker Hub (from GET /api/registries)" + }, + { + "name": "image", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Repository/image name" + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page number for Docker Hub pagination (default 1)" + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page size for Docker Hub pagination (default 20)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "lastUpdated": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "hasNext": { + "type": "boolean" + }, + "hasPrev": { + "type": "boolean" + } + }, + "required": [ + "tags", + "total", + "page", + "pageSize", + "hasNext", + "hasPrev" + ] + }, + "example": { + "tags": [ + { + "name": "1.27", + "lastUpdated": "2026-06-01T00:00:00Z" + } + ], + "total": 1, + "page": 1, + "pageSize": 20, + "hasNext": false, + "hasPrev": false + } + } + } + }, + "400": { + "description": "The image query parameter is missing" + }, + "404": { + "description": "The referenced registry does not exist" + }, + "500": { + "description": "Failed to fetch tags" + }, + "503": { + "description": "Could not connect to the registry (connection refused or host not found)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "With no registry parameter, Docker Hub is queried; V2 registries return all tags in one page. Upstream Docker Hub error status codes are proxied back." + } + }, + "/api/roles": { + "get": { + "operationId": "get_api_roles", + "tags": [ + "roles" + ], + "summary": "List all roles (built-in and custom); available in setup mode or with an enterprise license", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isSystem": { + "type": "boolean" + }, + "permissions": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "name", + "isSystem" + ] + } + }, + "example": [ + { + "id": 0, + "name": "string", + "description": "string", + "isSystem": true, + "permissions": {} + } + ] + } + } + }, + "403": { + "description": "Enterprise license required" + }, + "500": { + "description": "Failed to read the roles" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_roles", + "tags": [ + "roles" + ], + "summary": "Create a custom role (enterprise; admin required when auth is enabled)", + "parameters": [], + "responses": { + "201": { + "description": "The created role" + }, + "400": { + "description": "Name and permissions are required" + }, + "403": { + "description": "Enterprise license required, or admin access required" + }, + "409": { + "description": "A role with this name already exists" + }, + "500": { + "description": "Failed to create the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentIds from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "permissions": { + "type": "object", + "properties": {} + }, + "environmentIds": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "required": [ + "name", + "permissions" + ] + }, + "example": { + "name": "Operators", + "description": "Can manage containers", + "permissions": { + "containers": [ + "view", + "edit" + ] + }, + "environmentIds": [ + 1, + 2 + ] + } + } + } + } + } + }, + "/api/roles/{id}": { + "get": { + "operationId": "get_api_roles_id", + "tags": [ + "roles" + ], + "summary": "Get a single role by id; available in setup mode or with an enterprise license", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the role (from GET /api/roles)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isSystem": { + "type": "boolean" + }, + "permissions": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "name", + "isSystem" + ] + }, + "example": { + "id": 0, + "name": "string", + "description": "string", + "isSystem": true, + "permissions": {} + } + } + } + }, + "400": { + "description": "Role id is required" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "Role not found" + }, + "500": { + "description": "Failed to read the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_roles_id", + "tags": [ + "roles" + ], + "summary": "Update a custom role (system roles cannot be modified; enterprise, admin required when auth is enabled)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the role (from GET /api/roles)" + } + ], + "responses": { + "200": { + "description": "The updated role" + }, + "400": { + "description": "Role id is required, or the role is a system role and cannot be modified" + }, + "403": { + "description": "Enterprise license required, or admin access required" + }, + "404": { + "description": "Role not found" + }, + "409": { + "description": "A role with this name already exists" + }, + "500": { + "description": "Failed to update the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentIds from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "permissions": { + "type": "object", + "properties": {} + }, + "environmentIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "example": { + "description": "Updated description", + "permissions": { + "containers": [ + "view" + ] + } + } + } + } + } + }, + "delete": { + "operationId": "delete_api_roles_id", + "tags": [ + "roles" + ], + "summary": "Delete a custom role by id (system roles cannot be deleted; enterprise, admin required when auth is enabled)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the role (from GET /api/roles)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Role id is required, or the role is a system role and cannot be deleted" + }, + "403": { + "description": "Enterprise license required, or admin access required" + }, + "404": { + "description": "Role not found" + }, + "500": { + "description": "Failed to delete the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules": { + "get": { + "operationId": "get_api_schedules", + "tags": [ + "schedules" + ], + "summary": "List all schedules (container/env auto-updates, git syncs, image-prune, backups, repo maintenance, system jobs)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "schedules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "entityName": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "cronExpression": { + "type": "string" + }, + "nextRun": { + "type": "string" + }, + "isSystem": { + "type": "boolean" + } + }, + "required": [ + "id", + "type", + "name", + "entityName", + "enabled", + "isSystem" + ] + } + } + }, + "required": [ + "schedules" + ] + }, + "example": { + "schedules": [ + { + "id": 0, + "type": "string", + "name": "string", + "entityName": "string", + "environmentId": 0, + "enabled": true, + "cronExpression": "string", + "nextRun": "string", + "isSystem": true + } + ] + } + } + } + }, + "500": { + "description": "Unexpected error while assembling the schedule list" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/{type}/{id}": { + "delete": { + "operationId": "delete_api_schedules_type_id", + "tags": [ + "schedules" + ], + "summary": "Delete (or disable, for policy-driven types) a schedule by type and id", + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup)" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Schedule id (semantics depend on type — container/git-stack/backup-config id, environment id, or a synthetic repo-policy id) (from GET /api/schedules)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid schedule id, invalid/unsupported type, or system_cleanup (cannot be removed)" + }, + "404": { + "description": "Schedule (or backup destination, for repo_* types) not found" + }, + "500": { + "description": "Unexpected error while deleting the schedule" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/{type}/{id}/run": { + "post": { + "operationId": "post_api_schedules_type_id_run", + "tags": [ + "schedules" + ], + "summary": "Manually trigger a single run of a schedule (outside its cron), by type and id", + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify)" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Schedule id (semantics depend on type) (from GET /api/schedules)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ] + }, + "example": { + "success": true, + "message": "string" + } + } + } + }, + "400": { + "description": "Invalid schedule id/type, or the triggered task itself reported failure" + }, + "404": { + "description": "Schedule, backup config, or backup destination not found (or backup feature disabled)" + }, + "500": { + "description": "Unexpected error while triggering the schedule" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/{type}/{id}/toggle": { + "post": { + "operationId": "post_api_schedules_type_id_toggle", + "tags": [ + "schedules" + ], + "summary": "Toggle a schedule's enabled/disabled state (registers/unregisters the croner job accordingly)", + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup)" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Schedule id (semantics depend on type) (from GET /api/schedules)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "success", + "enabled" + ] + }, + "example": { + "success": true, + "enabled": true + } + } + } + }, + "400": { + "description": "Invalid schedule id, unsupported type, or system_cleanup (cannot be paused)" + }, + "404": { + "description": "Schedule, backup config, or backup destination not found" + }, + "500": { + "description": "Unexpected error while toggling the schedule" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/executions": { + "get": { + "operationId": "get_api_schedules_executions", + "tags": [ + "schedules" + ], + "summary": "List schedule execution history, filterable and paginated", + "parameters": [ + { + "name": "scheduleType", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by schedule type (container_update, git_stack_sync, ...)" + }, + { + "name": "scheduleId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter by the numeric id of the schedule (from GET /api/schedules)" + }, + { + "name": "environmentId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter by environment id (\"null\" for global/system schedules) (from GET /api/environments)" + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by execution status (queued/running/success/warning/failed/skipped)" + }, + { + "name": "statuses", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated list of statuses (alternative to status)" + }, + { + "name": "triggeredBy", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by trigger (cron/webhook/manual)" + }, + { + "name": "fromDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "ISO date lower bound" + }, + { + "name": "toDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "ISO date upper bound" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page size (default 50)" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page offset (default 0)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "executions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "scheduleType": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": "string" + } + }, + "required": [ + "id", + "scheduleType", + "status", + "startedAt" + ] + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "executions", + "total", + "limit", + "offset" + ] + }, + "example": { + "executions": [ + { + "id": 0, + "scheduleType": "string", + "status": "string", + "startedAt": "string" + } + ], + "total": 0, + "limit": 0, + "offset": 0 + } + } + } + }, + "500": { + "description": "Unexpected error while loading execution history" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/executions/{id}": { + "get": { + "operationId": "get_api_schedules_executions_id", + "tags": [ + "schedules" + ], + "summary": "Get a single schedule execution, including its logs", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Execution id (from GET /api/schedules/executions)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "scheduleType": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": "string" + }, + "log": { + "type": "string" + } + }, + "required": [ + "id", + "scheduleType", + "status", + "startedAt" + ] + }, + "example": { + "id": 0, + "scheduleType": "string", + "status": "string", + "startedAt": "string", + "log": "string" + } + } + } + }, + "400": { + "description": "Invalid execution id" + }, + "404": { + "description": "Execution not found" + }, + "500": { + "description": "Unexpected error while loading the execution" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_schedules_executions_id", + "tags": [ + "schedules" + ], + "summary": "Delete a single schedule execution record", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Execution id (from GET /api/schedules/executions)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid execution id" + }, + "403": { + "description": "Permission denied (RBAC 'schedules:edit' missing)" + }, + "500": { + "description": "Unexpected error while deleting the execution" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/settings": { + "get": { + "operationId": "get_api_schedules_settings", + "tags": [ + "schedules" + ], + "summary": "Get the schedules-page display preference (hide system jobs), per-user with a global fallback", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hideSystemJobs": { + "type": "boolean" + } + }, + "required": [ + "hideSystemJobs" + ] + }, + "example": { + "hideSystemJobs": true + } + } + } + }, + "500": { + "description": "Unexpected error while loading the setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_schedules_settings", + "tags": [ + "schedules" + ], + "summary": "Update the schedules-page display preference (hide system jobs) for the current user", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "hideSystemJobs": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "hideSystemJobs": true + } + } + } + }, + "400": { + "description": "hideSystemJobs is not a boolean" + }, + "500": { + "description": "Unexpected error while saving the setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hideSystemJobs": { + "type": "boolean" + } + } + }, + "example": { + "hideSystemJobs": true + } + } + } + } + } + }, + "/api/schedules/stream": { + "get": { + "operationId": "get_api_schedules_stream", + "tags": [ + "schedules" + ], + "summary": "Server-Sent Events stream that polls and pushes schedule updates (same shape as GET /api/schedules)", + "parameters": [], + "responses": { + "200": { + "description": "text/event-stream response, periodically emitting the current schedule list as SSE \"data:\" messages" + }, + "403": { + "description": "Permission denied (RBAC 'schedules:view' missing)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/system/{id}/toggle": { + "post": { + "operationId": "post_api_schedules_system_id_toggle", + "tags": [ + "schedules" + ], + "summary": "Toggle one of the three built-in system cleanup jobs (schedule/event/scanner cleanup) on or off", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "System schedule id (1=schedule cleanup, 2=event cleanup, 4=scanner cleanup) (from GET /api/schedules)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "success", + "enabled" + ] + }, + "example": { + "success": true, + "enabled": true + } + } + } + }, + "400": { + "description": "Invalid or unknown system schedule id" + }, + "403": { + "description": "Permission denied (RBAC 'settings:edit' missing)" + }, + "500": { + "description": "Unexpected error while toggling the job" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/secret-providers": { + "get": { + "operationId": "get_api_secret-providers", + "tags": [ + "secret-providers" + ], + "summary": "List configured secret providers (summaries never include the decrypted config)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ] + } + }, + "example": [ + { + "id": 0, + "name": "string", + "type": "string" + } + ] + } + } + }, + "403": { + "description": "Permission denied (needs secrets:view)" + }, + "500": { + "description": "Failed to fetch secret providers" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_secret-providers", + "tags": [ + "secret-providers" + ], + "summary": "Create a secret provider (Vault, Infisical, Doppler, 1Password Connect)", + "parameters": [], + "responses": { + "201": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ] + }, + "example": { + "id": 0, + "name": "string", + "type": "string" + } + } + } + }, + "400": { + "description": "Name and type are required, unknown provider type, config missing, or a provider with this name already exists" + }, + "403": { + "description": "Permission denied (needs secrets:create)" + }, + "500": { + "description": "Failed to create secret provider" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "config": { + "type": "string" + } + }, + "required": [ + "name", + "type", + "config" + ] + }, + "example": { + "name": "string", + "type": "string", + "config": "string" + } + } + } + } + } + }, + "/api/secret-providers/{id}": { + "get": { + "operationId": "get_api_secret-providers_id", + "tags": [ + "secret-providers" + ], + "summary": "Get one secret provider with its non-secret config coordinates (the token is redacted out)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The secret provider id" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "config": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "type", + "config" + ] + }, + "example": { + "id": 0, + "name": "string", + "type": "string", + "config": "string" + } + } + } + }, + "400": { + "description": "Invalid secret provider ID" + }, + "403": { + "description": "Permission denied (needs secrets:view)" + }, + "404": { + "description": "Secret provider not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_secret-providers_id", + "tags": [ + "secret-providers" + ], + "summary": "Update a secret provider (name, type, and/or rotate its config); omitted fields are left unchanged", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The secret provider id" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ] + }, + "example": { + "id": 0, + "name": "string", + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid ID, empty name, unknown type, config not an object, or the name already exists" + }, + "403": { + "description": "Permission denied (needs secrets:edit)" + }, + "404": { + "description": "Secret provider not found" + }, + "500": { + "description": "Failed to update secret provider" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "config": { + "type": "string" + } + } + }, + "example": { + "name": "string", + "type": "string", + "config": "string" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_secret-providers_id", + "tags": [ + "secret-providers" + ], + "summary": "Delete a secret provider", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The secret provider id" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid secret provider ID" + }, + "403": { + "description": "Permission denied (needs secrets:delete)" + }, + "404": { + "description": "Secret provider not found" + }, + "500": { + "description": "Failed to delete secret provider" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/secret-providers/{id}/probe": { + "post": { + "operationId": "post_api_secret-providers_id_probe", + "tags": [ + "secret-providers" + ], + "summary": "Live-probe which key NAMES exist in a stored provider (names only, values never leave the server)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The secret provider id" + } + ], + "responses": { + "200": { + "description": "ok=true returns the key names found via the bulk selector and the resolved op:// refs; ok=false folds a provider error in (still 200) so a slow or auth-gated provider never breaks the editor", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "bulkKeys": { + "type": "array", + "items": { + "type": "string" + } + }, + "resolvedRefs": { + "type": "array", + "items": { + "type": "string" + } + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok" + ] + }, + "example": { + "ok": true, + "bulkKeys": [ + "string" + ], + "resolvedRefs": [ + "string" + ], + "error": "string" + } + } + } + }, + "400": { + "description": "Invalid ID or unknown provider type" + }, + "403": { + "description": "Permission denied (needs secrets:view)" + }, + "404": { + "description": "Secret provider not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "selector": { + "type": "string" + }, + "refs": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "selector": "prod", + "refs": [ + "op://vault/db/password" + ] + } + } + } + } + } + }, + "/api/secret-providers/{id}/test": { + "post": { + "operationId": "post_api_secret-providers_id_test", + "tags": [ + "secret-providers" + ], + "summary": "Test a stored provider's connectivity, optionally against typed override fields the edit form would save", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The secret provider id" + } + ], + "responses": { + "200": { + "description": "ok=false carries the connection error (still 200 so the edit form can show it inline)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok" + ] + }, + "example": { + "ok": true, + "error": "string" + } + } + } + }, + "400": { + "description": "Invalid secret provider ID" + }, + "403": { + "description": "Permission denied (needs secrets:view)" + }, + "404": { + "description": "Secret provider not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "config": { + "type": "string" + } + } + }, + "example": { + "config": { + "host": "https://vault.example.com", + "mount": "secret" + } + } + } + } + } + } + }, + "/api/secret-providers/test": { + "post": { + "operationId": "post_api_secret-providers_test", + "tags": [ + "secret-providers" + ], + "summary": "Test an unsaved provider config (before creating the provider)", + "parameters": [], + "responses": { + "200": { + "description": "ok=false carries the reason (invalid type, missing config, or a connection error) - still 200 so the form shows it inline", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok" + ] + }, + "example": { + "ok": true, + "error": "string" + } + } + } + }, + "400": { + "description": "Invalid request body" + }, + "403": { + "description": "Permission denied (needs secrets:create)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "config": { + "type": "string" + } + }, + "required": [ + "type", + "config" + ] + }, + "example": { + "type": "vault", + "config": { + "host": "https://vault.example.com", + "mount": "secret", + "token": "hvs...." + } + } + } + } + } + } + }, + "/api/self-update": { + "post": { + "operationId": "post_api_self-update", + "tags": [ + "self-update" + ], + "summary": "Pull a new Dockhand image and hand off to an updater sidecar that replaces this running container", + "parameters": [], + "responses": { + "200": { + "description": "text/event-stream SSE response (steps: pulling_image, building_config, pulling_updater, creating_container, launching_updater, then a \"launched\"/\"error\" event) — or, with \"Accept: application/json\", the final event as plain JSON" + }, + "400": { + "description": "newImage missing, not running in Docker, or the Docker socket is read-only" + }, + "403": { + "description": "Admin access required" + }, + "500": { + "description": "Failed to inspect own container or determine its name before starting the update" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "newImage": { + "type": "string" + } + }, + "required": [ + "newImage" + ] + }, + "example": { + "newImage": "fnsys/dockhand:v1.0.40" + } + } + } + } + } + }, + "/api/self-update/check": { + "get": { + "operationId": "get_api_self-update_check", + "tags": [ + "self-update" + ], + "summary": "Check whether a newer Dockhand image is available (version-tag compare via GitHub changelog, or registry-digest compare for mutable tags like :latest)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "updateAvailable": { + "type": "boolean" + }, + "currentImage": { + "type": "string" + }, + "newImage": { + "type": "string" + }, + "latestVersion": { + "type": "string" + }, + "currentDigest": { + "type": "string" + }, + "newDigest": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "isComposeManaged": { + "type": "boolean" + }, + "isLocalImage": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "updateAvailable" + ] + }, + "example": { + "updateAvailable": true, + "currentImage": "string", + "newImage": "string", + "latestVersion": "string", + "currentDigest": "string", + "newDigest": "string", + "containerName": "string", + "isComposeManaged": true, + "isLocalImage": true, + "error": "string" + } + } + } + }, + "403": { + "description": "Admin access required" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/self-update/progress": { + "get": { + "operationId": "get_api_self-update_progress", + "tags": [ + "self-update" + ], + "summary": "Poll the self-update sidecar container's logs and exit state for progress tracking", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Updater container id (from POST /api/self-update)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "string" + }, + "status": { + "type": "string" + }, + "exitCode": { + "type": "integer" + } + }, + "required": [ + "logs", + "status" + ] + }, + "example": { + "logs": "string", + "status": "string", + "exitCode": 0 + } + } + } + }, + "400": { + "description": "Container ID is required" + }, + "403": { + "description": "Admin access required" + }, + "500": { + "description": "Failed to inspect the updater container, or failed to fetch its progress" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/settings/general": { + "get": { + "operationId": "get_api_settings_general", + "tags": [ + "settings" + ], + "summary": "Get global (instance-wide) general settings", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Failed to load settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_settings_general", + "tags": [ + "settings" + ], + "summary": "Update global general settings (all fields optional; only supplied keys are written)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs settings:edit)" + }, + "500": { + "description": "Failed to save settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "A large flat settings bag - theme/fonts, scanner defaults, cleanup schedules, event/metrics collection, editor options (e.g. editorIndentGuides), and more.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "animateIcons": { + "type": "boolean" + }, + "editorIndentGuides": { + "type": "boolean" + }, + "coloredActionButtons": { + "type": "boolean" + }, + "lightTheme": { + "type": "string" + }, + "darkTheme": { + "type": "string" + }, + "defaultTimezone": { + "type": "string" + }, + "logBufferSizeKb": { + "type": "integer" + }, + "externalStackPaths": { + "type": "string" + } + } + }, + "example": { + "animateIcons": true, + "editorIndentGuides": true, + "coloredActionButtons": true, + "lightTheme": "string", + "darkTheme": "string", + "defaultTimezone": "string", + "logBufferSizeKb": 0, + "externalStackPaths": "string" + } + } + } + } + } + }, + "/api/settings/navigation": { + "get": { + "operationId": "get_api_settings_navigation", + "tags": [ + "settings" + ], + "summary": "Get the effective navigation preferences (landing page, env-click target) plus the raw global and per-user values", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "effective": { + "type": "object", + "properties": { + "landingPage": { + "type": "string" + }, + "envClickPage": { + "type": "string" + } + }, + "required": [ + "landingPage", + "envClickPage" + ] + }, + "global": { + "type": "object", + "properties": { + "landingPage": { + "type": "string" + }, + "envClickPage": { + "type": "string" + } + } + }, + "user": { + "type": "object", + "properties": { + "landingPage": { + "type": "string" + }, + "envClickPage": { + "type": "string" + } + } + } + } + }, + "example": { + "effective": { + "landingPage": "dashboard", + "envClickPage": "containers" + }, + "global": { + "landingPage": null, + "envClickPage": null + }, + "user": null + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_settings_navigation", + "tags": [ + "settings" + ], + "summary": "Update navigation preferences (landing page, env-click target) at global or per-user scope", + "parameters": [ + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Preference scope to update — \"global\" (default) or \"user\"" + } + ], + "responses": { + "200": { + "description": "The updated preferences ({success:true, global:{...}} for scope=global, or {success:true, user:{...}} for scope=user)" + }, + "400": { + "description": "Invalid input — invalid landingPage/envClickPage value, or scope=user requested while authentication is disabled" + }, + "401": { + "description": "Not authenticated (scope=user, authentication is enabled but no valid session)" + }, + "403": { + "description": "Permission denied (scope=global, requires settings:edit)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "landingPage": { + "type": "string" + }, + "envClickPage": { + "type": "string" + } + } + }, + "example": { + "landingPage": "containers", + "envClickPage": "logs" + } + } + } + } + } + }, + "/api/settings/scanner": { + "get": { + "operationId": "get_api_settings_scanner", + "tags": [ + "settings" + ], + "summary": "Get the vulnerability-scanner settings for an environment, plus (unless settingsOnly) scanner availability, versions and optional update info", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id to read scanner settings for (falls back to global defaults) (from GET /api/environments)" + }, + { + "name": "checkUpdates", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When true, also check the scanner images for available updates (slower)" + }, + { + "name": "settingsOnly", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When true, return only settings + defaults and skip the Docker availability/version checks" + } + ], + "responses": { + "200": { + "description": "Scanner settings and (unless settingsOnly) availability, versions, updates and defaults" + }, + "403": { + "description": "Permission denied (missing settings:view for the environment)" + }, + "500": { + "description": "Failed to read the scanner settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_settings_scanner", + "tags": [ + "settings" + ], + "summary": "Save the vulnerability-scanner settings for an environment", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "settings": { + "type": "object", + "properties": { + "scanner": { + "type": "string" + }, + "grypeArgs": { + "type": "string" + }, + "trivyArgs": { + "type": "string" + } + }, + "required": [ + "scanner", + "grypeArgs", + "trivyArgs" + ] + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "settings": { + "scanner": "string", + "grypeArgs": "string", + "trivyArgs": "string" + } + } + } + } + }, + "400": { + "description": "Invalid scanner type (must be none, grype, trivy or both)" + }, + "403": { + "description": "Permission denied (missing settings:edit for the environment)" + }, + "500": { + "description": "Failed to save the scanner settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scanner": { + "type": "string" + }, + "grypeArgs": { + "type": "string" + }, + "trivyArgs": { + "type": "string" + }, + "envId": { + "type": "integer" + } + } + }, + "example": { + "scanner": "grype", + "grypeArgs": "-o json -v {image}", + "trivyArgs": "image --format json {image}", + "envId": 1 + } + } + } + } + }, + "delete": { + "operationId": "delete_api_settings_scanner", + "tags": [ + "settings" + ], + "summary": "Remove the scanner images (grype/trivy) and clean up scanner database volumes for an environment", + "parameters": [ + { + "name": "removeImages", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Must be true to actually perform the removal (required)" + }, + { + "name": "scanner", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Which scanner image to remove (grype or trivy); omit to remove both" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id whose scanner images should be removed (required) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "removed": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "removed": [ + "string" + ], + "errors": [ + "string" + ] + } + } + } + }, + "400": { + "description": "The removeImages parameter is required, or the environment id is missing" + }, + "403": { + "description": "Permission denied (missing settings:edit for the environment)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Failed to remove the scanner images" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/settings/scanner/cache": { + "delete": { + "operationId": "delete_api_settings_scanner_cache", + "tags": [ + "settings" + ], + "summary": "Clear the vulnerability-scanner cache (local volumes/bind-mount dirs plus each remote environment's scanner volume)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "removedVolumes": { + "type": "array", + "items": { + "type": "string" + } + }, + "removedDirs": { + "type": "array", + "items": { + "type": "string" + } + }, + "skippedEnvironments": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "success", + "removedVolumes", + "removedDirs", + "skippedEnvironments" + ] + }, + "example": { + "success": true, + "removedVolumes": [ + "string" + ], + "removedDirs": [ + "string" + ], + "skippedEnvironments": [ + "string" + ] + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to clear scanner cache" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/settings/theme": { + "get": { + "operationId": "get_api_settings_theme", + "tags": [ + "settings" + ], + "summary": "Public theme + editor display settings (used by the login page before auth)", + "parameters": [], + "responses": { + "200": { + "description": "Light/dark theme, fonts, and editor display flags (e.g. editorIndentGuides)" + } + }, + "security": [] + } + }, + "/api/stacks": { + "get": { + "operationId": "get_api_stacks", + "tags": [ + "stacks" + ], + "summary": "List compose stacks for one environment (internal, external, and git)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_stacks", + "tags": [ + "stacks" + ], + "summary": "Create and (optionally) deploy a compose stack", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid request (e.g. missing name/compose, or secretProviderId wrong type)" + }, + "403": { + "description": "Permission denied (needs stacks:create; binding a secret provider also needs secrets:view)" + }, + "500": { + "description": "Failed to create or deploy the stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Writes the compose + .env to the stack dir, stores secrets in the DB, and with start deploys it. Can bind a secret provider.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "compose": { + "type": "string" + }, + "composePath": { + "type": "string" + }, + "envPath": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "string" + } + }, + "rawEnvContent": { + "type": "string" + }, + "secretProviderId": { + "type": "integer" + }, + "start": { + "type": "boolean" + } + }, + "required": [ + "name", + "compose" + ] + }, + "example": { + "name": "string", + "compose": "string", + "composePath": "string", + "envPath": "string", + "envVars": [ + "string" + ], + "rawEnvContent": "string", + "secretProviderId": 0, + "start": true + } + } + } + } + } + }, + "/api/stacks/{name}": { + "delete": { + "operationId": "delete_api_stacks_name", + "tags": [ + "stacks" + ], + "summary": "Remove a stack completely (compose down + delete files + database cleanup)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Force removal even if the compose down step fails" + }, + { + "name": "volumes", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Also remove named volumes (docker compose down --volumes)" + }, + { + "name": "files", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Delete the stack's on-disk files/directory too (default true; pass files=false to keep them on disk)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Compose down failed and force was not set" + }, + "403": { + "description": "Permission denied, or access denied to this environment" + }, + "404": { + "description": "Compose file not found for this stack" + }, + "500": { + "description": "Unexpected error while removing the stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/{name}/check-path-change": { + "post": { + "operationId": "post_api_stacks_name_check-path-change", + "tags": [ + "stacks" + ], + "summary": "Check whether a proposed compose path moves the stack to a different directory and how many files the old directory still holds", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hasChanges": { + "type": "boolean" + }, + "oldDir": { + "type": "string" + }, + "newDir": { + "type": "string" + }, + "fileCount": { + "type": "integer" + }, + "currentComposePath": { + "type": "string" + } + }, + "required": [ + "hasChanges", + "fileCount" + ] + }, + "example": { + "hasChanges": true, + "oldDir": "/opt/stacks/old", + "newDir": "/opt/stacks/web", + "fileCount": 3, + "currentComposePath": "/opt/stacks/old/compose.yaml" + } + } + } + }, + "403": { + "description": "Permission denied (requires stacks:edit)" + }, + "500": { + "description": "Failed to check path changes" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "newComposePath": { + "type": "string" + } + }, + "required": [ + "newComposePath" + ] + }, + "example": { + "newComposePath": "/opt/stacks/web/compose.yaml" + } + } + } + } + } + }, + "/api/stacks/{name}/compose": { + "get": { + "operationId": "get_api_stacks_name_compose", + "tags": [ + "stacks" + ], + "summary": "Get a stack's compose file content plus its resolved compose/env paths", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the stack belongs to" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "404": { + "description": "Stack or compose file not found" + }, + "500": { + "description": "Failed to read the compose file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_stacks_name_compose", + "tags": [ + "stacks" + ], + "summary": "Save a stack's compose file (and optionally relocate it, bind a secret provider, and redeploy)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the stack belongs to" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid request (e.g. missing content, or secretProviderId wrong type)" + }, + "403": { + "description": "Permission denied (needs stacks:edit; binding a secret provider also needs secrets:view)" + }, + "500": { + "description": "Failed to save or deploy the compose file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Every accepted PUT persists the compose content; with restart it also redeploys. Supports moving the compose/env to a new path and binding a secret provider.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "composePath": { + "type": "string" + }, + "envPath": { + "type": "string" + }, + "oldComposePath": { + "type": "string" + }, + "oldEnvPath": { + "type": "string" + }, + "moveFromDir": { + "type": "string" + }, + "restart": { + "type": "boolean" + }, + "secretProviderId": { + "type": "integer" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "string", + "composePath": "string", + "envPath": "string", + "oldComposePath": "string", + "oldEnvPath": "string", + "moveFromDir": "string", + "restart": true, + "secretProviderId": 0 + } + } + } + } + } + }, + "/api/stacks/{name}/delete-preview": { + "get": { + "operationId": "get_api_stacks_name_delete-preview", + "tags": [ + "stacks" + ], + "summary": "Preview the on-disk directories and named volumes a delete-with-files would remove for a stack", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stackName": { + "type": "string" + }, + "sourceType": { + "type": "string" + }, + "stackDir": { + "type": "string" + }, + "gitDir": { + "type": "string" + }, + "namedVolumes": { + "type": "array", + "items": { + "type": "string" + } + }, + "canDeleteFiles": { + "type": "boolean" + } + }, + "required": [ + "stackName", + "namedVolumes", + "canDeleteFiles" + ] + }, + "example": { + "stackName": "web", + "sourceType": "internal", + "stackDir": "/opt/stacks/web", + "gitDir": null, + "namedVolumes": [ + "web_data" + ], + "canDeleteFiles": true + } + } + } + }, + "403": { + "description": "Permission denied (requires stacks:remove)" + }, + "500": { + "description": "Failed to compute the deletion preview" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/{name}/deploy": { + "post": { + "operationId": "post_api_stacks_name_deploy", + "tags": [ + "stacks" + ], + "summary": "Deploy (docker compose up) a stack, optionally pulling images, building, and force-recreating; progress and the final result stream over Server-Sent Events", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Server-Sent-Events job stream with progress events and a final result event ({success, output})" + }, + "403": { + "description": "Permission denied (requires stacks:start, or environment access denied on enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pull": { + "type": "boolean" + }, + "build": { + "type": "boolean" + }, + "forceRecreate": { + "type": "boolean" + } + } + }, + "example": { + "pull": true, + "build": false, + "forceRecreate": false + } + } + } + } + } + }, + "/api/stacks/{name}/down": { + "post": { + "operationId": "post_api_stacks_name_down", + "tags": [ + "stacks" + ], + "summary": "Take a stack down (docker compose down — removes containers, keeps files), asynchronously", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Fire-and-forget job id — poll GET /api/jobs/{jobId} for the result. Send \"Accept: application/json\" (without text/event-stream) to instead block and receive the final {success,output|error} synchronously.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ] + }, + "example": { + "jobId": "3f9c5b1a-2e4d-4a6f-9b0a-1c7d8e9f0a1b" + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "removeVolumes": { + "type": "boolean" + } + } + }, + "example": { + "removeVolumes": false + } + } + } + } + } + }, + "/api/stacks/{name}/env": { + "get": { + "operationId": "get_api_stacks_name_env", + "tags": [ + "stacks" + ], + "summary": "Get a stack's env vars (non-secrets from file, secrets masked from DB) plus injected provider keys", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the stack belongs to" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + } + }, + "injectedSecretKeys": { + "type": "array", + "items": { + "type": "string" + } + }, + "secretProvider": { + "type": "string" + } + }, + "required": [ + "variables" + ] + }, + "example": { + "variables": [ + "string" + ], + "injectedSecretKeys": [ + "string" + ], + "secretProvider": "string" + } + } + } + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "500": { + "description": "Failed to read env vars" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_stacks_name_env", + "tags": [ + "stacks" + ], + "summary": "Save a stack's secret env vars to the DB (secrets never hit disk)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the stack belongs to" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid variables payload" + }, + "403": { + "description": "Permission denied (needs stacks:edit)" + }, + "500": { + "description": "Failed to save env vars" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "variables" + ] + }, + "example": { + "variables": [ + "string" + ] + } + } + } + } + } + }, + "/api/stacks/{name}/env/raw": { + "get": { + "operationId": "get_api_stacks_name_env_raw", + "tags": [ + "stacks" + ], + "summary": "Get the raw .env file content as-is (comments and formatting preserved) for a stack", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "noEnvFile": { + "type": "boolean" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "FOO=bar\n# comment\nBAZ=qux\n" + } + } + } + }, + "403": { + "description": "Permission denied (requires stacks:view, or environment access denied on enterprise)" + }, + "500": { + "description": "Failed to get environment file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_stacks_name_env_raw", + "tags": [ + "stacks" + ], + "summary": "Write raw .env file content to disk for a stack; empty content deletes the .env file, and masked \"***\" placeholders are rejected to avoid corrupting secrets", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "noEnvFile": { + "type": "boolean" + }, + "deleted": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid body (content string required) or refusal to write a masked \"***\" placeholder" + }, + "403": { + "description": "Permission denied (requires stacks:edit, or environment access denied on enterprise)" + }, + "500": { + "description": "Failed to save environment file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "FOO=bar\nBAZ=qux\n" + } + } + } + } + } + }, + "/api/stacks/{name}/env/validate": { + "post": { + "operationId": "post_api_stacks_name_env_validate", + "tags": [ + "stacks" + ], + "summary": "Validate a stack's defined environment variables against the variables required/optional by its compose file (compose content and the defined variable list may be supplied in the body, otherwise loaded from the saved file/DB)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + }, + "optional": { + "type": "array", + "items": { + "type": "string" + } + }, + "defined": { + "type": "array", + "items": { + "type": "string" + } + }, + "missing": { + "type": "array", + "items": { + "type": "string" + } + }, + "unused": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "valid", + "required", + "optional", + "defined", + "missing", + "unused" + ] + }, + "example": { + "valid": false, + "required": [ + "IMAGE" + ], + "optional": [], + "defined": [], + "missing": [ + "IMAGE" + ], + "unused": [] + } + } + } + }, + "400": { + "description": "No compose content provided and no saved compose file found" + }, + "403": { + "description": "Permission denied (requires stacks:view, or environment access denied on enterprise)" + }, + "500": { + "description": "Failed to validate environment variables" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "compose": { + "type": "string" + }, + "variables": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "compose": "services:\n web:\n image: ${IMAGE}", + "variables": [ + "IMAGE" + ] + } + } + } + } + } + }, + "/api/stacks/{name}/relocate": { + "post": { + "operationId": "post_api_stacks_name_relocate", + "tags": [ + "stacks" + ], + "summary": "Move all stack files from the old directory to a new location, update the stored compose/env paths, and return the refreshed compose/env content", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "movedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "composeContent": { + "type": "string" + }, + "rawEnvContent": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + }, + "isSecret": { + "type": "boolean" + } + }, + "required": [ + "key", + "value", + "isSecret" + ] + } + } + }, + "required": [ + "success", + "movedFiles", + "composeContent", + "rawEnvContent", + "envVars" + ] + }, + "example": { + "success": true, + "movedFiles": [ + "compose.yaml", + ".env" + ], + "composeContent": "services: {}", + "rawEnvContent": "FOO=bar\n", + "envVars": [ + { + "key": "FOO", + "value": "bar", + "isSecret": false + } + ] + } + } + } + }, + "400": { + "description": "oldDir and newComposePath are required, or the source directory does not exist" + }, + "403": { + "description": "Permission denied (requires stacks:edit)" + }, + "500": { + "description": "Failed to relocate stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "oldDir": { + "type": "string" + }, + "newComposePath": { + "type": "string" + }, + "newEnvPath": { + "type": "string" + } + }, + "required": [ + "oldDir", + "newComposePath" + ] + }, + "example": { + "oldDir": "/opt/stacks/old", + "newComposePath": "/opt/stacks/web/compose.yaml", + "newEnvPath": "/opt/stacks/web/.env" + } + } + } + } + } + }, + "/api/stacks/{name}/restart": { + "post": { + "operationId": "post_api_stacks_name_restart", + "tags": [ + "stacks" + ], + "summary": "Restart a stack (mode=restart) or recreate its containers (mode=recreate); progress and the final result stream over Server-Sent Events", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + }, + { + "name": "mode", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restart mode — \"recreate\" recreates containers, anything else performs a plain restart" + } + ], + "responses": { + "200": { + "description": "Server-Sent-Events job stream with a final result event ({success, output})" + }, + "403": { + "description": "Permission denied (requires stacks:restart, or environment access denied on enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/{name}/start": { + "post": { + "operationId": "post_api_stacks_name_start", + "tags": [ + "stacks" + ], + "summary": "Start a stack (docker compose start/up), asynchronously", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Fire-and-forget job id — poll GET /api/jobs/{jobId} for the result. Send \"Accept: application/json\" (without text/event-stream) to instead block and receive the final {success,output|error} synchronously.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ] + }, + "example": { + "jobId": "3f9c5b1a-2e4d-4a6f-9b0a-1c7d8e9f0a1b" + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/{name}/stop": { + "post": { + "operationId": "post_api_stacks_name_stop", + "tags": [ + "stacks" + ], + "summary": "Stop a stack (docker compose stop), asynchronously", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Fire-and-forget job id — poll GET /api/jobs/{jobId} for the result. Send \"Accept: application/json\" (without text/event-stream) to instead block and receive the final {success,output|error} synchronously.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ] + }, + "example": { + "jobId": "3f9c5b1a-2e4d-4a6f-9b0a-1c7d8e9f0a1b" + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/adopt": { + "post": { + "operationId": "post_api_stacks_adopt", + "tags": [ + "stacks" + ], + "summary": "Adopt previously discovered compose stacks into Dockhand for a given environment", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "adopted": { + "type": "array", + "items": { + "type": "string" + } + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "name", + "error" + ] + } + } + }, + "required": [ + "adopted", + "failed" + ] + }, + "example": { + "adopted": [ + "web" + ], + "failed": [] + } + } + } + }, + "400": { + "description": "No stacks provided, missing environmentId, or a stack is missing name/composePath" + }, + "403": { + "description": "Permission denied (requires stacks:create)" + }, + "500": { + "description": "Unexpected error while adopting stacks" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stacks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "composePath": { + "type": "string" + } + }, + "required": [ + "name", + "composePath" + ] + } + }, + "environmentId": { + "type": "integer" + } + }, + "required": [ + "stacks", + "environmentId" + ] + }, + "example": { + "stacks": [ + { + "name": "web", + "composePath": "/opt/stacks/web/compose.yaml" + } + ], + "environmentId": 1 + } + } + } + } + } + }, + "/api/stacks/base-path": { + "get": { + "operationId": "get_api_stacks_base-path", + "tags": [ + "stacks" + ], + "summary": "Return the default Dockhand stacks directory ($DATA_DIR/stacks/) where new stacks are stored by default", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "basePath": { + "type": "string" + } + }, + "required": [ + "basePath" + ] + }, + "example": { + "basePath": "/data/stacks" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/default-path": { + "get": { + "operationId": "get_api_stacks_default-path", + "tags": [ + "stacks" + ], + "summary": "Compute the default compose/env file paths for a new stack, either under a custom base location or under Dockhand's default stacks directory", + "parameters": [ + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID (scopes the path under the environment name) (from GET /api/environments)" + }, + { + "name": "location", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Custom base location path" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stackDir": { + "type": "string" + }, + "composePath": { + "type": "string" + }, + "envPath": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": [ + "stackDir", + "composePath", + "envPath", + "source" + ] + }, + "example": { + "stackDir": "/data/stacks/prod/web", + "composePath": "/data/stacks/prod/web/compose.yaml", + "envPath": "/data/stacks/prod/web/.env", + "source": "default" + } + } + } + }, + "400": { + "description": "Stack name is required" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/path-hints": { + "get": { + "operationId": "get_api_stacks_path-hints", + "tags": [ + "stacks" + ], + "summary": "Return path hints (working directory and config file paths) extracted from a stack's Docker container labels", + "parameters": [ + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stackName": { + "type": "string" + }, + "workingDir": { + "type": "string" + }, + "configFiles": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "stackName" + ] + }, + "example": { + "stackName": "web", + "workingDir": "/opt/stacks/web", + "configFiles": [ + "/opt/stacks/web/compose.yaml" + ] + } + } + } + }, + "400": { + "description": "Stack name is required" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Failed to get path hints" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/scan": { + "post": { + "operationId": "post_api_stacks_scan", + "tags": [ + "stacks" + ], + "summary": "Scan a given filesystem path (or all configured external paths when none is given) for compose stacks, flagging which discovered stacks are already running", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "discovered": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + }, + "adopted": { + "type": "array", + "items": { + "type": "string" + } + }, + "skipped": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "path", + "error" + ] + } + } + }, + "required": [ + "discovered", + "adopted", + "skipped", + "errors" + ] + }, + "example": { + "discovered": [ + { + "name": "web" + } + ], + "adopted": [], + "skipped": [], + "errors": [] + } + } + } + }, + "403": { + "description": "Permission denied (requires stacks:create)" + }, + "500": { + "description": "Unexpected error while scanning" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + } + }, + "example": { + "path": "/opt/stacks" + } + } + } + } + } + }, + "/api/stacks/sources": { + "get": { + "operationId": "get_api_stacks_sources", + "tags": [ + "stacks" + ], + "summary": "List stack source records (their stored compose/env paths and source type)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "500": { + "description": "Failed to list stack sources" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/validate-path": { + "post": { + "operationId": "post_api_stacks_validate-path", + "tags": [ + "stacks" + ], + "summary": "Validate a candidate external stack path (exists, is a directory, no overlap with already-configured paths); validation failures are returned as 200 with valid=false", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "valid" + ] + }, + "example": { + "valid": false, + "error": "Path does not exist" + } + } + } + }, + "403": { + "description": "Permission denied (requires settings:edit)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "example": { + "path": "/opt/external-stacks" + } + } + } + } + } + }, + "/api/system": { + "get": { + "operationId": "get_api_system", + "tags": [ + "system" + ], + "summary": "Return aggregated system info — Docker daemon, host, Node.js runtime, database, and object counts for an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment to collect Docker info for (Docker fields are null when omitted or unreachable) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "docker": { + "type": "string" + }, + "host": { + "type": "string" + }, + "runtime": { + "type": "string" + }, + "database": { + "type": "string" + }, + "stats": { + "type": "string" + } + }, + "required": [ + "runtime", + "database", + "stats" + ] + }, + "example": { + "docker": { + "version": "27.0.3", + "apiVersion": "1.46" + }, + "host": { + "name": "docker-host", + "cpus": 8, + "memory": 16777216000 + }, + "runtime": { + "runtimeName": "Node.js", + "platform": "linux" + }, + "database": { + "type": "SQLite", + "schemaVersion": 42 + }, + "stats": { + "containers": { + "total": 10, + "running": 8, + "stopped": 2 + }, + "images": 25, + "volumes": 5, + "networks": 4, + "stacks": 3 + } + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + }, + "500": { + "description": "Failed to fetch system info" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/system/disk": { + "get": { + "operationId": "get_api_system_disk", + "tags": [ + "system" + ], + "summary": "Return Docker disk usage (df) for an environment, or null when collection is disabled, times out, or fails", + "parameters": [ + { + "name": "env", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the environment to query disk usage for (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "diskUsage is null when SKIP_DF_COLLECTION is set, or the query times out or errors", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "diskUsage": { + "type": "string" + } + } + }, + "example": { + "diskUsage": { + "LayersSize": 1420000000, + "Images": [], + "Containers": [], + "Volumes": [] + } + } + } + } + }, + "400": { + "description": "Environment ID is required" + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/system/files": { + "get": { + "operationId": "get_api_system_files", + "tags": [ + "system" + ], + "summary": "List the entries of a directory on Dockhand's local filesystem (protected paths are hidden)", + "parameters": [ + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Absolute directory path to list (defaults to \"/\")" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "parent": { + "type": "string" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "mtime": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "name", + "path", + "type", + "size", + "mtime", + "mode" + ] + } + } + }, + "required": [ + "path", + "entries" + ] + }, + "example": { + "path": "/docker", + "parent": "/", + "entries": [ + { + "name": "stacks", + "path": "/docker/stacks", + "type": "directory", + "size": 4096, + "mtime": "2026-07-01T10:00:00.000Z", + "mode": "755" + } + ] + } + } + } + }, + "400": { + "description": "The path exists but is not a directory" + }, + "403": { + "description": "Permission denied, or the path is protected" + }, + "404": { + "description": "Path not found" + }, + "500": { + "description": "Failed to list directory" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_system_files", + "tags": [ + "system" + ], + "summary": "Create a directory on Dockhand's local filesystem (absolute path, no traversal, non-protected)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "required": [ + "success", + "path" + ] + }, + "example": { + "success": true, + "path": "/docker/stacks/myapp" + } + } + } + }, + "400": { + "description": "Path is missing, not absolute, or contains \"..\"" + }, + "403": { + "description": "Permission denied, or the path is protected" + }, + "409": { + "description": "Path already exists" + }, + "500": { + "description": "Failed to create directory" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "example": { + "path": "/docker/stacks/myapp" + } + } + } + } + } + }, + "/api/system/files/content": { + "get": { + "operationId": "get_api_system_files_content", + "tags": [ + "system" + ], + "summary": "Read a text file from Dockhand's local filesystem (max 10MB, protected paths denied)", + "parameters": [ + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute path of the file to read" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "content": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "mtime": { + "type": "string" + } + }, + "required": [ + "path", + "content", + "size", + "mtime" + ] + }, + "example": { + "path": "/docker/stacks/myapp/compose.yaml", + "content": "services:\n app:\n image: nginx", + "size": 42, + "mtime": "2026-07-01T10:00:00.000Z" + } + } + } + }, + "400": { + "description": "Path is missing, points to a directory, or the file exceeds 10MB" + }, + "403": { + "description": "Permission denied, or the path is protected" + }, + "404": { + "description": "File not found" + }, + "500": { + "description": "Failed to read file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/templates": { + "get": { + "operationId": "get_api_templates", + "tags": [ + "templates" + ], + "summary": "Return the normalized app templates aggregated from all enabled template sources (server-side cached for 1 hour)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "logo": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "source": { + "type": "string" + }, + "image": { + "type": "string" + }, + "projectUrl": { + "type": "string" + } + }, + "required": [ + "id", + "type", + "title", + "description", + "logo", + "categories", + "source" + ] + } + }, + "example": [ + { + "id": "a1b2c3", + "type": "container", + "title": "Nginx", + "description": "Web server", + "logo": "https://example.com/nginx.png", + "categories": [ + "web" + ], + "source": "LinuxServer.io", + "image": "lscr.io/linuxserver/nginx:latest", + "projectUrl": "https://github.com/linuxserver/docker-nginx" + } + ] + } + } + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/templates/compose": { + "post": { + "operationId": "post_api_templates_compose", + "tags": [ + "templates" + ], + "summary": "Return the compose YAML for a template — generated for container templates, fetched from the repository for stack templates", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "compose": { + "type": "string" + } + }, + "required": [ + "compose" + ] + }, + "example": { + "compose": "services:\n nginx:\n image: nginx:latest\n restart: unless-stopped\n" + } + } + } + }, + "400": { + "description": "Template is missing from the request body" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to generate or fetch the compose file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "template": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "image": { + "type": "string" + }, + "repository": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "stackfile": { + "type": "string" + } + } + } + } + } + }, + "required": [ + "template" + ] + }, + "example": { + "template": { + "id": "a1b2c3", + "type": "container", + "title": "Nginx", + "image": "nginx:latest" + } + } + } + } + } + } + }, + "/api/templates/sources": { + "get": { + "operationId": "get_api_templates_sources", + "tags": [ + "templates" + ], + "summary": "List all configured template sources (built-in and custom)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "enabled" + ] + } + }, + "example": [ + { + "id": 1, + "name": "LinuxServer.io", + "url": "https://fleet.linuxserver.io/api/v1/images", + "enabled": true + } + ] + } + } + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_templates_sources", + "tags": [ + "templates" + ], + "summary": "Add a custom template source with a name and URL", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "enabled" + ] + }, + "example": { + "id": 4, + "name": "My Templates", + "url": "https://example.com/templates.json", + "enabled": true + } + } + } + }, + "400": { + "description": "Name and URL are required" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "name", + "url" + ] + }, + "example": { + "name": "My Templates", + "url": "https://example.com/templates.json" + } + } + } + } + }, + "put": { + "operationId": "put_api_templates_sources", + "tags": [ + "templates" + ], + "summary": "Update a template source — toggle enabled, or change its name/URL (only provided fields are changed)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ] + }, + "example": { + "ok": true + } + } + } + }, + "400": { + "description": "Missing id" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "example": { + "id": 3, + "enabled": false + } + } + } + } + }, + "delete": { + "operationId": "delete_api_templates_sources", + "tags": [ + "templates" + ], + "summary": "Delete a custom template source by ID", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the template source to delete (from GET /api/templates/sources)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ] + }, + "example": { + "ok": true + } + } + } + }, + "400": { + "description": "Missing id" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/users": { + "get": { + "operationId": "get_api_users", + "tags": [ + "users" + ], + "summary": "List all local/SSO users (any authenticated user may view — only mutations are RBAC-gated)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "isAdmin": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + }, + "isSso": { + "type": "boolean" + }, + "authProvider": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "isAdmin", + "isActive", + "isSso", + "authProvider" + ] + } + }, + "example": [ + { + "id": 1, + "username": "admin", + "email": "admin@example.com", + "displayName": "Admin", + "isAdmin": true, + "isActive": true, + "isSso": false, + "authProvider": "local" + } + ] + } + } + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Unexpected error while loading users" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_users", + "tags": [ + "users" + ], + "summary": "Create a local user (allowed without auth only during initial setup, before any admin exists)", + "parameters": [], + "responses": { + "201": { + "description": "The first user ever created (or every user in Free edition) automatically gets the Admin role", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "isAdmin": { + "type": "boolean" + } + }, + "required": [ + "id", + "username", + "isAdmin" + ] + }, + "example": { + "id": 1, + "username": "jdoe", + "email": "jdoe@example.com", + "isAdmin": true + } + } + } + }, + "400": { + "description": "Missing username/password, or password shorter than 8 characters" + }, + "403": { + "description": "Permission denied (RBAC 'users:create' missing — only applies once an admin already exists)" + }, + "409": { + "description": "Username already exists" + }, + "500": { + "description": "Unexpected error while creating the user" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "displayName": { + "type": "string" + } + }, + "required": [ + "username", + "password" + ] + }, + "example": { + "username": "jdoe", + "email": "jdoe@example.com", + "password": "correct horse battery staple", + "displayName": "Jane Doe" + } + } + } + } + } + }, + "/api/users/{id}": { + "get": { + "operationId": "get_api_users_id", + "tags": [ + "users" + ], + "summary": "Get a single user by id (password hash is never returned; isAdmin is derived from role assignment)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "mfaEnabled": { + "type": "boolean" + }, + "isAdmin": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + }, + "lastLogin": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "mfaEnabled", + "isAdmin", + "isActive", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 0, + "username": "string", + "email": "string", + "displayName": "string", + "mfaEnabled": true, + "isAdmin": true, + "isActive": true, + "lastLogin": "string", + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "User id is required" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to read the user" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_users_id", + "tags": [ + "users" + ], + "summary": "Update a user (self-edit is always allowed; admin/active changes require admin; demoting or deactivating the last admin needs confirmDisableAuth)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "mfaEnabled": { + "type": "boolean" + }, + "isAdmin": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + }, + "lastLogin": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "mfaEnabled", + "isAdmin", + "isActive", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 0, + "username": "string", + "email": "string", + "displayName": "string", + "mfaEnabled": true, + "isAdmin": true, + "isActive": true, + "lastLogin": "string", + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "User id is required, or the new password is shorter than 8 characters" + }, + "403": { + "description": "Permission denied (editing another user without users:edit)" + }, + "404": { + "description": "User not found" + }, + "409": { + "description": "This is the last admin user (confirmDisableAuth required), or the username already exists" + }, + "500": { + "description": "Failed to update the user" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "isAdmin": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + }, + "password": { + "type": "string" + }, + "confirmDisableAuth": { + "type": "boolean" + } + } + }, + "example": { + "displayName": "Jane Doe", + "email": "jane@example.com" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_users_id", + "tags": [ + "users" + ], + "summary": "Delete a user by id; deleting the last admin while auth is enabled disables authentication and requires confirmDisableAuth", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + }, + { + "name": "confirmDisableAuth", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Set to true to confirm deleting the last admin (which disables authentication)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "authDisabled": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "authDisabled": true + } + } + } + }, + "400": { + "description": "User id is required" + }, + "403": { + "description": "Permission denied (missing users:remove)" + }, + "404": { + "description": "User not found" + }, + "409": { + "description": "This is the last admin user — pass confirmDisableAuth=true to proceed" + }, + "500": { + "description": "Failed to delete the user" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/mfa": { + "post": { + "operationId": "post_api_users_id_mfa", + "tags": [ + "users" + ], + "summary": "Set up or verify MFA for a user (action=setup regenerates a secret; action=verify confirms a code)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The user id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing user id, missing/invalid MFA token, or an unknown action" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "User not found" + }, + "409": { + "description": "MFA is already enabled (disable it before setting up again)" + }, + "500": { + "description": "Failed to set up MFA" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": [ + "action" + ] + }, + "example": { + "action": "string", + "token": "string" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_users_id_mfa", + "tags": [ + "users" + ], + "summary": "Disable MFA for a user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The user id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing user id" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to disable MFA" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/roles": { + "get": { + "operationId": "get_api_users_id_roles", + "tags": [ + "users" + ], + "summary": "List the roles assigned to a user (enterprise only)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "roleId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "environmentId": { + "type": "integer" + } + }, + "required": [ + "roleId", + "name" + ] + } + }, + "example": [ + { + "roleId": 0, + "name": "string", + "environmentId": 0 + } + ] + } + } + }, + "400": { + "description": "User id is required" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to read the user roles" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_users_id_roles", + "tags": [ + "users" + ], + "summary": "Assign a role to a user, optionally scoped to an environment (enterprise, admin only)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + } + ], + "responses": { + "201": { + "description": "The created user-role assignment" + }, + "400": { + "description": "User id or role id is required" + }, + "403": { + "description": "Enterprise license required, or admin access required" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to assign the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "roleId from GET /api/roles. environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "roleId": { + "type": "integer" + }, + "environmentId": { + "type": "integer" + } + }, + "required": [ + "roleId" + ] + }, + "example": { + "roleId": 3, + "environmentId": 1 + } + } + } + } + }, + "delete": { + "operationId": "delete_api_users_id_roles", + "tags": [ + "users" + ], + "summary": "Remove a role assignment from a user, optionally scoped to an environment (enterprise, admin only)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "User id or role id is required" + }, + "403": { + "description": "Enterprise license required, or admin access required" + }, + "404": { + "description": "Role assignment not found" + }, + "500": { + "description": "Failed to remove the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "roleId from GET /api/roles. environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "roleId": { + "type": "integer" + }, + "environmentId": { + "type": "integer" + } + }, + "required": [ + "roleId" + ] + }, + "example": { + "roleId": 3, + "environmentId": 1 + } + } + } + } + } + }, + "/api/volumes": { + "get": { + "operationId": "get_api_volumes", + "tags": [ + "volumes" + ], + "summary": "List Docker volumes for an environment; returns an empty array when no environment is specified", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID to list volumes for (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Mountpoint": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "CreatedAt": { + "type": "string" + } + }, + "required": [ + "Name", + "Driver" + ] + } + }, + "example": [ + { + "Name": "web_data", + "Driver": "local", + "Mountpoint": "/var/lib/docker/volumes/web_data/_data", + "Scope": "local", + "CreatedAt": "2026-06-01T10:00:00Z" + } + ] + } + } + }, + "403": { + "description": "Permission denied (requires volumes:view, or environment access denied on enterprise)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Failed to list volumes" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_volumes", + "tags": [ + "volumes" + ], + "summary": "Create a Docker volume in an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID to create the volume in (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "required": [ + "success", + "name" + ] + }, + "example": { + "success": true, + "name": "web_data" + } + } + } + }, + "400": { + "description": "Volume name is required" + }, + "403": { + "description": "Permission denied (requires volumes:create, or environment access denied on enterprise)" + }, + "500": { + "description": "Failed to create volume" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "driver": { + "type": "string" + }, + "driverOpts": { + "type": "object", + "properties": {} + }, + "labels": { + "type": "object", + "properties": {} + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "web_data", + "driver": "local", + "driverOpts": {}, + "labels": { + "app": "web" + } + } + } + } + } + } + }, + "/api/volumes/{name}": { + "get": { + "operationId": "get_api_volumes_name", + "tags": [ + "volumes" + ], + "summary": "Inspect a single Docker volume by name (the name is validated as a Docker identifier)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Mountpoint": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "Labels": { + "type": "object", + "properties": {} + }, + "Options": { + "type": "object", + "properties": {} + }, + "CreatedAt": { + "type": "string" + } + }, + "required": [ + "Name", + "Driver", + "Mountpoint" + ] + }, + "example": { + "Name": "web_data", + "Driver": "local", + "Mountpoint": "/var/lib/docker/volumes/web_data/_data", + "Scope": "local", + "Labels": {}, + "Options": {}, + "CreatedAt": "2026-06-01T10:00:00Z" + } + } + } + }, + "403": { + "description": "Permission denied (requires volumes:inspect, or environment access denied on enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_volumes_name", + "tags": [ + "volumes" + ], + "summary": "Remove a Docker volume by name, optionally forcing removal", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Force removal of the volume" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (requires volumes:remove, or environment access denied on enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/volumes/{name}/browse": { + "get": { + "operationId": "get_api_volumes_name_browse", + "tags": [ + "volumes" + ], + "summary": "Browse a directory inside a Docker volume via a cached helper container; the volume is mounted read-only when in use by other containers", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Directory path inside the volume to list (defaults to \"/\")" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "permissions": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "group": { + "type": "string" + }, + "modified": { + "type": "string" + } + }, + "required": [ + "name", + "type", + "size", + "permissions", + "owner", + "group", + "modified" + ] + } + }, + "usage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "state": { + "type": "string" + } + }, + "required": [ + "containerId", + "containerName", + "state" + ] + } + }, + "isInUse": { + "type": "boolean" + }, + "helperId": { + "type": "string" + } + }, + "required": [ + "path", + "entries", + "usage", + "isInUse", + "helperId" + ] + }, + "example": { + "path": "/", + "entries": [ + { + "name": "data", + "type": "directory", + "size": 4096, + "permissions": "drwxr-xr-x", + "owner": "root", + "group": "root", + "modified": "2026-06-01 10:00" + } + ], + "usage": [], + "isInUse": false, + "helperId": "abc123" + } + } + } + }, + "403": { + "description": "Permission denied (requires volumes:inspect) or permission denied accessing the path" + }, + "404": { + "description": "Directory not found" + }, + "500": { + "description": "Failed to browse volume" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/volumes/{name}/browse/content": { + "get": { + "operationId": "get_api_volumes_name_browse_content", + "tags": [ + "volumes" + ], + "summary": "Read the content of a single file inside a Docker volume (files larger than 1MB are rejected)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "File path inside the volume to read" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "content", + "path" + ] + }, + "example": { + "content": "hello world\n", + "path": "/data/readme.txt" + } + } + } + }, + "400": { + "description": "Path is required, or the path points to a directory" + }, + "403": { + "description": "Permission denied (requires volumes:inspect) or permission denied reading the file" + }, + "404": { + "description": "File not found" + }, + "413": { + "description": "File is too large to view (max 1MB)" + }, + "500": { + "description": "Failed to read file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/volumes/{name}/browse/release": { + "post": { + "operationId": "post_api_volumes_name_browse_release", + "tags": [ + "volumes" + ], + "summary": "Release the cached helper container used to browse a Docker volume", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (requires volumes:inspect)" + }, + "500": { + "description": "Failed to release volume helper" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/volumes/{name}/clone": { + "post": { + "operationId": "post_api_volumes_name_clone", + "tags": [ + "volumes" + ], + "summary": "Clone a Docker volume into a new named volume, copying the data with a temporary helper container and preserving driver/options/labels", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Source Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "required": [ + "success", + "name" + ] + }, + "example": { + "success": true, + "name": "web_data_copy" + } + } + } + }, + "400": { + "description": "New volume name is required" + }, + "403": { + "description": "Permission denied (requires volumes:create)" + }, + "500": { + "description": "Failed to clone volume" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "web_data_copy" + } + } + } + } + } + }, + "/api/volumes/{name}/export": { + "get": { + "operationId": "get_api_volumes_name_export", + "tags": [ + "volumes" + ], + "summary": "Export a Docker volume (or a sub-path) as a downloadable archive — streaming tar, gzip-compressed tar.gz, or raw single-file bytes", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Sub-path inside the volume to export (defaults to \"/\")" + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Archive format: \"tar\" (default), \"tar.gz\", or \"raw\" (single file)" + } + ], + "responses": { + "200": { + "description": "Binary archive stream (Content-Disposition attachment); Content-Type is application/x-tar, application/gzip, or application/octet-stream depending on format" + }, + "403": { + "description": "Permission denied (requires volumes:inspect)" + }, + "404": { + "description": "Path not found" + }, + "500": { + "description": "Failed to export volume" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/volumes/{name}/inspect": { + "get": { + "operationId": "get_api_volumes_name_inspect", + "tags": [ + "volumes" + ], + "summary": "Inspect a Docker volume by name, returning the raw Docker volume inspect object", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Mountpoint": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "Labels": { + "type": "object", + "properties": {} + }, + "Options": { + "type": "object", + "properties": {} + }, + "CreatedAt": { + "type": "string" + } + }, + "required": [ + "Name", + "Driver", + "Mountpoint" + ] + }, + "example": { + "Name": "web_data", + "Driver": "local", + "Mountpoint": "/var/lib/docker/volumes/web_data/_data", + "Scope": "local", + "Labels": {}, + "Options": {}, + "CreatedAt": "2026-06-01T10:00:00Z" + } + } + } + }, + "403": { + "description": "Permission denied (requires volumes:inspect)" + }, + "500": { + "description": "Failed to inspect volume" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/vulnerabilities": { + "get": { + "operationId": "get_api_vulnerabilities", + "tags": [ + "vulnerabilities" + ], + "summary": "A filtered, sorted page of aggregated vulnerability findings for an environment", + "parameters": [], + "responses": { + "200": { + "description": "A page of findings plus the filtered total count; a permission failure returns the status from the access check", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "findings": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer" + } + }, + "required": [ + "findings", + "total" + ] + }, + "example": { + "findings": [ + { + "cve": "CVE-2024-0001", + "severity": "high", + "package": "openssl", + "imageName": "nginx:latest" + } + ], + "total": 1 + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Accepts limit, offset, q, severity, image, container, stack, sort and dir query params (parsed centrally). Returns an empty page when no environment resolves." + } + }, + "/api/vulnerabilities/count": { + "get": { + "operationId": "get_api_vulnerabilities_count", + "tags": [ + "vulnerabilities" + ], + "summary": "Return the total finding count, severity summary, and distinct image/container/stack filter values for an environment", + "parameters": [], + "responses": { + "200": { + "description": "An empty metadata object is returned when no environment resolves; a permission failure returns the status from the access check", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "summary": { + "type": "string" + }, + "images": { + "type": "array", + "items": { + "type": "string" + } + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + }, + "stacks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "total", + "summary", + "images", + "containers", + "stacks" + ] + }, + "example": { + "total": 12, + "summary": { + "critical": 1, + "high": 3, + "medium": 5, + "low": 3 + }, + "images": [ + "nginx:latest" + ], + "containers": [ + "web" + ], + "stacks": [ + "frontend" + ] + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/vulnerabilities/export": { + "get": { + "operationId": "get_api_vulnerabilities_export", + "tags": [ + "vulnerabilities" + ], + "summary": "Export an environment's aggregated vulnerability findings as json or csv, matching the grid's filters and sort", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Output format — json (default) or csv" + } + ], + "responses": { + "200": { + "description": "A permission failure returns the status from the access check; 500 is returned on an export error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/vulnerabilities/scan-all": { + "post": { + "operationId": "post_api_vulnerabilities_scan-all", + "tags": [ + "vulnerabilities" + ], + "summary": "Scan every image in an environment for vulnerabilities, streaming per-image progress as Server-Sent Events", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment whose images to scan (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "A Server-Sent Events stream of progress and per-image results, ending with a summary \"result\" event" + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/audit": { + "get": { + "operationId": "get_audit", + "tags": [ + "audit" + ], + "summary": "List audit log entries with optional filters (Enterprise only)", + "parameters": [ + { + "name": "username", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "" + }, + { + "name": "entity_type", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "" + }, + { + "name": "action", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "" + }, + { + "name": "environment_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "" + }, + { + "name": "from_date", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "" + }, + { + "name": "to_date", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "action": { + "type": "string" + }, + "entityType": { + "type": "string" + }, + "entityId": { + "type": "string" + }, + "entityName": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "details": { + "type": "object", + "properties": {} + }, + "ipAddress": { + "type": "string" + }, + "userAgent": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "action", + "entityType", + "createdAt" + ] + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "logs", + "total", + "limit", + "offset" + ] + }, + "example": { + "logs": [ + { + "id": 0, + "userId": 0, + "username": "string", + "action": "string", + "entityType": "string", + "entityId": "string", + "entityName": "string", + "environmentId": 0, + "description": "string", + "details": {}, + "ipAddress": "string", + "userAgent": "string", + "createdAt": "string" + } + ], + "total": 0, + "limit": 0, + "offset": 0 + } + } + } + }, + "403": { + "description": "Enterprise license required, or permission denied to view the audit log" + }, + "500": { + "description": "Failed to fetch audit logs" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/audit/users": { + "get": { + "operationId": "get_audit_users", + "tags": [ + "audit" + ], + "summary": "List distinct usernames that appear in the audit log, for use as a filter dropdown (Enterprise only)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": [ + "string" + ] + } + } + }, + "403": { + "description": "Enterprise license required, or permission denied to view the audit log" + }, + "500": { + "description": "Failed to fetch users" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/metrics": { + "get": { + "operationId": "get_metrics", + "tags": [ + "metrics" + ], + "summary": "Prometheus metrics endpoint (disabled and 404s unless EXPORT_METRICS=true; public if app auth is disabled, otherwise requires a session or bearer token)", + "parameters": [], + "responses": { + "200": { + "description": "Prometheus text-exposition-format metrics body" + }, + "401": { + "description": "Not authenticated (session or bearer token required when app auth is enabled)" + }, + "404": { + "description": "Metrics export disabled (EXPORT_METRICS is not true)" + }, + "500": { + "description": "Metrics collection failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + } + } +} \ No newline at end of file diff --git a/src/lib/server/features.ts b/src/lib/server/features.ts index 6f5eb58..632e18b 100644 --- a/src/lib/server/features.ts +++ b/src/lib/server/features.ts @@ -13,3 +13,10 @@ * 5. Drop the BACKUPS_ENABLED guards in src/lib/server/scheduler/index.ts. */ export const BACKUPS_ENABLED = process.env.FEAT_BACKUPS_ENABLED === 'true'; + +/** + * API docs (OpenAPI spec + Scalar viewer at /api/docs and /api/docs/ui). + * Off by default: the docs routes are unauthenticated, so exposing the full + * API surface is opt-in per instance with FEAT_API_DOCS=true. Read once at boot. + */ +export const API_DOCS_ENABLED = process.env.FEAT_API_DOCS === 'true'; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 338913a..5cb56f2 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -2,10 +2,11 @@ import type { LayoutServerLoad } from './$types'; import { redirect } from '@sveltejs/kit'; import { isAuthEnabled, validateSession } from '$lib/server/auth'; import { hasAdminUser } from '$lib/server/db'; -import { BACKUPS_ENABLED } from '$lib/server/features'; +import { BACKUPS_ENABLED, API_DOCS_ENABLED } from '$lib/server/features'; -// Routes that don't require authentication -const PUBLIC_PATHS = ['/login']; +// Routes that don't require authentication. The API docs viewer is public only +// when opt-in via FEAT_API_DOCS (the route 404s otherwise; see features.ts). +const PUBLIC_PATHS = ['/login', ...(API_DOCS_ENABLED ? ['/api/docs'] : [])]; export const load: LayoutServerLoad = async ({ cookies, url }) => { const authEnabled = await isAuthEnabled(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ae18eeb..a507fca 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -23,6 +23,10 @@ // Check if current route is login page (no sidebar needed) const isLoginPage = $derived($page.url.pathname === '/login'); + // Same for the OpenAPI docs viewer (Scalar) — it brings its own + // full-page chrome. Matches with or without a trailing slash. + const isDocsPage = $derived($page.url.pathname.replace(/\/$/, '') === '/api/docs/ui'); + const noSidebar = $derived(isLoginPage || isDocsPage); let { children, data } = $props(); let envId = $state(null); @@ -130,8 +134,8 @@ Dockhand - Docker Management -{#if isLoginPage} - +{#if noSidebar} + {@render children?.()} {:else} diff --git a/src/routes/api/activity/+server.ts b/src/routes/api/activity/+server.ts index fe0a3c1..c38793f 100644 --- a/src/routes/api/activity/+server.ts +++ b/src/routes/api/activity/+server.ts @@ -3,6 +3,22 @@ import { getContainerEvents, getContainerEventContainers, getContainerEventActio import { authorize } from '$lib/server/authorize'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Query container activity events with filters and pagination + * query: environmentId:integer Filter to a single environment (from GET /api/environments) + * query: containerId:string Filter by container ID (from GET /api/containers) + * query: containerName:string Filter by container name + * query: actions:string Comma-separated event actions to filter by + * query: labels:string Comma-separated labels to filter by + * query: fromDate:string Start of the date range (ISO 8601) + * query: toDate:string End of the date range (ISO 8601) + * query: limit:integer Maximum number of events to return + * query: offset:integer Number of events to skip (pagination) + * resp-200: {events:array<{id:integer!, containerName:string, action:string, timestamp:string}>, total:integer!, limit:integer!, offset:integer!} + * resp-403: Permission denied (requires the activity:view permission) + * resp-500: Failed to fetch container events + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -69,6 +85,14 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; +/** + * @openapi + * summary: Clear all stored container activity events + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-403: Permission denied (requires the activity:delete permission) + * resp-500: Failed to clear container events + */ export const DELETE: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/activity/containers/+server.ts b/src/routes/api/activity/containers/+server.ts index 836d438..323e739 100644 --- a/src/routes/api/activity/containers/+server.ts +++ b/src/routes/api/activity/containers/+server.ts @@ -3,6 +3,15 @@ import { getContainerEventContainers } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: List distinct container names that appear in the activity log, for filter dropdowns + * query: environment_id:integer Filter to a single environment (from GET /api/environments) + * resp-200: array + * resp-200-example: ["web-1","db-1","cache-1"] + * resp-403: Permission denied (requires the activity:view permission) + * resp-500: Failed to fetch container names + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/activity/events/+server.ts b/src/routes/api/activity/events/+server.ts index 8226dc4..59277b2 100644 --- a/src/routes/api/activity/events/+server.ts +++ b/src/routes/api/activity/events/+server.ts @@ -4,6 +4,12 @@ import { authorize } from '$lib/server/authorize'; import { json } from '@sveltejs/kit'; +/** + * @openapi + * summary: Stream live container activity and environment-status events over Server-Sent Events + * resp-200: Server-Sent Events stream (text/event-stream) emitting connected, heartbeat, activity and env_status events + * resp-403: Permission denied (requires the activity:view permission) + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/activity/stats/+server.ts b/src/routes/api/activity/stats/+server.ts index 922cef2..0dfdf89 100644 --- a/src/routes/api/activity/stats/+server.ts +++ b/src/routes/api/activity/stats/+server.ts @@ -3,6 +3,15 @@ import { getContainerEventStats } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Get aggregate container activity statistics (totals and counts by action) + * query: environment_id:integer Filter to a single environment (from GET /api/environments) + * resp-200: {total:integer!, today:integer!, byAction:{}} + * resp-200-example: {"total":128,"today":7,"byAction":{"start":40,"stop":30}} + * resp-403: Permission denied (requires the activity:view permission) + * resp-500: Failed to fetch stats + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/audit/+server.ts b/src/routes/api/audit/+server.ts index 684be76..04cc763 100644 --- a/src/routes/api/audit/+server.ts +++ b/src/routes/api/audit/+server.ts @@ -3,6 +3,25 @@ import { authorize, enterpriseRequired } from '$lib/server/authorize'; import { getAuditLogs, getAuditLogUsers, type AuditLogFilters, type AuditEntityType, type AuditAction } from '$lib/server/db'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Query the audit log with filters and pagination (Enterprise only) + * query: usernames:string Comma-separated usernames to filter by + * query: entityTypes:string Comma-separated entity types to filter by + * query: actions:string Comma-separated actions to filter by + * query: username:string Legacy single-username filter + * query: entityType:string Legacy single entity-type filter + * query: action:string Legacy single-action filter + * query: environmentId:integer Filter to a single environment (from GET /api/environments) + * query: labels:string Comma-separated labels to filter by + * query: fromDate:string Start of the date range (ISO 8601) + * query: toDate:string End of the date range (ISO 8601) + * query: limit:integer Maximum number of entries to return + * query: offset:integer Number of entries to skip (pagination) + * resp-200: {logs:array<{id:integer!, username:string, action:string, entityType:string, entityName:string, createdAt:string}>, total:integer} + * resp-403: Enterprise required, or permission denied + * resp-500: Failed to fetch audit logs + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/audit/events/+server.ts b/src/routes/api/audit/events/+server.ts index 1fe76ec..9e348f0 100644 --- a/src/routes/api/audit/events/+server.ts +++ b/src/routes/api/audit/events/+server.ts @@ -2,6 +2,12 @@ import type { RequestHandler } from './$types'; import { authorize, enterpriseRequired } from '$lib/server/authorize'; import { auditEvents, type AuditEventData } from '$lib/server/audit-events'; +/** + * @openapi + * summary: Stream live audit-log events over Server-Sent Events (Enterprise only) + * resp-200: Server-Sent Events stream (text/event-stream) emitting connected, heartbeat and audit events + * resp-403: Enterprise required, or permission denied + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/audit/export/+server.ts b/src/routes/api/audit/export/+server.ts index bb84ff7..dee9849 100644 --- a/src/routes/api/audit/export/+server.ts +++ b/src/routes/api/audit/export/+server.ts @@ -84,6 +84,20 @@ function formatToMarkdown(logs: AuditLog[]): string { return lines.join('\n'); } +/** + * @openapi + * summary: Export the audit log as a downloadable file in JSON, CSV or Markdown (Enterprise only) + * query: username:string Filter by username + * query: entityType:string Filter by entity type + * query: action:string Filter by action + * query: environmentId:integer Filter to a single environment (from GET /api/environments) + * query: fromDate:string Start of the date range (ISO 8601) + * query: toDate:string End of the date range (ISO 8601) + * query: format:string Output format — json (default), csv or md + * resp-200: Downloadable audit-log export (Content-Disposition attachment; JSON, CSV or Markdown) + * resp-403: Enterprise required, or permission denied + * resp-500: Failed to export audit logs + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/audit/users/+server.ts b/src/routes/api/audit/users/+server.ts index 8d4d161..9782f45 100644 --- a/src/routes/api/audit/users/+server.ts +++ b/src/routes/api/audit/users/+server.ts @@ -3,6 +3,14 @@ import { authorize, enterpriseRequired } from '$lib/server/authorize'; import { getAuditLogUsers } from '$lib/server/db'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: List the distinct usernames that appear in the audit log, for filter dropdowns (Enterprise only) + * resp-200: array + * resp-200-example: ["admin","ci-bot","alice"] + * resp-403: Enterprise required, or permission denied + * resp-500: Failed to fetch audit log users + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/auth/ldap/+server.ts b/src/routes/api/auth/ldap/+server.ts index 820fb95..763e5c9 100644 --- a/src/routes/api/auth/ldap/+server.ts +++ b/src/routes/api/auth/ldap/+server.ts @@ -5,6 +5,15 @@ import { getLdapConfigs, createLdapConfig } from '$lib/server/db'; import { auditLdapConfig } from '$lib/server/audit'; // GET /api/auth/ldap - List all LDAP configurations +/** + * @openapi + * summary: List all configured LDAP providers (enterprise only; bind passwords are masked) + * resp-200: array<{id:integer!, name:string!, enabled:boolean!, serverUrl:string!, baseDn:string!}> + * resp-200-example: [{"id":1,"name":"Corporate LDAP","enabled":true,"serverUrl":"ldaps://ldap.example.com:636","baseDn":"dc=example,dc=com"}] + * resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin) + * resp-403: Enterprise license required + * resp-500: Failed to read the LDAP configurations + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -32,6 +41,18 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // POST /api/auth/ldap - Create a new LDAP configuration +/** + * @openapi + * summary: Create a new LDAP provider configuration (enterprise only) + * body: {name:string!, serverUrl:string!, baseDn:string!, enabled:boolean, bindDn:string, bindPassword:string, userFilter:string, usernameAttribute:string, emailAttribute:string, displayNameAttribute:string, groupBaseDn:string, groupFilter:string, adminGroup:string, roleMappings:{}, tlsEnabled:boolean, tlsCa:string} + * body-example: {"name":"Corporate LDAP","serverUrl":"ldaps://ldap.example.com:636","baseDn":"dc=example,dc=com","bindDn":"cn=admin,dc=example,dc=com","bindPassword":"***","enabled":true,"tlsEnabled":true} + * resp-201: {id:integer!, name:string!, enabled:boolean!, serverUrl:string!, baseDn:string!} + * resp-201-desc: LDAP configuration created (bindPassword is masked in the response) + * resp-400: Name, server URL, and base DN are required + * resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin) + * resp-403: Enterprise license required + * resp-500: Failed to create the LDAP configuration + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/auth/ldap/[id]/+server.ts b/src/routes/api/auth/ldap/[id]/+server.ts index 92d6631..1a19786 100644 --- a/src/routes/api/auth/ldap/[id]/+server.ts +++ b/src/routes/api/auth/ldap/[id]/+server.ts @@ -6,6 +6,17 @@ import { auditLdapConfig } from '$lib/server/audit'; import { computeAuditDiff } from '$lib/utils/diff'; // GET /api/auth/ldap/[id] - Get a specific LDAP configuration +/** + * @openapi + * summary: Get a single LDAP provider configuration by id (enterprise only; bind password is masked) + * path: id:integer! Numeric id of the LDAP configuration (from GET /api/auth/ldap) + * resp-200: {id:integer!, name:string!, enabled:boolean!, serverUrl:string!, baseDn:string!} + * resp-400: Invalid id (not a number) + * resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin) + * resp-403: Enterprise license required + * resp-404: LDAP configuration not found + * resp-500: Failed to read the LDAP configuration + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -40,6 +51,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => { }; // PUT /api/auth/ldap/[id] - Update a LDAP configuration +/** + * @openapi + * summary: Update an existing LDAP provider configuration (only supplied fields change; a masked bindPassword is ignored) + * path: id:integer! Numeric id of the LDAP configuration (from GET /api/auth/ldap) + * body: {name:string, enabled:boolean, serverUrl:string, bindDn:string, bindPassword:string, baseDn:string, userFilter:string, usernameAttribute:string, emailAttribute:string, displayNameAttribute:string, groupBaseDn:string, groupFilter:string, adminGroup:string, roleMappings:{}, tlsEnabled:boolean, tlsCa:string} + * body-example: {"enabled":false,"userFilter":"(sAMAccountName={{username}})","bindPassword":"***"} + * resp-200: {id:integer!, name:string!, enabled:boolean!, serverUrl:string!, baseDn:string!} + * resp-400: Invalid id (not a number) + * resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin) + * resp-403: Enterprise license required + * resp-404: LDAP configuration not found + * resp-500: Failed to update the LDAP configuration + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -111,6 +135,17 @@ export const PUT: RequestHandler = async (event) => { }; // DELETE /api/auth/ldap/[id] - Delete a LDAP configuration +/** + * @openapi + * summary: Delete an LDAP provider configuration by id (enterprise only) + * path: id:integer! Numeric id of the LDAP configuration (from GET /api/auth/ldap) + * resp-200: {success:boolean!} + * resp-400: Invalid id (not a number) + * resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin) + * resp-403: Enterprise license required + * resp-404: LDAP configuration not found + * resp-500: Failed to delete the LDAP configuration + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/auth/ldap/[id]/test/+server.ts b/src/routes/api/auth/ldap/[id]/test/+server.ts index ed5089b..798a854 100644 --- a/src/routes/api/auth/ldap/[id]/test/+server.ts +++ b/src/routes/api/auth/ldap/[id]/test/+server.ts @@ -5,6 +5,17 @@ import { authorize } from '$lib/server/authorize'; import { getLdapConfig } from '$lib/server/db'; // POST /api/auth/ldap/[id]/test - Test LDAP connection +/** + * @openapi + * summary: Test connectivity of a stored LDAP configuration by id (enterprise only) + * path: id:integer! Numeric id of the LDAP configuration to test (from GET /api/auth/ldap) + * resp-200: Connection test result (success flag plus diagnostic detail from the LDAP server) + * resp-400: Invalid id (not a number) + * resp-401: Authentication required (auth is enabled and the caller is not an authenticated admin) + * resp-403: Enterprise license required + * resp-404: LDAP configuration not found + * resp-500: Failed to test the LDAP connection + */ export const POST: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 68c9d2f..de2087f 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -15,7 +15,20 @@ import { getUser, getUserByUsername } from '$lib/server/db'; import { auditAuth } from '$lib/server/audit'; import { getClientIp } from '$lib/server/client-ip'; -// POST /api/auth/login - Authenticate user +/** + * @openapi + * summary: Authenticate with username/password (local or LDAP) and set the session cookie + * body: {username:string!, password:string!, mfaToken:string, provider:string} + * body-example: {"username":"admin","password":"correct horse battery staple","provider":"local"} + * resp-200: {success:boolean!, user:{id:integer!, username:string!, email:string, displayName:string, isAdmin:boolean!}} + * resp-200-desc: Login succeeded and dockhand_session cookie was set — OR requiresMfa:true if a second factor is needed first + * resp-200-example: {"success":true,"user":{"id":1,"username":"admin","email":"admin@example.com","displayName":"Admin","isAdmin":true}} + * resp-400: Authentication disabled, or username/password missing + * resp-401: Invalid credentials or invalid MFA code + * resp-403: Local login disabled via DISABLE_LOCAL_LOGIN + * resp-429: Rate-limited (too many attempts for this IP+username) + * resp-500: Unexpected error during authentication + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; // Check if auth is enabled diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts index 6678a42..c0e4b4d 100644 --- a/src/routes/api/auth/logout/+server.ts +++ b/src/routes/api/auth/logout/+server.ts @@ -5,7 +5,13 @@ import { authorize } from '$lib/server/authorize'; import { auditAuth } from '$lib/server/audit'; import { getClientIp } from '$lib/server/client-ip'; -// POST /api/auth/logout - End session +/** + * @openapi + * summary: Destroy the current session (clears the dockhand_session cookie) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-500: Unexpected error while destroying the session + */ export const POST: RequestHandler = async (event) => { const { cookies } = event; try { diff --git a/src/routes/api/auth/oidc/+server.ts b/src/routes/api/auth/oidc/+server.ts index b3d0201..8fa01fc 100644 --- a/src/routes/api/auth/oidc/+server.ts +++ b/src/routes/api/auth/oidc/+server.ts @@ -9,6 +9,15 @@ import { import { auditOidcProvider } from '$lib/server/audit'; // GET /api/auth/oidc - List all OIDC configurations +/** + * @openapi + * summary: List all configured OIDC providers (client secrets are masked) + * resp-200: array<{id:integer!, name:string!, enabled:boolean!, issuerUrl:string!, clientId:string!}> + * resp-200-example: [{"id":1,"name":"Authentik","enabled":true,"issuerUrl":"https://auth.example.com/application/o/dockhand/","clientId":"dockhand"}] + * resp-401: Authentication required (auth is enabled and the caller is not authenticated) + * resp-403: Permission denied (missing settings:view) + * resp-500: Failed to read the OIDC configurations + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -37,6 +46,18 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // POST /api/auth/oidc - Create new OIDC configuration +/** + * @openapi + * summary: Create a new OIDC provider configuration + * body: {name:string!, issuerUrl:string!, clientId:string!, clientSecret:string!, redirectUri:string!, enabled:boolean, scopes:string, usernameClaim:string, emailClaim:string, displayNameClaim:string, adminClaim:string, adminValue:string, roleMappingsClaim:string, roleMappings:{}} + * body-example: {"name":"Authentik","issuerUrl":"https://auth.example.com/application/o/dockhand/","clientId":"dockhand","clientSecret":"***","redirectUri":"https://dockhand.example.com/api/auth/oidc/callback","enabled":true,"scopes":"openid profile email"} + * resp-201: {id:integer!, name:string!, enabled:boolean!, issuerUrl:string!, clientId:string!} + * resp-201-desc: OIDC configuration created (clientSecret is masked in the response) + * resp-400: A required field is missing (name, issuerUrl, clientId, clientSecret, redirectUri) + * resp-401: Authentication required (auth is enabled and the caller is not authenticated) + * resp-403: Permission denied (missing settings:edit) + * resp-500: Failed to create the OIDC configuration + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/auth/oidc/[id]/+server.ts b/src/routes/api/auth/oidc/[id]/+server.ts index 4f7b8d2..22a0a7f 100644 --- a/src/routes/api/auth/oidc/[id]/+server.ts +++ b/src/routes/api/auth/oidc/[id]/+server.ts @@ -10,6 +10,17 @@ import { auditOidcProvider } from '$lib/server/audit'; import { computeAuditDiff } from '$lib/utils/diff'; // GET /api/auth/oidc/[id] - Get specific OIDC configuration +/** + * @openapi + * summary: Get a single OIDC provider configuration by id (client secret is masked) + * path: id:integer! Numeric id of the OIDC configuration (from GET /api/auth/oidc) + * resp-200: {id:integer!, name:string!, enabled:boolean!, issuerUrl:string!, clientId:string!} + * resp-400: Invalid configuration id (not a number) + * resp-401: Authentication required (auth is enabled and the caller is not authenticated) + * resp-403: Permission denied (missing settings:view) + * resp-404: OIDC configuration not found + * resp-500: Failed to read the OIDC configuration + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -45,6 +56,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => { }; // PUT /api/auth/oidc/[id] - Update OIDC configuration +/** + * @openapi + * summary: Update an existing OIDC provider configuration (only supplied fields change; a masked clientSecret is ignored) + * path: id:integer! Numeric id of the OIDC configuration (from GET /api/auth/oidc) + * body: {name:string, enabled:boolean, issuerUrl:string, clientId:string, clientSecret:string, redirectUri:string, scopes:string, usernameClaim:string, emailClaim:string, displayNameClaim:string, adminClaim:string, adminValue:string, roleMappingsClaim:string, roleMappings:{}} + * body-example: {"enabled":true,"scopes":"openid profile email groups","clientSecret":"***"} + * resp-200: {id:integer!, name:string!, enabled:boolean!, issuerUrl:string!, clientId:string!} + * resp-400: Invalid configuration id (not a number) + * resp-401: Authentication required (auth is enabled and the caller is not authenticated) + * resp-403: Permission denied (missing settings:edit) + * resp-404: OIDC configuration not found + * resp-500: Failed to update the OIDC configuration + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -115,6 +139,17 @@ export const PUT: RequestHandler = async (event) => { }; // DELETE /api/auth/oidc/[id] - Delete OIDC configuration +/** + * @openapi + * summary: Delete an OIDC provider configuration by id + * path: id:integer! Numeric id of the OIDC configuration (from GET /api/auth/oidc) + * resp-200: {success:boolean!} + * resp-400: Invalid configuration id (not a number) + * resp-401: Authentication required (auth is enabled and the caller is not authenticated) + * resp-403: Permission denied (missing settings:edit) + * resp-404: OIDC configuration not found + * resp-500: Failed to delete the OIDC configuration + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/auth/oidc/[id]/initiate/+server.ts b/src/routes/api/auth/oidc/[id]/initiate/+server.ts index a837e40..9fb4fc1 100644 --- a/src/routes/api/auth/oidc/[id]/initiate/+server.ts +++ b/src/routes/api/auth/oidc/[id]/initiate/+server.ts @@ -5,6 +5,16 @@ import { getOidcConfig } from '$lib/server/db'; import { safeRedirectOrRoot } from '$lib/utils/safe-redirect'; // GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow +/** + * @openapi + * summary: Start the OIDC login flow for a provider — on success throws a 302 redirect to the IdP authorization URL + * path: id:integer! Numeric id of the OIDC provider (from GET /api/auth/oidc) + * query: redirect:string Post-login destination path to return to (defaults to /) + * resp-302: Redirect to the IdP's authorization URL + * resp-400: Authentication is not enabled, or the configuration id is invalid + * resp-404: OIDC provider not found or disabled + * resp-500: Failed to build the authorization URL / initiate SSO + */ export const GET: RequestHandler = async ({ params, url }) => { // Check if auth is enabled if (!await isAuthEnabled()) { @@ -44,6 +54,17 @@ export const GET: RequestHandler = async ({ params, url }) => { }; // POST /api/auth/oidc/[id]/initiate - Get authorization URL without redirect +/** + * @openapi + * summary: Return the OIDC authorization URL for a provider without redirecting (JSON response) + * path: id:integer! Numeric id of the OIDC provider (from GET /api/auth/oidc) + * body: {redirect:string} + * body-example: {"redirect":"/dashboard"} + * resp-200: {url:string!} + * resp-400: Authentication is not enabled, or the configuration id is invalid + * resp-404: OIDC provider not found or disabled + * resp-500: Failed to build the authorization URL / initiate SSO + */ export const POST: RequestHandler = async ({ params, request }) => { // Check if auth is enabled if (!await isAuthEnabled()) { diff --git a/src/routes/api/auth/oidc/[id]/test/+server.ts b/src/routes/api/auth/oidc/[id]/test/+server.ts index b7e5fc2..212911d 100644 --- a/src/routes/api/auth/oidc/[id]/test/+server.ts +++ b/src/routes/api/auth/oidc/[id]/test/+server.ts @@ -3,6 +3,15 @@ import type { RequestHandler } from '@sveltejs/kit'; import { validateSession, testOidcConnection, isAuthEnabled } from '$lib/server/auth'; // POST /api/auth/oidc/[id]/test - Test OIDC connection +/** + * @openapi + * summary: Test the discovery/connection of a stored OIDC configuration by id + * path: id:integer! Numeric id of the OIDC configuration to test (from GET /api/auth/oidc) + * resp-200: Connection test result (success flag plus diagnostic detail from the provider) + * resp-400: Invalid configuration id (not a number) + * resp-403: Admin access required (auth is enabled and the caller is not an admin) + * resp-500: Failed to test the OIDC connection + */ export const POST: RequestHandler = async ({ params, cookies }) => { // When auth is disabled, allow access (for initial setup) // When auth is enabled, require admin diff --git a/src/routes/api/auth/oidc/callback/+server.ts b/src/routes/api/auth/oidc/callback/+server.ts index 8625e38..6b8e865 100644 --- a/src/routes/api/auth/oidc/callback/+server.ts +++ b/src/routes/api/auth/oidc/callback/+server.ts @@ -6,6 +6,15 @@ import { getClientIp } from '$lib/server/client-ip'; import { safeRedirectOrRoot } from '$lib/utils/safe-redirect'; // GET /api/auth/oidc/callback - Handle OIDC callback from IdP +/** + * @openapi + * summary: Handle the OIDC redirect callback from the IdP — always responds with a 302 redirect (to the original destination on success, or to /login with an error query param on failure) + * query: code:string Authorization code returned by the IdP + * query: state:string Opaque state value used to correlate the request and carry the post-login redirect + * query: error:string Error code returned by the IdP when authentication failed + * query: error_description:string Human-readable error detail returned by the IdP + * resp-302: Always a redirect — on success a session cookie is set and the caller is redirected to the original destination; on any error the caller is redirected to /login with an error query param + */ export const GET: RequestHandler = async (event) => { const { url, cookies } = event; // Check if auth is enabled diff --git a/src/routes/api/auth/providers/+server.ts b/src/routes/api/auth/providers/+server.ts index 8c267bb..d0a95d4 100644 --- a/src/routes/api/auth/providers/+server.ts +++ b/src/routes/api/auth/providers/+server.ts @@ -5,6 +5,12 @@ import { getAuthSettings } from '$lib/server/db'; import { isEnterprise } from '$lib/server/license'; // GET /api/auth/providers - Get available authentication providers +/** + * @openapi + * summary: List the authentication providers offered on the login page (local, LDAP, OIDC), plus the default provider + * resp-200: {providers:array<{id:string!, name:string!, type:string!, initiateUrl:string}>, defaultProvider:string} + * resp-200-example: {"providers":[{"id":"local","name":"Local","type":"local"},{"id":"oidc:1","name":"Authentik","type":"oidc","initiateUrl":"/api/auth/oidc/1/initiate"}],"defaultProvider":"local"} + */ export const GET: RequestHandler = async () => { if (!(await isAuthEnabled())) { return json({ providers: [] }); diff --git a/src/routes/api/auth/session/+server.ts b/src/routes/api/auth/session/+server.ts index 48ad444..fe810d8 100644 --- a/src/routes/api/auth/session/+server.ts +++ b/src/routes/api/auth/session/+server.ts @@ -3,7 +3,14 @@ import type { RequestHandler } from '@sveltejs/kit'; import { validateSession, isAuthEnabled } from '$lib/server/auth'; import { getAuthSettings } from '$lib/server/db'; -// GET /api/auth/session - Get current session/user +/** + * @openapi + * summary: Get the current session (public — used by the frontend to bootstrap auth state) + * resp-200: {authenticated:boolean!, authEnabled:boolean!, user:{id:integer, username:string, email:string, displayName:string, avatar:string, isAdmin:boolean, provider:string}} + * resp-200-desc: user is present only when authenticated:true + * resp-200-example: {"authenticated":true,"authEnabled":true,"user":{"id":1,"username":"admin","email":"admin@example.com","displayName":"Admin","avatar":null,"isAdmin":true,"provider":"local"}} + * resp-500: Unexpected error while validating the session + */ export const GET: RequestHandler = async ({ cookies }) => { try { const authEnabled = await isAuthEnabled(); diff --git a/src/routes/api/auth/settings/+server.ts b/src/routes/api/auth/settings/+server.ts index c38c31d..8c3f04c 100644 --- a/src/routes/api/auth/settings/+server.ts +++ b/src/routes/api/auth/settings/+server.ts @@ -6,6 +6,15 @@ import { authorize } from '$lib/server/authorize'; // GET /api/auth/settings - Get auth settings // Public when auth is disabled, requires authentication when enabled +/** + * @openapi + * summary: Get the global authentication settings (whether auth is enabled and the default provider) + * resp-200: {authEnabled:boolean!, defaultProvider:string} + * resp-200-example: {"authEnabled":true,"defaultProvider":"local"} + * resp-401: Authentication required (auth is enabled and the caller is not authenticated) + * resp-403: Permission denied (missing settings:view) + * resp-500: Failed to read the auth settings + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -30,6 +39,17 @@ export const GET: RequestHandler = async ({ cookies }) => { // PUT /api/auth/settings - Update auth settings // Requires authentication and settings:edit permission +/** + * @openapi + * summary: Update the global authentication settings (enabling auth requires at least one admin/user to exist) + * body: {authEnabled:boolean, defaultProvider:string} + * body-example: {"authEnabled":true,"defaultProvider":"local"} + * resp-200: {authEnabled:boolean!, defaultProvider:string} + * resp-400: Cannot enable authentication without an existing user/admin (response includes requiresUser:true) + * resp-401: Authentication required (auth is enabled and the caller is not authenticated) + * resp-403: Permission denied (missing settings:edit) + * resp-500: Failed to update the auth settings + */ export const PUT: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/auth/tokens/+server.ts b/src/routes/api/auth/tokens/+server.ts index 5f01163..044ab86 100644 --- a/src/routes/api/auth/tokens/+server.ts +++ b/src/routes/api/auth/tokens/+server.ts @@ -53,6 +53,13 @@ function recordPwFailure(userId: number): void { /** * GET /api/auth/tokens - List the current user's API tokens + * + * @openapi + * summary: List the authenticated user's API tokens (never returns the hash/secret) + * resp-200: array<{id:integer!, name:string!, tokenPrefix:string!, lastUsed:string, expiresAt:string, createdAt:string!}> + * resp-200-example: [{"id":1,"name":"CI/CD Pipeline","tokenPrefix":"a1b2c3d4","lastUsed":"2027-01-02T08:00:00Z","expiresAt":"2027-01-01T00:00:00Z","createdAt":"2026-06-01T10:00:00Z"}] + * resp-400: Authentication is not enabled + * resp-401: Not authenticated */ export const GET: RequestHandler = async ({ cookies }) => { const authEnabled = await isAuthEnabled(); @@ -71,6 +78,19 @@ export const GET: RequestHandler = async ({ cookies }) => { /** * POST /api/auth/tokens - Create a new API token + * + * @openapi + * summary: Create a new API token for the authenticated user (requires a session, not a Bearer token) + * body: {name:string!, expiresAt:string, password:string} + * body-example: {"name":"CI/CD Pipeline","expiresAt":"2027-01-01T00:00:00Z","password":"correct horse battery staple"} + * resp-201: {token:string!, id:integer!, tokenPrefix:string!} + * resp-201-desc: Token created — the plaintext value is shown ONLY this once and cannot be retrieved again + * resp-201-example: {"token":"dh_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","id":42,"tokenPrefix":"a1b2c3d4"} + * resp-400: Invalid body, missing password for local users, or MAX_TOKENS_PER_USER (25) reached + * resp-401: Not authenticated + * resp-403: Token creation attempted via Bearer auth (session login required) or wrong password + * resp-404: User record not found while confirming the password (local provider) + * resp-429: Too many failed password confirmation attempts (rate limited) */ export const POST: RequestHandler = async (event) => { const { cookies, request } = event; diff --git a/src/routes/api/auth/tokens/[id]/+server.ts b/src/routes/api/auth/tokens/[id]/+server.ts index 719f3bc..660fcb9 100644 --- a/src/routes/api/auth/tokens/[id]/+server.ts +++ b/src/routes/api/auth/tokens/[id]/+server.ts @@ -8,6 +8,16 @@ import { audit } from '$lib/server/audit'; /** * DELETE /api/auth/tokens/[id] - Revoke an API token + * + * @openapi + * summary: Revoke (permanently delete) one of the authenticated user's API tokens + * path: id:integer! Token id (from GET /api/auth/tokens) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: Authentication not enabled, or id is not a valid integer + * resp-401: Not authenticated + * resp-403: Attempted via Bearer auth (a leaked token cannot revoke other tokens — session required) + * resp-404: Token not found, or belongs to a different (non-admin) user */ export const DELETE: RequestHandler = async (event) => { const { cookies, params } = event; diff --git a/src/routes/api/auto-update/+server.ts b/src/routes/api/auto-update/+server.ts index 02f4554..9cc9820 100644 --- a/src/routes/api/auto-update/+server.ts +++ b/src/routes/api/auto-update/+server.ts @@ -5,6 +5,13 @@ import { getAutoUpdateSettings } from '$lib/server/db'; /** * Batch endpoint to get all auto-update settings for an environment. * Returns a map of containerName -> settings for efficient lookup. + * + * @openapi + * summary: Get all enabled auto-update settings for an environment, keyed by container name + * query: env:integer Environment ID to read settings for (from GET /api/environments) + * resp-200: {} + * resp-200-example: {"web-1":{"enabled":true,"scheduleType":"daily","cronExpression":"0 3 * * *","vulnerabilityCriteria":"never"}} + * resp-500: Failed to get auto-update settings */ export const GET: RequestHandler = async ({ url }) => { try { diff --git a/src/routes/api/auto-update/[containerName]/+server.ts b/src/routes/api/auto-update/[containerName]/+server.ts index 9dda7ee..6342954 100644 --- a/src/routes/api/auto-update/[containerName]/+server.ts +++ b/src/routes/api/auto-update/[containerName]/+server.ts @@ -9,6 +9,15 @@ import { import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; import { authorize } from '$lib/server/authorize'; +/** + * @openapi + * summary: Get the auto-update setting for a container (returns sensible defaults when none is stored) + * path: containerName:string! Container name (URL-encoded) + * query: env:integer Environment the container belongs to (from GET /api/environments) + * resp-200: {enabled:boolean!, scheduleType:string!, cronExpression:string, vulnerabilityCriteria:string!} + * resp-200-example: {"enabled":false,"scheduleType":"daily","cronExpression":"0 3 * * *","vulnerabilityCriteria":"never"} + * resp-500: Failed to get auto-update setting + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); @@ -47,6 +56,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { } }; +/** + * @openapi + * summary: Create or update a container's auto-update setting; enabled=false deletes it and its schedule + * path: containerName:string! Container name (URL-encoded) + * query: env:integer Environment the container belongs to (from GET /api/environments) + * body: {enabled:boolean, cronExpression:string, cron_expression:string, vulnerabilityCriteria:string, vulnerability_criteria:string} + * body-example: {"enabled":true,"cronExpression":"0 3 * * *","vulnerabilityCriteria":"never"} + * resp-200: {id:integer!, enabled:boolean!, scheduleType:string!, cronExpression:string, vulnerabilityCriteria:string!, deleted:boolean, success:boolean} + * resp-200-example: {"success":true,"deleted":true} + * resp-500: Failed to save auto-update setting + */ export const POST: RequestHandler = async ({ params, url, request, cookies }) => { const auth = await authorize(cookies); @@ -118,6 +138,15 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) => } }; +/** + * @openapi + * summary: Delete a container's auto-update setting and unregister its schedule + * path: containerName:string! Container name (URL-encoded) + * query: env:integer Environment the container belongs to (from GET /api/environments) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-500: Failed to delete auto-update setting + */ export const DELETE: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/backup/configs/+server.ts b/src/routes/api/backup/configs/+server.ts index 7f42ce5..6629e0c 100644 --- a/src/routes/api/backup/configs/+server.ts +++ b/src/routes/api/backup/configs/+server.ts @@ -11,6 +11,17 @@ import { registerSchedule, isValidCron } from '$lib/server/scheduler'; import { assertStackBackupable } from '$lib/server/backups'; import { validateRetention, retentionToStore } from '$lib/server/backups/helpers'; +/** + * GET /api/backup/configs - List backup configurations + * + * @openapi + * summary: List backup configurations, optionally filtered by type, target and environment (enterprise callers only see configs for environments they can access) + * description: Permission denial (403, "backups:view") is produced by the shared requireBackups route guard. + * query: type:string Filter by backup config type (e.g. "container", "stack") + * query: target:string Filter by the backed-up target name (container or stack name) + * query: env:integer Filter by environment id (from GET /api/environments) + * resp-200: Array of backup configuration objects + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); @@ -42,6 +53,19 @@ export const GET: RequestHandler = async ({ url, cookies }) => { return json(configs); }; +/** + * POST /api/backup/configs - Create a backup configuration + * + * @openapi + * summary: Create a backup configuration for a container or stack, optionally scheduled, and register its cron schedule when enabled + * description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. A requested environmentId the caller can't access also 403s (enterprise). destinationId from GET /api/backup/destinations. environmentId from GET /api/environments. + * body: {destinationId:integer!, targetName:string!, type:string, environmentId:integer, enabled:boolean, allVolumes:boolean, selectedVolumes:array, stopBeforeBackup:boolean, schedule:string, retention:{keepLast:integer, keepDaily:integer, keepWeekly:integer, keepMonthly:integer, keepYearly:integer}, options:{}, tags:array} + * body-example: {"destinationId":3,"targetName":"nextcloud","type":"container","environmentId":1,"enabled":true,"allVolumes":true,"stopBeforeBackup":false,"schedule":"0 3 * * *","retention":{"keepDaily":7,"keepWeekly":4}} + * resp-201: The created backup configuration object + * resp-400: Invalid input — missing destinationId/targetName, invalid targetName, invalid cron expression, invalid retention, or a local repository paired with a remote environment / non-backupable stack + * resp-403: Access denied to the requested environment (enterprise) + * resp-500: Failed to create the backup configuration (persistence error) + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/backup/configs/[id]/+server.ts b/src/routes/api/backup/configs/[id]/+server.ts index aabb53c..aa6ec3f 100644 --- a/src/routes/api/backup/configs/[id]/+server.ts +++ b/src/routes/api/backup/configs/[id]/+server.ts @@ -11,6 +11,15 @@ import { isBackupRunning } from '$lib/server/backups'; import { validateRetention, retentionToStore, resolveEnabledOnScheduleChange } from '$lib/server/backups/helpers'; import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards'; +/** + * GET /api/backup/configs/{id} - Get a single backup configuration + * + * @openapi + * summary: Fetch a single backup configuration by id, enforcing environment-scoped access + * description: Permission ("backups:view") and environment-access denials (403) and not-found (404) are produced by the shared route guards. + * path: id:integer! Backup configuration id (from GET /api/backup/configs) + * resp-200: The backup configuration object + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); @@ -24,6 +33,20 @@ export const GET: RequestHandler = async ({ params, cookies }) => { return json(gated.config); }; +/** + * PUT /api/backup/configs/{id} - Update a backup configuration + * + * @openapi + * summary: Update a backup configuration and re-register or remove its cron schedule accordingly + * description: Permission ("backups:manage") and environment-access denials (403) and not-found (404) are produced by the shared route guards. The environment is fixed at creation and cannot be changed here. destinationId from GET /api/backup/destinations. + * path: id:integer! Backup configuration id (from GET /api/backup/configs) + * body: {destinationId:integer, enabled:boolean, allVolumes:boolean, selectedVolumes:array, stopBeforeBackup:boolean, schedule:string, retention:{keepLast:integer, keepDaily:integer, keepWeekly:integer, keepMonthly:integer, keepYearly:integer}, options:{}, tags:array} + * body-example: {"schedule":"0 4 * * *","enabled":true,"stopBeforeBackup":true,"retention":{"keepDaily":14}} + * resp-200: The updated backup configuration object + * resp-400: Invalid input — invalid cron expression, invalid retention, or a local repository paired with a remote environment + * resp-409: Cannot change the destination while a backup is running for this config + * resp-500: Update failed (persistence error) + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -101,6 +124,17 @@ export const PUT: RequestHandler = async (event) => { } }; +/** + * DELETE /api/backup/configs/{id} - Delete a backup configuration + * + * @openapi + * summary: Delete a backup configuration and unregister its schedule + * description: Permission ("backups:manage") and environment-access denials (403) and not-found (404) are produced by the shared route guards. + * path: id:integer! Backup configuration id (from GET /api/backup/configs) + * resp-200: Returns { success: true } once the configuration is deleted + * resp-200-example: {"success":true} + * resp-409: A backup is currently running for this config — stop it before deleting + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/backup/configs/[id]/run/+server.ts b/src/routes/api/backup/configs/[id]/run/+server.ts index 152fc77..5e6e7dc 100644 --- a/src/routes/api/backup/configs/[id]/run/+server.ts +++ b/src/routes/api/backup/configs/[id]/run/+server.ts @@ -6,6 +6,15 @@ import { createJobResponse } from '$lib/server/sse'; import { auditBackup } from '$lib/server/audit'; import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards'; +/** + * POST /api/backup/configs/{id}/run - Run a backup now (streamed job) + * + * @openapi + * summary: Trigger a manual backup run for a configuration, streaming progress as a Server-Sent Events job + * description: Returns a text/event-stream that emits `progress` events during the run and a final `result` event. Permission ("backups:manage") and environment-access denials (403) and not-found (404) are produced by the shared route guards. + * path: id:integer! Backup configuration id (from GET /api/backup/configs) + * resp-200: Server-Sent Events stream of progress and a final result event (status "success", "warning", "skipped" or "error") + */ export const POST: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/backup/configs/[id]/stop/+server.ts b/src/routes/api/backup/configs/[id]/stop/+server.ts index 69e2d5a..fa81b91 100644 --- a/src/routes/api/backup/configs/[id]/stop/+server.ts +++ b/src/routes/api/backup/configs/[id]/stop/+server.ts @@ -5,6 +5,17 @@ import { auditBackup } from '$lib/server/audit'; import { cancelBackup } from '$lib/server/backups'; import { requireBackups, loadConfigGateEnv } from '$lib/server/backups/route-guards'; +/** + * POST /api/backup/configs/{id}/stop - Cancel a running backup + * + * @openapi + * summary: Cancel the in-flight backup for a configuration + * description: Permission ("backups:manage") and environment-access denials (403) and not-found (404) are produced by the shared route guards. + * path: id:integer! Backup configuration id (from GET /api/backup/configs) + * resp-200: Returns { success: true, stopped } where "stopped" indicates whether a running backup helper was actually killed + * resp-200-example: {"success":true,"stopped":true} + * resp-500: Failed to cancel the backup (internal error) + */ export const POST: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/backup/destinations/+server.ts b/src/routes/api/backup/destinations/+server.ts index b3a0e37..60266cf 100644 --- a/src/routes/api/backup/destinations/+server.ts +++ b/src/routes/api/backup/destinations/+server.ts @@ -36,6 +36,14 @@ function prepareDestination(dest: any, opts: { includeEnvVars: boolean }): any { return result; } +/** + * GET /api/backup/destinations - List backup destinations + * + * @openapi + * summary: List all backup destinations (restic repositories); the password is stripped and cloud-credential env vars are omitted from the list view + * description: Permission denial (403, "backups:view") is produced by the shared requireBackups route guard. + * resp-200: Array of backup destination objects without secrets (no password, no envVars) + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); @@ -46,6 +54,19 @@ export const GET: RequestHandler = async ({ cookies }) => { return json(destinations.map(d => prepareDestination(d, { includeEnvVars: false }))); }; +/** + * POST /api/backup/destinations - Create a backup destination + * + * @openapi + * summary: Create a restic backup destination, auto-initialize and test the repository, and register its default maintenance schedules + * description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. + * body: {name:string!, repository:string!, password:string!, envVars:{}, flags:string, hostPath:string, policies:string} + * body-example: {"name":"S3 Offsite","repository":"s3:s3.amazonaws.com/my-bucket/restic","password":"***","envVars":{"AWS_ACCESS_KEY_ID":"***","AWS_SECRET_ACCESS_KEY":"***"}} + * resp-201: The created backup destination object (includes decrypted envVars since the caller just supplied them; password is stripped) + * resp-400: Invalid input — missing name/repository/password, unsupported/SSRF-blocked repository, invalid restic flags, or an invalid cron schedule in the policies + * resp-409: A destination with this name already exists + * resp-500: Failed to create the destination (persistence error) + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/backup/destinations/[id]/+server.ts b/src/routes/api/backup/destinations/[id]/+server.ts index 977b5f2..d266d18 100644 --- a/src/routes/api/backup/destinations/[id]/+server.ts +++ b/src/routes/api/backup/destinations/[id]/+server.ts @@ -37,6 +37,17 @@ function prepareDestination(dest: any, includeSecrets: boolean): any { return result; } +/** + * GET /api/backup/destinations/{id} - Get a single backup destination + * + * @openapi + * summary: Fetch a single backup destination; decrypted cloud-credential env vars are only included for callers who can manage backups, and the password is always stripped + * description: Permission denial (403, "backups:view") is produced by the shared requireBackups route guard. + * path: id:integer! Backup destination id (from GET /api/backup/destinations) + * resp-200: The backup destination object (envVars included only for "backups:manage" callers; password always stripped) + * resp-400: Invalid id (not a number) + * resp-404: Destination not found + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); @@ -54,6 +65,21 @@ export const GET: RequestHandler = async ({ params, cookies }) => { return json(prepareDestination(destination, canManage)); }; +/** + * PUT /api/backup/destinations/{id} - Update a backup destination + * + * @openapi + * summary: Update a backup destination, re-validating repository and flags when supplied and re-registering maintenance schedules when policies change + * description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. + * path: id:integer! Backup destination id (from GET /api/backup/destinations) + * body: {name:string, repository:string, password:string, envVars:{}, flags:string, hostPath:string, policies:string} + * body-example: {"name":"S3 Offsite (renamed)","policies":"{\"pruneEnabled\":true,\"pruneSchedule\":\"0 0 1 * *\"}"} + * resp-200: The updated backup destination object (password stripped, envVars echoed back to the managing caller) + * resp-400: Invalid input — invalid id, unsupported/SSRF-blocked repository, invalid restic flags, invalid policy cron, or switching to a local repository used by a remote-environment config + * resp-404: Destination not found + * resp-409: A backup using this destination is currently running, or a destination with the new name already exists + * resp-500: Update failed (persistence error) + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -140,6 +166,19 @@ export const PUT: RequestHandler = async (event) => { } }; +/** + * DELETE /api/backup/destinations/{id} - Delete a backup destination + * + * @openapi + * summary: Delete a backup destination and unregister all its maintenance and dependent backup-config schedules + * description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. + * path: id:integer! Backup destination id (from GET /api/backup/destinations) + * resp-200: Returns { success: true } once the destination is deleted + * resp-200-example: {"success":true} + * resp-400: Invalid id (not a number) + * resp-404: Destination not found + * resp-409: A backup using this destination is currently running — try again once it finishes + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/backup/destinations/[id]/init/+server.ts b/src/routes/api/backup/destinations/[id]/init/+server.ts index fface9d..7d97d7d 100644 --- a/src/routes/api/backup/destinations/[id]/init/+server.ts +++ b/src/routes/api/backup/destinations/[id]/init/+server.ts @@ -6,6 +6,19 @@ import { auditBackupDestination } from '$lib/server/audit'; import { getBackupDestination, updateBackupDestinationTestStatus } from '$lib/server/db'; import { initRepository } from '$lib/server/backups'; +/** + * POST /api/backup/destinations/{id}/init - Initialize the restic repository + * + * @openapi + * summary: Initialize the restic repository for a destination and record the resulting test status + * description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. + * path: id:integer! Backup destination id (from GET /api/backup/destinations) + * resp-200: Returns { success: true, message } when the repository is initialized + * resp-200-example: {"success":true,"message":"Repository initialized"} + * resp-400: Invalid id (not a number) + * resp-404: Destination not found + * resp-500: Repository initialization failed; returns { success: false, error } + */ export const POST: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/backup/destinations/[id]/rotate-key/+server.ts b/src/routes/api/backup/destinations/[id]/rotate-key/+server.ts index fea786f..610f58f 100644 --- a/src/routes/api/backup/destinations/[id]/rotate-key/+server.ts +++ b/src/routes/api/backup/destinations/[id]/rotate-key/+server.ts @@ -9,14 +9,18 @@ import { rotateDestinationPassword } from '$lib/server/backups'; /** * Rotate the restic repository password for a destination. * - * Body: { currentPassword: string, newPassword: string } - * - * 200 — rotated, DB updated - * 400 — input invalid or current password wrong - * 404 — destination not found - * 409 — restic rotated but DB write failed (manual recovery needed; details - * include dbOutOfSync: true) - * 500 — restic call failed for an unrelated reason + * @openapi + * summary: Rotate the restic repository password for a destination and persist the new password + * description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. + * path: id:integer! Backup destination id (from GET /api/backup/destinations) + * body: {currentPassword:string!, newPassword:string!} + * body-example: {"currentPassword":"***","newPassword":"***"} + * resp-200: Returns { success: true } once the password is rotated and the database is updated + * resp-200-example: {"success":true} + * resp-400: Invalid input — invalid id, missing passwords, or the current password is incorrect + * resp-404: Destination not found + * resp-409: restic rotated the key but the database write failed (manual recovery needed; the response includes dbOutOfSync:true) + * resp-500: restic call failed for an unrelated reason */ export const POST: RequestHandler = async (event) => { const { params, request, cookies } = event; diff --git a/src/routes/api/backup/destinations/[id]/task/+server.ts b/src/routes/api/backup/destinations/[id]/task/+server.ts index c4dfd02..61019b2 100644 --- a/src/routes/api/backup/destinations/[id]/task/+server.ts +++ b/src/routes/api/backup/destinations/[id]/task/+server.ts @@ -7,6 +7,20 @@ import { getBackupDestination, getBackupConfigs } from '$lib/server/db'; import { runRepoTask } from '$lib/server/backups'; import { createJobResponse } from '$lib/server/sse'; +/** + * POST /api/backup/destinations/{id}/task - Run a repository maintenance task + * + * @openapi + * summary: Run a restic repository maintenance task (unlock, check, prune, stats, repair-index or repair-snapshots) against a destination + * description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. Streamed via createJobResponse — a task failure never surfaces as an HTTP 500; it comes back as a 200 with a failed job result (JSON-preferring callers) or a job/SSE 'result' event with success:false (streaming callers). + * path: id:integer! Backup destination id (from GET /api/backup/destinations) + * body: {task:string!} + * body-example: {"task":"check"} + * resp-200: The task result object — { success: true, ... } on success, or { success: false, error } when the task itself fails + * resp-400: Invalid input — invalid destination id, or an unknown task name (must be one of unlock, check, prune, stats, repair-index, repair-snapshots) + * resp-403: No access to an environment whose configs use this destination (enterprise) + * resp-404: Destination not found + */ export const POST: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/backup/destinations/[id]/test/+server.ts b/src/routes/api/backup/destinations/[id]/test/+server.ts index c14f860..6a3437f 100644 --- a/src/routes/api/backup/destinations/[id]/test/+server.ts +++ b/src/routes/api/backup/destinations/[id]/test/+server.ts @@ -8,6 +8,18 @@ import { } from '$lib/server/db'; import { testRepository } from '$lib/server/backups'; +/** + * POST /api/backup/destinations/{id}/test - Test a saved backup destination + * + * @openapi + * summary: Test connectivity to a saved backup destination's repository and update its stored test status + * description: Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. + * path: id:integer! Backup destination id (from GET /api/backup/destinations) + * resp-200: Test result — { success: true, status: "success" } when reachable, or { success: false, status: "needs_init" | "failed", error } otherwise + * resp-200-example: {"success":true,"status":"success"} + * resp-400: Invalid id (not a number) + * resp-404: Destination not found + */ export const POST: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'manage'); diff --git a/src/routes/api/backup/destinations/[id]/verify/+server.ts b/src/routes/api/backup/destinations/[id]/verify/+server.ts index f1b156f..39d8ceb 100644 --- a/src/routes/api/backup/destinations/[id]/verify/+server.ts +++ b/src/routes/api/backup/destinations/[id]/verify/+server.ts @@ -7,6 +7,18 @@ import { getBackupDestination } from '$lib/server/db'; import { verifyBackup } from '$lib/server/backups'; import { createJobResponse } from '$lib/server/sse'; +/** + * POST /api/backup/destinations/{id}/verify - Verify backup integrity (streamed job) + * + * @openapi + * summary: Verify the integrity of a destination's backups by reading a data subset, streaming progress as a Server-Sent Events job + * description: Returns a text/event-stream that emits `progress` events and a final `result` event. Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. + * path: id:integer! Backup destination id (from GET /api/backup/destinations) + * body: {dataSubset:string} + * body-example: {"dataSubset":"5%"} + * resp-200: Server-Sent Events stream of progress and a final result event ({ success, ... }) + * resp-400: Invalid destination id (not a number) + */ export const POST: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/backup/destinations/test/+server.ts b/src/routes/api/backup/destinations/test/+server.ts index 6d66c94..4d9c32f 100644 --- a/src/routes/api/backup/destinations/test/+server.ts +++ b/src/routes/api/backup/destinations/test/+server.ts @@ -11,6 +11,16 @@ import { withGcsCredFile } from '$lib/server/backups/restic'; * Test a backup destination configuration. * - With `destinationId`: uses saved credentials from DB * - Without: uses inline credentials from request body (for testing before save) + * + * @openapi + * summary: Test a backup repository — either a saved destination (by destinationId) or inline credentials supplied for a not-yet-saved destination + * description: Provide `destinationId` to test a saved destination using its stored credentials, or omit it and supply `repository`/`password`/`envVars` to test before saving. Permission denial (403, "backups:manage") is produced by the shared requireBackups route guard. destinationId from GET /api/backup/destinations. + * body: {destinationId:integer, repository:string, password:string, envVars:{}} + * body-example: {"repository":"s3:s3.amazonaws.com/my-bucket/restic","password":"***","envVars":{"AWS_ACCESS_KEY_ID":"***","AWS_SECRET_ACCESS_KEY":"***"}} + * resp-200: Test result — { success: true } when the repository is reachable, or { success: false, needsInit?, error } otherwise + * resp-200-example: {"success":true} + * resp-400: Invalid input — missing repository/password for an inline test, or an unsupported/SSRF-blocked repository + * resp-404: Destination not found (when testing a saved destinationId) */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/backup/instance/+server.ts b/src/routes/api/backup/instance/+server.ts index 2147a30..fca8b48 100644 --- a/src/routes/api/backup/instance/+server.ts +++ b/src/routes/api/backup/instance/+server.ts @@ -9,6 +9,12 @@ import { getInstanceId } from '$lib/server/backups/identity'; // abort the sync `restic snapshots` at ~15s) and headers don't survive polling. The // client fetches the id here once and matches it against each snapshot's // dockhand:instance= tag to tell own snapshots from foreign orphans (#1351). +/** + * @openapi + * summary: This install's stable backup instance id, used to tell own snapshots from foreign orphans + * description: Requires backups:view; 403 (no permission) and 404 (backup feature disabled) come from the shared requireBackups guard. + * resp-200: {instanceId:string!} + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); diff --git a/src/routes/api/backup/restore/+server.ts b/src/routes/api/backup/restore/+server.ts index 0f24d33..a6341c7 100644 --- a/src/routes/api/backup/restore/+server.ts +++ b/src/routes/api/backup/restore/+server.ts @@ -8,6 +8,18 @@ import { createJobResponse } from '$lib/server/sse'; import { auditRestore } from '$lib/server/audit'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; +/** + * POST /api/backup/restore - Restore a snapshot (streamed job) + * + * @openapi + * summary: Restore a backup snapshot in-place or to a new location, streaming progress as a Server-Sent Events job + * description: Returns a text/event-stream that emits `progress` events and a final `result` event. An in-place restore is destructive and requires confirmOverwrite:true. Authorization and snapshot-environment access are checked before the request shape is validated. destinationId from GET /api/backup/destinations. snapshotId from GET /api/backup/snapshots. environmentId from GET /api/environments. + * body: {destinationId:integer!, snapshotId:string!, mode:string, targetType:string, volumes:array, environmentId:integer, confirmOverwrite:boolean, targetPath:string, targetName:string, postRestore:string, volumeDestinations:array<{}>} + * body-example: {"destinationId":3,"snapshotId":"a1b2c3d4","mode":"new-location","targetType":"container","targetName":"nextcloud-restored","environmentId":1,"confirmOverwrite":false} + * resp-200: Server-Sent Events stream of progress and a final result event (status "success" or "error") + * resp-400: Invalid restore request; the response "issues" array lists the validation problems + * resp-403: Permission denied — requires "backups:manage", or no access to the target or snapshot-owning environment + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/backup/restore/preview/+server.ts b/src/routes/api/backup/restore/preview/+server.ts index e546814..3f6a3b6 100644 --- a/src/routes/api/backup/restore/preview/+server.ts +++ b/src/routes/api/backup/restore/preview/+server.ts @@ -7,6 +7,16 @@ import { validateSnapshotId } from '$lib/server/docker-validation'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { jobResult } from '$lib/server/sse'; +/** + * @openapi + * summary: Preview what a restore would write (resolved targets + volume/stack contents) + * description: With includeTargets it also resolves the concrete restore targets (host paths / volumes); without it, returns the metadata-only preview the initial modal load relies on. + * body: {destinationId:integer!, snapshotId:string!, includeTargets:boolean, targetEnvId:integer} + * resp-200: object + * resp-200-desc: The snapshot preview, optionally with a resolved targets list + * resp-400: Missing required fields (destinationId, snapshotId) + * resp-403: Permission denied (needs backups:view) or access denied to the target environment + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); const rbacDenied = await requireBackups(auth, 'manage'); diff --git a/src/routes/api/backup/restore/stop/+server.ts b/src/routes/api/backup/restore/stop/+server.ts index 5e07234..a970526 100644 --- a/src/routes/api/backup/restore/stop/+server.ts +++ b/src/routes/api/backup/restore/stop/+server.ts @@ -11,6 +11,17 @@ import { isValidSnapshotId } from '$lib/server/backups/helpers'; * the helper container was never targeted by any stop path. Kills the restore * helper for the given snapshotId (or all restore helpers if omitted) so restic * exits and runRestore's failure/cleanup path runs. + * + * @openapi + * summary: Cancel a running restore — kills the restore helper for a given snapshotId, or all restore helpers when snapshotId is omitted + * description: snapshotId from GET /api/backup/snapshots. environmentId from GET /api/environments. + * body: {snapshotId:string, environmentId:integer} + * body-example: {"snapshotId":"a1b2c3d4"} + * resp-200: Returns { success: true, stopped } where "stopped" indicates whether a running restore helper was actually killed + * resp-200-example: {"success":true,"stopped":true} + * resp-400: Invalid snapshotId + * resp-403: Permission denied — requires "backups:manage", or no access to the targeted environment + * resp-500: Failed to cancel the restore (internal error) */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; diff --git a/src/routes/api/backup/snapshots/+server.ts b/src/routes/api/backup/snapshots/+server.ts index 2054c3f..3dd24d4 100644 --- a/src/routes/api/backup/snapshots/+server.ts +++ b/src/routes/api/backup/snapshots/+server.ts @@ -7,6 +7,17 @@ import { listSnapshots } from '$lib/server/backups'; import { filterSnapshotsByEnvAccess } from '$lib/server/backups/route-guards'; import { jobResult } from '$lib/server/sse'; +/** + * @openapi + * summary: List restic snapshots for a backup config or a single destination (job-polled) + * description: Pass configId to list across the config's destinations, or destinationId for one. Job-polled so a reverse proxy can't abort the sync restic snapshots read at ~15s; match each snapshot's instance tag against GET /api/backup/instance to tell own snapshots from foreign orphans. + * query: configId:integer Backup config id (mutually exclusive with destinationId) + * query: destinationId:integer Single destination id (mutually exclusive with configId) + * query: allDestinations:boolean With configId, include every destination of the config + * resp-400: Invalid or missing configId/destinationId + * resp-403: Permission denied (needs backups:view) or environment access denied + * resp-404: Backup config not found + */ export const GET: RequestHandler = async ({ url, cookies, request }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); diff --git a/src/routes/api/backup/snapshots/[id]/+server.ts b/src/routes/api/backup/snapshots/[id]/+server.ts index e99dec0..474b089 100644 --- a/src/routes/api/backup/snapshots/[id]/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/+server.ts @@ -8,6 +8,21 @@ import { getBackupDestination } from '$lib/server/db'; import { forgetSnapshot } from '$lib/server/backups'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; +/** + * DELETE /api/backup/snapshots/{id} - Forget (delete) a snapshot + * + * @openapi + * summary: Forget and prune a single snapshot from a destination (destructive restic forget --prune), with a server-authoritative environment access gate + * path: id:string! Restic snapshot id to forget (from GET /api/backup/snapshots) + * query: destinationId:integer! Destination holding the snapshot (required) (from GET /api/backup/destinations) + * query: env:integer Optional environment id for an early enterprise access check; the authoritative gate resolves the snapshot's owning environment server-side (from GET /api/environments) + * resp-200: Returns { success: true } once the snapshot is forgotten and pruned + * resp-200-example: {"success":true} + * resp-400: Missing/invalid destinationId or an invalid snapshot id + * resp-403: Permission denied — requires "backups:manage", or no access to the snapshot's owning environment + * resp-404: Snapshot not found for this installation (not owned) + * resp-500: Failed to forget the snapshot (restic error) + */ export const DELETE: RequestHandler = async (event) => { const { params, url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/backup/snapshots/[id]/browse/+server.ts b/src/routes/api/backup/snapshots/[id]/browse/+server.ts index 3e8333f..14fb8a7 100644 --- a/src/routes/api/backup/snapshots/[id]/browse/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/browse/+server.ts @@ -7,6 +7,17 @@ import { browseSnapshot } from '$lib/server/backups'; import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { jobResult } from '$lib/server/sse'; +/** + * @openapi + * summary: List directory entries at a path inside a snapshot (job-polled) + * description: Job-polled so a proxy can't abort the restic read at ~15s. + * path: id:string The restic snapshot id + * query: destinationId:integer Destination the snapshot lives in + * query: path:string Directory path inside the snapshot to list + * query: env:integer Environment context for access checks + * resp-400: Missing/invalid destinationId + * resp-403: Permission denied (needs backups:view) or environment access denied + */ export const GET: RequestHandler = async ({ params, url, cookies, request }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); diff --git a/src/routes/api/backup/snapshots/[id]/dump/+server.ts b/src/routes/api/backup/snapshots/[id]/dump/+server.ts index e75ab61..395db04 100644 --- a/src/routes/api/backup/snapshots/[id]/dump/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/dump/+server.ts @@ -8,6 +8,19 @@ import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { parseSnapshotLayout, redactSnapshotLayout } from '$lib/server/backups/snapshot-layout'; import { jobResult } from '$lib/server/sse'; +/** + * @openapi + * summary: Read a single file (or archive) out of a snapshot (job-polled restic dump) + * description: Job-polled so a proxy can't abort the restic dump at ~15s. type selects the payload (inline preview vs archive); download requests a raw file/directory tar. A raw metadata.json download is refused (403) - it would bypass redaction. + * path: id:string The restic snapshot id + * query: destinationId:integer Destination the snapshot lives in + * query: path:string Path inside the snapshot (must resolve under /volumes or /metadata) + * query: type:string Payload kind (e.g. inline preview vs archive) + * query: download:string When set, stream the file/directory as a tar + * resp-400: Missing/invalid destinationId or path + * resp-403: Permission denied (needs backups:view), or a raw metadata.json download was refused + * resp-500: The restic dump failed + */ export const GET: RequestHandler = async ({ params, url, cookies, request }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); diff --git a/src/routes/api/backup/snapshots/[id]/metadata/+server.ts b/src/routes/api/backup/snapshots/[id]/metadata/+server.ts index 2adaae0..8e03a3e 100644 --- a/src/routes/api/backup/snapshots/[id]/metadata/+server.ts +++ b/src/routes/api/backup/snapshots/[id]/metadata/+server.ts @@ -8,6 +8,14 @@ import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { redactSnapshotLayout } from '$lib/server/backups/snapshot-layout'; import { jobResult } from '$lib/server/sse'; +/** + * @openapi + * summary: The snapshot's redacted metadata layout (job-polled restic dump) + * description: Job-polled so a proxy can't abort the restic dump at ~15s. The layout is redacted (stack secrets and container Config.Env/Labels stripped) before it leaves the server. + * path: id:string The restic snapshot id + * query: destinationId:integer Destination the snapshot lives in + * resp-400: Missing/invalid destinationId + */ export const GET: RequestHandler = async ({ params, url, cookies, request }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); diff --git a/src/routes/api/backup/snapshots/diff/+server.ts b/src/routes/api/backup/snapshots/diff/+server.ts index b3182b8..e6cc3c7 100644 --- a/src/routes/api/backup/snapshots/diff/+server.ts +++ b/src/routes/api/backup/snapshots/diff/+server.ts @@ -7,6 +7,15 @@ import { guardSnapshotEnvAccess } from '$lib/server/backups/route-guards'; import { validateSnapshotId } from '$lib/server/docker-validation'; import { jobResult } from '$lib/server/sse'; +/** + * @openapi + * summary: Diff two snapshots' file trees (added/changed/removed) - job-polled + * description: Job-polled so a proxy can't abort the restic read at ~15s. + * query: destinationId:integer Destination both snapshots live in + * query: snapshotA:string The baseline snapshot id + * query: snapshotB:string The snapshot to compare against the baseline + * resp-400: Missing destinationId, snapshotA, or snapshotB + */ export const GET: RequestHandler = async ({ url, cookies, request }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); diff --git a/src/routes/api/backup/stack-dir-listing/+server.ts b/src/routes/api/backup/stack-dir-listing/+server.ts index 18728ad..aa2207d 100644 --- a/src/routes/api/backup/stack-dir-listing/+server.ts +++ b/src/routes/api/backup/stack-dir-listing/+server.ts @@ -9,6 +9,15 @@ import { probeStackDir } from '$lib/server/backups'; // create/edit backup dialog can show the resolved host path + let the user pick which // stack-dir entries to back up. Returns { kind:'listed', hostPath, entries } or // { kind:'unknown', reason } (never throws for an operational failure). +/** + * @openapi + * summary: Probe the target host and list the actual contents of a stack's directory, for the backup create/edit dialog's file picker + * query: target:string! Target stack name + * query: env:integer Environment ID the stack belongs to (from GET /api/environments) + * resp-200: The stack directory probe result — kind "listed"/"tar" with hostPath/localStackDir and entries, or kind "helper-failed"/"unknown" with a reason (never throws for an operational failure) + * resp-400: target is required + * resp-403: Permission denied (requires backups:view), or no access to the target's environment (enterprise) + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); diff --git a/src/routes/api/backup/stack-path/+server.ts b/src/routes/api/backup/stack-path/+server.ts index 4da5c4e..cd29dca 100644 --- a/src/routes/api/backup/stack-path/+server.ts +++ b/src/routes/api/backup/stack-path/+server.ts @@ -8,6 +8,15 @@ import { previewStackBackupPath } from '$lib/server/backups'; // Preview WHERE a stack's directory will be captured from on the host (the resolved host // bind path), or `unknown` with a reason - so the create-schedule dialog can show it up front // and warn before scheduling a backup that would hard-fail the stack-dir probe. +/** + * @openapi + * summary: Preview where a stack's directory will be captured from on the host, without running a backup + * query: target:string! Target stack name + * query: env:integer Environment ID the stack belongs to (from GET /api/environments) + * resp-200: The resolved capture path preview — kind "candidate" with hostPath, kind "tar" with composeFile only, or kind "unknown" with a reason + * resp-400: target is required + * resp-403: Permission denied (requires backups:view), or no access to the target's environment (enterprise) + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); const denied = await requireBackups(auth, 'view'); diff --git a/src/routes/api/batch/+server.ts b/src/routes/api/batch/+server.ts index 0c7757c..ec0409d 100644 --- a/src/routes/api/batch/+server.ts +++ b/src/routes/api/batch/+server.ts @@ -126,8 +126,18 @@ async function processWithConcurrency( } /** - * Unified batch operations endpoint (job pattern). - * Handles bulk operations for containers, images, volumes, networks, and stacks. + * Unified batch operations endpoint (job pattern) for containers, images, + * volumes, networks, and stacks. + * + * @openapi + * summary: Run a bulk operation (start/stop/restart/pause/unpause/remove/down, depending on entity type) across many containers, images, volumes, networks, or stacks; clients that send Accept application/json get a synchronous summary, others get a jobId and progress over Server-Sent Events + * query: env:integer Environment ID the entities belong to (from GET /api/environments) + * body: {operation:string!, entityType:string!, items:array<{id:string!, name:string!}>!, options:{force:boolean, removeVolumes:boolean}} + * body-example: {"operation":"restart","entityType":"containers","items":[{"id":"abc123","name":"web"}],"options":{"force":false}} + * resp-200: Either a synchronous completion summary ({type:"complete", summary:{total, success, failed}}) or an async job handle ({jobId}) whose progress streams over SSE + * resp-200-example: {"jobId":"job_abc123"} + * resp-400: Invalid JSON body, invalid entity type, invalid operation for the entity type, or empty items array + * resp-403: Permission denied for the requested operation, or environment access denied on enterprise */ export const POST: RequestHandler = async ({ url, cookies, request }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/changelog/+server.ts b/src/routes/api/changelog/+server.ts index 8c48096..27df589 100644 --- a/src/routes/api/changelog/+server.ts +++ b/src/routes/api/changelog/+server.ts @@ -2,6 +2,14 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import changelog from '$lib/data/changelog.json'; +/** + * GET /api/changelog - Dockhand changelog + * + * @openapi + * summary: Return the bundled Dockhand changelog data + * resp-200: array<{version:string!, date:string, changes:array}> + * resp-200-example: [{"version":"1.0.39","date":"2026-06-01","changes":["Fixed image export streaming"]}] + */ export const GET: RequestHandler = async () => { return json(changelog); }; diff --git a/src/routes/api/config-sets/+server.ts b/src/routes/api/config-sets/+server.ts index ce4d34f..2441382 100644 --- a/src/routes/api/config-sets/+server.ts +++ b/src/routes/api/config-sets/+server.ts @@ -4,6 +4,14 @@ import { getConfigSets, createConfigSet } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { auditConfigSet } from '$lib/server/audit'; +/** + * @openapi + * summary: List all config sets (reusable bundles of env vars, labels, ports, volumes, and runtime defaults) + * resp-200: array<{id:integer!, name:string!, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string!, restartPolicy:string!, createdAt:string!, updatedAt:string!}> + * resp-200-example: [{"id":1,"name":"web-defaults","description":"Defaults for web stacks","envVars":[{"key":"TZ","value":"UTC"}],"labels":[],"ports":[],"volumes":[],"networkMode":"bridge","restartPolicy":"unless-stopped","createdAt":"2026-06-01T10:00:00Z","updatedAt":"2026-06-01T10:00:00Z"}] + * resp-403: Permission denied (requires configsets:view) + * resp-500: Failed to fetch config sets + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('configsets', 'view')) { @@ -19,6 +27,18 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; +/** + * @openapi + * summary: Create a new config set + * body: {name:string!, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string, restartPolicy:string} + * body-example: {"name":"web-defaults","description":"Defaults for web stacks","envVars":[{"key":"TZ","value":"UTC"}],"networkMode":"bridge","restartPolicy":"unless-stopped"} + * resp-201: {id:integer!, name:string!, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string!, restartPolicy:string!, createdAt:string!, updatedAt:string!} + * resp-201-desc: Config set created + * resp-201-example: {"id":1,"name":"web-defaults","description":"Defaults for web stacks","envVars":[{"key":"TZ","value":"UTC"}],"labels":[],"ports":[],"volumes":[],"networkMode":"bridge","restartPolicy":"unless-stopped","createdAt":"2026-06-01T10:00:00Z","updatedAt":"2026-06-01T10:00:00Z"} + * resp-400: Name is required, or a config set with this name already exists + * resp-403: Permission denied (requires configsets:create) + * resp-500: Failed to create config set + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/config-sets/[id]/+server.ts b/src/routes/api/config-sets/[id]/+server.ts index f4bb2f8..8c36d99 100644 --- a/src/routes/api/config-sets/[id]/+server.ts +++ b/src/routes/api/config-sets/[id]/+server.ts @@ -5,6 +5,17 @@ import { authorize } from '$lib/server/authorize'; import { auditConfigSet } from '$lib/server/audit'; import { computeAuditDiff } from '$lib/utils/diff'; +/** + * @openapi + * summary: Fetch a single config set by its numeric ID + * path: id:integer! Config set ID (from GET /api/config-sets) + * resp-200: {id:integer!, name:string!, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string!, restartPolicy:string!, createdAt:string!, updatedAt:string!} + * resp-200-example: {"id":1,"name":"web-defaults","description":"Defaults for web stacks","envVars":[{"key":"TZ","value":"UTC"}],"labels":[],"ports":[],"volumes":[],"networkMode":"bridge","restartPolicy":"unless-stopped","createdAt":"2026-06-01T10:00:00Z","updatedAt":"2026-06-01T10:00:00Z"} + * resp-400: Invalid ID + * resp-403: Permission denied (requires configsets:view) + * resp-404: Config set not found + * resp-500: Failed to fetch config set + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('configsets', 'view')) { @@ -29,6 +40,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; +/** + * @openapi + * summary: Update an existing config set by ID + * path: id:integer! Config set ID (from GET /api/config-sets) + * body: {name:string, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string, restartPolicy:string} + * body-example: {"name":"web-defaults","networkMode":"host","restartPolicy":"always"} + * resp-200: {id:integer!, name:string!, description:string, envVars:array<{key:string!, value:string!}>, labels:array<{key:string!, value:string!}>, ports:array<{hostPort:string!, containerPort:string!, protocol:string!}>, volumes:array<{hostPath:string!, containerPath:string!, mode:string!}>, networkMode:string!, restartPolicy:string!, createdAt:string!, updatedAt:string!} + * resp-200-example: {"id":1,"name":"web-defaults","description":"Defaults for web stacks","envVars":[],"labels":[],"ports":[],"volumes":[],"networkMode":"host","restartPolicy":"always","createdAt":"2026-06-01T10:00:00Z","updatedAt":"2026-06-02T12:00:00Z"} + * resp-400: Invalid ID, or a config set with this name already exists + * resp-403: Permission denied (requires configsets:edit) + * resp-404: Config set not found + * resp-500: Failed to update config set + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -81,6 +105,17 @@ export const PUT: RequestHandler = async (event) => { } }; +/** + * @openapi + * summary: Delete a config set by ID + * path: id:integer! Config set ID (from GET /api/config-sets) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: Invalid ID + * resp-403: Permission denied (requires configsets:delete) + * resp-404: Config set not found + * resp-500: Failed to delete config set + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/containers/+server.ts b/src/routes/api/containers/+server.ts index d0d1651..8233f49 100644 --- a/src/routes/api/containers/+server.ts +++ b/src/routes/api/containers/+server.ts @@ -6,6 +6,16 @@ import { hasEnvironments } from '$lib/server/db'; import { isHiddenByLabel } from '$lib/server/container-labels'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: List containers for one environment (dockhand.hidden=true label is filtered out) + * query: env:integer Environment id — an empty array is returned if omitted (from GET /api/environments) + * query: all:boolean Include stopped containers (default true; pass "false" to only show running ones) + * resp-200: array<{id:string!, name:string!, image:string!, state:string!, status:string!}> + * resp-200-example: [{"id":"a1b2c3d4e5f6","name":"immich_server","image":"ghcr.io/immich-app/immich-server:latest","state":"running","status":"Up 2 hours (healthy)"}] + * resp-403: Permission denied, or access denied to this environment + * resp-404: Environment not found + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -51,6 +61,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; +/** + * @openapi + * summary: Create a new container in an environment (auto-pulls the image first if it isn't present locally), optionally starting it right away + * query: env:integer Environment id (from GET /api/environments) + * body: {name:string!, image:string!, ports:{}, volumes:{}, volumeBinds:array, env:array, labels:{}, cmd:array, entrypoint:array, workingDir:string, restartPolicy:string, restartMaxRetries:integer, networkMode:string, additionalNetworks:array, networkAliases:array, networkIpv4Address:string, networkIpv6Address:string, startAfterCreate:boolean} + * body-example: {"name":"my-app","image":"nginx:latest","startAfterCreate":true} + * resp-200: {success:boolean!, id:string!, imagePulled:boolean} + * resp-403: Permission denied, or access denied to this environment + * resp-500: Container creation failed, or the image could not be pulled + */ export const POST: RequestHandler = async (event) => { const { request, url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/containers/[id]/+server.ts b/src/routes/api/containers/[id]/+server.ts index 3fc6435..d981c33 100644 --- a/src/routes/api/containers/[id]/+server.ts +++ b/src/routes/api/containers/[id]/+server.ts @@ -12,6 +12,18 @@ import { unregisterSchedule } from '$lib/server/scheduler'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * GET /api/containers/{id} - Inspect a container + * + * @openapi + * summary: Return the full Docker inspect payload for a single container + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: The raw Docker inspect object for the container + * resp-403: Permission denied, or (enterprise) no access to the requested environment + * resp-404: Container not found + * resp-500: Failed to inspect the container (e.g. it no longer exists or the daemon is unreachable) + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; @@ -64,6 +76,20 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { } }; +/** + * DELETE /api/containers/{id} - Remove a container + * + * @openapi + * summary: Remove a container and clean up its auto-update schedule and pending-update record + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * query: force:boolean Force removal of a running container (Docker force flag) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-403: Permission denied, or (enterprise) no access to the requested environment + * resp-404: Container not found + * resp-500: Failed to remove the container + */ export const DELETE: RequestHandler = async (event) => { const { params, url, cookies } = event; const invalid = validateDockerIdParam(params.id, 'container'); diff --git a/src/routes/api/containers/[id]/exec/+server.ts b/src/routes/api/containers/[id]/exec/+server.ts index 41ae105..555fb32 100644 --- a/src/routes/api/containers/[id]/exec/+server.ts +++ b/src/routes/api/containers/[id]/exec/+server.ts @@ -11,6 +11,20 @@ import { createExec, getDockerConnectionInfo } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * POST /api/containers/{id}/exec - Create an exec instance for terminal attachment + * + * @openapi + * summary: Create a Docker exec instance in a container and return its ID plus the Docker connection info for a terminal WebSocket + * path: id:string! Container ID or name (from GET /api/containers) + * query: envId:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * body: {shell:string, user:string} + * body-example: {"shell":"/bin/sh","user":"root"} + * resp-200: {execId:string!, connectionInfo:{type:string!, host:string, port:integer}} + * resp-401: Not authenticated + * resp-403: Permission denied + * resp-500: Failed to create the exec instance + */ export const POST: RequestHandler = async ({ params, request, cookies, url }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/files/+server.ts b/src/routes/api/containers/[id]/files/+server.ts index 1556bff..125f1be 100644 --- a/src/routes/api/containers/[id]/files/+server.ts +++ b/src/routes/api/containers/[id]/files/+server.ts @@ -4,6 +4,20 @@ import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * GET /api/containers/{id}/files - List a directory inside a container + * + * @openapi + * summary: List the contents of a directory inside a container's filesystem + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * query: path:string Absolute directory path inside the container (default "/") + * query: simpleLs:boolean Use a lightweight `ls` listing instead of a full stat of each entry + * resp-200: Directory listing (entries with name, type, size and permission metadata) + * resp-403: Permission denied + * resp-404: Container not found + * resp-500: Failed to list the directory + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/files/chmod/+server.ts b/src/routes/api/containers/[id]/files/chmod/+server.ts index aedc3ad..15140d3 100644 --- a/src/routes/api/containers/[id]/files/chmod/+server.ts +++ b/src/routes/api/containers/[id]/files/chmod/+server.ts @@ -4,6 +4,22 @@ import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * POST /api/containers/{id}/files/chmod - Change permissions of a path in a container + * + * @openapi + * summary: Change the mode (permissions) of a file or directory inside a container (requires the 'exec' permission) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * body: {path:string!, mode:string!, recursive:boolean} + * body-example: {"path":"/app/entrypoint.sh","mode":"755","recursive":false} + * resp-200: {success:boolean!, path:string!, mode:string!, recursive:boolean!} + * resp-200-example: {"success":true,"path":"/app/entrypoint.sh","mode":"755","recursive":false} + * resp-400: Path or mode missing, an invalid chmod mode, or the container is not running + * resp-403: Permission denied, read-only file system, or operation not permitted + * resp-404: Path not found + * resp-500: Failed to change permissions + */ export const POST: RequestHandler = async ({ params, url, cookies, request }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/files/content/+server.ts b/src/routes/api/containers/[id]/files/content/+server.ts index 069e6d3..7bc991f 100644 --- a/src/routes/api/containers/[id]/files/content/+server.ts +++ b/src/routes/api/containers/[id]/files/content/+server.ts @@ -7,6 +7,21 @@ import type { RequestHandler } from './$types'; // Max file size for reading (1MB) const MAX_FILE_SIZE = 1024 * 1024; +/** + * GET /api/containers/{id}/files/content - Read a file's content from a container + * + * @openapi + * summary: Read the content of a single file inside a container (max 1 MB) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * query: path:string! Absolute file path inside the container + * resp-200: {content:string!, path:string!} + * resp-400: Path is missing, the target is a directory, or the container is not running + * resp-403: Permission denied to read the file + * resp-404: File not found + * resp-413: File is larger than the 1 MB read limit + * resp-500: Failed to read the file + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; @@ -63,6 +78,25 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { } }; +/** + * PUT /api/containers/{id}/files/content - Write a file's content in a container + * + * @openapi + * summary: Overwrite the content of a file inside a container (max 1 MB, requires the 'exec' permission) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * query: path:string! Absolute file path inside the container + * body: {content:string!} + * body-example: {"content":"server {\n listen 80;\n}\n"} + * resp-200: {success:boolean!, path:string!} + * resp-200-example: {"success":true,"path":"/etc/nginx/nginx.conf"} + * resp-400: Path is missing, content is missing/not a string, or the container is not running + * resp-403: Permission denied, or the target file system is read-only + * resp-404: Target directory not found + * resp-413: Content is larger than the 1 MB write limit + * resp-500: Failed to write the file + * resp-507: No space left on device + */ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/files/create/+server.ts b/src/routes/api/containers/[id]/files/create/+server.ts index 9b534a7..3276319 100644 --- a/src/routes/api/containers/[id]/files/create/+server.ts +++ b/src/routes/api/containers/[id]/files/create/+server.ts @@ -4,6 +4,23 @@ import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * POST /api/containers/{id}/files/create - Create a file or directory in a container + * + * @openapi + * summary: Create an empty file or a directory inside a container (requires the 'exec' permission) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * body: {path:string!, type:string!} + * body-example: {"path":"/app/data","type":"directory"} + * resp-200: {success:boolean!, path:string!, type:string!} + * resp-200-example: {"success":true,"path":"/app/data","type":"directory"} + * resp-400: Path missing, type not "file" or "directory", or the container is not running + * resp-403: Permission denied + * resp-404: Parent directory not found + * resp-409: Path already exists + * resp-500: Failed to create the path + */ export const POST: RequestHandler = async ({ params, url, cookies, request }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/files/delete/+server.ts b/src/routes/api/containers/[id]/files/delete/+server.ts index ac6826b..8899e20 100644 --- a/src/routes/api/containers/[id]/files/delete/+server.ts +++ b/src/routes/api/containers/[id]/files/delete/+server.ts @@ -4,6 +4,21 @@ import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * DELETE /api/containers/{id}/files/delete - Delete a path in a container + * + * @openapi + * summary: Delete a file or directory inside a container (requires the 'exec' permission) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * query: path:string! Absolute path inside the container to delete + * resp-200: {success:boolean!, path:string!} + * resp-200-example: {"success":true,"path":"/app/tmp/old.log"} + * resp-400: Path missing, a refused critical-path delete, a non-empty directory, or the container is not running + * resp-403: Permission denied, or read-only file system + * resp-404: Path not found + * resp-500: Failed to delete the path + */ export const DELETE: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/files/download/+server.ts b/src/routes/api/containers/[id]/files/download/+server.ts index b54f508..4a38b2c 100644 --- a/src/routes/api/containers/[id]/files/download/+server.ts +++ b/src/routes/api/containers/[id]/files/download/+server.ts @@ -6,6 +6,21 @@ import { extractFirstFileFromTar } from '$lib/server/tar-extract'; import { attachmentContentDisposition } from '$lib/server/content-disposition'; import type { RequestHandler } from './$types'; +/** + * GET /api/containers/{id}/files/download - Download a path from a container as an archive + * + * @openapi + * summary: Download a file or directory from a container as a tar (optionally gzip-compressed) archive attachment + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * query: path:string! Absolute path inside the container to archive and download + * query: format:string Archive format, "tar" (default) or "tar.gz" + * resp-200: Archive stream (application/x-tar or application/gzip) as a file attachment + * resp-400: Path is missing + * resp-403: Permission denied to access the path + * resp-404: File not found + * resp-500: Failed to download the file + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/files/rename/+server.ts b/src/routes/api/containers/[id]/files/rename/+server.ts index 099d246..bda7e7e 100644 --- a/src/routes/api/containers/[id]/files/rename/+server.ts +++ b/src/routes/api/containers/[id]/files/rename/+server.ts @@ -4,6 +4,23 @@ import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * POST /api/containers/{id}/files/rename - Rename/move a path in a container + * + * @openapi + * summary: Rename or move a file or directory inside a container (requires the 'exec' permission) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * body: {oldPath:string!, newPath:string!} + * body-example: {"oldPath":"/app/config.yaml","newPath":"/app/config.old.yaml"} + * resp-200: {success:boolean!, oldPath:string!, newPath:string!} + * resp-200-example: {"success":true,"oldPath":"/app/config.yaml","newPath":"/app/config.old.yaml"} + * resp-400: oldPath or newPath missing, or the container is not running + * resp-403: Permission denied, or read-only file system + * resp-404: Source path not found + * resp-409: Destination already exists + * resp-500: Failed to rename the path + */ export const POST: RequestHandler = async ({ params, url, cookies, request }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/files/upload/+server.ts b/src/routes/api/containers/[id]/files/upload/+server.ts index 497fafb..7bea39d 100644 --- a/src/routes/api/containers/[id]/files/upload/+server.ts +++ b/src/routes/api/containers/[id]/files/upload/+server.ts @@ -84,6 +84,22 @@ function createTarArchive(filename: string, content: Uint8Array): Uint8Array { return tar; } +/** + * POST /api/containers/{id}/files/upload - Upload files into a container + * + * @openapi + * summary: Upload one or more files into a container directory (requires the 'exec' permission) + * description: Send `multipart/form-data` with one or more `files` parts; each file is tar-wrapped and written into the target directory. Partial success is reported per file in `uploaded`/`errors`. + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * query: path:string! Absolute target directory inside the container + * resp-200: {success:boolean!, uploaded:array!, errors:array} + * resp-200-example: {"success":true,"uploaded":["app.conf"]} + * resp-400: Target path missing, or no files provided + * resp-403: Permission denied to write to the path + * resp-404: Target directory not found + * resp-500: Failed to upload the files + */ export const POST: RequestHandler = async ({ params, url, request, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/inspect/+server.ts b/src/routes/api/containers/[id]/inspect/+server.ts index 91eb38a..3b6e5f2 100644 --- a/src/routes/api/containers/[id]/inspect/+server.ts +++ b/src/routes/api/containers/[id]/inspect/+server.ts @@ -10,6 +10,17 @@ import { detectImageLabelDivergence } from '$lib/server/container-image-divergence'; +/** + * GET /api/containers/{id}/inspect - Inspect a container + * + * @openapi + * summary: Return the full Docker inspect payload for a container (requires the 'inspect' permission) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: The raw Docker inspect object for the container + * resp-403: Permission denied + * resp-500: Failed to inspect the container + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/logs/+server.ts b/src/routes/api/containers/[id]/logs/+server.ts index ce60cd0..376d55a 100644 --- a/src/routes/api/containers/[id]/logs/+server.ts +++ b/src/routes/api/containers/[id]/logs/+server.ts @@ -4,6 +4,20 @@ import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * GET /api/containers/{id}/logs - Read container logs (non-streaming) + * + * @openapi + * summary: Return the last N lines of a container's combined stdout/stderr logs as a single string + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * query: tail:integer Number of trailing log lines to return (default 100) + * query: since:string Only return logs since this time (Unix timestamp or Docker duration, e.g. 10m) + * query: until:string Only return logs before this time (Unix timestamp or Docker duration) + * resp-200: {logs:string!} + * resp-403: Permission denied + * resp-500: Failed to read the container logs + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/logs/stream/+server.ts b/src/routes/api/containers/[id]/logs/stream/+server.ts index 10cb980..a670305 100644 --- a/src/routes/api/containers/[id]/logs/stream/+server.ts +++ b/src/routes/api/containers/[id]/logs/stream/+server.ts @@ -254,6 +254,20 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme }); } +/** + * GET /api/containers/{id}/logs/stream - Stream container logs (Server-Sent Events) + * + * @openapi + * summary: Stream a container's logs live as Server-Sent Events (connected/log/end/error events, with keepalive heartbeats) + * description: Returns a `text/event-stream`. For Hawser Edge environments whose agent is not connected the stream body carries an error event. Docker stdout/stderr frames are demultiplexed server-side; TTY containers stream raw text. + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * query: tail:string Number of trailing log lines to prime the stream with (default "100") + * query: since:string Only stream logs since this time (Unix timestamp or Docker duration, e.g. 10m) + * query: until:string Only stream logs before this time (Unix timestamp or Docker duration) + * resp-200: Server-Sent Events stream (text/event-stream) of log lines + * resp-403: Permission denied + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/pause/+server.ts b/src/routes/api/containers/[id]/pause/+server.ts index e4c214f..b77f25e 100644 --- a/src/routes/api/containers/[id]/pause/+server.ts +++ b/src/routes/api/containers/[id]/pause/+server.ts @@ -5,6 +5,18 @@ import { authorize } from '$lib/server/authorize'; import { auditContainer } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * POST /api/containers/{id}/pause - Pause a container + * + * @openapi + * summary: Pause all processes in a running container (requires the container 'stop' permission) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-403: Permission denied + * resp-500: Failed to pause the container + */ export const POST: RequestHandler = async (event) => { const { params, url, cookies } = event; const invalid = validateDockerIdParam(params.id, 'container'); diff --git a/src/routes/api/containers/[id]/rename/+server.ts b/src/routes/api/containers/[id]/rename/+server.ts index afb036a..e974fd2 100644 --- a/src/routes/api/containers/[id]/rename/+server.ts +++ b/src/routes/api/containers/[id]/rename/+server.ts @@ -6,6 +6,22 @@ import { auditContainer } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * POST /api/containers/{id}/rename - Rename a container + * + * @openapi + * summary: Rename a container and update any associated auto-update schedule (requires the 'create' permission) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * body: {name:string!} + * body-example: {"name":"my-renamed-container"} + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: The new name is missing or not a string + * resp-403: Permission denied + * resp-404: Container not found + * resp-500: Failed to rename the container + */ export const POST: RequestHandler = async (event) => { const { params, request, url, cookies } = event; const invalid = validateDockerIdParam(params.id, 'container'); diff --git a/src/routes/api/containers/[id]/restart/+server.ts b/src/routes/api/containers/[id]/restart/+server.ts index 87c27ad..0ec0d2a 100644 --- a/src/routes/api/containers/[id]/restart/+server.ts +++ b/src/routes/api/containers/[id]/restart/+server.ts @@ -5,6 +5,19 @@ import { auditContainer } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * POST /api/containers/{id}/restart - Restart a container + * + * @openapi + * summary: Restart a container + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-403: Permission denied, or (enterprise) no access to the requested environment + * resp-404: Container not found + * resp-500: Failed to restart the container + */ export const POST: RequestHandler = async (event) => { const { params, url, cookies } = event; const invalid = validateDockerIdParam(params.id, 'container'); diff --git a/src/routes/api/containers/[id]/shells/+server.ts b/src/routes/api/containers/[id]/shells/+server.ts index b6fa751..fd13fe2 100644 --- a/src/routes/api/containers/[id]/shells/+server.ts +++ b/src/routes/api/containers/[id]/shells/+server.ts @@ -12,6 +12,18 @@ const SHELLS_TO_CHECK = [ { path: '/bin/ash', label: 'Ash (Alpine)' } ]; +/** + * GET /api/containers/{id}/shells - Detect available shells in a container + * + * @openapi + * summary: Probe a container for available shells (bash/sh/zsh/ash) and report the preferred default (requires the 'exec' permission) + * description: On probe failure the endpoint still returns 200 with empty results rather than an error, so callers always get a usable shell list. + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: {shells:array!, defaultShell:string, allShells:array<{path:string!, label:string!, available:boolean!}>!} + * resp-200-example: {"shells":["/bin/sh"],"defaultShell":"/bin/sh","allShells":[{"path":"/bin/bash","label":"Bash","available":false},{"path":"/bin/sh","label":"Shell (sh)","available":true}]} + * resp-403: Permission denied, or (enterprise) no access to the requested environment + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/start/+server.ts b/src/routes/api/containers/[id]/start/+server.ts index 7bfd5e6..6bdf50a 100644 --- a/src/routes/api/containers/[id]/start/+server.ts +++ b/src/routes/api/containers/[id]/start/+server.ts @@ -5,6 +5,19 @@ import { auditContainer } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * POST /api/containers/{id}/start - Start a container + * + * @openapi + * summary: Start a stopped container + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-403: Permission denied, or (enterprise) no access to the requested environment + * resp-404: Container not found + * resp-500: Failed to start the container + */ export const POST: RequestHandler = async (event) => { const { params, url, cookies } = event; const invalid = validateDockerIdParam(params.id, 'container'); diff --git a/src/routes/api/containers/[id]/stats/+server.ts b/src/routes/api/containers/[id]/stats/+server.ts index 3ad627d..f055fa8 100644 --- a/src/routes/api/containers/[id]/stats/+server.ts +++ b/src/routes/api/containers/[id]/stats/+server.ts @@ -70,6 +70,18 @@ function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; c return { usage, raw, cache }; } +/** + * GET /api/containers/{id}/stats - Get a single container's resource stats + * + * @openapi + * summary: Return a one-shot CPU/memory/network/block-IO stats snapshot for a container (Docker-CLI-equivalent memory accounting) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: {cpuPercent:number!, memoryUsage:integer!, memoryRaw:integer!, memoryCache:integer!, memoryLimit:integer!, memoryPercent:number!, networkRx:integer!, networkTx:integer!, blockRead:integer!, blockWrite:integer!, timestamp:integer!} + * resp-403: Permission denied + * resp-404: No environment configured, the environment was not found, or the container was not found + * resp-500: Failed to read the container stats + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/stop/+server.ts b/src/routes/api/containers/[id]/stop/+server.ts index b8c4506..97535b1 100644 --- a/src/routes/api/containers/[id]/stop/+server.ts +++ b/src/routes/api/containers/[id]/stop/+server.ts @@ -5,6 +5,19 @@ import { auditContainer } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * POST /api/containers/{id}/stop - Stop a container + * + * @openapi + * summary: Stop a running container + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-403: Permission denied, or (enterprise) no access to the requested environment + * resp-404: Container not found + * resp-500: Failed to stop the container + */ export const POST: RequestHandler = async (event) => { const { params, url, cookies } = event; const invalid = validateDockerIdParam(params.id, 'container'); diff --git a/src/routes/api/containers/[id]/top/+server.ts b/src/routes/api/containers/[id]/top/+server.ts index 5ad7ad3..a8c1893 100644 --- a/src/routes/api/containers/[id]/top/+server.ts +++ b/src/routes/api/containers/[id]/top/+server.ts @@ -28,6 +28,18 @@ function parsePsOutput(output: string): { Titles: string[]; Processes: string[][ return { Titles: headers, Processes: processes }; } +/** + * GET /api/containers/{id}/top - List processes running in a container + * + * @openapi + * summary: List the processes running inside a container (via `ps` in the container, falling back to the Docker top API) + * description: Uses the container 'inspect' permission. The `source` field indicates whether the result came from an in-container `ps` (`"ps"`) or the Docker top API fallback (`"top"`). + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: {Titles:array!, Processes:array>!, source:string!} + * resp-403: Permission denied + * resp-500: Failed to read the container process list + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); if (invalid) return invalid; diff --git a/src/routes/api/containers/[id]/unpause/+server.ts b/src/routes/api/containers/[id]/unpause/+server.ts index d3831f7..76f8348 100644 --- a/src/routes/api/containers/[id]/unpause/+server.ts +++ b/src/routes/api/containers/[id]/unpause/+server.ts @@ -5,6 +5,18 @@ import { authorize } from '$lib/server/authorize'; import { auditContainer } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * POST /api/containers/{id}/unpause - Unpause a container + * + * @openapi + * summary: Resume all processes in a paused container (requires the container 'start' permission) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-403: Permission denied + * resp-500: Failed to unpause the container + */ export const POST: RequestHandler = async (event) => { const { params, url, cookies } = event; const invalid = validateDockerIdParam(params.id, 'container'); diff --git a/src/routes/api/containers/[id]/update-runtime/+server.ts b/src/routes/api/containers/[id]/update-runtime/+server.ts index d2bd588..6d7d257 100644 --- a/src/routes/api/containers/[id]/update-runtime/+server.ts +++ b/src/routes/api/containers/[id]/update-runtime/+server.ts @@ -17,6 +17,19 @@ import { auditContainer } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import { inspectContainer, updateContainerRuntime, IN_PLACE_UPDATE_FIELDS, type InPlaceUpdateField } from '$lib/server/docker'; +/** + * @openapi + * summary: Update a running container's restart policy and/or CPU/memory/blkio/pids limits in-place (no recreate) + * path: id:string! Container id (from GET /api/containers) + * query: env:integer Environment id (from GET /api/environments) + * body: {RestartPolicy:{}, CpuShares:integer, CpuPeriod:integer, CpuQuota:integer, CpuRealtimePeriod:integer, CpuRealtimeRuntime:integer, CpusetCpus:string, CpusetMems:string, NanoCpus:integer, Memory:integer, MemorySwap:integer, MemoryReservation:integer, MemorySwappiness:integer, KernelMemory:integer, BlkioWeight:integer, BlkioWeightDevice:array, BlkioDeviceReadBps:array, BlkioDeviceWriteBps:array, BlkioDeviceReadIOps:array, BlkioDeviceWriteIOps:array, PidsLimit:integer} (unsupported fields are silently dropped) + * body-example: {"Memory":536870912,"PidsLimit":512} + * resp-200: {success:boolean!, warnings:array} + * resp-400: Invalid JSON body, or no supported fields were provided + * resp-403: Permission denied + * resp-404: Container not found + * resp-500: Update failed + */ export const POST: RequestHandler = async (event) => { const { params, request, url, cookies } = event; const invalid = validateDockerIdParam(params.id, 'container'); diff --git a/src/routes/api/containers/[id]/update/+server.ts b/src/routes/api/containers/[id]/update/+server.ts index a3e23b2..3a32238 100644 --- a/src/routes/api/containers/[id]/update/+server.ts +++ b/src/routes/api/containers/[id]/update/+server.ts @@ -6,6 +6,22 @@ import { removePendingContainerUpdate } from '$lib/server/db'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * POST /api/containers/{id}/update - Recreate a container with new settings/image + * + * @openapi + * summary: Recreate a container with updated create-options (optionally re-pulling the image first), preserving its configuration (requires the 'create' permission) + * description: The body carries the container create-options (image, name, env, ports, volumes, …) alongside the two control flags below; `repullImage` pulls the image before recreation and `startAfterUpdate` starts the new container once created. + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * body: {image:string, name:string, repullImage:boolean, startAfterUpdate:boolean} + * body-example: {"image":"nginx:latest","name":"web","repullImage":true,"startAfterUpdate":true} + * resp-200: {success:boolean!, id:string!} + * resp-200-example: {"success":true,"id":"3f4a1c2b9d8e"} + * resp-403: Permission denied + * resp-404: Container not found + * resp-500: Failed to update the container (e.g. the image pull or recreation failed) + */ export const POST: RequestHandler = async (event) => { const { params, request, url, cookies } = event; const invalid = validateDockerIdParam(params.id, 'container'); diff --git a/src/routes/api/containers/batch-update-stream/+server.ts b/src/routes/api/containers/batch-update-stream/+server.ts index 03ab088..9f937de 100644 --- a/src/routes/api/containers/batch-update-stream/+server.ts +++ b/src/routes/api/containers/batch-update-stream/+server.ts @@ -73,6 +73,16 @@ export interface UpdateProgress { /** * Batch update containers with streaming progress. * Expects JSON body: { containerIds: string[], vulnerabilityCriteria?: VulnerabilityCriteria } + * + * @openapi + * summary: Recreate a set of containers with live streaming progress (Server-Sent Events), optionally blocking on vulnerability criteria (requires the 'create' permission) + * description: Returns a `text/event-stream` reporting per-container progress. `vulnerabilityCriteria` (default "never") can block an update when a container's image scan exceeds the configured severity threshold. containerIds from GET /api/containers. + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * body: {containerIds:array!, vulnerabilityCriteria:string} + * body-example: {"containerIds":["3f4a1c2b9d8e"],"vulnerabilityCriteria":"never"} + * resp-200: Server-Sent Events stream (text/event-stream) of per-container update progress + * resp-400: Invalid JSON body, or the containerIds array is missing or empty + * resp-403: Permission denied */ export const POST: RequestHandler = async (event) => { const { url, cookies, request } = event; diff --git a/src/routes/api/containers/batch-update/+server.ts b/src/routes/api/containers/batch-update/+server.ts index 0be5b62..128b225 100644 --- a/src/routes/api/containers/batch-update/+server.ts +++ b/src/routes/api/containers/batch-update/+server.ts @@ -18,6 +18,17 @@ export interface BatchUpdateResult { * Preserves ALL container settings including health checks, resource limits, * capabilities, DNS, security options, ulimits, and network connections. * Expects JSON body: { containerIds: string[] } + * + * @openapi + * summary: Recreate a set of containers with their latest images, preserving all settings (requires the 'create' permission) + * description: Containers are processed sequentially; the response reports per-container success/failure plus a summary. Use the streaming variant for live progress. containerIds from GET /api/containers. + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * body: {containerIds:array!} + * body-example: {"containerIds":["3f4a1c2b9d8e","a1b2c3d4e5f6"]} + * resp-200: {success:boolean!, results:array<{containerId:string!, containerName:string!, success:boolean!, error:string}>!, summary:{total:integer!, success:integer!, failed:integer!}!} + * resp-400: The containerIds array is missing or empty + * resp-403: Permission denied + * resp-500: Failed to run the batch update */ export const POST: RequestHandler = async (event) => { const { url, cookies, request } = event; diff --git a/src/routes/api/containers/sizes/+server.ts b/src/routes/api/containers/sizes/+server.ts index 1f8fdff..ca13480 100644 --- a/src/routes/api/containers/sizes/+server.ts +++ b/src/routes/api/containers/sizes/+server.ts @@ -3,6 +3,16 @@ import type { RequestHandler } from './$types'; import { listContainersWithSize } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; +/** + * GET /api/containers/sizes - List containers with their on-disk sizes + * + * @openapi + * summary: List all containers in an environment together with their writable-layer and root-filesystem sizes (requires the 'view' permission) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: Array of containers with size metadata (SizeRw / SizeRootFs) + * resp-403: Permission denied + * resp-500: Failed to get container sizes (returns an empty object) + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/containers/stats/+server.ts b/src/routes/api/containers/stats/+server.ts index 4429a5d..d85c891 100644 --- a/src/routes/api/containers/stats/+server.ts +++ b/src/routes/api/containers/stats/+server.ts @@ -78,6 +78,18 @@ function withTimeout(promise: Promise, ms: number, fallback: T): Promise` it returns the raw memory_stats for a single container instead. On internal error it returns an empty array with status 200. + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * query: debug:string Return raw Docker stats for the single container with this name instead of the aggregate list + * resp-200: Array of per-container stats snapshots (or, with `debug`, the raw stats for one container) + * resp-403: Permission denied + * resp-404: The requested debug container was not found + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/containers/stats/stream/+server.ts b/src/routes/api/containers/stats/stream/+server.ts index 8922c67..4c3c442 100644 --- a/src/routes/api/containers/stats/stream/+server.ts +++ b/src/routes/api/containers/stats/stream/+server.ts @@ -65,6 +65,13 @@ function withTimeout(promise: Promise, ms: number, fallback: T): Promise { const auth = await authorize(cookies); diff --git a/src/routes/api/dashboard/preferences/+server.ts b/src/routes/api/dashboard/preferences/+server.ts index f3946b2..52fdefd 100644 --- a/src/routes/api/dashboard/preferences/+server.ts +++ b/src/routes/api/dashboard/preferences/+server.ts @@ -48,6 +48,13 @@ async function savePrefs(userId: number | null, prefs: StoredDashboardPrefs): Pr ); } +/** + * @openapi + * summary: Get the current user's dashboard preferences (grid layout, lock state, view mode) + * resp-200: {gridLayout:array<{}>, locked:boolean!, viewMode:string!} + * resp-200-example: {"gridLayout":[],"locked":false,"viewMode":"grid"} + * resp-500: Failed to get dashboard preferences + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -61,6 +68,14 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; +/** + * @openapi + * summary: Update the current user's dashboard preferences (only provided fields are merged) + * body: {gridLayout:array<{}>, locked:boolean, viewMode:string} + * body-example: {"locked":true,"viewMode":"list"} + * resp-200: {gridLayout:array<{}>, locked:boolean!, viewMode:string!} + * resp-500: Failed to save dashboard preferences + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/dashboard/stats/+server.ts b/src/routes/api/dashboard/stats/+server.ts index 2e148f2..62d66b3 100644 --- a/src/routes/api/dashboard/stats/+server.ts +++ b/src/routes/api/dashboard/stats/+server.ts @@ -117,6 +117,16 @@ export interface EnvironmentStats { loading?: LoadingStates; } +/** + * @openapi + * summary: Get aggregated dashboard statistics per environment (containers, images, volumes, stacks, metrics) + * description: Returns an array of per-environment stats. When env is supplied and matches, a single object is returned instead of an array. An empty array is returned on a fresh install with no environments. + * query: env:integer Restrict the stats to a single environment (returns one object) (from GET /api/environments) + * resp-200: array<{id:integer!, name:string!, online:boolean, containers:{}, images:{}, volumes:{}, networks:{}, stacks:{}, metrics:{}, events:{}}> + * resp-403: Permission denied (requires the environments:view permission) + * resp-404: Environment not found (when env is supplied) + * resp-500: Failed to get dashboard stats + */ export const GET: RequestHandler = async ({ cookies, url }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts index c1d82fa..66e9f52 100644 --- a/src/routes/api/dashboard/stats/stream/+server.ts +++ b/src/routes/api/dashboard/stats/stream/+server.ts @@ -563,6 +563,13 @@ async function getEnvironmentStatsProgressive( return envStats; } +/** + * @openapi + * summary: Stream per-environment dashboard stats progressively over Server-Sent Events + * description: Emits environments, partial, complete, error and done events as each environment's stats resolve. When the client sends Accept application/json, the stream is collected and returned as a single JSON payload instead. + * resp-200: Server-Sent Events stream (text/event-stream), or a JSON payload when Accept application/json is requested + * resp-403: Permission denied (requires the environments:view permission) + */ export const GET: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('environments', 'view')) { diff --git a/src/routes/api/debug/memory/+server.ts b/src/routes/api/debug/memory/+server.ts index fd1784d..f79a4ef 100644 --- a/src/routes/api/debug/memory/+server.ts +++ b/src/routes/api/debug/memory/+server.ts @@ -19,6 +19,17 @@ import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rs const startupTime = Date.now(); const startupRss = process.memoryUsage().rss; +/** + * GET /api/debug/memory - Node.js memory diagnostics (opt-in) + * + * @openapi + * summary: Return Node.js/V8 memory diagnostics; only enabled when MEMORY_MONITOR=true. Also supports ?snapshot and ?snapshots to dump/list heap snapshots. + * query: gc:boolean Force garbage collection before reporting when true (requires Node started with --expose-gc) + * resp-200: {timestamp:string!, uptime:object!, gcForced:boolean!, gcAvailable:boolean!, process:object!, growth:object!, v8Heap:object!, system:object!, rssTracker:object} + * resp-200-desc: With ?snapshot returns {snapshot}, with ?snapshots returns {snapshots}; otherwise the full memory report + * resp-200-example: {"timestamp":"2026-07-01T10:00:00.000Z","uptime":{"ms":3600000,"hours":1,"human":"1h 0m"},"gcForced":false,"gcAvailable":false,"process":{"rss":"180 MB"},"growth":{"rssPerHour":"2 MB"},"v8Heap":{"usedHeapSize":"90 MB"},"system":{"cpus":8,"platform":"linux"}} + * resp-403: Memory monitor not enabled (set MEMORY_MONITOR=true) + */ export const GET: RequestHandler = async ({ url }) => { if (process.env.MEMORY_MONITOR !== 'true') { return json({ error: 'Memory monitor not enabled. Set MEMORY_MONITOR=true.' }, { status: 403 }); diff --git a/src/routes/api/dependencies/+server.ts b/src/routes/api/dependencies/+server.ts index 13bf572..e71e045 100644 --- a/src/routes/api/dependencies/+server.ts +++ b/src/routes/api/dependencies/+server.ts @@ -23,6 +23,14 @@ const externalTools = [ } ]; +/** + * GET /api/dependencies - Dependency and external-tool inventory + * + * @openapi + * summary: Return the combined list of npm dependencies and external tool images (grype, trivy), sorted by name, excluding Dockhand itself + * resp-200: array<{name:string!, version:string!, license:string, repository:string}> + * resp-200-example: [{"name":"anchore/grype","version":"v0.110.0","license":"Apache-2.0","repository":"https://github.com/anchore/grype"}] + */ export const GET: RequestHandler = async () => { // Combine npm dependencies with external tools, exclude dockhand itself const allDependencies = [...dependencies, ...externalTools] diff --git a/src/routes/api/docs/+server.ts b/src/routes/api/docs/+server.ts new file mode 100644 index 0000000..e52c237 --- /dev/null +++ b/src/routes/api/docs/+server.ts @@ -0,0 +1,45 @@ +import { error } from '@sveltejs/kit'; +import { createHash } from 'node:crypto'; +import type { RequestHandler } from './$types'; +import { API_DOCS_ENABLED } from '$lib/server/features'; +// Written by `npm run generate:openapi` (scripts/generate-openapi.ts), which +// runs as part of the regular build (see package.json "prebuild"). Imported +// as a module rather than read from disk at request time: the production +// Docker image only copies build/ (not static/), so a static/-only spec +// would 404 in the container even though it's present in the repo. Importing +// it makes Vite bundle the spec straight into the built server output, +// so it's always available regardless of what ships alongside build/. +import spec from '$lib/openapi.generated.json'; + +// The spec is immutable at runtime (baked into the build), so serialize it and +// hash its ETag ONCE at module load - not on every request. The doc is ~800 KB; +// re-stringifying it per request is wasted CPU, and without an ETag the Scalar +// viewer re-downloads the whole thing on every open. +const SPEC_JSON = JSON.stringify(spec); +const SPEC_ETAG = `"${createHash('sha1').update(SPEC_JSON).digest('base64url')}"`; + +/** + * @openapi + * summary: The full OpenAPI 3.0 specification for the Dockhand REST API (unauthenticated) + * resp-200: object + * resp-200-desc: The generated OpenAPI document — see GET /api/docs/ui for an interactive viewer + * resp-304: Not modified — the client's If-None-Match matches the current spec ETag + * resp-404: Not found — API docs are disabled (set FEAT_API_DOCS=true to enable) + */ +export const GET: RequestHandler = async ({ request }) => { + // Gated: unauthenticated route, opt-in per instance (see features.ts). + if (!API_DOCS_ENABLED) throw error(404, 'Not found'); + + // max-age is short so a redeploy (new spec, new ETag) is picked up quickly; + // the ETag then serves 304s within that window instead of re-sending ~800 KB. + const headers = { + 'content-type': 'application/json', + etag: SPEC_ETAG, + 'cache-control': 'public, max-age=300' + }; + + if (request.headers.get('if-none-match') === SPEC_ETAG) { + return new Response(null, { status: 304, headers }); + } + return new Response(SPEC_JSON, { headers }); +}; diff --git a/src/routes/api/docs/ui/+page.server.ts b/src/routes/api/docs/ui/+page.server.ts new file mode 100644 index 0000000..43aa118 --- /dev/null +++ b/src/routes/api/docs/ui/+page.server.ts @@ -0,0 +1,9 @@ +import type { PageServerLoad } from './$types'; + +// No data needed server-side — the client-side Scalar bundle fetches +// GET /api/docs itself. The FEAT_API_DOCS gate is enforced in hooks.server.ts +// (a page load can't gate here: the app runs ssr=false, so this never fires on +// a cold server render). +export const load: PageServerLoad = async () => { + return {}; +}; diff --git a/src/routes/api/docs/ui/+page.svelte b/src/routes/api/docs/ui/+page.svelte new file mode 100644 index 0000000..b7eb156 --- /dev/null +++ b/src/routes/api/docs/ui/+page.svelte @@ -0,0 +1,49 @@ + + + + Dockhand API Docs + + +
+ + diff --git a/src/routes/api/environments/+server.ts b/src/routes/api/environments/+server.ts index 094c1fb..1298778 100644 --- a/src/routes/api/environments/+server.ts +++ b/src/routes/api/environments/+server.ts @@ -10,6 +10,15 @@ import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-color import { cleanPem } from '$lib/utils/pem'; import { validateEnvName } from '$lib/utils/env-name'; +/** + * @openapi + * summary: List all Docker environments (hosts) known to Dockhand + * resp-200: array<{id:integer!, name:string!, connectionType:string!, host:string, port:integer, protocol:string, icon:string, publicIp:string, timezone:string}> + * resp-200-desc: Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition) + * resp-200-example: [{"id":1,"name":"hhdocker01","connectionType":"socket","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":"203.0.113.10","timezone":"Europe/Berlin"},{"id":2,"name":"hhdocker02","connectionType":"hawser-edge","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":null,"timezone":"Europe/Berlin"}] + * resp-403: Permission denied (RBAC 'environments:view' missing) + * resp-500: Unexpected error while loading environments + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('environments', 'view')) { @@ -63,6 +72,18 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; +/** + * @openapi + * summary: Register a new Docker environment (host) + * body: {name:string!, connectionType:string, host:string, port:integer, protocol:string, socketPath:string, icon:string, publicIp:string, labels:string} + * body-example: {"name":"hhdocker03","connectionType":"socket","socketPath":"/var/run/docker.sock","icon":"server"} + * resp-200: {id:integer!, name:string!, connectionType:string!} + * resp-200-example: {"id":3,"name":"hhdocker03","connectionType":"socket"} + * resp-400: Invalid name, invalid connectionType, or host missing for direct/hawser-standard + * resp-403: Permission denied (RBAC 'environments:create' missing) + * resp-409: An environment with this name already exists + * resp-500: Unexpected error while creating the environment + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/environments/[id]/+server.ts b/src/routes/api/environments/[id]/+server.ts index 668c4e1..1eb3562 100644 --- a/src/routes/api/environments/[id]/+server.ts +++ b/src/routes/api/environments/[id]/+server.ts @@ -18,6 +18,15 @@ import { closeEdgeConnection } from '$lib/server/hawser'; import { computeAuditDiff } from '$lib/utils/diff'; import { deleteEnvironmentIcon } from '$lib/server/env-icons'; +/** + * @openapi + * summary: Get a single environment by id, including its parsed labels and public IP + * path: id:integer! Environment id (from GET /api/environments) + * resp-200: {id:integer!, name:string!, connectionType:string!, labels:array, publicIp:string} + * resp-403: Permission denied (RBAC 'environments:view' missing) + * resp-404: Environment not found + * resp-500: Unexpected error while loading the environment + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('environments', 'view')) { @@ -48,6 +57,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; +/** + * @openapi + * summary: Update an environment; renaming also renames its on-disk stacks/git-repos directories + * path: id:integer! Environment id (from GET /api/environments) + * body: {name:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, icon:string, socketPath:string, collectActivity:boolean, collectMetrics:boolean, highlightChanges:boolean, labels:string, connectionType:string, hawserToken:string, publicIp:string} + * body-example: {"name":"hhdocker03","collectMetrics":true} + * resp-200: {id:integer!, name:string!, connectionType:string!, labels:array, publicIp:string} + * resp-400: Invalid new name (rename validation) + * resp-403: Permission denied (RBAC 'environments:edit' missing) + * resp-404: Environment not found + * resp-409: Rename target directory already exists, or the on-disk rename failed (e.g. EXDEV across filesystems) + * resp-500: Unexpected error while updating the environment + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -195,6 +217,16 @@ export const PUT: RequestHandler = async (event) => { } }; +/** + * @openapi + * summary: Delete an environment and all its associated git stacks, schedules, icons, and on-disk directories + * path: id:integer! Environment id (from GET /api/environments) + * resp-200: {success:boolean!} + * resp-400: Invalid environment id, or the environment could not be deleted + * resp-403: Permission denied (RBAC 'environments:delete' missing) + * resp-404: Environment not found + * resp-500: Environment name is empty/whitespace (refuses to delete to avoid an unsafe directory cleanup), or an unexpected error + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/environments/[id]/disk-warning/+server.ts b/src/routes/api/environments/[id]/disk-warning/+server.ts index 45b465c..96733ab 100644 --- a/src/routes/api/environments/[id]/disk-warning/+server.ts +++ b/src/routes/api/environments/[id]/disk-warning/+server.ts @@ -3,6 +3,16 @@ import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; import { getEnvironment, getEnvSetting, setEnvSetting } from '$lib/server/db'; +/** + * @openapi + * summary: Get the disk-space warning thresholds for an environment + * path: id:integer! Environment id (from GET /api/environments) + * resp-200: {enabled:boolean!, mode:string!, threshold:integer!, thresholdGb:integer!} + * resp-200-example: {"enabled":true,"mode":"percentage","threshold":80,"thresholdGb":50} + * resp-403: Permission denied (RBAC 'environments:view' missing) + * resp-404: Environment not found + * resp-500: Unexpected error while loading the settings + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !(await auth.can('environments', 'view'))) { @@ -28,6 +38,17 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; +/** + * @openapi + * summary: Save the disk-space warning thresholds for an environment (each field optional/independent) + * path: id:integer! Environment id (from GET /api/environments) + * body: {enabled:boolean, mode:string, threshold:integer, thresholdGb:integer} + * body-example: {"enabled":true,"mode":"percentage","threshold":85} + * resp-200: {success:boolean!} + * resp-403: Permission denied (RBAC 'environments:edit' missing) + * resp-404: Environment not found + * resp-500: Unexpected error while saving the settings + */ export const POST: RequestHandler = async ({ params, request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !(await auth.can('environments', 'edit'))) { diff --git a/src/routes/api/environments/[id]/icon/+server.ts b/src/routes/api/environments/[id]/icon/+server.ts index 719d938..9c2855d 100644 --- a/src/routes/api/environments/[id]/icon/+server.ts +++ b/src/routes/api/environments/[id]/icon/+server.ts @@ -4,6 +4,13 @@ import { getEnvironment, updateEnvironment } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { saveEnvironmentIcon, deleteEnvironmentIcon, getEnvironmentIconBuffer } from '$lib/server/env-icons'; +/** + * @openapi + * summary: Get the custom icon image for an environment (raw image/webp bytes, not JSON) + * path: id:integer! Environment id (from GET /api/environments) + * resp-200: Binary image/webp response body, Cache-Control public max-age=3600 + * resp-404: No custom icon set for this environment + */ export const GET: RequestHandler = async ({ params }) => { const id = parseInt(params.id); const buffer = getEnvironmentIconBuffer(id); @@ -20,6 +27,17 @@ export const GET: RequestHandler = async ({ params }) => { }); }; +/** + * @openapi + * summary: Upload a custom icon for an environment (base64-encoded image, ~200KB limit) + * path: id:integer! Environment id (from GET /api/environments) + * body: {image:string!} + * resp-200: {success:boolean!, icon:string!} + * resp-200-example: {"success":true,"icon":"custom:env-3.webp"} + * resp-400: Missing image data, or image exceeds the ~300000-char base64 size limit + * resp-403: Permission denied (RBAC 'environments:edit' missing) + * resp-404: Environment not found + */ export const POST: RequestHandler = async ({ params, request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('environments', 'edit')) { @@ -49,6 +67,15 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { return json({ success: true, icon: iconValue }); }; +/** + * @openapi + * summary: Remove an environment's custom icon and reset it to the default "globe" icon + * path: id:integer! Environment id (from GET /api/environments) + * resp-200: {success:boolean!, icon:string!} + * resp-200-example: {"success":true,"icon":"globe"} + * resp-403: Permission denied (RBAC 'environments:edit' missing) + * resp-404: Environment not found + */ export const DELETE: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('environments', 'edit')) { diff --git a/src/routes/api/environments/[id]/image-prune/+server.ts b/src/routes/api/environments/[id]/image-prune/+server.ts index b1a5e37..76b8b99 100644 --- a/src/routes/api/environments/[id]/image-prune/+server.ts +++ b/src/routes/api/environments/[id]/image-prune/+server.ts @@ -10,6 +10,15 @@ import { registerSchedule, unregisterSchedule, triggerImagePrune } from '$lib/se /** * Get image prune settings for an environment. + * + * @openapi + * summary: Get the automatic image-prune schedule settings for an environment + * path: id:integer! Environment id (from GET /api/environments) + * resp-200: {settings:{enabled:boolean!, cronExpression:string!, pruneMode:string!}!} + * resp-200-example: {"settings":{"enabled":false,"cronExpression":"0 3 * * 0","pruneMode":"dangling"}} + * resp-403: Permission denied (RBAC 'environments:view' missing) + * resp-404: Environment not found + * resp-500: Unexpected error while loading the settings */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -43,6 +52,16 @@ export const GET: RequestHandler = async ({ params, cookies }) => { /** * Save image prune settings for an environment. + * + * @openapi + * summary: Save the automatic image-prune schedule for an environment (registers/unregisters the croner job) + * path: id:integer! Environment id (from GET /api/environments) + * body: {enabled:boolean, cronExpression:string, pruneMode:string} + * body-example: {"enabled":true,"cronExpression":"0 3 * * 0","pruneMode":"dangling"} + * resp-200: {success:boolean!, settings:{enabled:boolean!, cronExpression:string!, pruneMode:string!}!} + * resp-403: Permission denied (RBAC 'environments:edit' missing) + * resp-404: Environment not found + * resp-500: Unexpected error while saving the settings */ export const POST: RequestHandler = async ({ params, request, cookies }) => { const auth = await authorize(cookies); @@ -91,6 +110,15 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { /** * Manually trigger image prune for an environment. + * + * @openapi + * summary: Immediately run an image prune for an environment (outside its schedule) + * path: id:integer! Environment id (from GET /api/environments) + * resp-200: {success:boolean!} + * resp-400: The prune operation itself failed (Docker error) + * resp-403: Permission denied (RBAC 'environments:edit' missing) + * resp-404: Environment not found + * resp-500: Unexpected error while triggering the prune */ export const PUT: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/environments/[id]/notifications/+server.ts b/src/routes/api/environments/[id]/notifications/+server.ts index b8664fc..418c2f1 100644 --- a/src/routes/api/environments/[id]/notifications/+server.ts +++ b/src/routes/api/environments/[id]/notifications/+server.ts @@ -10,6 +10,16 @@ import { authorize } from '$lib/server/authorize'; import type { RequestHandler } from './$types'; // GET /api/environments/[id]/notifications - List all notification configurations for an environment +/** + * @openapi + * summary: List notification channel configurations attached to an environment + * path: id:integer! Environment id (from GET /api/environments) + * resp-200: array<{id:integer!, notificationId:integer!, enabled:boolean!, eventTypes:array}> + * resp-400: Invalid environment id + * resp-403: Permission denied (RBAC 'notifications:view' missing) + * resp-404: Environment not found + * resp-500: Unexpected error while loading notifications + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('notifications', 'view')) { @@ -36,6 +46,20 @@ export const GET: RequestHandler = async ({ params, cookies }) => { }; // POST /api/environments/[id]/notifications - Add a notification channel to an environment +/** + * @openapi + * summary: Attach a notification channel to an environment + * description: notificationId from GET /api/notifications. + * path: id:integer! Environment id (from GET /api/environments) + * body: {notificationId:integer!, enabled:boolean, eventTypes:array} + * body-example: {"notificationId":1,"enabled":true,"eventTypes":["stack_deploy_failed"]} + * resp-200: {id:integer!, notificationId:integer!, enabled:boolean!} + * resp-400: Invalid environment id, or notificationId missing + * resp-403: Permission denied (RBAC 'notifications:edit' missing) + * resp-404: Environment not found + * resp-409: This notification channel is already configured for this environment + * resp-500: Unexpected error while creating the notification link + */ export const POST: RequestHandler = async ({ params, request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('notifications', 'edit')) { diff --git a/src/routes/api/environments/[id]/notifications/[notificationId]/+server.ts b/src/routes/api/environments/[id]/notifications/[notificationId]/+server.ts index 7397d36..56d6ed4 100644 --- a/src/routes/api/environments/[id]/notifications/[notificationId]/+server.ts +++ b/src/routes/api/environments/[id]/notifications/[notificationId]/+server.ts @@ -10,6 +10,17 @@ import { authorize } from '$lib/server/authorize'; import type { RequestHandler } from './$types'; // GET /api/environments/[id]/notifications/[notificationId] - Get a specific environment notification +/** + * @openapi + * summary: Get one notification-channel link for an environment + * path: id:integer! Environment id (from GET /api/environments) + * path: notificationId:integer! Notification channel id (from GET /api/notifications) + * resp-200: {id:integer!, notificationId:integer!, enabled:boolean!, eventTypes:array} + * resp-400: Invalid id or notificationId + * resp-403: Permission denied (RBAC 'notifications:view' missing) + * resp-404: Environment not found, or no such notification link for this environment + * resp-500: Unexpected error while loading the notification link + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('notifications', 'view')) { @@ -40,6 +51,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => { }; // PUT /api/environments/[id]/notifications/[notificationId] - Update an environment notification +/** + * @openapi + * summary: Update a notification-channel link (enabled state and/or event type filter) + * path: id:integer! Environment id (from GET /api/environments) + * path: notificationId:integer! Notification channel id (from GET /api/notifications) + * body: {enabled:boolean, eventTypes:array} + * body-example: {"enabled":false} + * resp-200: {id:integer!, notificationId:integer!, enabled:boolean!, eventTypes:array} + * resp-400: Invalid id or notificationId + * resp-403: Permission denied (RBAC 'notifications:edit' missing) + * resp-404: Environment not found, or no such notification link for this environment + * resp-500: Unexpected error while updating the notification link + */ export const PUT: RequestHandler = async ({ params, request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('notifications', 'edit')) { @@ -78,6 +102,17 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { }; // DELETE /api/environments/[id]/notifications/[notificationId] - Remove a notification from an environment +/** + * @openapi + * summary: Remove a notification-channel link from an environment + * path: id:integer! Environment id (from GET /api/environments) + * path: notificationId:integer! Notification channel id (from GET /api/notifications) + * resp-200: {success:boolean!} + * resp-400: Invalid id or notificationId + * resp-403: Permission denied (RBAC 'notifications:delete' missing) + * resp-404: No such notification link for this environment + * resp-500: Unexpected error while deleting the notification link + */ export const DELETE: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('notifications', 'delete')) { diff --git a/src/routes/api/environments/[id]/remote-stacks-dir/+server.ts b/src/routes/api/environments/[id]/remote-stacks-dir/+server.ts index d06729c..b43dcdf 100644 --- a/src/routes/api/environments/[id]/remote-stacks-dir/+server.ts +++ b/src/routes/api/environments/[id]/remote-stacks-dir/+server.ts @@ -9,6 +9,15 @@ import { getEnvironment, getEnvSetting, setEnvSetting } from '$lib/server/db'; // remote host under this dir before `docker compose up`. Empty/unset = current behavior. // Stored via the generic per-env settings store (no schema column). +/** + * @openapi + * summary: Get the per-environment remote stacks directory used to stage stack files onto a direct (agentless) daemon + * path: id:integer! Environment ID (from GET /api/environments) + * resp-200: {remoteStacksDir:string} + * resp-200-example: {"remoteStacksDir":"/mnt/dockhand/stacks"} + * resp-403: Permission denied (requires environments:view) + * resp-404: Environment not found + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !(await auth.can('environments', 'view'))) { @@ -23,6 +32,18 @@ export const GET: RequestHandler = async ({ params, cookies }) => { return json({ remoteStacksDir }); }; +/** + * @openapi + * summary: Set (or clear) the per-environment remote stacks directory for a direct (agentless) daemon + * path: id:integer! Environment ID (from GET /api/environments) + * body: {remoteStacksDir:string} + * body-example: {"remoteStacksDir":"/mnt/dockhand/stacks"} + * resp-200: {success:boolean!, remoteStacksDir:string} + * resp-200-example: {"success":true,"remoteStacksDir":"/mnt/dockhand/stacks"} + * resp-400: Invalid input — remoteStacksDir must be a string or null, or must be an absolute path with no ".." + * resp-403: Permission denied (requires environments:edit) + * resp-404: Environment not found + */ export const POST: RequestHandler = async ({ params, request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !(await auth.can('environments', 'edit'))) { diff --git a/src/routes/api/environments/[id]/test/+server.ts b/src/routes/api/environments/[id]/test/+server.ts index 47cd163..c49ad5a 100644 --- a/src/routes/api/environments/[id]/test/+server.ts +++ b/src/routes/api/environments/[id]/test/+server.ts @@ -5,6 +5,14 @@ import { getDockerInfo, getHawserInfo } from '$lib/server/docker'; import { edgeConnections, isEdgeConnected } from '$lib/server/hawser'; import { daemonIsPodman } from '$lib/server/scanner-socket-detect'; +/** + * @openapi + * summary: Test connectivity to a saved environment's Docker/Hawser endpoint + * path: id:integer! Environment id (from GET /api/environments) + * resp-200: {success:boolean!, info:{serverVersion:string, containers:integer, images:integer, name:string, engine:string}, isEdgeMode:boolean, hawser:{}} + * resp-200-desc: success:false with a human-readable error message is also returned as HTTP 200 (connection/agent-not-connected states are not transport errors) + * resp-404: Environment not found + */ export const POST: RequestHandler = async ({ params }) => { try { const id = parseInt(params.id); diff --git a/src/routes/api/environments/[id]/timezone/+server.ts b/src/routes/api/environments/[id]/timezone/+server.ts index f4c5d40..9687fb8 100644 --- a/src/routes/api/environments/[id]/timezone/+server.ts +++ b/src/routes/api/environments/[id]/timezone/+server.ts @@ -38,6 +38,15 @@ function normalizeTimezone(tz: string): string { /** * Get timezone for an environment. + * + * @openapi + * summary: Get the IANA timezone used for scheduling on an environment + * path: id:integer! Environment id (from GET /api/environments) + * resp-200: {timezone:string!} + * resp-200-example: {"timezone":"Europe/Berlin"} + * resp-403: Permission denied (RBAC 'environments:view' missing) + * resp-404: Environment not found + * resp-500: Unexpected error while loading the timezone */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -66,6 +75,17 @@ export const GET: RequestHandler = async ({ params, cookies }) => { /** * Set timezone for an environment. + * + * @openapi + * summary: Set the IANA timezone for an environment and refresh its schedules to use it + * path: id:integer! Environment id (from GET /api/environments) + * body: {timezone:string!} + * body-example: {"timezone":"Europe/Berlin"} + * resp-200: {success:boolean!, timezone:string!} + * resp-400: Not a recognized IANA timezone (Intl.supportedValuesOf('timeZone')) + * resp-403: Permission denied (RBAC 'environments:edit' missing) + * resp-404: Environment not found + * resp-500: Unexpected error while saving the timezone */ export const POST: RequestHandler = async ({ params, request, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/environments/detect-socket/+server.ts b/src/routes/api/environments/detect-socket/+server.ts index 373fdb4..5dcc24e 100644 --- a/src/routes/api/environments/detect-socket/+server.ts +++ b/src/routes/api/environments/detect-socket/+server.ts @@ -11,6 +11,11 @@ interface DetectedSocket { /** * Detect available Docker sockets on the system + * + * @openapi + * summary: Detect common Docker/Podman socket paths that exist on the Dockhand host + * resp-200: {sockets:array<{path:string!, name:string!, exists:boolean!}>!, homedir:string!} + * resp-200-example: {"sockets":[{"path":"/var/run/docker.sock","name":"Docker (default)","exists":true}],"homedir":"/home/dockhand"} */ export const GET: RequestHandler = async () => { const home = homedir(); diff --git a/src/routes/api/environments/test/+server.ts b/src/routes/api/environments/test/+server.ts index 3027680..2205122 100644 --- a/src/routes/api/environments/test/+server.ts +++ b/src/routes/api/environments/test/+server.ts @@ -41,6 +41,14 @@ function buildDockerClientConfig(config: TestConnectionRequest): DockerClientCon /** * Test Docker connection with provided configuration (without saving to database) + * + * @openapi + * summary: Test a Docker/Hawser connection configuration WITHOUT saving it as an environment + * body: {connectionType:string!, socketPath:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, hawserToken:string} + * body-example: {"connectionType":"socket","socketPath":"/var/run/docker.sock"} + * resp-200: {success:boolean!, info:{serverVersion:string, containers:integer, images:integer, name:string}, hawser:{}} + * resp-200-desc: success:false with a human-readable error message is also returned as HTTP 200 (connection failures are not transport errors) + * resp-400: Host is required for direct/hawser-standard connection types */ export const POST: RequestHandler = async ({ request }) => { try { diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts index 3854ae0..acab50d 100644 --- a/src/routes/api/events/+server.ts +++ b/src/routes/api/events/+server.ts @@ -3,6 +3,12 @@ import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker'; import { getEnvironment } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +/** + * @openapi + * summary: Stream live Docker events (container/image/volume/network) for an environment via SSE, with periodic heartbeats + * query: env:integer Environment id — without it, an "info" SSE message is sent and the stream ends (from GET /api/environments) + * resp-200: text/event-stream SSE stream ("connected", "heartbeat" every 5s, "docker" events with {type,action,actor,time,timeNano}, or an "error"/"info" event for edge environments, missing/unknown environment, or a lost Docker connection) + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/git/credentials/+server.ts b/src/routes/api/git/credentials/+server.ts index a427b26..8178266 100644 --- a/src/routes/api/git/credentials/+server.ts +++ b/src/routes/api/git/credentials/+server.ts @@ -8,6 +8,14 @@ import { import { authorize } from '$lib/server/authorize'; import { auditGitCredential } from '$lib/server/audit'; +/** + * @openapi + * summary: List all stored git credentials with secrets stripped (only hasPassword/hasSshKey flags returned) + * resp-200: array<{id:integer!, name:string!, authType:string!, username:string, hasPassword:boolean!, hasSshKey:boolean!, createdAt:string, updatedAt:string}> + * resp-200-example: [{"id":1,"name":"github-deploy","authType":"ssh","username":"git","hasPassword":false,"hasSshKey":true,"createdAt":"2026-06-01T10:00:00Z","updatedAt":"2026-06-01T10:00:00Z"}] + * resp-403: Caller lacks the git:view permission + * resp-500: Failed to read git credentials from the database + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('git', 'view')) { @@ -34,6 +42,16 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; +/** + * @openapi + * summary: Create a new git credential (none/password/ssh auth) and return it with secrets stripped + * body: {name:string!, authType:string, username:string, password:string, sshPrivateKey:string, sshPassphrase:string} + * body-example: {"name":"github-deploy","authType":"ssh","username":"git","sshPrivateKey":"***","sshPassphrase":"***"} + * resp-200: {id:integer!, name:string!, authType:string!, username:string, hasPassword:boolean!, hasSshKey:boolean!, createdAt:string, updatedAt:string} + * resp-400: Missing name, invalid authType, a missing secret for the chosen authType, or a duplicate credential name + * resp-403: Caller lacks the git:create permission + * resp-500: Failed to create the git credential + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/git/credentials/[id]/+server.ts b/src/routes/api/git/credentials/[id]/+server.ts index 44ba755..daf9324 100644 --- a/src/routes/api/git/credentials/[id]/+server.ts +++ b/src/routes/api/git/credentials/[id]/+server.ts @@ -10,6 +10,16 @@ import { authorize } from '$lib/server/authorize'; import { auditGitCredential } from '$lib/server/audit'; import { computeAuditDiff } from '$lib/utils/diff'; +/** + * @openapi + * summary: Get a single git credential by ID with secrets stripped (only hasPassword/hasSshKey flags returned) + * path: id:integer! Git credential ID (from GET /api/git/credentials) + * resp-200: {id:integer!, name:string!, authType:string!, username:string, hasPassword:boolean!, hasSshKey:boolean!, createdAt:string, updatedAt:string} + * resp-400: The id path segment is not a valid integer + * resp-403: Caller lacks the git:view permission + * resp-404: No credential exists with that ID + * resp-500: Failed to read the git credential + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('git', 'view')) { @@ -44,6 +54,18 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; +/** + * @openapi + * summary: Update a git credential and return it with secrets stripped + * path: id:integer! Git credential ID (from GET /api/git/credentials) + * body: {name:string, authType:string, username:string, password:string, sshPrivateKey:string, sshPassphrase:string} + * body-example: {"name":"github-deploy","authType":"password","username":"git","password":"***"} + * resp-200: {id:integer!, name:string!, authType:string!, username:string, hasPassword:boolean!, hasSshKey:boolean!, createdAt:string, updatedAt:string} + * resp-400: Invalid id, invalid authType, or a duplicate credential name + * resp-403: Caller lacks the git:edit permission + * resp-404: No credential exists with that ID + * resp-500: The update failed or the credential could not be persisted + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -109,6 +131,17 @@ export const PUT: RequestHandler = async (event) => { } }; +/** + * @openapi + * summary: Delete a git credential by ID + * path: id:integer! Git credential ID (from GET /api/git/credentials) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: The id path segment is not a valid integer + * resp-403: Caller lacks the git:delete permission + * resp-404: No credential exists with that ID + * resp-500: The deletion failed + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/git/preview-env/+server.ts b/src/routes/api/git/preview-env/+server.ts index ec776e2..30964db 100644 --- a/src/routes/api/git/preview-env/+server.ts +++ b/src/routes/api/git/preview-env/+server.ts @@ -26,6 +26,18 @@ import { authorize } from '$lib/server/authorize'; * error?: string * } */ +/** + * @openapi + * summary: Clone a repo to a temp dir and preview its merged env-file variables for the git-stack env editor + * description: repositoryId from GET /api/git/repositories. credentialId from GET /api/git/credentials. + * body: {repositoryId:integer, url:string, branch:string, credentialId:integer, composePath:string!, envFilePath:string} + * body-example: {"repositoryId":3,"composePath":"docker-compose.yml","envFilePath":".env.prod"} + * resp-200: {vars:object!, sources:object!} + * resp-400: composePath missing, neither repositoryId nor url supplied, or the repo/env-file preview reported an error + * resp-401: Authentication required + * resp-404: The referenced repository does not exist + * resp-500: Failed to preview the env files (clone or read error) + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/git/repositories/+server.ts b/src/routes/api/git/repositories/+server.ts index 3644517..7e06f6b 100644 --- a/src/routes/api/git/repositories/+server.ts +++ b/src/routes/api/git/repositories/+server.ts @@ -8,6 +8,14 @@ import { import { authorize } from '$lib/server/authorize'; import { auditGitRepository } from '$lib/server/audit'; +/** + * @openapi + * summary: List all git repositories (repositories are global, not scoped to an environment) + * resp-200: array<{id:integer!, name:string!, url:string!, branch:string!, credentialId:integer}> + * resp-200-example: [{"id":1,"name":"homelab","url":"https://github.com/example/homelab.git","branch":"main","credentialId":2}] + * resp-403: Caller lacks the git:view permission + * resp-500: Failed to read git repositories + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('git', 'view')) { @@ -25,6 +33,17 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; +/** + * @openapi + * summary: Create a git repository (branch defaults to main); deployment config lives on git stacks, not here + * description: credentialId from GET /api/git/credentials. + * body: {name:string!, url:string!, branch:string, credentialId:integer} + * body-example: {"name":"homelab","url":"https://github.com/example/homelab.git","branch":"main","credentialId":2} + * resp-200: {id:integer!, name:string!, url:string!, branch:string!, credentialId:integer} + * resp-400: Missing name/url, an invalid credentialId, or a duplicate repository name + * resp-403: Caller lacks the git:create permission + * resp-500: Failed to create the git repository + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/git/repositories/[id]/+server.ts b/src/routes/api/git/repositories/[id]/+server.ts index 4c246e2..cb79674 100644 --- a/src/routes/api/git/repositories/[id]/+server.ts +++ b/src/routes/api/git/repositories/[id]/+server.ts @@ -12,6 +12,16 @@ import { authorize } from '$lib/server/authorize'; import { auditGitRepository } from '$lib/server/audit'; import { computeAuditDiff } from '$lib/utils/diff'; +/** + * @openapi + * summary: Get a single git repository by ID + * path: id:integer! Git repository ID (from GET /api/git/repositories) + * resp-200: {id:integer!, name:string!, url:string!, branch:string!, credentialId:integer} + * resp-400: The id path segment is not a valid integer + * resp-403: Caller lacks the git:view permission + * resp-404: No repository exists with that ID + * resp-500: Failed to read the git repository + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('git', 'view')) { @@ -36,6 +46,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; +/** + * @openapi + * summary: Update a git repository's basic fields (name/url/branch/credential) + * description: credentialId from GET /api/git/credentials. + * path: id:integer! Git repository ID (from GET /api/git/repositories) + * body: {name:string, url:string, branch:string, credentialId:integer} + * body-example: {"name":"homelab","url":"https://github.com/example/homelab.git","branch":"production","credentialId":2} + * resp-200: {id:integer!, name:string!, url:string!, branch:string!, credentialId:integer} + * resp-400: Invalid id, an invalid credentialId, or a duplicate repository name + * resp-403: Caller lacks the git:edit permission + * resp-404: No repository exists with that ID + * resp-500: The update failed or the repository could not be persisted + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -94,6 +117,17 @@ export const PUT: RequestHandler = async (event) => { } }; +/** + * @openapi + * summary: Delete a git repository, first removing the clone directories of every git stack it backs + * path: id:integer! Git repository ID (from GET /api/git/repositories) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: The id path segment is not a valid integer + * resp-403: Caller lacks the git:delete permission + * resp-404: No repository exists with that ID + * resp-500: The deletion failed + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/git/repositories/[id]/deploy/+server.ts b/src/routes/api/git/repositories/[id]/deploy/+server.ts index 249bc0b..e72c260 100644 --- a/src/routes/api/git/repositories/[id]/deploy/+server.ts +++ b/src/routes/api/git/repositories/[id]/deploy/+server.ts @@ -3,6 +3,16 @@ import type { RequestHandler } from './$types'; import { getGitRepository } from '$lib/server/db'; import { deployFromRepository } from '$lib/server/git'; +/** + * @openapi + * summary: Deploy the compose stack(s) defined in a git repository (clones/pulls, then runs docker compose) + * path: id:integer! Git repository ID (from GET /api/git/repositories) + * resp-200: {success:boolean!, error:string} + * resp-200-example: {"success":true} + * resp-400: The id path segment is not a valid integer + * resp-404: No repository exists with that ID + * resp-500: The deployment failed + */ export const POST: RequestHandler = async ({ params }) => { try { const id = parseInt(params.id); diff --git a/src/routes/api/git/repositories/[id]/sync/+server.ts b/src/routes/api/git/repositories/[id]/sync/+server.ts index a5367e0..5484a5a 100644 --- a/src/routes/api/git/repositories/[id]/sync/+server.ts +++ b/src/routes/api/git/repositories/[id]/sync/+server.ts @@ -3,6 +3,16 @@ import type { RequestHandler } from './$types'; import { getGitRepository } from '$lib/server/db'; import { syncRepository, checkForUpdates } from '$lib/server/git'; +/** + * @openapi + * summary: Sync (git pull) the local clone of a repository to the latest commit on its tracked branch + * path: id:integer! Git repository ID (from GET /api/git/repositories) + * resp-200: {success:boolean!, error:string} + * resp-200-example: {"success":true} + * resp-400: The id path segment is not a valid integer + * resp-404: No repository exists with that ID + * resp-500: The sync failed + */ export const POST: RequestHandler = async ({ params }) => { try { const id = parseInt(params.id); @@ -23,6 +33,16 @@ export const POST: RequestHandler = async ({ params }) => { } }; +/** + * @openapi + * summary: Check whether the tracked branch has new commits upstream without pulling them + * path: id:integer! Git repository ID (from GET /api/git/repositories) + * resp-200: {hasUpdates:boolean!, error:string} + * resp-200-example: {"hasUpdates":false} + * resp-400: The id path segment is not a valid integer + * resp-404: No repository exists with that ID + * resp-500: The update check failed + */ export const GET: RequestHandler = async ({ params }) => { // Check for updates without syncing try { diff --git a/src/routes/api/git/repositories/[id]/test/+server.ts b/src/routes/api/git/repositories/[id]/test/+server.ts index 9d1f91c..a40ecb8 100644 --- a/src/routes/api/git/repositories/[id]/test/+server.ts +++ b/src/routes/api/git/repositories/[id]/test/+server.ts @@ -3,6 +3,16 @@ import type { RequestHandler } from './$types'; import { getGitRepository } from '$lib/server/db'; import { testRepository } from '$lib/server/git'; +/** + * @openapi + * summary: Test connectivity/authentication to a saved repository using its stored credential + * path: id:integer! Git repository ID (from GET /api/git/repositories) + * resp-200: {success:boolean!, error:string} + * resp-200-example: {"success":true} + * resp-400: The id path segment is not a valid integer + * resp-404: No repository exists with that ID + * resp-500: The connectivity test failed + */ export const POST: RequestHandler = async ({ params }) => { try { const id = parseInt(params.id); diff --git a/src/routes/api/git/repositories/test/+server.ts b/src/routes/api/git/repositories/test/+server.ts index 2d09abd..957fb42 100644 --- a/src/routes/api/git/repositories/test/+server.ts +++ b/src/routes/api/git/repositories/test/+server.ts @@ -14,6 +14,18 @@ import { authorize } from '$lib/server/authorize'; * credentialId?: number; // Optional credential ID from database * } */ +/** + * @openapi + * summary: Test an unsaved repository configuration (url/branch/credentialId) before creating it + * description: credentialId from GET /api/git/credentials. + * body: {url:string!, branch:string, credentialId:integer} + * body-example: {"url":"https://github.com/example/homelab.git","branch":"main","credentialId":2} + * resp-200: {success:boolean!, error:string} + * resp-200-example: {"success":true} + * resp-400: The url field is missing + * resp-403: Caller lacks the settings:manage permission + * resp-500: The connectivity test failed + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('settings', 'manage')) { diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts index db2a598..68215fb 100644 --- a/src/routes/api/git/stacks/+server.ts +++ b/src/routes/api/git/stacks/+server.ts @@ -20,6 +20,13 @@ import { createJobResponse } from '$lib/server/sse'; // letter or number, and contain only lowercase letters, numbers, hyphens, underscores const STACK_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/; +/** + * @openapi + * summary: List git-deployed stacks (optionally scoped to one environment) + * query: env:integer Filter to a single environment id + * resp-403: Permission denied (needs stacks:view) + * resp-500: Failed to list git stacks + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -41,6 +48,15 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; +/** + * @openapi + * summary: Create a git-deployed stack (from an existing repo or new repo url/branch) + * body: {stackName:string!, environmentId:integer, repositoryId:integer, secretProviderId:integer, webhookEnabled:boolean, webhookSecret:string} + * resp-400: Invalid stack name, or secretProviderId is not a number/null + * resp-403: Permission denied (needs stacks:create; binding a secret provider also needs secrets:view) + * resp-409: A git stack with this name already exists in the environment + * resp-500: Failed to create the git stack + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/git/stacks/[id]/+server.ts b/src/routes/api/git/stacks/[id]/+server.ts index 58a510a..b3f7c1f 100644 --- a/src/routes/api/git/stacks/[id]/+server.ts +++ b/src/routes/api/git/stacks/[id]/+server.ts @@ -11,6 +11,14 @@ import { createJobResponse } from '$lib/server/sse'; // Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; +/** + * @openapi + * summary: Get one git stack + * path: id:integer The git stack id + * resp-403: Permission denied (needs stacks:view) + * resp-404: Git stack not found + * resp-500: Failed to load the git stack + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -33,6 +41,16 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; +/** + * @openapi + * summary: Update a git stack (rename, schedule, webhook, secret-provider binding) + * path: id:integer The git stack id + * body: {stackName:string, secretProviderId:integer, webhookEnabled:boolean, webhookSecret:string} + * resp-400: Invalid stack name, or secretProviderId is not a number/null + * resp-403: Permission denied (needs stacks:edit; binding a secret provider also needs secrets:view) + * resp-404: Git stack not found + * resp-500: Failed to update the git stack + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -204,6 +222,14 @@ export const PUT: RequestHandler = async (event) => { } }; +/** + * @openapi + * summary: Delete a git stack (removes its deploy files and stack source) + * path: id:integer The git stack id + * resp-403: Permission denied (needs stacks:delete) + * resp-404: Git stack not found + * resp-500: Failed to delete the git stack + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/git/stacks/[id]/deploy-stream/+server.ts b/src/routes/api/git/stacks/[id]/deploy-stream/+server.ts index 55eb6e5..cb7480c 100644 --- a/src/routes/api/git/stacks/[id]/deploy-stream/+server.ts +++ b/src/routes/api/git/stacks/[id]/deploy-stream/+server.ts @@ -6,6 +6,17 @@ import { authorize } from '$lib/server/authorize'; import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs'; import { prefersJSON, sseToJSON } from '$lib/server/sse'; +/** + * @openapi + * summary: Deploy a git stack with live progress; streams SSE, or returns a jobId to poll (Accept negotiated) + * description: Clients sending `Accept: application/json` get a synchronous, buffered SSE-as-JSON result; otherwise a jobId is returned immediately and progress is delivered out-of-band. + * path: id:integer! Git stack ID (from GET /api/git/stacks) + * resp-200: {jobId:string} + * resp-200-desc: Deployment started — either a jobId to poll or a streamed SSE deploy log + * resp-200-example: {"jobId":"a1b2c3d4"} + * resp-403: Caller lacks the stacks:start permission for the stack's environment + * resp-404: No git stack exists with that ID + */ export const POST: RequestHandler = async ({ params, cookies, request }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/git/stacks/[id]/deploy/+server.ts b/src/routes/api/git/stacks/[id]/deploy/+server.ts index 1cbbd50..0141e1a 100644 --- a/src/routes/api/git/stacks/[id]/deploy/+server.ts +++ b/src/routes/api/git/stacks/[id]/deploy/+server.ts @@ -6,6 +6,16 @@ import { authorize } from '$lib/server/authorize'; import { auditGitStack } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; +/** + * @openapi + * summary: Deploy a git stack, streaming the deploy log as SSE result events via the job-response channel + * path: id:integer! Git stack ID (from GET /api/git/stacks) + * resp-200: {} + * resp-200-desc: An SSE stream whose final `result` event carries {success, error} + * resp-403: Caller lacks the stacks:start permission for the stack's environment + * resp-404: No git stack exists with that ID + * resp-500: Failed to start the deployment + */ export const POST: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/git/stacks/[id]/env-files/+server.ts b/src/routes/api/git/stacks/[id]/env-files/+server.ts index cc23e05..f6a33b5 100644 --- a/src/routes/api/git/stacks/[id]/env-files/+server.ts +++ b/src/routes/api/git/stacks/[id]/env-files/+server.ts @@ -9,6 +9,18 @@ import { authorize } from '$lib/server/authorize'; * List all .env files in the git stack's repository. * Returns: { files: string[] } */ +/** + * @openapi + * summary: List the .env files present in a git stack's cloned repository + * description: Lists the `.env*` files in the git stack's synced repository checkout (read-only). Use this to discover which file to configure as the stack's `envFilePath`. This endpoint does not write — to choose the env file, PUT /api/git/stacks/{id} with `envFilePath`; to set env values, use the stack env endpoints. + * path: id:integer! Git stack ID (from GET /api/git/stacks) + * resp-200: {files:array!} + * resp-200-example: {"files":[".env",".env.prod"]} + * resp-400: The repository could not be read (e.g. not yet cloned) + * resp-403: Caller lacks the stacks:view permission for the stack's environment + * resp-404: No git stack exists with that ID + * resp-500: Failed to list the env files + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -42,6 +54,20 @@ export const GET: RequestHandler = async ({ params, cookies }) => { * Body: { path: string } * Returns: { vars: Record } */ +/** + * @openapi + * summary: Read and parse one .env file from a git stack's repository into a key/value map + * description: Reads and parses a single `.env` file (by `path`) from the git stack's synced repository checkout into a key/value map (read-only). There is no write counterpart — git-stack env is set via `envFilePath` (PUT /api/git/stacks/{id}) or the stack env endpoints. + * path: id:integer! Git stack ID (from GET /api/git/stacks) + * body: {path:string!} + * body-example: {"path":".env.prod"} + * resp-200: {vars:object!} + * resp-200-example: {"vars":{"TZ":"Europe/Berlin"}} + * resp-400: The path field is missing or the env file could not be read + * resp-403: Caller lacks the stacks:view permission for the stack's environment + * resp-404: No git stack exists with that ID + * resp-500: Failed to read the env file + */ export const POST: RequestHandler = async ({ params, cookies, request }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/git/stacks/[id]/sync/+server.ts b/src/routes/api/git/stacks/[id]/sync/+server.ts index 59d237c..336db46 100644 --- a/src/routes/api/git/stacks/[id]/sync/+server.ts +++ b/src/routes/api/git/stacks/[id]/sync/+server.ts @@ -4,6 +4,16 @@ import { getGitStack } from '$lib/server/db'; import { syncGitStack } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; +/** + * @openapi + * summary: Sync (git pull) a git stack's repository clone to the latest tracked commit + * path: id:integer! Git stack ID (from GET /api/git/stacks) + * resp-200: {success:boolean!, error:string} + * resp-200-example: {"success":true} + * resp-403: Caller lacks the stacks:edit permission for the stack's environment + * resp-404: No git stack exists with that ID + * resp-500: The sync failed + */ export const POST: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/git/stacks/[id]/test/+server.ts b/src/routes/api/git/stacks/[id]/test/+server.ts index 4cd46c2..5f6db0f 100644 --- a/src/routes/api/git/stacks/[id]/test/+server.ts +++ b/src/routes/api/git/stacks/[id]/test/+server.ts @@ -4,6 +4,16 @@ import { getGitStack } from '$lib/server/db'; import { testGitStack } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; +/** + * @openapi + * summary: Test a git stack's repository access and compose configuration without deploying + * path: id:integer! Git stack ID (from GET /api/git/stacks) + * resp-200: {success:boolean!, error:string} + * resp-200-example: {"success":true} + * resp-403: Caller lacks the stacks:view permission for the stack's environment + * resp-404: No git stack exists with that ID + * resp-500: The test failed + */ export const POST: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/git/stacks/[id]/webhook/+server.ts b/src/routes/api/git/stacks/[id]/webhook/+server.ts index 82b241a..84e6c10 100644 --- a/src/routes/api/git/stacks/[id]/webhook/+server.ts +++ b/src/routes/api/git/stacks/[id]/webhook/+server.ts @@ -11,6 +11,19 @@ function detectSource(request: Request): string { return 'unknown'; } +/** + * @openapi + * summary: Webhook trigger (GitHub/GitLab) that deploys a git stack when its signature/token verifies + * description: Public endpoint authenticated by the stack's webhook secret via `X-Hub-Signature-256` (GitHub) or `X-Gitlab-Token` (GitLab); the raw request body is used for HMAC verification. + * path: id:integer! Git stack ID (from GET /api/git/stacks) + * resp-200: {success:boolean, skipped:boolean, error:string} + * resp-200-example: {"success":true,"skipped":false} + * resp-400: The id path segment is not a valid integer + * resp-401: The webhook signature or token did not verify + * resp-403: Webhooks are not enabled for this stack + * resp-404: No git stack exists with that ID + * resp-500: The deployment triggered by the webhook failed + */ export const POST: RequestHandler = async (event) => { const { params, request } = event; try { @@ -64,6 +77,19 @@ export const POST: RequestHandler = async (event) => { }; // Also support GET for simple polling/manual triggers +/** + * @openapi + * summary: GET webhook trigger for a git stack, with the secret passed as the `secret` query parameter + * path: id:integer! Git stack ID (from GET /api/git/stacks) + * query: secret:string Webhook secret; required only if the stack has a webhook secret configured + * resp-200: {success:boolean, skipped:boolean, error:string} + * resp-200-example: {"success":true,"skipped":false} + * resp-400: The id path segment is not a valid integer + * resp-401: The provided secret did not match the stack's webhook secret + * resp-403: Webhooks are not enabled for this stack + * resp-404: No git stack exists with that ID + * resp-500: The deployment triggered by the webhook failed + */ export const GET: RequestHandler = async (event) => { const { params, url } = event; try { diff --git a/src/routes/api/git/webhook/[id]/+server.ts b/src/routes/api/git/webhook/[id]/+server.ts index 61d36d3..d98d831 100644 --- a/src/routes/api/git/webhook/[id]/+server.ts +++ b/src/routes/api/git/webhook/[id]/+server.ts @@ -11,6 +11,19 @@ function detectSource(request: Request): string { return 'unknown'; } +/** + * @openapi + * summary: Webhook trigger (GitHub/GitLab) that deploys from a git repository when its signature/token verifies + * description: Public endpoint authenticated by the repository's webhook secret via `X-Hub-Signature-256` (GitHub) or `X-Gitlab-Token` (GitLab); the raw request body is used for HMAC verification. + * path: id:integer! Git repository ID (from GET /api/git/repositories) + * resp-200: {success:boolean, error:string} + * resp-200-example: {"success":true} + * resp-400: The id path segment is not a valid integer + * resp-401: The webhook signature or token did not verify + * resp-403: Webhooks are not enabled for this repository + * resp-404: No repository exists with that ID + * resp-500: The deployment triggered by the webhook failed + */ export const POST: RequestHandler = async (event) => { const { params, request } = event; try { @@ -70,6 +83,19 @@ export const POST: RequestHandler = async (event) => { }; // Also support GET for simple polling/manual triggers +/** + * @openapi + * summary: GET webhook trigger for a git repository, with the secret passed as the `secret` query parameter + * path: id:integer! Git repository ID (from GET /api/git/repositories) + * query: secret:string Webhook secret; required only if the repository has a webhook secret configured + * resp-200: {success:boolean, error:string} + * resp-200-example: {"success":true} + * resp-400: The id path segment is not a valid integer + * resp-401: The provided secret did not match the repository's webhook secret + * resp-403: Webhooks are not enabled for this repository + * resp-404: No repository exists with that ID + * resp-500: The deployment triggered by the webhook failed + */ export const GET: RequestHandler = async (event) => { const { params, url } = event; try { diff --git a/src/routes/api/hawser/connect/+server.ts b/src/routes/api/hawser/connect/+server.ts index 57d368d..5484830 100644 --- a/src/routes/api/hawser/connect/+server.ts +++ b/src/routes/api/hawser/connect/+server.ts @@ -17,6 +17,12 @@ import { isEdgeConnected, getAllEdgeConnections } from '$lib/server/hawser'; * Returns status of the Hawser Edge connection endpoint * This is used for health checks and debugging */ +/** + * @openapi + * summary: Report the Hawser Edge WebSocket endpoint status and list currently connected agents + * resp-200: {status:string!, message:string!, protocol:string!, activeConnections:integer!, connections:array<{environmentId:integer!, agentId:string, agentName:string, agentVersion:string, dockerVersion:string, hostname:string, capabilities:array, connectedAt:string, lastHeartbeat:string}>!} + * resp-200-example: {"status":"ready","message":"Hawser Edge WebSocket endpoint. Connect via WebSocket.","protocol":"wss:///api/hawser/connect","activeConnections":0,"connections":[]} + */ export const GET: RequestHandler = async () => { const connections = getAllEdgeConnections(); const connectionList = Array.from(connections.entries()).map(([envId, conn]) => ({ @@ -45,6 +51,12 @@ export const GET: RequestHandler = async () => { * This is a fallback for non-WebSocket clients. * Returns instructions for connecting via WebSocket. */ +/** + * @openapi + * summary: Fallback for non-WebSocket clients; always advises upgrading to a WebSocket connection + * resp-426: This endpoint requires a WebSocket upgrade (ws:// or wss://) + * resp-426-example: {"error":"WebSocket required","message":"This endpoint requires a WebSocket connection. Use the ws:// or wss:// protocol.","instructions":["1. Generate a token in Settings","2. Configure the Hawser agent","3. The agent connects automatically"]} + */ export const POST: RequestHandler = async () => { return json( { diff --git a/src/routes/api/hawser/tokens/+server.ts b/src/routes/api/hawser/tokens/+server.ts index 675b978..d8977cb 100644 --- a/src/routes/api/hawser/tokens/+server.ts +++ b/src/routes/api/hawser/tokens/+server.ts @@ -14,6 +14,15 @@ import { generateHawserToken, revokeHawserToken } from '$lib/server/hawser'; * GET /api/hawser/tokens * List all Hawser tokens (without revealing full token values) */ +/** + * @openapi + * summary: List all Hawser agent tokens (only the prefix is returned, never the full token) + * resp-200: array<{id:integer!, tokenPrefix:string!, name:string!, environmentId:integer!, isActive:boolean!, lastUsed:string, createdAt:string!, expiresAt:string}> + * resp-200-example: [{"id":1,"tokenPrefix":"hw_abc123","name":"edge-01","environmentId":1,"isActive":true,"lastUsed":null,"createdAt":"2026-06-01T10:00:00Z","expiresAt":null}] + * resp-401: Not authenticated + * resp-403: Admin access required + * resp-500: Failed to read the tokens + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -54,6 +63,20 @@ export const GET: RequestHandler = async ({ cookies }) => { * Body: { name: string, environmentId: number, expiresAt?: string } * Returns: { token: string, tokenId: number } - token is only shown ONCE */ +/** + * @openapi + * summary: Generate a new Hawser agent token for an environment (the plaintext token is returned only once) + * description: environmentId from GET /api/environments. + * body: {name:string!, environmentId:integer!, expiresAt:string, rawToken:string} + * body-example: {"name":"edge-01","environmentId":1,"expiresAt":"2027-01-01T00:00:00Z"} + * resp-200: {token:string!, tokenId:integer!, message:string!} + * resp-200-desc: Token generated — the plaintext token is shown only once and cannot be retrieved again + * resp-200-example: {"token":"***","tokenId":42,"message":"Token generated successfully. Save this token - it will not be shown again."} + * resp-400: The name or environmentId field is missing or of the wrong type + * resp-401: Not authenticated + * resp-403: Admin access required + * resp-500: Failed to generate the token + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); @@ -96,6 +119,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => { * * Query: ?id= */ +/** + * @openapi + * summary: Revoke a Hawser agent token by ID + * query: id:integer! ID of the token to revoke (from GET /api/hawser/tokens) + * resp-200: {success:boolean!, message:string!} + * resp-200-example: {"success":true,"message":"Token revoked"} + * resp-400: The id query parameter is missing + * resp-401: Not authenticated + * resp-403: Admin access required + * resp-500: Failed to revoke the token + */ export const DELETE: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts index 2e53030..43b5d21 100644 --- a/src/routes/api/health/+server.ts +++ b/src/routes/api/health/+server.ts @@ -1,6 +1,12 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Liveness probe — always returns 200 when the SvelteKit process is up + * resp-200: {status:string!, timestamp:string!} + * resp-200-example: {"status":"ok","timestamp":"2027-01-01T12:00:00.000Z"} + */ export const GET: RequestHandler = async () => { return json({ status: 'ok', timestamp: new Date().toISOString() }); }; diff --git a/src/routes/api/health/database/+server.ts b/src/routes/api/health/database/+server.ts index b3c7b5d..cc46c6d 100644 --- a/src/routes/api/health/database/+server.ts +++ b/src/routes/api/health/database/+server.ts @@ -17,6 +17,18 @@ import type { RequestHandler } from './$types'; import { checkSchemaHealth } from '$lib/server/db/drizzle'; import { authorize } from '$lib/server/authorize'; +/** + * GET /api/health/database - Database schema health check + * + * @openapi + * summary: Public database health check reporting schema/migration/table status; authenticated settings:view callers also get connection details + * description: Returns HTTP 200 when healthy and 503 when unhealthy (schema drift or table loss); 500 on an unexpected error. Connection details and the migration tag are only included for authenticated callers with settings:view. + * resp-200: {healthy:boolean!, database:string!, migrationsTable:boolean!, appliedMigrations:integer!, pendingMigrations:integer!, tables:integer!, timestamp:string!} + * resp-200-desc: Schema health; an unhealthy database returns the same shape with HTTP 503 + * resp-200-example: {"healthy":true,"database":"sqlite","migrationsTable":true,"appliedMigrations":42,"pendingMigrations":0,"tables":25,"timestamp":"2026-07-01T10:00:00.000Z"} + * resp-500: Unexpected error while checking database health + * resp-500-example: {"healthy":false,"error":"connection refused","timestamp":"2026-07-01T10:00:00.000Z"} + */ export const GET: RequestHandler = async ({ cookies }) => { try { const health = await checkSchemaHealth(); diff --git a/src/routes/api/host/+server.ts b/src/routes/api/host/+server.ts index 6ba3eb1..721e0ed 100644 --- a/src/routes/api/host/+server.ts +++ b/src/routes/api/host/+server.ts @@ -45,6 +45,17 @@ function getLocalIpAddress(): string { return '127.0.0.1'; } +/** + * GET /api/host - Host and environment info for the Docker daemon + * + * @openapi + * summary: Return host info (hostname, IP, CPU, memory, uptime, Docker counts) for the daemon behind an environment + * query: env:integer ID of the environment to describe (basic local info is returned when omitted) (from GET /api/environments) + * resp-200: {hostname:string!, ipAddress:string!, platform:string!, arch:string!, cpus:integer!, totalMemory:integer!, freeMemory:integer!, uptime:integer!, dockerVersion:string, dockerContainers:integer!, dockerContainersRunning:integer!, dockerImages:integer!, environment:object} + * resp-200-example: {"hostname":"docker-host","ipAddress":"192.168.1.10","platform":"linux","arch":"x64","cpus":8,"totalMemory":16777216000,"freeMemory":8388608000,"uptime":123456,"dockerVersion":"27.0.3","dockerContainers":10,"dockerContainersRunning":8,"dockerImages":25,"environment":{"id":1,"name":"local","connectionType":"socket"}} + * resp-403: Permission denied, or (enterprise) no access to this environment + * resp-500: Failed to get host info + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/images/+server.ts b/src/routes/api/images/+server.ts index 5198a13..3623e44 100644 --- a/src/routes/api/images/+server.ts +++ b/src/routes/api/images/+server.ts @@ -4,6 +4,18 @@ import { authorize } from '$lib/server/authorize'; import { hasEnvironments } from '$lib/server/db'; import type { RequestHandler } from './$types'; +/** + * GET /api/images - List Docker images for an environment + * + * @openapi + * summary: List the Docker images of an environment (returns an empty array when no env is given or Docker is unreachable) + * query: env:integer ID of the environment whose images to list (from GET /api/environments) + * resp-200: array<{Id:string!, RepoTags:array, Size:integer, Created:integer}> + * resp-200-desc: Array of images (empty if no env is specified or the Docker connection fails) + * resp-200-example: [{"Id":"sha256:abc123","RepoTags":["nginx:latest"],"Size":142000000,"Created":1719830400}] + * resp-403: Permission denied, or (enterprise) no access to this environment + * resp-404: Environment not found + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/images/[id]/+server.ts b/src/routes/api/images/[id]/+server.ts index 573e5f4..4c4f258 100644 --- a/src/routes/api/images/[id]/+server.ts +++ b/src/routes/api/images/[id]/+server.ts @@ -5,6 +5,20 @@ import { auditImage } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * DELETE /api/images/{id} - Remove a Docker image + * + * @openapi + * summary: Remove a Docker image by ID or name (optionally forced), scoped to an environment + * path: id:string! Image ID or name to remove (from GET /api/images) + * query: env:integer ID of the environment the image belongs to (from GET /api/environments) + * query: force:boolean Force removal even if the image is tagged or referenced (default false) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-403: Permission denied, or (enterprise) no access to this environment + * resp-409: Image is in use by a running container or has dependent child images + * resp-500: Failed to remove image + */ export const DELETE: RequestHandler = async (event) => { const { params, url, cookies } = event; const invalid = validateDockerIdParam(params.id, 'image'); diff --git a/src/routes/api/images/[id]/export/+server.ts b/src/routes/api/images/[id]/export/+server.ts index 890bfed..890a880 100644 --- a/src/routes/api/images/[id]/export/+server.ts +++ b/src/routes/api/images/[id]/export/+server.ts @@ -6,6 +6,18 @@ import { Readable } from 'stream'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * GET /api/images/{id}/export - Download an image as a tar (optionally gzipped) + * + * @openapi + * summary: Export a Docker image as a downloadable tar (or tar.gz) stream + * path: id:string! Image ID or name to export (from GET /api/images) + * query: env:integer ID of the environment the image belongs to (from GET /api/environments) + * query: compress:boolean Gzip the tar stream and serve it as .tar.gz (default false) + * resp-200: The image tar (application/x-tar) or gzipped tar (application/gzip) as an attachment + * resp-403: Permission denied + * resp-500: Docker returned no response body, or the export failed + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'image'); if (invalid) return invalid; diff --git a/src/routes/api/images/[id]/history/+server.ts b/src/routes/api/images/[id]/history/+server.ts index 5112cb1..4f528f0 100644 --- a/src/routes/api/images/[id]/history/+server.ts +++ b/src/routes/api/images/[id]/history/+server.ts @@ -4,6 +4,18 @@ import { getImageHistory } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * GET /api/images/{id}/history - Layer history of an image + * + * @openapi + * summary: Return the layer build history of a Docker image + * path: id:string! Image ID or name whose history to return (from GET /api/images) + * query: env:integer ID of the environment the image belongs to (from GET /api/environments) + * resp-200: array<{Id:string, Created:integer, CreatedBy:string, Size:integer, Comment:string}> + * resp-200-example: [{"Id":"sha256:abc123","Created":1719830400,"CreatedBy":"/bin/sh -c #(nop) CMD","Size":0,"Comment":""}] + * resp-403: Permission denied + * resp-500: Failed to get image history + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'image'); if (invalid) return invalid; diff --git a/src/routes/api/images/[id]/tag/+server.ts b/src/routes/api/images/[id]/tag/+server.ts index 484ef04..0a1f532 100644 --- a/src/routes/api/images/[id]/tag/+server.ts +++ b/src/routes/api/images/[id]/tag/+server.ts @@ -4,6 +4,21 @@ import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; import type { RequestHandler } from './$types'; +/** + * POST /api/images/{id}/tag - Add a repository tag to an image + * + * @openapi + * summary: Tag a Docker image into a repository (defaults the tag to "latest") + * path: id:string! Image ID or name to tag (from GET /api/images) + * query: env:integer ID of the environment the image belongs to (from GET /api/environments) + * body: {repo:string!, tag:string} + * body-example: {"repo":"registry.example.com/myapp","tag":"v1.2.3"} + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: Repository name is missing or not a string + * resp-403: Permission denied + * resp-500: Failed to tag image + */ export const POST: RequestHandler = async ({ params, request, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'image'); if (invalid) return invalid; diff --git a/src/routes/api/images/load/+server.ts b/src/routes/api/images/load/+server.ts index 5ef249d..b8255b0 100644 --- a/src/routes/api/images/load/+server.ts +++ b/src/routes/api/images/load/+server.ts @@ -17,6 +17,16 @@ import type { RequestHandler } from './$types'; * to the daemon while the request is still open, then return the result. * * Local/socket and direct TCP only (loadImage rejects Hawser). + * + * @openapi + * summary: Load a Docker image from an uploaded tar (docker load) for air-gapped hosts + * description: The request body is the raw image tar (Content-Type application/x-tar), streamed straight to the daemon without buffering. Local/socket or direct TCP only; Hawser is rejected. + * query: env:integer Target environment id + * resp-200: {success:boolean!, loaded:string} + * resp-200-desc: loaded echoes the daemon's final line, e.g. "Loaded image: alpine:3.20" + * resp-400: Request body (an image tar) is required + * resp-403: Permission denied (needs images:load), or access denied to this environment + * resp-500: The daemon rejected the tar or the load failed */ export const POST: RequestHandler = async (event) => { const { request, url, cookies } = event; diff --git a/src/routes/api/images/pull/+server.ts b/src/routes/api/images/pull/+server.ts index 4c95c5c..5a12558 100644 --- a/src/routes/api/images/pull/+server.ts +++ b/src/routes/api/images/pull/+server.ts @@ -46,6 +46,17 @@ function buildPullUrl(imageName: string): string { : `/images/create?fromImage=${encodeURIComponent(fromImage)}`; } +/** + * POST /api/images/pull - Pull an image, streaming progress (SSE) + * + * @openapi + * summary: Pull a Docker image and stream pull (and optional scan-on-pull) progress as Server-Sent Events + * query: env:integer ID of the environment to pull into (from GET /api/environments) + * body: {image:string!, scanAfterPull:boolean} + * body-example: {"image":"nginx:latest","scanAfterPull":false} + * resp-200: A Server-Sent Events stream of pull progress, ending with a "result" event + * resp-403: Permission denied, or (enterprise) no access to this environment + */ export const POST: RequestHandler = async (event) => { const { request, url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/images/push/+server.ts b/src/routes/api/images/push/+server.ts index 2618e5b..674fa24 100644 --- a/src/routes/api/images/push/+server.ts +++ b/src/routes/api/images/push/+server.ts @@ -23,6 +23,22 @@ async function isEdgeMode(envId?: number): Promise<{ isEdge: boolean; environmen return { isEdge: false }; } +/** + * POST /api/images/push - Tag and push an image to a registry + * + * @openapi + * summary: Tag a local image and push it to a configured registry (streams progress, or runs synchronously for Accept: application/json) + * query: env:integer ID of the environment the source image belongs to (from GET /api/environments) + * description: imageId from GET /api/images. registryId from GET /api/registries. + * body: {imageId:string!, registryId:integer!, imageName:string, newTag:string} + * body-example: {"imageId":"sha256:abc123","registryId":2,"imageName":"myapp:latest","newTag":"myapp:v1.2.3"} + * resp-200: {jobId:string} when streaming, or the final push result for Accept: application/json clients + * resp-200-example: {"jobId":"a1b2c3d4"} + * resp-400: Image ID or registry ID missing, or the image has no usable tag + * resp-403: Permission denied + * resp-404: Registry not found + * resp-500: Failed to push image + */ export const POST: RequestHandler = async (event) => { const { request, url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/images/scan/+server.ts b/src/routes/api/images/scan/+server.ts index f864ba3..7cb0ce0 100644 --- a/src/routes/api/images/scan/+server.ts +++ b/src/routes/api/images/scan/+server.ts @@ -5,7 +5,18 @@ import { inspectImage } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; import { createJobResponse } from '$lib/server/sse'; -// POST - Start a scan (returns { jobId } for progress polling, or synchronous JSON for Accept: application/json) +/** + * POST /api/images/scan - Start a vulnerability scan (SSE progress) + * + * @openapi + * summary: Start a vulnerability scan of an image and stream scan progress as Server-Sent Events, persisting the results + * query: env:integer ID of the environment the image belongs to (from GET /api/environments) + * body: {imageName:string!, scanner:string} + * body-example: {"imageName":"nginx:latest","scanner":"grype"} + * resp-200: A Server-Sent Events stream of scan progress, ending with a "result" event + * resp-400: Image name is required + * resp-403: Permission denied + */ export const POST: RequestHandler = async ({ request, url, cookies }) => { const auth = await authorize(cookies); @@ -59,7 +70,21 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { }, request); }; -// GET - Get cached scan results for an image +/** + * GET /api/images/scan - Cached scan results for an image + * + * @openapi + * summary: Return the latest cached vulnerability scan for an image, resolving its tag to a SHA256 ID + * query: env:integer ID of the environment the image belongs to (from GET /api/environments) + * query: image:string! Image name or ID to look up cached results for + * query: scanner:string Restrict the lookup to a specific scanner (e.g. grype or trivy) + * resp-200: {found:boolean!, result:object} + * resp-200-desc: found=false when no cached scan exists; otherwise result holds the stored scan + * resp-200-example: {"found":true,"result":{"imageId":"sha256:abc123","scanner":"grype","summary":{"critical":0,"high":2}}} + * resp-400: Image name is required + * resp-403: Permission denied + * resp-500: Failed to get scan results + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/images/scan/export/+server.ts b/src/routes/api/images/scan/export/+server.ts index 62c55ed..2455634 100644 --- a/src/routes/api/images/scan/export/+server.ts +++ b/src/routes/api/images/scan/export/+server.ts @@ -20,6 +20,17 @@ function toCSV(findings: Finding[]): string { return rowsToCSV(headers, rows); } +/** + * GET /api/images/scan/export - Export one image's cached findings (json|csv|sarif) + * + * @openapi + * summary: Export the cached vulnerability findings for a single image as json, csv, or sarif + * query: imageId:string! Image SHA/ID to export (falls back to the "image" param when omitted) (from GET /api/images) + * query: image:string Image name/ID used as a fallback when imageId is not given + * query: format:string Output format — json (default), csv, or sarif + * resp-200: The findings as a downloadable json, csv, or sarif attachment + * resp-200-desc: A 400 is returned when imageId is missing or (auth enabled) env is not specified, and 500 on failure + */ export const GET: RequestHandler = async ({ url, cookies }) => { const imageId = url.searchParams.get('imageId') || url.searchParams.get('image'); if (!imageId) return jsonError('imageId is required', 400); diff --git a/src/routes/api/jobs/[id]/+server.ts b/src/routes/api/jobs/[id]/+server.ts index b399222..6ee2939 100644 --- a/src/routes/api/jobs/[id]/+server.ts +++ b/src/routes/api/jobs/[id]/+server.ts @@ -7,6 +7,13 @@ import type { RequestHandler } from './$types'; * Poll a job's status and accumulated lines. * Returns all lines every time — client tracks its own cursor locally. */ +/** + * @openapi + * summary: Poll a background job's status and accumulated output lines (no auth — job ids are unguessable UUIDs) + * path: id:string! Job id (UUID) + * resp-200: {id:string!, status:string!, lines:array<{event:string, data:{}}>!, result:{}} + * resp-404: Job not found + */ export const GET: RequestHandler = async ({ params }) => { const job = getJob(params.id); if (!job) { @@ -25,6 +32,11 @@ export const GET: RequestHandler = async ({ params }) => { * DELETE /api/jobs/[id] * Request cancellation of a running job. The job's operation polls the flag * between units of work and stops gracefully. + * + * @openapi + * summary: Request cancellation of a running background job (no auth — job ids are unguessable UUIDs) + * path: id:string! Job id (UUID) + * resp-200: {cancelled:boolean!} */ export const DELETE: RequestHandler = async ({ params }) => { const cancelled = cancelJob(params.id); diff --git a/src/routes/api/labels/+server.ts b/src/routes/api/labels/+server.ts index 2fea43c..6e349f0 100644 --- a/src/routes/api/labels/+server.ts +++ b/src/routes/api/labels/+server.ts @@ -10,7 +10,14 @@ async function getLabelColors(): Promise> { } /** - * GET /api/labels — returns all unique labels with usage counts, environment details, and custom colors + * GET /api/labels + * + * @openapi + * summary: List all unique environment labels with their usage counts, the environments carrying each label, and any custom color + * resp-200: {labels:array<{label:string!, environments:array<{envId:integer!, envName:string!}>!, count:integer!, color:string}>!, colors:{}} + * resp-200-example: {"labels":[{"label":"prod","environments":[{"envId":1,"envName":"Production"}],"count":1,"color":"#ff0000"}],"colors":{"prod":"#ff0000"}} + * resp-403: Permission denied (requires environments:view) + * resp-500: Failed to get labels */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -51,11 +58,20 @@ export const GET: RequestHandler = async ({ cookies }) => { }; /** - * POST /api/labels — bulk label operations - * Body: { action: 'rename', oldLabel: string, newLabel: string } - * | { action: 'delete', label: string } - * | { action: 'add', label: string, environmentIds: number[] } - * | { action: 'set-color', label: string, color: string | null } + * POST /api/labels — bulk label operations. + * The action field selects the operation: rename (oldLabel + newLabel), + * delete (label), add (label + environmentIds), or set-color (label + color). + * + * @openapi + * summary: Perform a bulk label operation on environments — rename, delete, add to environments, or set a custom color — selected via the action field + * description: environmentIds from GET /api/environments. + * body: {action:string!, oldLabel:string, newLabel:string, label:string, environmentIds:array, color:string} + * body-example: {"action":"rename","oldLabel":"staging","newLabel":"stg"} + * resp-200: {success:boolean!, affected:integer} + * resp-200-example: {"success":true,"affected":2} + * resp-400: Invalid action or missing/empty required fields for the chosen action + * resp-403: Permission denied (requires environments:edit) + * resp-500: Failed to manage labels */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/legal/license/+server.ts b/src/routes/api/legal/license/+server.ts index af08cfe..9271d57 100644 --- a/src/routes/api/legal/license/+server.ts +++ b/src/routes/api/legal/license/+server.ts @@ -3,6 +3,14 @@ import type { RequestHandler } from './$types'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +/** + * @openapi + * summary: Return the bundled LICENSE.txt — as JSON by default, or as raw text/plain when format=text + * query: format:string Set to "text" to return the raw license as text/plain instead of JSON + * resp-200: {content:string!} + * resp-200-desc: The license text (as {content} JSON, or raw text/plain when format=text) + * resp-404: LICENSE.txt could not be found/read + */ export const GET: RequestHandler = async ({ url }) => { try { const licensePath = join(process.cwd(), 'LICENSE.txt'); diff --git a/src/routes/api/legal/privacy/+server.ts b/src/routes/api/legal/privacy/+server.ts index 824856c..408ea09 100644 --- a/src/routes/api/legal/privacy/+server.ts +++ b/src/routes/api/legal/privacy/+server.ts @@ -3,6 +3,14 @@ import type { RequestHandler } from './$types'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +/** + * @openapi + * summary: Return the bundled PRIVACY.txt — as JSON by default, or as raw text/plain when format=text + * query: format:string Set to "text" to return the raw privacy policy as text/plain instead of JSON + * resp-200: {content:string!} + * resp-200-desc: The privacy policy text (as {content} JSON, or raw text/plain when format=text) + * resp-404: PRIVACY.txt could not be found/read + */ export const GET: RequestHandler = async ({ url }) => { try { const privacyPath = join(process.cwd(), 'PRIVACY.txt'); diff --git a/src/routes/api/license/+server.ts b/src/routes/api/license/+server.ts index c3d3d9a..aab253b 100644 --- a/src/routes/api/license/+server.ts +++ b/src/routes/api/license/+server.ts @@ -11,6 +11,13 @@ import { clearTokenCache } from '$lib/server/api-tokens'; // GET /api/license - Get current license status // Any authenticated user can view license status (needed to determine if RBAC applies) +/** + * @openapi + * summary: Get the current license status plus the server hostname the license is bound to + * resp-200: The license status object merged with the current hostname + * resp-401: Authentication required (auth is enabled and the caller is not authenticated) + * resp-500: Failed to read the license status + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !auth.isAuthenticated) { @@ -35,6 +42,16 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // POST /api/license - Activate a license +/** + * @openapi + * summary: Activate a license by name and key + * body: {name:string!, key:string!} + * body-example: {"name":"ACME Corp","key":"***"} + * resp-200: {success:boolean!, license:{}} + * resp-400: Name and key are required, or activation was rejected (invalid key) + * resp-403: Permission denied (missing license:manage) + * resp-500: Failed to activate the license + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('license', 'manage')) { @@ -77,6 +94,13 @@ export const POST: RequestHandler = async ({ request, cookies }) => { }; // DELETE /api/license - Deactivate license +/** + * @openapi + * summary: Deactivate the currently active license + * resp-200: {success:boolean!} + * resp-403: Permission denied (missing license:manage) + * resp-500: Failed to deactivate the license + */ export const DELETE: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('license', 'manage')) { diff --git a/src/routes/api/logs/merged/+server.ts b/src/routes/api/logs/merged/+server.ts index 4387032..6570360 100644 --- a/src/routes/api/logs/merged/+server.ts +++ b/src/routes/api/logs/merged/+server.ts @@ -356,6 +356,18 @@ async function handleEdgeMergedLogs(containerIds: string[], tail: string, enviro }); } +/** + * @openapi + * summary: Stream merged, color-tagged logs from multiple containers over Server-Sent Events + * query: containers:string! Comma-separated container IDs to merge logs from + * query: tail:string Number of lines to tail per container (default 100) + * query: since:string Only return logs since this timestamp (Docker since format) + * query: until:string Only return logs until this timestamp (Docker until format) + * query: env:integer Environment the containers belong to (from GET /api/environments) + * resp-200: Server-Sent Events stream (text/event-stream) emitting heartbeat and per-container log events + * resp-400: No containers specified + * resp-403: Permission denied (requires the containers:logs permission) + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/networks/+server.ts b/src/routes/api/networks/+server.ts index 02c1f1f..bca971e 100644 --- a/src/routes/api/networks/+server.ts +++ b/src/routes/api/networks/+server.ts @@ -5,6 +5,15 @@ import { authorize } from '$lib/server/authorize'; import { auditNetwork } from '$lib/server/audit'; import { hasEnvironments } from '$lib/server/db'; +/** + * @openapi + * summary: List Docker networks for an environment (returns an empty array when no env is given) + * query: env:integer Environment ID to list networks from (from GET /api/environments) + * resp-200: array<{Id:string!, Name:string!, Driver:string, Scope:string, Internal:boolean, Attachable:boolean}> + * resp-403: Permission denied, or access denied to the requested environment (enterprise) + * resp-404: Environment not found + * resp-500: Failed to list networks + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -40,6 +49,18 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; +/** + * @openapi + * summary: Create a Docker network in the given environment + * query: env:integer Environment ID to create the network in (from GET /api/environments) + * body: {name:string!, driver:string, internal:boolean, attachable:boolean, ingress:boolean, enableIPv6:boolean, options:{}, labels:{}, ipam:{driver:string, config:array<{}>, options:{}}} + * body-example: {"name":"app-net","driver":"bridge","attachable":true,"labels":{"project":"dockhand"}} + * resp-200: {success:boolean!, id:string!} + * resp-200-example: {"success":true,"id":"3f1c...e9"} + * resp-400: Network name is required + * resp-403: Permission denied, or access denied to the requested environment (enterprise) + * resp-500: Failed to create network + */ export const POST: RequestHandler = async (event) => { const { url, request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/networks/[id]/+server.ts b/src/routes/api/networks/[id]/+server.ts index ca7ff2a..1c76f13 100644 --- a/src/routes/api/networks/[id]/+server.ts +++ b/src/routes/api/networks/[id]/+server.ts @@ -5,6 +5,15 @@ import { authorize } from '$lib/server/authorize'; import { auditNetwork } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * @openapi + * summary: Inspect a Docker network by ID (a malformed ID is rejected with 400 by input validation) + * path: id:string! Docker network ID (from GET /api/networks) + * query: env:integer Environment the network belongs to (from GET /api/environments) + * resp-200: {Id:string!, Name:string!, Driver:string, Scope:string, IPAM:{}, Containers:{}} + * resp-403: Permission denied, or access denied to the requested environment (enterprise) + * resp-500: Failed to inspect network + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'network'); if (invalid) return invalid; @@ -34,6 +43,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { } }; +/** + * @openapi + * summary: Remove a Docker network by ID (a malformed ID is rejected with 400 by input validation) + * path: id:string! Docker network ID (from GET /api/networks) + * query: env:integer Environment the network belongs to (from GET /api/environments) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-403: Permission denied, or access denied to the requested environment (enterprise) + * resp-500: Failed to remove network + */ export const DELETE: RequestHandler = async (event) => { const { params, url, cookies } = event; const invalid = validateDockerIdParam(params.id, 'network'); diff --git a/src/routes/api/networks/[id]/connect/+server.ts b/src/routes/api/networks/[id]/connect/+server.ts index 1337700..16b02a5 100644 --- a/src/routes/api/networks/[id]/connect/+server.ts +++ b/src/routes/api/networks/[id]/connect/+server.ts @@ -5,6 +5,20 @@ import { authorize } from '$lib/server/authorize'; import { auditNetwork } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * @openapi + * summary: Connect a container to a Docker network + * description: containerId from GET /api/containers. + * path: id:string! Docker network ID (from GET /api/networks) + * query: env:integer Environment the network belongs to (from GET /api/environments) + * body: {containerId:string!, containerName:string} + * body-example: {"containerId":"a1b2c3d4e5f6","containerName":"web-1"} + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: Container ID is required + * resp-403: Permission denied + * resp-500: Failed to connect container to network + */ export const POST: RequestHandler = async (event) => { const { params, url, request, cookies } = event; const invalid = validateDockerIdParam(params.id, 'network'); diff --git a/src/routes/api/networks/[id]/disconnect/+server.ts b/src/routes/api/networks/[id]/disconnect/+server.ts index 3c44a8a..18c73dc 100644 --- a/src/routes/api/networks/[id]/disconnect/+server.ts +++ b/src/routes/api/networks/[id]/disconnect/+server.ts @@ -5,6 +5,20 @@ import { authorize } from '$lib/server/authorize'; import { auditNetwork } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * @openapi + * summary: Disconnect a container from a Docker network (optionally forcing the disconnect) + * description: containerId from GET /api/containers. + * path: id:string! Docker network ID (from GET /api/networks) + * query: env:integer Environment the network belongs to (from GET /api/environments) + * body: {containerId:string!, containerName:string, force:boolean} + * body-example: {"containerId":"a1b2c3d4e5f6","containerName":"web-1","force":false} + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: Container ID is required + * resp-403: Permission denied + * resp-500: Failed to disconnect container from network + */ export const POST: RequestHandler = async (event) => { const { params, url, request, cookies } = event; const invalid = validateDockerIdParam(params.id, 'network'); diff --git a/src/routes/api/networks/[id]/inspect/+server.ts b/src/routes/api/networks/[id]/inspect/+server.ts index f74b7ae..eb8b141 100644 --- a/src/routes/api/networks/[id]/inspect/+server.ts +++ b/src/routes/api/networks/[id]/inspect/+server.ts @@ -4,6 +4,15 @@ import { inspectNetwork } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * @openapi + * summary: Inspect a Docker network by ID (a malformed ID is rejected with 400 by input validation) + * path: id:string! Docker network ID (from GET /api/networks) + * query: env:integer Environment the network belongs to (from GET /api/environments) + * resp-200: {Id:string!, Name:string!, Driver:string, Scope:string, IPAM:{}, Containers:{}} + * resp-403: Permission denied + * resp-500: Failed to inspect network + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'network'); if (invalid) return invalid; diff --git a/src/routes/api/notifications/+server.ts b/src/routes/api/notifications/+server.ts index f8b62ce..89ea321 100644 --- a/src/routes/api/notifications/+server.ts +++ b/src/routes/api/notifications/+server.ts @@ -10,6 +10,13 @@ import { authorize } from '$lib/server/authorize'; import { auditNotification } from '$lib/server/audit'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: List all notification settings (SMTP and Apprise) with SMTP passwords masked + * resp-200: array<{id:integer!, type:string!, name:string!, enabled:boolean!, config:{host:string, port:integer, from_email:string, to_emails:array, urls:array, password:string}, eventTypes:array}> + * resp-403: Permission denied (requires the notifications:view permission when auth is enabled) + * resp-500: Failed to fetch notification settings + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('notifications', 'view')) { @@ -33,6 +40,16 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; +/** + * @openapi + * summary: Create a notification setting (SMTP or Apprise); the response masks any SMTP password + * body: {type:string!, name:string!, enabled:boolean, config:{host:string, port:integer, secure:boolean, username:string, password:string, from_email:string, from_name:string, to_emails:array, urls:array}, eventTypes:array, event_types:array} + * body-example: {"type":"smtp","name":"Ops Alerts","enabled":true,"config":{"host":"smtp.example.com","port":587,"secure":false,"from_email":"dockhand@example.com","to_emails":["ops@example.com"],"password":"***"},"eventTypes":["container_unhealthy"]} + * resp-200: {id:integer!, type:string!, name:string!, enabled:boolean!, config:{}, eventTypes:array} + * resp-400: Missing or invalid fields — type/name/config required, type must be smtp or apprise, SMTP needs host/port/from_email/to_emails, Apprise needs at least one URL + * resp-403: Permission denied (requires the notifications:create permission) + * resp-500: Failed to create notification setting + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/notifications/[id]/+server.ts b/src/routes/api/notifications/[id]/+server.ts index 02b133b..608326c 100644 --- a/src/routes/api/notifications/[id]/+server.ts +++ b/src/routes/api/notifications/[id]/+server.ts @@ -12,6 +12,16 @@ import { auditNotification } from '$lib/server/audit'; import { computeAuditDiff } from '$lib/utils/diff'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Get a single notification setting by ID, with any SMTP password masked + * path: id:integer! Notification setting ID (from GET /api/notifications) + * resp-200: {id:integer!, type:string!, name:string!, enabled:boolean!, config:{host:string, port:integer, from_email:string, to_emails:array, urls:array, password:string}, eventTypes:array} + * resp-400: Invalid ID (not a number) + * resp-403: Permission denied (requires the notifications:view permission) + * resp-404: Notification setting not found + * resp-500: Failed to fetch notification setting + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('notifications', 'view')) { @@ -45,6 +55,18 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; +/** + * @openapi + * summary: Update a notification setting; a masked SMTP password ("********") keeps the stored value + * path: id:integer! Notification setting ID (from GET /api/notifications) + * body: {name:string, enabled:boolean, config:{host:string, port:integer, secure:boolean, username:string, password:string, from_email:string, from_name:string, to_emails:array, urls:array}, eventTypes:array, event_types:array} + * body-example: {"name":"Ops Alerts","enabled":false,"config":{"host":"smtp.example.com","port":587,"from_email":"dockhand@example.com","to_emails":["ops@example.com"],"password":"********"},"eventTypes":["container_unhealthy","container_oom"]} + * resp-200: {id:integer!, type:string!, name:string!, enabled:boolean!, config:{}, eventTypes:array} + * resp-400: Invalid ID, or config validation failed (SMTP needs host/port/from_email/to_emails, Apprise needs at least one URL) + * resp-403: Permission denied (requires the notifications:edit permission) + * resp-404: Notification setting not found + * resp-500: Failed to update notification setting + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -122,6 +144,17 @@ export const PUT: RequestHandler = async (event) => { } }; +/** + * @openapi + * summary: Delete a notification setting by ID + * path: id:integer! Notification setting ID (from GET /api/notifications) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: Invalid ID (not a number) + * resp-403: Permission denied (requires the notifications:delete permission) + * resp-404: Notification setting not found + * resp-500: Failed to delete notification setting + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/notifications/[id]/test/+server.ts b/src/routes/api/notifications/[id]/test/+server.ts index 2c6403c..6cb7058 100644 --- a/src/routes/api/notifications/[id]/test/+server.ts +++ b/src/routes/api/notifications/[id]/test/+server.ts @@ -3,6 +3,16 @@ import { getNotificationSetting } from '$lib/server/db'; import { testNotification } from '$lib/server/notifications'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Send a test notification through an already-saved notification setting + * path: id:integer! Notification setting ID (from GET /api/notifications) + * resp-200: {success:boolean!, message:string, error:string} + * resp-200-example: {"success":true,"message":"Test notification sent successfully"} + * resp-400: Invalid ID (not a number) + * resp-404: Notification setting not found + * resp-500: Failed to test notification + */ export const POST: RequestHandler = async ({ params }) => { try { const id = parseInt(params.id); diff --git a/src/routes/api/notifications/test/+server.ts b/src/routes/api/notifications/test/+server.ts index 083a6dd..4f02e2c 100644 --- a/src/routes/api/notifications/test/+server.ts +++ b/src/routes/api/notifications/test/+server.ts @@ -4,6 +4,17 @@ import { authorize } from '$lib/server/authorize'; import type { RequestHandler } from './$types'; // Test notification with provided config (without saving) +/** + * @openapi + * summary: Send a test notification using an ad-hoc config supplied in the body (nothing is saved) + * body: {type:string!, name:string, config:{host:string, port:integer, secure:boolean, username:string, password:string, from_email:string, from_name:string, to_emails:array, urls:array}} + * body-example: {"type":"smtp","name":"Test","config":{"host":"smtp.example.com","port":587,"from_email":"dockhand@example.com","to_emails":["ops@example.com"],"password":"***"}} + * resp-200: {success:boolean!, message:string, error:string} + * resp-200-example: {"success":true,"message":"Test notification sent successfully"} + * resp-400: Missing/invalid fields — type and config required; SMTP needs host/from_email/to_emails; Apprise needs at least one URL + * resp-403: Permission denied (requires the settings:edit permission) + * resp-500: Failed to test notification + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('settings', 'edit')) { diff --git a/src/routes/api/notifications/trigger-test/+server.ts b/src/routes/api/notifications/trigger-test/+server.ts index 50ed170..a3046af 100644 --- a/src/routes/api/notifications/trigger-test/+server.ts +++ b/src/routes/api/notifications/trigger-test/+server.ts @@ -6,12 +6,15 @@ import { NOTIFICATION_EVENT_TYPES, type NotificationEventType } from '$lib/serve * Test endpoint to trigger notifications for any event type. * This is intended for development/testing purposes only. * - * POST /api/notifications/trigger-test - * Body: { - * eventType: string, - * environmentId?: number, - * payload: { title: string, message: string, type?: string } - * } + * @openapi + * summary: Trigger a real notification for a given event type (development/testing helper) + * description: environmentId from GET /api/environments. + * body: {eventType:string!, environmentId:integer, payload:{title:string!, message:string!, type:string}} + * body-example: {"eventType":"container_unhealthy","environmentId":1,"payload":{"title":"Container unhealthy","message":"web-1 is unhealthy","type":"warning"}} + * resp-200: {success:boolean!, sent:integer, eventType:string!, environmentId:integer} + * resp-200-example: {"success":true,"sent":1,"eventType":"container_unhealthy","environmentId":1} + * resp-400: eventType required, payload with title and message required, unknown event type, or environmentId missing for a non-system event + * resp-500: Unknown error while sending the notification */ export const POST: RequestHandler = async ({ request }) => { try { @@ -83,6 +86,10 @@ export const POST: RequestHandler = async ({ request }) => { /** * GET endpoint to list all available event types + * + * @openapi + * summary: List all available notification event types, grouped into categories + * resp-200: {eventTypes:array<{id:string!, label:string}>, categories:{container:array, autoUpdate:array, gitStack:array, stack:array, security:array, system:array}} */ export const GET: RequestHandler = async () => { return json({ diff --git a/src/routes/api/preferences/favorite-groups/+server.ts b/src/routes/api/preferences/favorite-groups/+server.ts index b34e369..23f15a5 100644 --- a/src/routes/api/preferences/favorite-groups/+server.ts +++ b/src/routes/api/preferences/favorite-groups/+server.ts @@ -12,6 +12,15 @@ export interface FavoriteGroup { containers: string[]; // Container names (not IDs, since IDs change on recreate) } +/** + * @openapi + * summary: Get the saved log favorite-groups for an environment + * query: env:integer! Environment ID (from GET /api/environments) + * resp-200: {groups:array<{name:string!, containers:array}>} + * resp-200-example: {"groups":[{"name":"frontend","containers":["web-1","web-2"]}]} + * resp-400: Environment ID is required, or invalid (not a number) + * resp-500: Failed to get favorite groups + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -42,6 +51,17 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; +/** + * @openapi + * summary: Add, remove, update or reorder log favorite-groups for an environment + * description: environmentId from GET /api/environments. + * body: {environmentId:integer!, action:string!, name:string, containers:array, newName:string, groups:array<{name:string!, containers:array}>} + * body-example: {"environmentId":1,"action":"add","name":"frontend","containers":["web-1","web-2"]} + * resp-200: {groups:array<{name:string!, containers:array}>} + * resp-400: Invalid environmentId, unknown action, missing group name, empty/missing containers, duplicate group name, or missing groups array for reorder + * resp-404: Group not found (update action) + * resp-500: Failed to update favorite groups + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/preferences/favorites/+server.ts b/src/routes/api/preferences/favorites/+server.ts index 7b3f238..99d375b 100644 --- a/src/routes/api/preferences/favorites/+server.ts +++ b/src/routes/api/preferences/favorites/+server.ts @@ -7,6 +7,15 @@ const LOGS_FAVORITES_KEY = 'logs_favorites'; // Favorites are stored as an array of container names (strings) // Per environment, so environmentId is required +/** + * @openapi + * summary: Get the saved log favorites (container names) for an environment + * query: env:integer! Environment ID (from GET /api/environments) + * resp-200: {favorites:array} + * resp-200-example: {"favorites":["web-1","db-1"]} + * resp-400: Environment ID is required, or invalid (not a number) + * resp-500: Failed to get favorites + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -37,6 +46,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; +/** + * @openapi + * summary: Add, remove or reorder log favorites (container names) for an environment + * description: environmentId from GET /api/environments. + * body: {environmentId:integer!, action:string!, containerName:string, favorites:array} + * body-example: {"environmentId":1,"action":"add","containerName":"web-1"} + * resp-200: {favorites:array} + * resp-400: Invalid environmentId, unknown action, missing favorites array for reorder, or missing containerName for add/remove + * resp-500: Failed to update favorites + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/preferences/grid/+server.ts b/src/routes/api/preferences/grid/+server.ts index 549d927..402a89d 100644 --- a/src/routes/api/preferences/grid/+server.ts +++ b/src/routes/api/preferences/grid/+server.ts @@ -4,6 +4,13 @@ import { authorize } from '$lib/server/authorize'; import type { GridColumnPreferences } from '$lib/types'; // GET - retrieve all grid preferences +/** + * @openapi + * summary: Retrieve all saved data-grid column preferences (per-user when auth is enabled) + * resp-200: {preferences:{}} + * resp-200-example: {"preferences":{"containers":{"columns":[{"id":"name","visible":true}]}}} + * resp-500: Failed to get grid preferences + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -20,6 +27,15 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // POST - update grid preferences for a specific grid +/** + * @openapi + * summary: Save column preferences for one data grid and return all grid preferences + * body: {gridId:string!, columns:array<{id:string!, visible:boolean!}>} + * body-example: {"gridId":"containers","columns":[{"id":"name","visible":true},{"id":"image","visible":false}]} + * resp-200: {preferences:{}} + * resp-400: gridId is required, columns array is required, or a column is missing id (string) / visible (boolean) + * resp-500: Failed to save grid preferences + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); @@ -58,6 +74,13 @@ export const POST: RequestHandler = async ({ request, cookies }) => { }; // DELETE - reset grid preferences (single grid or all) +/** + * @openapi + * summary: Reset grid preferences — a single grid when gridId is given, otherwise all grids + * query: gridId:string Grid ID to reset; omit to reset every grid + * resp-200: {preferences:{}} + * resp-500: Failed to reset grid preferences + */ export const DELETE: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/preferences/sidebar/+server.ts b/src/routes/api/preferences/sidebar/+server.ts index 9b5facb..c430e03 100644 --- a/src/routes/api/preferences/sidebar/+server.ts +++ b/src/routes/api/preferences/sidebar/+server.ts @@ -4,6 +4,13 @@ import { authorize } from '$lib/server/authorize'; import type { SidebarPreferences } from '$lib/server/db'; // GET - retrieve sidebar menu preferences +/** + * @openapi + * summary: Retrieve the saved sidebar menu preferences (order and hidden items) + * resp-200: {preferences:{order:array, hidden:array}} + * resp-200-example: {"preferences":{"order":["dashboard","containers"],"hidden":["volumes"]}} + * resp-500: Failed to get sidebar preferences + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -20,6 +27,15 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // POST - update sidebar menu preferences +/** + * @openapi + * summary: Save the sidebar menu preferences (order and hidden items) + * body: {order:array!, hidden:array!} + * body-example: {"order":["dashboard","containers","stacks"],"hidden":["volumes"]} + * resp-200: {preferences:{order:array, hidden:array}} + * resp-400: order must be an array of strings, or hidden must be an array of strings + * resp-500: Failed to save sidebar preferences + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); @@ -50,6 +66,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => { }; // DELETE - reset sidebar menu preferences to default +/** + * @openapi + * summary: Reset the sidebar menu preferences to their default order/visibility + * resp-200: {preferences:{order:array, hidden:array}} + * resp-500: Failed to reset sidebar preferences + */ export const DELETE: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/profile/+server.ts b/src/routes/api/profile/+server.ts index 919ea44..79cc26c 100644 --- a/src/routes/api/profile/+server.ts +++ b/src/routes/api/profile/+server.ts @@ -5,6 +5,15 @@ import { validateSession, hashPassword, isAuthEnabled } from '$lib/server/auth'; import { invalidateTokenCacheForUser } from '$lib/server/api-tokens'; // GET /api/profile - Get current user's profile +/** + * @openapi + * summary: Get the authenticated user's own profile + * resp-200: {id:integer!, username:string!, email:string, displayName:string, avatar:string, mfaEnabled:boolean!, isAdmin:boolean!, provider:string!, lastLogin:string, createdAt:string!, updatedAt:string!} + * resp-400: Authentication is not enabled + * resp-401: Not authenticated + * resp-404: User not found + * resp-500: Failed to read the profile + */ export const GET: RequestHandler = async ({ cookies }) => { if (!(await isAuthEnabled())) { return json({ error: 'Authentication is not enabled' }, { status: 400 }); @@ -44,6 +53,17 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // PUT /api/profile - Update current user's profile +/** + * @openapi + * summary: Update the authenticated user's own profile (email/display name, and optionally the password with current-password confirmation) + * body: {email:string, displayName:string, currentPassword:string, newPassword:string} + * body-example: {"displayName":"Jane Doe","currentPassword":"***","newPassword":"***"} + * resp-200: {id:integer!, username:string!, email:string, displayName:string, avatar:string, mfaEnabled:boolean!, isAdmin:boolean!, lastLogin:string, createdAt:string!, updatedAt:string!} + * resp-400: Authentication is not enabled, current password missing, new password too short (<8), or current password incorrect + * resp-401: Not authenticated + * resp-404: User not found + * resp-500: Failed to update the profile + */ export const PUT: RequestHandler = async ({ request, cookies }) => { if (!(await isAuthEnabled())) { return json({ error: 'Authentication is not enabled' }, { status: 400 }); diff --git a/src/routes/api/profile/avatar/+server.ts b/src/routes/api/profile/avatar/+server.ts index 964b2f3..f2e2209 100644 --- a/src/routes/api/profile/avatar/+server.ts +++ b/src/routes/api/profile/avatar/+server.ts @@ -4,6 +4,16 @@ import { updateUser as dbUpdateUser, getUser } from '$lib/server/db'; import { validateSession, isAuthEnabled } from '$lib/server/auth'; // POST /api/profile/avatar - Upload avatar (base64 data URL) +/** + * @openapi + * summary: Upload the authenticated user's avatar as a base64 image data URL (max ~500KB) + * body: {avatar:string!} + * body-example: {"avatar":"data:image/png;base64,iVBORw0KGgo..."} + * resp-200: {success:boolean!, avatar:string!} + * resp-400: Authentication is not enabled, avatar data missing, not an image data URL, or image too large (>500KB) + * resp-401: Not authenticated + * resp-500: Failed to upload the avatar + */ export const POST: RequestHandler = async ({ request, cookies }) => { if (!(await isAuthEnabled())) { return json({ error: 'Authentication is not enabled' }, { status: 400 }); @@ -45,6 +55,14 @@ export const POST: RequestHandler = async ({ request, cookies }) => { }; // DELETE /api/profile/avatar - Remove avatar +/** + * @openapi + * summary: Remove the authenticated user's avatar + * resp-200: {success:boolean!} + * resp-400: Authentication is not enabled + * resp-401: Not authenticated + * resp-500: Failed to remove the avatar + */ export const DELETE: RequestHandler = async ({ cookies }) => { if (!(await isAuthEnabled())) { return json({ error: 'Authentication is not enabled' }, { status: 400 }); diff --git a/src/routes/api/profile/preferences/+server.ts b/src/routes/api/profile/preferences/+server.ts index c0f0d7a..c62c2a8 100644 --- a/src/routes/api/profile/preferences/+server.ts +++ b/src/routes/api/profile/preferences/+server.ts @@ -5,6 +5,13 @@ import { validateSession, isAuthEnabled } from '$lib/server/auth'; import { lightThemes, darkThemes, fonts, monospaceFonts } from '$lib/themes'; // GET /api/profile/preferences - Get current user's theme preferences +/** + * @openapi + * summary: Get the current user's UI preferences (theme, fonts, editor options) + * resp-400: Not authenticated / no user in context + * resp-401: Not authenticated + * resp-500: Failed to load preferences + */ export const GET: RequestHandler = async ({ cookies }) => { if (!(await isAuthEnabled())) { return json({ error: 'Authentication is not enabled' }, { status: 400 }); @@ -25,6 +32,14 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // PUT /api/profile/preferences - Update current user's theme preferences +/** + * @openapi + * summary: Update the current user's UI preferences (each field is optional) + * body: {lightTheme:string, darkTheme:string, font:string, fontSize:string, gridFontSize:string, terminalFont:string, editorFont:string, animateIcons:boolean, coloredActionButtons:boolean, actionIconSize:string, editorIndentGuides:boolean} + * resp-400: A supplied field has the wrong type (e.g. editorIndentGuides not a boolean) + * resp-401: Not authenticated + * resp-500: Failed to save preferences + */ export const PUT: RequestHandler = async ({ request, cookies }) => { if (!(await isAuthEnabled())) { return json({ error: 'Authentication is not enabled' }, { status: 400 }); diff --git a/src/routes/api/prune/all/+server.ts b/src/routes/api/prune/all/+server.ts index b99b42f..65cc3f9 100644 --- a/src/routes/api/prune/all/+server.ts +++ b/src/routes/api/prune/all/+server.ts @@ -4,6 +4,17 @@ import { authorize } from '$lib/server/authorize'; import { audit } from '$lib/server/audit'; import type { RequestHandler } from './$types'; +/** + * POST /api/prune/all - Prune every unused Docker resource type at once + * + * @openapi + * summary: Prune all unused Docker resources (containers, images, volumes and networks) in a single operation + * query: env:integer Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments) + * resp-200: Returns { success: true, result } where result is the aggregated Docker prune report (space reclaimed, items deleted) + * resp-200-example: {"success":true,"result":{"ContainersDeleted":["abc123"],"ImagesDeleted":[],"VolumesDeleted":[],"NetworksDeleted":[],"SpaceReclaimed":10485760}} + * resp-403: Permission denied — requires the "remove" permission on containers, images, volumes AND networks for the target environment + * resp-500: Failed to prune the system (Docker error); the message is returned in "details" + */ export const POST: RequestHandler = async (event) => { const { url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/prune/containers/+server.ts b/src/routes/api/prune/containers/+server.ts index 0f79803..7070106 100644 --- a/src/routes/api/prune/containers/+server.ts +++ b/src/routes/api/prune/containers/+server.ts @@ -4,6 +4,17 @@ import { authorize } from '$lib/server/authorize'; import { audit } from '$lib/server/audit'; import type { RequestHandler } from './$types'; +/** + * POST /api/prune/containers - Remove all stopped containers + * + * @openapi + * summary: Prune (delete) all stopped containers in the target environment + * query: env:integer Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments) + * resp-200: Returns { success: true, result } where result is the Docker container-prune report (deleted container ids, space reclaimed) + * resp-200-example: {"success":true,"result":{"ContainersDeleted":["abc123","def456"],"SpaceReclaimed":5242880}} + * resp-403: Permission denied — requires the "remove" permission on containers for the target environment + * resp-500: Failed to prune containers (Docker error) + */ export const POST: RequestHandler = async (event) => { const { url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/prune/images/+server.ts b/src/routes/api/prune/images/+server.ts index 92a5abe..d20ffc2 100644 --- a/src/routes/api/prune/images/+server.ts +++ b/src/routes/api/prune/images/+server.ts @@ -5,6 +5,17 @@ import { audit } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; import type { RequestHandler } from './$types'; +/** + * POST /api/prune/images - Remove unused images (streamed job) + * + * @openapi + * summary: Prune unused Docker images, streaming progress as a Server-Sent Events job + * description: Returns a text/event-stream. On completion a `result` event carries { success, result } on success or { success:false, error } on failure — the operation itself never returns a non-200 HTTP status once the permission check passes. + * query: dangling:boolean When not "false", prune only dangling images; set "dangling=false" to prune all unused images (defaults to dangling-only) + * query: env:integer Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments) + * resp-200: Server-Sent Events stream; the final `result` event contains { success, result } or { success:false, error } + * resp-403: Permission denied — requires the "remove" permission on images for the target environment + */ export const POST: RequestHandler = async (event) => { const { url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/prune/networks/+server.ts b/src/routes/api/prune/networks/+server.ts index a45ae6b..6f5662e 100644 --- a/src/routes/api/prune/networks/+server.ts +++ b/src/routes/api/prune/networks/+server.ts @@ -4,6 +4,17 @@ import { authorize } from '$lib/server/authorize'; import { audit } from '$lib/server/audit'; import type { RequestHandler } from './$types'; +/** + * POST /api/prune/networks - Remove all unused networks + * + * @openapi + * summary: Prune (delete) all unused Docker networks in the target environment + * query: env:integer Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments) + * resp-200: Returns { success: true, result } where result is the Docker network-prune report (deleted network names) + * resp-200-example: {"success":true,"result":{"NetworksDeleted":["bridge-old","test-net"]}} + * resp-403: Permission denied — requires the "remove" permission on networks for the target environment + * resp-500: Failed to prune networks (Docker error) + */ export const POST: RequestHandler = async (event) => { const { url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/prune/volumes/+server.ts b/src/routes/api/prune/volumes/+server.ts index 7b1c995..7cc7ae5 100644 --- a/src/routes/api/prune/volumes/+server.ts +++ b/src/routes/api/prune/volumes/+server.ts @@ -4,6 +4,17 @@ import { authorize } from '$lib/server/authorize'; import { audit } from '$lib/server/audit'; import type { RequestHandler } from './$types'; +/** + * POST /api/prune/volumes - Remove all unused volumes + * + * @openapi + * summary: Prune (delete) all unused Docker volumes in the target environment + * query: env:integer Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments) + * resp-200: Returns { success: true, result } where result is the Docker volume-prune report (deleted volume names, space reclaimed) + * resp-200-example: {"success":true,"result":{"VolumesDeleted":["orphan-data"],"SpaceReclaimed":20971520}} + * resp-403: Permission denied — requires the "remove" permission on volumes for the target environment + * resp-500: Failed to prune volumes (Docker error) + */ export const POST: RequestHandler = async (event) => { const { url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/registries/+server.ts b/src/routes/api/registries/+server.ts index fed2f9a..2d7915c 100644 --- a/src/routes/api/registries/+server.ts +++ b/src/routes/api/registries/+server.ts @@ -5,6 +5,14 @@ import { authorize } from '$lib/server/authorize'; import { auditRegistry } from '$lib/server/audit'; import { parseRegistryUrl, DOCKER_HUB_HOSTS } from '$lib/server/docker'; +/** + * @openapi + * summary: List all configured container registries with passwords stripped (only a hasCredentials flag) + * resp-200: array<{id:integer!, name:string!, url:string!, isDefault:boolean, hasCredentials:boolean!}> + * resp-200-example: [{"id":1,"name":"Docker Hub","url":"https://docker.io","isDefault":true,"hasCredentials":false}] + * resp-403: Caller lacks the registries:view permission + * resp-500: Failed to read registries + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('registries', 'view')) { @@ -25,6 +33,17 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; +/** + * @openapi + * summary: Create a container registry (optionally set it as default); credentials are trimmed and stored encrypted + * body: {name:string!, url:string!, username:string, password:string, isDefault:boolean} + * body-example: {"name":"GHCR","url":"https://ghcr.io","username":"deploy","password":"***","isDefault":false} + * resp-201: {id:integer!, name:string!, url:string!, isDefault:boolean, hasCredentials:boolean!} + * resp-201-desc: Registry created + * resp-400: Missing name or url, or a duplicate registry name + * resp-403: Caller lacks the registries:create permission + * resp-500: Failed to create the registry + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/registries/[id]/+server.ts b/src/routes/api/registries/[id]/+server.ts index 6f68a8c..eb4819d 100644 --- a/src/routes/api/registries/[id]/+server.ts +++ b/src/routes/api/registries/[id]/+server.ts @@ -6,6 +6,16 @@ import { auditRegistry } from '$lib/server/audit'; import { computeAuditDiff } from '$lib/utils/diff'; import { parseRegistryUrl, DOCKER_HUB_HOSTS } from '$lib/server/docker'; +/** + * @openapi + * summary: Get a single registry by ID with the password stripped (only a hasCredentials flag) + * path: id:integer! Registry ID (from GET /api/registries) + * resp-200: {id:integer!, name:string!, url:string!, isDefault:boolean, hasCredentials:boolean!} + * resp-400: The id path segment is not a valid integer + * resp-403: Caller lacks the registries:view permission + * resp-404: No registry exists with that ID + * resp-500: Failed to read the registry + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('registries', 'view')) { @@ -32,6 +42,18 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; +/** + * @openapi + * summary: Update a registry (optionally set as default); credentials are trimmed and the response strips the password + * path: id:integer! Registry ID (from GET /api/registries) + * body: {name:string, url:string, username:string, password:string, isDefault:boolean} + * body-example: {"name":"GHCR","url":"https://ghcr.io","username":"deploy","password":"***","isDefault":true} + * resp-200: {id:integer!, name:string!, url:string!, isDefault:boolean, hasCredentials:boolean!} + * resp-400: Invalid id, or a duplicate registry name + * resp-403: Caller lacks the registries:edit permission + * resp-404: No registry exists with that ID + * resp-500: The update failed + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -98,6 +120,17 @@ export const PUT: RequestHandler = async (event) => { } }; +/** + * @openapi + * summary: Delete a registry by ID + * path: id:integer! Registry ID (from GET /api/registries) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: Invalid id, or the registry cannot be deleted + * resp-403: Caller lacks the registries:delete permission + * resp-404: No registry exists with that ID + * resp-500: The deletion failed + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/registries/[id]/default/+server.ts b/src/routes/api/registries/[id]/default/+server.ts index 11dc02f..67a8381 100644 --- a/src/routes/api/registries/[id]/default/+server.ts +++ b/src/routes/api/registries/[id]/default/+server.ts @@ -3,6 +3,17 @@ import type { RequestHandler } from './$types'; import { setDefaultRegistry, getRegistry } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +/** + * @openapi + * summary: Mark a registry as the default registry + * path: id:integer! Registry ID (from GET /api/registries) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: The id path segment is not a valid integer + * resp-403: Caller lacks the settings:edit permission + * resp-404: No registry exists with that ID + * resp-500: Failed to set the default registry + */ export const POST: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('settings', 'edit')) { diff --git a/src/routes/api/registries/test/+server.ts b/src/routes/api/registries/test/+server.ts index c065538..2a34faf 100644 --- a/src/routes/api/registries/test/+server.ts +++ b/src/routes/api/registries/test/+server.ts @@ -10,6 +10,18 @@ import { getRegistry } from '$lib/server/db'; * Accepts either inline credentials (from the modal form) or a registry ID * (to test an already-saved registry using stored credentials). */ +/** + * @openapi + * summary: Test registry connectivity and (if credentials are given) authentication against the V2 endpoint + * description: Pass either a saved registryId or inline url/username/password. The outcome is always reported in a 200 body via the success/connectivity/authenticated fields, never as an HTTP error. registryId from GET /api/registries. + * body: {registryId:integer, url:string, username:string, password:string} + * body-example: {"url":"https://ghcr.io","username":"deploy","password":"***"} + * resp-200: {success:boolean!, connectivity:boolean!, authenticated:boolean, message:string!} + * resp-200-example: {"success":true,"connectivity":true,"authenticated":true,"message":"Connected and authenticated as deploy"} + * resp-400: The url field is missing (and no registryId was supplied) + * resp-403: Caller lacks the registries:view permission + * resp-404: The referenced registryId does not exist + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('registries', 'view')) { diff --git a/src/routes/api/registry/catalog/+server.ts b/src/routes/api/registry/catalog/+server.ts index 85e700c..6e54f6f 100644 --- a/src/routes/api/registry/catalog/+server.ts +++ b/src/routes/api/registry/catalog/+server.ts @@ -5,6 +5,19 @@ import { getRegistryAuth, isHarborRegistry, harborListRepositories, parseRegistr const PAGE_SIZE = 100; +/** + * @openapi + * summary: List repositories in a registry's V2 catalog (with Harbor project-API fallback), paginated + * description: Docker Hub is rejected (no catalog API). For 401/403/404 from the upstream registry the same status is proxied back to the caller. + * query: registry:integer! ID of the configured registry to query (from GET /api/registries) + * query: last:string Opaque pagination cursor from a previous page's nextLast + * resp-200: {repositories:array<{name:string!, description:string, star_count:integer, is_official:boolean, is_automated:boolean}>!, pagination:{pageSize:integer!, hasMore:boolean!, nextLast:string}!} + * resp-200-example: {"repositories":[{"name":"library/nginx","description":"","star_count":0,"is_official":false,"is_automated":false}],"pagination":{"pageSize":100,"hasMore":false,"nextLast":null}} + * resp-400: Missing registry parameter, or Docker Hub was targeted (catalog listing unsupported) + * resp-404: Registry not found, or the registry does not implement the V2 catalog API + * resp-500: Failed to fetch the catalog + * resp-503: Could not connect to the registry (connection refused, host not found, or a TLS error) + */ export const GET: RequestHandler = async ({ url }) => { try { const registryId = url.searchParams.get('registry'); diff --git a/src/routes/api/registry/image/+server.ts b/src/routes/api/registry/image/+server.ts index b20e794..efadd78 100644 --- a/src/routes/api/registry/image/+server.ts +++ b/src/routes/api/registry/image/+server.ts @@ -19,6 +19,23 @@ const MANIFEST_TYPES = [ 'application/vnd.oci.image.index.v1+json' ]; +/** + * @openapi + * summary: Delete a tagged image from a registry by resolving its manifest digest, then deleting by digest + * description: Docker Hub deletion is rejected. Upstream 401 responses during manifest resolution/deletion are proxied back as 401. + * query: registry:integer! ID of the configured registry (from GET /api/registries) + * query: image:string! Repository/image name (may include an org path) + * query: tag:string! Tag to delete + * resp-200: {success:boolean!, message:string} + * resp-200-example: {"success":true,"message":"Deleted library/nginx:1.27"} + * resp-400: A required query parameter is missing, or Docker Hub was targeted (deletion unsupported) + * resp-401: The registry rejected authentication while resolving or deleting the manifest + * resp-403: Caller lacks the settings:edit permission + * resp-404: Registry not found, or the image/tag/manifest does not exist + * resp-405: The registry has manifest deletion disabled (REGISTRY_STORAGE_DELETE_ENABLED) + * resp-500: Failed to delete the image + * resp-503: Could not connect to the registry (connection refused or host not found) + */ export const DELETE: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('settings', 'edit')) { diff --git a/src/routes/api/registry/search/+server.ts b/src/routes/api/registry/search/+server.ts index dc2d238..7e3fe19 100644 --- a/src/routes/api/registry/search/+server.ts +++ b/src/routes/api/registry/search/+server.ts @@ -220,6 +220,20 @@ async function searchCatalog(registry: any, term: string, limit: number): Promis return results; } +/** + * @openapi + * summary: Search for images by name in Docker Hub or a configured private registry (direct + catalog fallback) + * description: With no registry parameter the search runs against Docker Hub; otherwise against the given registry, falling back to Docker Hub search when the registry is a Docker Hub mirror. + * query: term:string! Search term / image name + * query: limit:integer Maximum number of results (default 25) + * query: registry:integer ID of the configured registry to search; omit to search Docker Hub (from GET /api/registries) + * resp-200: array<{name:string!, description:string, star_count:integer, is_official:boolean, is_automated:boolean}> + * resp-200-example: [{"name":"nginx","description":"Official build of Nginx","star_count":20000,"is_official":true,"is_automated":false}] + * resp-400: The term query parameter is missing + * resp-404: The referenced registry does not exist + * resp-500: Failed to search images + * resp-503: Could not connect to the registry (connection refused or host not found) + */ export const GET: RequestHandler = async ({ url }) => { const term = url.searchParams.get('term'); const limit = parseInt(url.searchParams.get('limit') || '25', 10); diff --git a/src/routes/api/registry/tag-info/+server.ts b/src/routes/api/registry/tag-info/+server.ts index 32ec08e..cdbc3cc 100644 --- a/src/routes/api/registry/tag-info/+server.ts +++ b/src/routes/api/registry/tag-info/+server.ts @@ -106,6 +106,17 @@ async function resolveTagInfo(registry: any, image: string, tag: string): Promis return result; } +/** + * @openapi + * summary: Resolve the compressed size and creation date of a single registry tag from its manifest (lazy per-tag fetch for the registry browser) + * query: registry:integer Registry ID; omitted or Docker Hub registries return a graceful "not supported" result instead of a manifest fetch (from GET /api/registries) + * query: image:string! Image repository/name + * query: tag:string! Tag name or digest + * resp-200: {size:number, lastUpdated:string, reason:string} + * resp-200-example: {"size":104857600,"lastUpdated":"2026-06-01T12:00:00Z"} + * resp-400: image and tag are required + * resp-404: Registry not found + */ export const GET: RequestHandler = async ({ url }) => { const registryId = url.searchParams.get('registry'); const image = url.searchParams.get('image'); diff --git a/src/routes/api/registry/tags/+server.ts b/src/routes/api/registry/tags/+server.ts index 33609d4..805abc5 100644 --- a/src/routes/api/registry/tags/+server.ts +++ b/src/routes/api/registry/tags/+server.ts @@ -116,6 +116,21 @@ async function fetchRegistryTags(registry: any, imageName: string): Promise!, total:integer!, page:integer!, pageSize:integer!, hasNext:boolean!, hasPrev:boolean!} + * resp-200-example: {"tags":[{"name":"1.27","lastUpdated":"2026-06-01T00:00:00Z"}],"total":1,"page":1,"pageSize":20,"hasNext":false,"hasPrev":false} + * resp-400: The image query parameter is missing + * resp-404: The referenced registry does not exist + * resp-500: Failed to fetch tags + * resp-503: Could not connect to the registry (connection refused or host not found) + */ export const GET: RequestHandler = async ({ url }) => { try { const registryId = url.searchParams.get('registry'); diff --git a/src/routes/api/roles/+server.ts b/src/routes/api/roles/+server.ts index 9f6f2f5..99065fb 100644 --- a/src/routes/api/roles/+server.ts +++ b/src/routes/api/roles/+server.ts @@ -8,6 +8,13 @@ import { authorize } from '$lib/server/authorize'; import { auditRole } from '$lib/server/audit'; // GET /api/roles - List all roles +/** + * @openapi + * summary: List all roles (built-in and custom); available in setup mode or with an enterprise license + * resp-200: array<{id:integer!, name:string!, description:string, isSystem:boolean!, permissions:{}}> + * resp-403: Enterprise license required + * resp-500: Failed to read the roles + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -27,6 +34,18 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // POST /api/roles - Create a new role +/** + * @openapi + * summary: Create a custom role (enterprise; admin required when auth is enabled) + * description: environmentIds from GET /api/environments. + * body: {name:string!, description:string, permissions:{}!, environmentIds:array} + * body-example: {"name":"Operators","description":"Can manage containers","permissions":{"containers":["view","edit"]},"environmentIds":[1,2]} + * resp-201: The created role + * resp-400: Name and permissions are required + * resp-403: Enterprise license required, or admin access required + * resp-409: A role with this name already exists + * resp-500: Failed to create the role + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/roles/[id]/+server.ts b/src/routes/api/roles/[id]/+server.ts index 19e4f57..4f6b3f4 100644 --- a/src/routes/api/roles/[id]/+server.ts +++ b/src/routes/api/roles/[id]/+server.ts @@ -11,6 +11,16 @@ import { computeAuditDiff } from '$lib/utils/diff'; import { clearTokenCache } from '$lib/server/api-tokens'; // GET /api/roles/[id] - Get a specific role +/** + * @openapi + * summary: Get a single role by id; available in setup mode or with an enterprise license + * path: id:integer! Numeric id of the role (from GET /api/roles) + * resp-200: {id:integer!, name:string!, description:string, isSystem:boolean!, permissions:{}} + * resp-400: Role id is required + * resp-403: Enterprise license required + * resp-404: Role not found + * resp-500: Failed to read the role + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -39,6 +49,20 @@ export const GET: RequestHandler = async ({ params, cookies }) => { }; // PUT /api/roles/[id] - Update a role +/** + * @openapi + * summary: Update a custom role (system roles cannot be modified; enterprise, admin required when auth is enabled) + * description: environmentIds from GET /api/environments. + * path: id:integer! Numeric id of the role (from GET /api/roles) + * body: {name:string, description:string, permissions:{}, environmentIds:array} + * body-example: {"description":"Updated description","permissions":{"containers":["view"]}} + * resp-200: The updated role + * resp-400: Role id is required, or the role is a system role and cannot be modified + * resp-403: Enterprise license required, or admin access required + * resp-404: Role not found + * resp-409: A role with this name already exists + * resp-500: Failed to update the role + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -96,6 +120,16 @@ export const PUT: RequestHandler = async (event) => { }; // DELETE /api/roles/[id] - Delete a role +/** + * @openapi + * summary: Delete a custom role by id (system roles cannot be deleted; enterprise, admin required when auth is enabled) + * path: id:integer! Numeric id of the role (from GET /api/roles) + * resp-200: {success:boolean!} + * resp-400: Role id is required, or the role is a system role and cannot be deleted + * resp-403: Enterprise license required, or admin access required + * resp-404: Role not found + * resp-500: Failed to delete the role + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/schedules/+server.ts b/src/routes/api/schedules/+server.ts index 43722ea..cd07585 100644 --- a/src/routes/api/schedules/+server.ts +++ b/src/routes/api/schedules/+server.ts @@ -47,6 +47,12 @@ export interface ScheduleInfo { vulnerabilityCriteria?: VulnerabilityCriteria | null; } +/** + * @openapi + * summary: List all schedules (container/env auto-updates, git syncs, image-prune, backups, repo maintenance, system jobs) + * resp-200: {schedules:array<{id:integer!, type:string!, name:string!, entityName:string!, environmentId:integer, enabled:boolean!, cronExpression:string, nextRun:string, isSystem:boolean!}>!} + * resp-500: Unexpected error while assembling the schedule list + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/schedules/[type]/[id]/+server.ts b/src/routes/api/schedules/[type]/[id]/+server.ts index ce66043..ae1fec5 100644 --- a/src/routes/api/schedules/[type]/[id]/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/+server.ts @@ -20,6 +20,16 @@ import { import { unregisterSchedule } from '$lib/server/scheduler'; import { authorize } from '$lib/server/authorize'; +/** + * @openapi + * summary: Delete (or disable, for policy-driven types) a schedule by type and id + * path: type:string! Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup) + * path: id:integer! Schedule id (semantics depend on type — container/git-stack/backup-config id, environment id, or a synthetic repo-policy id) (from GET /api/schedules) + * resp-200: {success:boolean!} + * resp-400: Invalid schedule id, invalid/unsupported type, or system_cleanup (cannot be removed) + * resp-404: Schedule (or backup destination, for repo_* types) not found + * resp-500: Unexpected error while deleting the schedule + */ export const DELETE: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/schedules/[type]/[id]/run/+server.ts b/src/routes/api/schedules/[type]/[id]/run/+server.ts index c8a7117..fc636ee 100644 --- a/src/routes/api/schedules/[type]/[id]/run/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/run/+server.ts @@ -18,6 +18,16 @@ import { authorize } from '$lib/server/authorize'; import { getAutoUpdateSettingById, getGitStack } from '$lib/server/db'; import { BACKUPS_ENABLED } from '$lib/server/features'; +/** + * @openapi + * summary: Manually trigger a single run of a schedule (outside its cron), by type and id + * path: type:string! Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify) + * path: id:integer! Schedule id (semantics depend on type) (from GET /api/schedules) + * resp-200: {success:boolean!, message:string!} + * resp-400: Invalid schedule id/type, or the triggered task itself reported failure + * resp-404: Schedule, backup config, or backup destination not found (or backup feature disabled) + * resp-500: Unexpected error while triggering the schedule + */ export const POST: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts index 269906a..e85a751 100644 --- a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts @@ -10,6 +10,16 @@ import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; import { authorize } from '$lib/server/authorize'; import { auditBackup, auditBackupDestination } from '$lib/server/audit'; +/** + * @openapi + * summary: Toggle a schedule's enabled/disabled state (registers/unregisters the croner job accordingly) + * path: type:string! Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup) + * path: id:integer! Schedule id (semantics depend on type) (from GET /api/schedules) + * resp-200: {success:boolean!, enabled:boolean!} + * resp-400: Invalid schedule id, unsupported type, or system_cleanup (cannot be paused) + * resp-404: Schedule, backup config, or backup destination not found + * resp-500: Unexpected error while toggling the schedule + */ export const POST: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/schedules/executions/+server.ts b/src/routes/api/schedules/executions/+server.ts index 56ba7ea..08e4e2d 100644 --- a/src/routes/api/schedules/executions/+server.ts +++ b/src/routes/api/schedules/executions/+server.ts @@ -24,6 +24,22 @@ import { type ScheduleStatus } from '$lib/server/db'; +/** + * @openapi + * summary: List schedule execution history, filterable and paginated + * query: scheduleType:string Filter by schedule type (container_update, git_stack_sync, ...) + * query: scheduleId:integer Filter by the numeric id of the schedule (from GET /api/schedules) + * query: environmentId:integer Filter by environment id ("null" for global/system schedules) (from GET /api/environments) + * query: status:string Filter by execution status (queued/running/success/warning/failed/skipped) + * query: statuses:string Comma-separated list of statuses (alternative to status) + * query: triggeredBy:string Filter by trigger (cron/webhook/manual) + * query: fromDate:string ISO date lower bound + * query: toDate:string ISO date upper bound + * query: limit:integer Page size (default 50) + * query: offset:integer Page offset (default 0) + * resp-200: {executions:array<{id:integer!, scheduleType:string!, status:string!, startedAt:string!}>!, total:integer!, limit:integer!, offset:integer!} + * resp-500: Unexpected error while loading execution history + */ export const GET: RequestHandler = async ({ url }) => { try { const scheduleType = url.searchParams.get('scheduleType') as ScheduleType | null; diff --git a/src/routes/api/schedules/executions/[id]/+server.ts b/src/routes/api/schedules/executions/[id]/+server.ts index ce41dde..b2d7f56 100644 --- a/src/routes/api/schedules/executions/[id]/+server.ts +++ b/src/routes/api/schedules/executions/[id]/+server.ts @@ -10,6 +10,15 @@ import type { RequestHandler } from './$types'; import { getScheduleExecution, deleteScheduleExecution } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +/** + * @openapi + * summary: Get a single schedule execution, including its logs + * path: id:integer! Execution id (from GET /api/schedules/executions) + * resp-200: {id:integer!, scheduleType:string!, status:string!, startedAt:string!, log:string} + * resp-400: Invalid execution id + * resp-404: Execution not found + * resp-500: Unexpected error while loading the execution + */ export const GET: RequestHandler = async ({ params }) => { try { const id = parseInt(params.id, 10); @@ -29,6 +38,15 @@ export const GET: RequestHandler = async ({ params }) => { } }; +/** + * @openapi + * summary: Delete a single schedule execution record + * path: id:integer! Execution id (from GET /api/schedules/executions) + * resp-200: {success:boolean!} + * resp-400: Invalid execution id + * resp-403: Permission denied (RBAC 'schedules:edit' missing) + * resp-500: Unexpected error while deleting the execution + */ export const DELETE: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('schedules', 'edit')) { diff --git a/src/routes/api/schedules/settings/+server.ts b/src/routes/api/schedules/settings/+server.ts index ed21bc2..e33f64c 100644 --- a/src/routes/api/schedules/settings/+server.ts +++ b/src/routes/api/schedules/settings/+server.ts @@ -16,6 +16,12 @@ import { authorize } from '$lib/server/authorize'; const getHideSystemJobsKey = (userId?: number) => userId ? `user_${userId}_schedules_hide_system_jobs` : 'schedules_hide_system_jobs'; +/** + * @openapi + * summary: Get the schedules-page display preference (hide system jobs), per-user with a global fallback + * resp-200: {hideSystemJobs:boolean!} + * resp-500: Unexpected error while loading the setting + */ export const GET: RequestHandler = async ({ cookies }) => { try { const auth = await authorize(cookies); @@ -37,6 +43,15 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; +/** + * @openapi + * summary: Update the schedules-page display preference (hide system jobs) for the current user + * body: {hideSystemJobs:boolean} + * body-example: {"hideSystemJobs":true} + * resp-200: {success:boolean!, hideSystemJobs:boolean} + * resp-400: hideSystemJobs is not a boolean + * resp-500: Unexpected error while saving the setting + */ export const PUT: RequestHandler = async ({ request, cookies }) => { try { const auth = await authorize(cookies); diff --git a/src/routes/api/schedules/stream/+server.ts b/src/routes/api/schedules/stream/+server.ts index d896231..0aa75f1 100644 --- a/src/routes/api/schedules/stream/+server.ts +++ b/src/routes/api/schedules/stream/+server.ts @@ -336,6 +336,12 @@ async function getSchedulesData(): Promise { return schedules; } +/** + * @openapi + * summary: Server-Sent Events stream that polls and pushes schedule updates (same shape as GET /api/schedules) + * resp-200: text/event-stream response, periodically emitting the current schedule list as SSE "data:" messages + * resp-403: Permission denied (RBAC 'schedules:view' missing) + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('schedules', 'view')) { diff --git a/src/routes/api/schedules/system/[id]/toggle/+server.ts b/src/routes/api/schedules/system/[id]/toggle/+server.ts index ef30fcd..2d6dae4 100644 --- a/src/routes/api/schedules/system/[id]/toggle/+server.ts +++ b/src/routes/api/schedules/system/[id]/toggle/+server.ts @@ -14,6 +14,15 @@ const SYSTEM_SCHEDULE_CLEANUP_ID = 1; const SYSTEM_EVENT_CLEANUP_ID = 2; const SYSTEM_SCANNER_CLEANUP_ID = 4; +/** + * @openapi + * summary: Toggle one of the three built-in system cleanup jobs (schedule/event/scanner cleanup) on or off + * path: id:integer! System schedule id (1=schedule cleanup, 2=event cleanup, 4=scanner cleanup) (from GET /api/schedules) + * resp-200: {success:boolean!, enabled:boolean!} + * resp-400: Invalid or unknown system schedule id + * resp-403: Permission denied (RBAC 'settings:edit' missing) + * resp-500: Unexpected error while toggling the job + */ export const POST: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('settings', 'edit')) { diff --git a/src/routes/api/secret-providers/+server.ts b/src/routes/api/secret-providers/+server.ts index 4759716..b70716d 100644 --- a/src/routes/api/secret-providers/+server.ts +++ b/src/routes/api/secret-providers/+server.ts @@ -5,6 +5,13 @@ import { hasProvider } from '$lib/server/secretproviders'; import { authorize } from '$lib/server/authorize'; import { auditSecretProvider } from '$lib/server/audit'; +/** + * @openapi + * summary: List configured secret providers (summaries never include the decrypted config) + * resp-200: array<{id:integer!, name:string!, type:string!}> + * resp-403: Permission denied (needs secrets:view) + * resp-500: Failed to fetch secret providers + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !(await auth.can('secrets', 'view'))) { @@ -21,6 +28,15 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; +/** + * @openapi + * summary: Create a secret provider (Vault, Infisical, Doppler, 1Password Connect) + * body: {name:string!, type:string!, config:object!} + * resp-201: {id:integer!, name:string!, type:string!} + * resp-400: Name and type are required, unknown provider type, config missing, or a provider with this name already exists + * resp-403: Permission denied (needs secrets:create) + * resp-500: Failed to create secret provider + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/secret-providers/[id]/+server.ts b/src/routes/api/secret-providers/[id]/+server.ts index 23e11f7..7776c5a 100644 --- a/src/routes/api/secret-providers/[id]/+server.ts +++ b/src/routes/api/secret-providers/[id]/+server.ts @@ -11,6 +11,15 @@ import { redactProviderConfig } from '$lib/server/secretproviders/shared'; import { authorize } from '$lib/server/authorize'; import { auditSecretProvider } from '$lib/server/audit'; +/** + * @openapi + * summary: Get one secret provider with its non-secret config coordinates (the token is redacted out) + * path: id:integer The secret provider id + * resp-200: {id:integer!, name:string!, type:string!, config:object!} + * resp-400: Invalid secret provider ID + * resp-403: Permission denied (needs secrets:view) + * resp-404: Secret provider not found + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !(await auth.can('secrets', 'view'))) { @@ -33,6 +42,17 @@ export const GET: RequestHandler = async ({ params, cookies }) => { return json({ ...summary, config: redactProviderConfig(config) }); }; +/** + * @openapi + * summary: Update a secret provider (name, type, and/or rotate its config); omitted fields are left unchanged + * path: id:integer The secret provider id + * body: {name:string, type:string, config:object} + * resp-200: {id:integer!, name:string!, type:string!} + * resp-400: Invalid ID, empty name, unknown type, config not an object, or the name already exists + * resp-403: Permission denied (needs secrets:edit) + * resp-404: Secret provider not found + * resp-500: Failed to update secret provider + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -92,6 +112,16 @@ export const PUT: RequestHandler = async (event) => { } }; +/** + * @openapi + * summary: Delete a secret provider + * path: id:integer The secret provider id + * resp-200: {success:boolean!} + * resp-400: Invalid secret provider ID + * resp-403: Permission denied (needs secrets:delete) + * resp-404: Secret provider not found + * resp-500: Failed to delete secret provider + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/secret-providers/[id]/probe/+server.ts b/src/routes/api/secret-providers/[id]/probe/+server.ts index 9793ae8..57779fd 100644 --- a/src/routes/api/secret-providers/[id]/probe/+server.ts +++ b/src/routes/api/secret-providers/[id]/probe/+server.ts @@ -11,6 +11,17 @@ import { probeBulkKeysCached } from '$lib/server/secretproviders/probe-cache'; * references, return which KEY NAMES currently exist in the provider. Only names * are returned - secret values never leave the server. Powers the editor's live * "IN VAULT" marker. + * + * @openapi + * summary: Live-probe which key NAMES exist in a stored provider (names only, values never leave the server) + * path: id:integer The secret provider id + * body: {selector:string, refs:array} + * body-example: {"selector":"prod","refs":["op://vault/db/password"]} + * resp-200: {ok:boolean!, bulkKeys:array, resolvedRefs:array, error:string} + * resp-200-desc: ok=true returns the key names found via the bulk selector and the resolved op:// refs; ok=false folds a provider error in (still 200) so a slow or auth-gated provider never breaks the editor + * resp-400: Invalid ID or unknown provider type + * resp-403: Permission denied (needs secrets:view) + * resp-404: Secret provider not found */ export const POST: RequestHandler = async ({ params, cookies, request }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/secret-providers/[id]/test/+server.ts b/src/routes/api/secret-providers/[id]/test/+server.ts index c56a174..35958e2 100644 --- a/src/routes/api/secret-providers/[id]/test/+server.ts +++ b/src/routes/api/secret-providers/[id]/test/+server.ts @@ -12,6 +12,17 @@ import type { SecretProviderConfig } from '$lib/server/secretproviders/shared'; * Save would persist: the incoming config merged over the stored one, with a blank token * falling back to the stored secret. The secret is NEVER supplied by the client - it comes * from storage server-side - so this stays a `secrets:view` operation. + * + * @openapi + * summary: Test a stored provider's connectivity, optionally against typed override fields the edit form would save + * path: id:integer The secret provider id + * body: {config:object} + * body-example: {"config":{"host":"https://vault.example.com","mount":"secret"}} + * resp-200: {ok:boolean!, error:string} + * resp-200-desc: ok=false carries the connection error (still 200 so the edit form can show it inline) + * resp-400: Invalid secret provider ID + * resp-403: Permission denied (needs secrets:view) + * resp-404: Secret provider not found */ export const POST: RequestHandler = async ({ params, cookies, request }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/secret-providers/test/+server.ts b/src/routes/api/secret-providers/test/+server.ts index ead545f..63d66ef 100644 --- a/src/routes/api/secret-providers/test/+server.ts +++ b/src/routes/api/secret-providers/test/+server.ts @@ -3,7 +3,18 @@ import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; import { hasProvider, testProviderConnection } from '$lib/server/secretproviders'; -/** Test a provider config before it's persisted to the database. */ +/** + * Test a provider config before it's persisted to the database. + * + * @openapi + * summary: Test an unsaved provider config (before creating the provider) + * body: {type:string!, config:object!} + * body-example: {"type":"vault","config":{"host":"https://vault.example.com","mount":"secret","token":"hvs...."}} + * resp-200: {ok:boolean!, error:string} + * resp-200-desc: ok=false carries the reason (invalid type, missing config, or a connection error) - still 200 so the form shows it inline + * resp-400: Invalid request body + * resp-403: Permission denied (needs secrets:create) + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !(await auth.can('secrets', 'create'))) { diff --git a/src/routes/api/self-update/+server.ts b/src/routes/api/self-update/+server.ts index b52efba..a33764b 100644 --- a/src/routes/api/self-update/+server.ts +++ b/src/routes/api/self-update/+server.ts @@ -237,6 +237,16 @@ function buildNetworkEnvVars(inspectData: any): string[] { * SSE stream endpoint for self-update. * Pulls image, creates new container, then launches minimal sidecar. */ +/** + * @openapi + * summary: Pull a new Dockhand image and hand off to an updater sidecar that replaces this running container + * body: {newImage:string!} + * body-example: {"newImage":"fnsys/dockhand:v1.0.40"} + * resp-200: text/event-stream SSE response (steps: pulling_image, building_config, pulling_updater, creating_container, launching_updater, then a "launched"/"error" event) — or, with "Accept: application/json", the final event as plain JSON + * resp-400: newImage missing, not running in Docker, or the Docker socket is read-only + * resp-403: Admin access required + * resp-500: Failed to inspect own container or determine its name before starting the update + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !auth.isAdmin) { diff --git a/src/routes/api/self-update/check/+server.ts b/src/routes/api/self-update/check/+server.ts index 7681ead..f7d8438 100644 --- a/src/routes/api/self-update/check/+server.ts +++ b/src/routes/api/self-update/check/+server.ts @@ -86,6 +86,12 @@ async function localDockerFetch(path: string, options: RequestInit = {}): Promis * Uses localDockerFetch exclusively to avoid environment routing issues * when the image comes from a private registry. */ +/** + * @openapi + * summary: Check whether a newer Dockhand image is available (version-tag compare via GitHub changelog, or registry-digest compare for mutable tags like :latest) + * resp-200: {updateAvailable:boolean!, currentImage:string, newImage:string, latestVersion:string, currentDigest:string, newDigest:string, containerName:string, isComposeManaged:boolean, isLocalImage:boolean, error:string} (also used for "not running in Docker" / inspection / changelog / registry failures — reported as updateAvailable:false + error, not an HTTP error status) + * resp-403: Admin access required + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !auth.isAdmin) { diff --git a/src/routes/api/self-update/progress/+server.ts b/src/routes/api/self-update/progress/+server.ts index a160730..bc3b688 100644 --- a/src/routes/api/self-update/progress/+server.ts +++ b/src/routes/api/self-update/progress/+server.ts @@ -61,6 +61,15 @@ function stripDockerLogHeaders(raw: Uint8Array): string { /** * Poll updater container logs and status for progress tracking. */ +/** + * @openapi + * summary: Poll the self-update sidecar container's logs and exit state for progress tracking + * query: id:string! Updater container id (from POST /api/self-update) + * resp-200: {logs:string!, status:string!, exitCode:integer} + * resp-400: Container ID is required + * resp-403: Admin access required + * resp-500: Failed to inspect the updater container, or failed to fetch its progress + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !auth.isAdmin) { diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts index 6bb73fc..30169dc 100644 --- a/src/routes/api/settings/general/+server.ts +++ b/src/routes/api/settings/general/+server.ts @@ -215,6 +215,12 @@ function parseScannerDnsStorage(raw: string | null | undefined): string[] { return trimmed.split(',').map((s) => s.trim()).filter(Boolean); } +/** + * @openapi + * summary: Get global (instance-wide) general settings + * resp-401: Not authenticated + * resp-500: Failed to load settings + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); // UI preferences (time format, date format) should be available to all authenticated users @@ -391,6 +397,14 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; +/** + * @openapi + * summary: Update global general settings (all fields optional; only supplied keys are written) + * description: A large flat settings bag - theme/fonts, scanner defaults, cleanup schedules, event/metrics collection, editor options (e.g. editorIndentGuides), and more. + * body: {animateIcons:boolean, editorIndentGuides:boolean, coloredActionButtons:boolean, lightTheme:string, darkTheme:string, defaultTimezone:string, logBufferSizeKb:integer, externalStackPaths:string} + * resp-403: Permission denied (needs settings:edit) + * resp-500: Failed to save settings + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('settings', 'edit')) { diff --git a/src/routes/api/settings/navigation/+server.ts b/src/routes/api/settings/navigation/+server.ts index 73c829e..a3449f6 100644 --- a/src/routes/api/settings/navigation/+server.ts +++ b/src/routes/api/settings/navigation/+server.ts @@ -12,6 +12,12 @@ import { authorize } from '$lib/server/authorize'; // GET /api/settings/navigation — returns effective prefs (for redirect/env-click), plus the raw // global + per-user values so the UI can show what's inherited. Auth off => global only. +/** + * @openapi + * summary: Get the effective navigation preferences (landing page, env-click target) plus the raw global and per-user values + * resp-200: {effective:{landingPage:string!, envClickPage:string!}, global:{landingPage:string, envClickPage:string}, user:{landingPage:string, envClickPage:string}} + * resp-200-example: {"effective":{"landingPage":"dashboard","envClickPage":"containers"},"global":{"landingPage":null,"envClickPage":null},"user":null} + */ export const GET: RequestHandler = async ({ cookies }) => { const authEnabled = await isAuthEnabled(); const user = authEnabled ? await validateSession(cookies) : null; @@ -22,6 +28,17 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // PUT /api/settings/navigation?scope=global|user +/** + * @openapi + * summary: Update navigation preferences (landing page, env-click target) at global or per-user scope + * query: scope:string Preference scope to update — "global" (default) or "user" + * body: {landingPage:string, envClickPage:string} + * body-example: {"landingPage":"containers","envClickPage":"logs"} + * resp-200: The updated preferences ({success:true, global:{...}} for scope=global, or {success:true, user:{...}} for scope=user) + * resp-400: Invalid input — invalid landingPage/envClickPage value, or scope=user requested while authentication is disabled + * resp-401: Not authenticated (scope=user, authentication is enabled but no valid session) + * resp-403: Permission denied (scope=global, requires settings:edit) + */ export const PUT: RequestHandler = async ({ request, url, cookies }) => { const scope = url.searchParams.get('scope') ?? 'global'; let patch: Partial; diff --git a/src/routes/api/settings/scanner/+server.ts b/src/routes/api/settings/scanner/+server.ts index 8e7ece4..7c4c55f 100644 --- a/src/routes/api/settings/scanner/+server.ts +++ b/src/routes/api/settings/scanner/+server.ts @@ -19,6 +19,16 @@ export interface ScannerSettings { trivyImage: string; } +/** + * @openapi + * summary: Get the vulnerability-scanner settings for an environment, plus (unless settingsOnly) scanner availability, versions and optional update info + * query: env:integer Environment id to read scanner settings for (falls back to global defaults) (from GET /api/environments) + * query: checkUpdates:boolean When true, also check the scanner images for available updates (slower) + * query: settingsOnly:boolean When true, return only settings + defaults and skip the Docker availability/version checks + * resp-200: Scanner settings and (unless settingsOnly) availability, versions, updates and defaults + * resp-403: Permission denied (missing settings:view for the environment) + * resp-500: Failed to read the scanner settings + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -79,6 +89,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; +/** + * @openapi + * summary: Save the vulnerability-scanner settings for an environment + * body: {scanner:string, grypeArgs:string, trivyArgs:string, envId:integer} + * body-example: {"scanner":"grype","grypeArgs":"-o json -v {image}","trivyArgs":"image --format json {image}","envId":1} + * resp-200: {success:boolean!, settings:{scanner:string!, grypeArgs:string!, trivyArgs:string!}} + * resp-400: Invalid scanner type (must be none, grype, trivy or both) + * resp-403: Permission denied (missing settings:edit for the environment) + * resp-500: Failed to save the scanner settings + */ export const POST: RequestHandler = async ({ request, url, cookies }) => { const auth = await authorize(cookies); @@ -134,6 +154,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { } }; +/** + * @openapi + * summary: Remove the scanner images (grype/trivy) and clean up scanner database volumes for an environment + * query: removeImages:boolean Must be true to actually perform the removal (required) + * query: scanner:string Which scanner image to remove (grype or trivy); omit to remove both + * query: env:integer Environment id whose scanner images should be removed (required) (from GET /api/environments) + * resp-200: {success:boolean!, removed:array, errors:array} + * resp-400: The removeImages parameter is required, or the environment id is missing + * resp-403: Permission denied (missing settings:edit for the environment) + * resp-404: Environment not found + * resp-500: Failed to remove the scanner images + */ export const DELETE: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/settings/scanner/cache/+server.ts b/src/routes/api/settings/scanner/cache/+server.ts index d609222..f2bd1cb 100644 --- a/src/routes/api/settings/scanner/cache/+server.ts +++ b/src/routes/api/settings/scanner/cache/+server.ts @@ -3,6 +3,13 @@ import { cleanupScannerCache } from '$lib/server/scanner'; import { authorize } from '$lib/server/authorize'; import { getEnvironments } from '$lib/server/db'; +/** + * @openapi + * summary: Clear the vulnerability-scanner cache (local volumes/bind-mount dirs plus each remote environment's scanner volume) + * resp-200: {success:boolean!, removedVolumes:array!, removedDirs:array!, skippedEnvironments:array!} + * resp-403: Permission denied + * resp-500: Failed to clear scanner cache + */ export const DELETE: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/settings/theme/+server.ts b/src/routes/api/settings/theme/+server.ts index 25e5970..75cf21b 100644 --- a/src/routes/api/settings/theme/+server.ts +++ b/src/routes/api/settings/theme/+server.ts @@ -20,6 +20,12 @@ const DEFAULT_THEME_SETTINGS = { editorIndentGuides: false }; +/** + * @openapi + * summary: Public theme + editor display settings (used by the login page before auth) + * resp-200: object + * resp-200-desc: Light/dark theme, fonts, and editor display flags (e.g. editorIndentGuides) + */ export const GET: RequestHandler = async () => { try { const [ diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts index b34e45b..f7fdcd9 100644 --- a/src/routes/api/stacks/+server.ts +++ b/src/routes/api/stacks/+server.ts @@ -7,6 +7,13 @@ import { auditStack } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: List compose stacks for one environment (internal, external, and git) + * query: env:integer Environment id + * resp-403: Permission denied (needs stacks:view) + * resp-404: Environment not found + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -72,6 +79,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; +/** + * @openapi + * summary: Create and (optionally) deploy a compose stack + * description: Writes the compose + .env to the stack dir, stores secrets in the DB, and with start deploys it. Can bind a secret provider. + * query: env:integer Target environment id + * body: {name:string!, compose:string!, composePath:string, envPath:string, envVars:array, rawEnvContent:string, secretProviderId:integer, start:boolean} + * resp-400: Invalid request (e.g. missing name/compose, or secretProviderId wrong type) + * resp-403: Permission denied (needs stacks:create; binding a secret provider also needs secrets:view) + * resp-500: Failed to create or deploy the stack + */ export const POST: RequestHandler = async (event) => { const { request, url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/[name]/+server.ts b/src/routes/api/stacks/[name]/+server.ts index 9f48acc..e6cb722 100644 --- a/src/routes/api/stacks/[name]/+server.ts +++ b/src/routes/api/stacks/[name]/+server.ts @@ -4,6 +4,21 @@ import { authorize } from '$lib/server/authorize'; import { auditStack } from '$lib/server/audit'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Remove a stack completely (compose down + delete files + database cleanup) + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment id (from GET /api/environments) + * query: force:boolean Force removal even if the compose down step fails + * query: volumes:boolean Also remove named volumes (docker compose down --volumes) + * query: files:boolean Delete the stack's on-disk files/directory too (default true; pass files=false to keep them on disk) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-400: Compose down failed and force was not set + * resp-403: Permission denied, or access denied to this environment + * resp-404: Compose file not found for this stack + * resp-500: Unexpected error while removing the stack + */ export const DELETE: RequestHandler = async (event) => { const { params, url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/[name]/check-path-change/+server.ts b/src/routes/api/stacks/[name]/check-path-change/+server.ts index 0c08666..d1a7921 100644 --- a/src/routes/api/stacks/[name]/check-path-change/+server.ts +++ b/src/routes/api/stacks/[name]/check-path-change/+server.ts @@ -9,8 +9,16 @@ import { join, dirname } from 'node:path'; /** * POST /api/stacks/[name]/check-path-change * - * Check if the proposed compose path differs from current and if old directory has files. - * Returns information about what would need to be moved if location changes. + * @openapi + * summary: Check whether a proposed compose path moves the stack to a different directory and how many files the old directory still holds + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment ID the stack belongs to (from GET /api/environments) + * body: {newComposePath:string!} + * body-example: {"newComposePath":"/opt/stacks/web/compose.yaml"} + * resp-200: {hasChanges:boolean!, oldDir:string, newDir:string, fileCount:integer!, currentComposePath:string} + * resp-200-example: {"hasChanges":true,"oldDir":"/opt/stacks/old","newDir":"/opt/stacks/web","fileCount":3,"currentComposePath":"/opt/stacks/old/compose.yaml"} + * resp-403: Permission denied (requires stacks:edit) + * resp-500: Failed to check path changes */ export const POST: RequestHandler = async ({ params, request, url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts index 71d8f25..8fe2689 100644 --- a/src/routes/api/stacks/[name]/compose/+server.ts +++ b/src/routes/api/stacks/[name]/compose/+server.ts @@ -5,6 +5,15 @@ import { authorize } from '$lib/server/authorize'; import { createJobResponse } from '$lib/server/sse'; // GET /api/stacks/[name]/compose - Get compose file content +/** + * @openapi + * summary: Get a stack's compose file content plus its resolved compose/env paths + * path: name:string The stack name + * query: env:integer Environment id the stack belongs to + * resp-403: Permission denied (needs stacks:view) + * resp-404: Stack or compose file not found + * resp-500: Failed to read the compose file + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !(await auth.can('stacks', 'view'))) { @@ -42,6 +51,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { }; // PUT /api/stacks/[name]/compose - Update compose file content +/** + * @openapi + * summary: Save a stack's compose file (and optionally relocate it, bind a secret provider, and redeploy) + * description: Every accepted PUT persists the compose content; with restart it also redeploys. Supports moving the compose/env to a new path and binding a secret provider. + * path: name:string The stack name + * query: env:integer Environment id the stack belongs to + * body: {content:string!, composePath:string, envPath:string, oldComposePath:string, oldEnvPath:string, moveFromDir:string, restart:boolean, secretProviderId:integer} + * resp-400: Invalid request (e.g. missing content, or secretProviderId wrong type) + * resp-403: Permission denied (needs stacks:edit; binding a secret provider also needs secrets:view) + * resp-500: Failed to save or deploy the compose file + */ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/[name]/delete-preview/+server.ts b/src/routes/api/stacks/[name]/delete-preview/+server.ts index 7143f92..06ee6b9 100644 --- a/src/routes/api/stacks/[name]/delete-preview/+server.ts +++ b/src/routes/api/stacks/[name]/delete-preview/+server.ts @@ -6,6 +6,16 @@ import { authorize } from '$lib/server/authorize'; // GET /api/stacks/[name]/delete-preview?env=N // Returns the on-disk directories a delete-with-files would remove, so the confirm modal // shows the user EXACTLY what the backend will delete (same source of truth — no drift). +/** + * @openapi + * summary: Preview the on-disk directories and named volumes a delete-with-files would remove for a stack + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment ID the stack belongs to (from GET /api/environments) + * resp-200: {stackName:string!, sourceType:string, stackDir:string, gitDir:string, namedVolumes:array!, canDeleteFiles:boolean!} + * resp-200-example: {"stackName":"web","sourceType":"internal","stackDir":"/opt/stacks/web","gitDir":null,"namedVolumes":["web_data"],"canDeleteFiles":true} + * resp-403: Permission denied (requires stacks:remove) + * resp-500: Failed to compute the deletion preview + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); const envId = url.searchParams.get('env'); diff --git a/src/routes/api/stacks/[name]/deploy/+server.ts b/src/routes/api/stacks/[name]/deploy/+server.ts index 73d141e..aa507a8 100644 --- a/src/routes/api/stacks/[name]/deploy/+server.ts +++ b/src/routes/api/stacks/[name]/deploy/+server.ts @@ -5,6 +5,16 @@ import { auditStack } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Deploy (docker compose up) a stack, optionally pulling images, building, and force-recreating; progress and the final result stream over Server-Sent Events + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment ID the stack belongs to (from GET /api/environments) + * body: {pull:boolean, build:boolean, forceRecreate:boolean} + * body-example: {"pull":true,"build":false,"forceRecreate":false} + * resp-200: Server-Sent-Events job stream with progress events and a final result event ({success, output}) + * resp-403: Permission denied (requires stacks:start, or environment access denied on enterprise) + */ export const POST: RequestHandler = async (event) => { const { params, url, cookies, request } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/[name]/down/+server.ts b/src/routes/api/stacks/[name]/down/+server.ts index e3cd648..724d9e3 100644 --- a/src/routes/api/stacks/[name]/down/+server.ts +++ b/src/routes/api/stacks/[name]/down/+server.ts @@ -5,6 +5,18 @@ import { auditStack } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Take a stack down (docker compose down — removes containers, keeps files), asynchronously + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment id (from GET /api/environments) + * body: {removeVolumes:boolean} + * body-example: {"removeVolumes":false} + * resp-200: {jobId:string!} + * resp-200-desc: Fire-and-forget job id — poll GET /api/jobs/{jobId} for the result. Send "Accept: application/json" (without text/event-stream) to instead block and receive the final {success,output|error} synchronously. + * resp-200-example: {"jobId":"3f9c5b1a-2e4d-4a6f-9b0a-1c7d8e9f0a1b"} + * resp-403: Permission denied, or access denied to this environment + */ export const POST: RequestHandler = async (event) => { const { params, url, cookies, request } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/[name]/env/+server.ts b/src/routes/api/stacks/[name]/env/+server.ts index 8111253..b02782c 100644 --- a/src/routes/api/stacks/[name]/env/+server.ts +++ b/src/routes/api/stacks/[name]/env/+server.ts @@ -34,6 +34,15 @@ function parseEnvFile(content: string): Record { * Secrets are stored only in the database and injected via shell environment at runtime. * The .env file only contains non-secret variables. */ +/** + * @openapi + * summary: Get a stack's env vars (non-secrets from file, secrets masked from DB) plus injected provider keys + * path: name:string The stack name + * query: env:integer Environment id the stack belongs to + * resp-200: {variables:array!, injectedSecretKeys:array, secretProvider:object} + * resp-403: Permission denied (needs stacks:view) + * resp-500: Failed to read env vars + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); const envId = url.searchParams.get('env'); @@ -134,6 +143,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { * If a secret's value is '***' (masked placeholder), the original value * from the database is preserved. */ +/** + * @openapi + * summary: Save a stack's secret env vars to the DB (secrets never hit disk) + * path: name:string The stack name + * query: env:integer Environment id the stack belongs to + * body: {variables:array!} + * resp-400: Invalid variables payload + * resp-403: Permission denied (needs stacks:edit) + * resp-500: Failed to save env vars + */ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => { const auth = await authorize(cookies); const envId = url.searchParams.get('env'); diff --git a/src/routes/api/stacks/[name]/env/raw/+server.ts b/src/routes/api/stacks/[name]/env/raw/+server.ts index 60b09d0..512d902 100644 --- a/src/routes/api/stacks/[name]/env/raw/+server.ts +++ b/src/routes/api/stacks/[name]/env/raw/+server.ts @@ -8,7 +8,15 @@ import type { RequestHandler } from './$types'; /** * GET /api/stacks/[name]/env/raw?env=X - * Get the raw .env file content as-is (with comments, formatting, etc.) + * + * @openapi + * summary: Get the raw .env file content as-is (comments and formatting preserved) for a stack + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment ID the stack belongs to (from GET /api/environments) + * resp-200: {content:string!, noEnvFile:boolean} + * resp-200-example: {"content":"FOO=bar\n# comment\nBAZ=qux\n"} + * resp-403: Permission denied (requires stacks:view, or environment access denied on enterprise) + * resp-500: Failed to get environment file */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); @@ -73,8 +81,18 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { /** * PUT /api/stacks/[name]/env/raw?env=X - * Save raw .env file content directly to disk. - * Body: { content: string } + * + * @openapi + * summary: Write raw .env file content to disk for a stack; empty content deletes the .env file, and masked "***" placeholders are rejected to avoid corrupting secrets + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment ID the stack belongs to (from GET /api/environments) + * body: {content:string!} + * body-example: {"content":"FOO=bar\nBAZ=qux\n"} + * resp-200: {success:boolean!, noEnvFile:boolean, deleted:boolean} + * resp-200-example: {"success":true} + * resp-400: Invalid body (content string required) or refusal to write a masked "***" placeholder + * resp-403: Permission denied (requires stacks:edit, or environment access denied on enterprise) + * resp-500: Failed to save environment file */ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/[name]/env/validate/+server.ts b/src/routes/api/stacks/[name]/env/validate/+server.ts index 6b01b14..7020154 100644 --- a/src/routes/api/stacks/[name]/env/validate/+server.ts +++ b/src/routes/api/stacks/[name]/env/validate/+server.ts @@ -91,9 +91,18 @@ function extractComposeVars(yaml: string): { required: string[]; optional: strin /** * POST /api/stacks/[name]/env/validate?env=X - * Validate stack environment variables against compose file requirements. - * Can use saved compose file or accept compose content in body. - * Body (optional): { compose: "yaml content..." } + * + * @openapi + * summary: Validate a stack's defined environment variables against the variables required/optional by its compose file (compose content and the defined variable list may be supplied in the body, otherwise loaded from the saved file/DB) + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment ID the stack belongs to (from GET /api/environments) + * body: {compose:string, variables:array} + * body-example: {"compose":"services:\n web:\n image: ${IMAGE}","variables":["IMAGE"]} + * resp-200: {valid:boolean!, required:array!, optional:array!, defined:array!, missing:array!, unused:array!} + * resp-200-example: {"valid":false,"required":["IMAGE"],"optional":[],"defined":[],"missing":["IMAGE"],"unused":[]} + * resp-400: No compose content provided and no saved compose file found + * resp-403: Permission denied (requires stacks:view, or environment access denied on enterprise) + * resp-500: Failed to validate environment variables */ export const POST: RequestHandler = async ({ params, url, cookies, request }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/[name]/relocate/+server.ts b/src/routes/api/stacks/[name]/relocate/+server.ts index e3f47d1..3440f2f 100644 --- a/src/routes/api/stacks/[name]/relocate/+server.ts +++ b/src/routes/api/stacks/[name]/relocate/+server.ts @@ -8,8 +8,17 @@ import { join, dirname } from 'node:path'; /** * POST /api/stacks/[name]/relocate * - * Move all stack files from old directory to new location. - * Updates the database with new paths and returns refreshed content. + * @openapi + * summary: Move all stack files from the old directory to a new location, update the stored compose/env paths, and return the refreshed compose/env content + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment ID the stack belongs to (from GET /api/environments) + * body: {oldDir:string!, newComposePath:string!, newEnvPath:string} + * body-example: {"oldDir":"/opt/stacks/old","newComposePath":"/opt/stacks/web/compose.yaml","newEnvPath":"/opt/stacks/web/.env"} + * resp-200: {success:boolean!, movedFiles:array!, errors:array, composeContent:string!, rawEnvContent:string!, envVars:array<{key:string!, value:string!, isSecret:boolean!}>!} + * resp-200-example: {"success":true,"movedFiles":["compose.yaml",".env"],"composeContent":"services: {}","rawEnvContent":"FOO=bar\n","envVars":[{"key":"FOO","value":"bar","isSecret":false}]} + * resp-400: oldDir and newComposePath are required, or the source directory does not exist + * resp-403: Permission denied (requires stacks:edit) + * resp-500: Failed to relocate stack */ export const POST: RequestHandler = async ({ params, request, url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/[name]/restart/+server.ts b/src/routes/api/stacks/[name]/restart/+server.ts index 893c198..d95d23b 100644 --- a/src/routes/api/stacks/[name]/restart/+server.ts +++ b/src/routes/api/stacks/[name]/restart/+server.ts @@ -5,6 +5,15 @@ import { auditStack } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Restart a stack (mode=restart) or recreate its containers (mode=recreate); progress and the final result stream over Server-Sent Events + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment ID the stack belongs to (from GET /api/environments) + * query: mode:string Restart mode — "recreate" recreates containers, anything else performs a plain restart + * resp-200: Server-Sent-Events job stream with a final result event ({success, output}) + * resp-403: Permission denied (requires stacks:restart, or environment access denied on enterprise) + */ export const POST: RequestHandler = async (event) => { const { params, url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/[name]/start/+server.ts b/src/routes/api/stacks/[name]/start/+server.ts index de52be3..6de3f20 100644 --- a/src/routes/api/stacks/[name]/start/+server.ts +++ b/src/routes/api/stacks/[name]/start/+server.ts @@ -5,6 +5,16 @@ import { auditStack } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Start a stack (docker compose start/up), asynchronously + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment id (from GET /api/environments) + * resp-200: {jobId:string!} + * resp-200-desc: Fire-and-forget job id — poll GET /api/jobs/{jobId} for the result. Send "Accept: application/json" (without text/event-stream) to instead block and receive the final {success,output|error} synchronously. + * resp-200-example: {"jobId":"3f9c5b1a-2e4d-4a6f-9b0a-1c7d8e9f0a1b"} + * resp-403: Permission denied, or access denied to this environment + */ export const POST: RequestHandler = async (event) => { const { params, url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/[name]/stop/+server.ts b/src/routes/api/stacks/[name]/stop/+server.ts index 264466f..c92434d 100644 --- a/src/routes/api/stacks/[name]/stop/+server.ts +++ b/src/routes/api/stacks/[name]/stop/+server.ts @@ -5,6 +5,16 @@ import { auditStack } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Stop a stack (docker compose stop), asynchronously + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment id (from GET /api/environments) + * resp-200: {jobId:string!} + * resp-200-desc: Fire-and-forget job id — poll GET /api/jobs/{jobId} for the result. Send "Accept: application/json" (without text/event-stream) to instead block and receive the final {success,output|error} synchronously. + * resp-200-example: {"jobId":"3f9c5b1a-2e4d-4a6f-9b0a-1c7d8e9f0a1b"} + * resp-403: Permission denied, or access denied to this environment + */ export const POST: RequestHandler = async (event) => { const { params, url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/adopt/+server.ts b/src/routes/api/stacks/adopt/+server.ts index 543d132..9036518 100644 --- a/src/routes/api/stacks/adopt/+server.ts +++ b/src/routes/api/stacks/adopt/+server.ts @@ -2,6 +2,18 @@ import { json, type RequestHandler } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; import { adoptSelectedStacks, type DiscoveredStack } from '$lib/server/stack-scanner'; +/** + * @openapi + * summary: Adopt previously discovered compose stacks into Dockhand for a given environment + * description: environmentId from GET /api/environments. + * body: {stacks:array<{name:string!, composePath:string!}>!, environmentId:integer!} + * body-example: {"stacks":[{"name":"web","composePath":"/opt/stacks/web/compose.yaml"}],"environmentId":1} + * resp-200: {adopted:array!, failed:array<{name:string!, error:string!}>!} + * resp-200-example: {"adopted":["web"],"failed":[]} + * resp-400: No stacks provided, missing environmentId, or a stack is missing name/composePath + * resp-403: Permission denied (requires stacks:create) + * resp-500: Unexpected error while adopting stacks + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('stacks', 'create')) { diff --git a/src/routes/api/stacks/base-path/+server.ts b/src/routes/api/stacks/base-path/+server.ts index 9573760..cbae22c 100644 --- a/src/routes/api/stacks/base-path/+server.ts +++ b/src/routes/api/stacks/base-path/+server.ts @@ -5,8 +5,10 @@ import type { RequestHandler } from './$types'; /** * GET /api/stacks/base-path * - * Returns the default Dockhand stacks directory path. - * This is where stacks are stored by default ($DATA_DIR/stacks/). + * @openapi + * summary: Return the default Dockhand stacks directory ($DATA_DIR/stacks/) where new stacks are stored by default + * resp-200: {basePath:string!} + * resp-200-example: {"basePath":"/data/stacks"} */ export const GET: RequestHandler = async () => { const basePath = getStacksDir(); diff --git a/src/routes/api/stacks/default-path/+server.ts b/src/routes/api/stacks/default-path/+server.ts index a4e77c9..4fd6fac 100644 --- a/src/routes/api/stacks/default-path/+server.ts +++ b/src/routes/api/stacks/default-path/+server.ts @@ -5,16 +5,18 @@ import { getEnvironment } from '$lib/server/db'; import type { RequestHandler } from './$types'; /** - * Get the default path for a new stack - * Used by the UI to show where files will be created + * Get the default path for a new stack — used by the UI to show where files will be created. + * With location set the path is {location}/{envName}/{stackName}/, otherwise Dockhand's + * default $DATA_DIR/stacks/{envName}/{stackName}/. * - * Query params: - * - name: Stack name (required) - * - env: Environment ID (optional) - * - location: Custom base location path (optional) - * - * If location is provided, path will be: {location}/{envName}/{stackName}/ - * Otherwise uses Dockhand's default: $DATA_DIR/stacks/{envName}/{stackName}/ + * @openapi + * summary: Compute the default compose/env file paths for a new stack, either under a custom base location or under Dockhand's default stacks directory + * query: name:string! Stack name + * query: env:integer Environment ID (scopes the path under the environment name) (from GET /api/environments) + * query: location:string Custom base location path + * resp-200: {stackDir:string!, composePath:string!, envPath:string!, source:string!} + * resp-200-example: {"stackDir":"/data/stacks/prod/web","composePath":"/data/stacks/prod/web/compose.yaml","envPath":"/data/stacks/prod/web/.env","source":"default"} + * resp-400: Stack name is required */ export const GET: RequestHandler = async ({ url }) => { const stackName = url.searchParams.get('name'); diff --git a/src/routes/api/stacks/path-hints/+server.ts b/src/routes/api/stacks/path-hints/+server.ts index 92b7715..fae467b 100644 --- a/src/routes/api/stacks/path-hints/+server.ts +++ b/src/routes/api/stacks/path-hints/+server.ts @@ -4,7 +4,16 @@ import { authorize } from '$lib/server/authorize'; /** * GET /api/stacks/path-hints?name=stackName&env=envId - * Returns path hints extracted from Docker container labels for a stack. + * + * @openapi + * summary: Return path hints (working directory and config file paths) extracted from a stack's Docker container labels + * query: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment ID the stack belongs to (from GET /api/environments) + * resp-200: {stackName:string!, workingDir:string, configFiles:array} + * resp-200-example: {"stackName":"web","workingDir":"/opt/stacks/web","configFiles":["/opt/stacks/web/compose.yaml"]} + * resp-400: Stack name is required + * resp-401: Unauthorized + * resp-500: Failed to get path hints */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/scan/+server.ts b/src/routes/api/stacks/scan/+server.ts index 0b55f79..a1103d7 100644 --- a/src/routes/api/stacks/scan/+server.ts +++ b/src/routes/api/stacks/scan/+server.ts @@ -2,6 +2,16 @@ import { json, type RequestHandler } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; import { scanExternalPaths, scanPaths, detectRunningStacks } from '$lib/server/stack-scanner'; +/** + * @openapi + * summary: Scan a given filesystem path (or all configured external paths when none is given) for compose stacks, flagging which discovered stacks are already running + * body: {path:string} + * body-example: {"path":"/opt/stacks"} + * resp-200: {discovered:array<{name:string!}>!, adopted:array!, skipped:array!, errors:array<{path:string!, error:string!}>!} + * resp-200-example: {"discovered":[{"name":"web"}],"adopted":[],"skipped":[],"errors":[]} + * resp-403: Permission denied (requires stacks:create) + * resp-500: Unexpected error while scanning + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('stacks', 'create')) { diff --git a/src/routes/api/stacks/sources/+server.ts b/src/routes/api/stacks/sources/+server.ts index 868c6d5..168290e 100644 --- a/src/routes/api/stacks/sources/+server.ts +++ b/src/routes/api/stacks/sources/+server.ts @@ -3,6 +3,13 @@ import type { RequestHandler } from './$types'; import { getStackSources } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +/** + * @openapi + * summary: List stack source records (their stored compose/env paths and source type) + * query: env:integer Filter to a single environment id + * resp-403: Permission denied (needs stacks:view) + * resp-500: Failed to list stack sources + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/stacks/validate-path/+server.ts b/src/routes/api/stacks/validate-path/+server.ts index 1958902..667b447 100644 --- a/src/routes/api/stacks/validate-path/+server.ts +++ b/src/routes/api/stacks/validate-path/+server.ts @@ -3,6 +3,15 @@ import { authorize } from '$lib/server/authorize'; import { validatePath } from '$lib/server/stack-scanner'; import { getExternalStackPaths } from '$lib/server/db'; +/** + * @openapi + * summary: Validate a candidate external stack path (exists, is a directory, no overlap with already-configured paths); validation failures are returned as 200 with valid=false + * body: {path:string!} + * body-example: {"path":"/opt/external-stacks"} + * resp-200: {valid:boolean!, error:string} + * resp-200-example: {"valid":false,"error":"Path does not exist"} + * resp-403: Permission denied (requires settings:edit) + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('settings', 'edit')) { diff --git a/src/routes/api/system/+server.ts b/src/routes/api/system/+server.ts index 001f4e0..e5ca111 100644 --- a/src/routes/api/system/+server.ts +++ b/src/routes/api/system/+server.ts @@ -120,6 +120,17 @@ async function getOwnContainerInfo(containerId: string | undefined): Promise { const auth = await authorize(cookies); diff --git a/src/routes/api/system/disk/+server.ts b/src/routes/api/system/disk/+server.ts index cee1538..1a79903 100644 --- a/src/routes/api/system/disk/+server.ts +++ b/src/routes/api/system/disk/+server.ts @@ -8,6 +8,18 @@ const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process. const DISK_USAGE_TIMEOUT = 15000; // 15 second timeout +/** + * GET /api/system/disk - Docker disk usage for an environment + * + * @openapi + * summary: Return Docker disk usage (df) for an environment, or null when collection is disabled, times out, or fails + * query: env:integer! ID of the environment to query disk usage for (from GET /api/environments) + * resp-200: {diskUsage:object} + * resp-200-desc: diskUsage is null when SKIP_DF_COLLECTION is set, or the query times out or errors + * resp-200-example: {"diskUsage":{"LayersSize":1420000000,"Images":[],"Containers":[],"Volumes":[]}} + * resp-400: Environment ID is required + * resp-403: Permission denied, or (enterprise) no access to this environment + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/system/files/+server.ts b/src/routes/api/system/files/+server.ts index 8bf3176..0d66c7d 100644 --- a/src/routes/api/system/files/+server.ts +++ b/src/routes/api/system/files/+server.ts @@ -18,7 +18,16 @@ export interface FileEntry { * POST /api/system/files * Create a directory * - * Body: { path: string } + * @openapi + * summary: Create a directory on Dockhand's local filesystem (absolute path, no traversal, non-protected) + * body: {path:string!} + * body-example: {"path":"/docker/stacks/myapp"} + * resp-200: {success:boolean!, path:string!} + * resp-200-example: {"success":true,"path":"/docker/stacks/myapp"} + * resp-400: Path is missing, not absolute, or contains ".." + * resp-403: Permission denied, or the path is protected + * resp-409: Path already exists + * resp-500: Failed to create directory */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); @@ -65,8 +74,15 @@ export const POST: RequestHandler = async ({ request, cookies }) => { * GET /api/system/files * Browse Dockhand's local filesystem (for mount browsing) * - * Query params: - * - path: Directory path to list + * @openapi + * summary: List the entries of a directory on Dockhand's local filesystem (protected paths are hidden) + * query: path:string Absolute directory path to list (defaults to "/") + * resp-200: {path:string!, parent:string, entries:array<{name:string!, path:string!, type:string!, size:integer!, mtime:string!, mode:string!}>!} + * resp-200-example: {"path":"/docker","parent":"/","entries":[{"name":"stacks","path":"/docker/stacks","type":"directory","size":4096,"mtime":"2026-07-01T10:00:00.000Z","mode":"755"}]} + * resp-400: The path exists but is not a directory + * resp-403: Permission denied, or the path is protected + * resp-404: Path not found + * resp-500: Failed to list directory */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/system/files/content/+server.ts b/src/routes/api/system/files/content/+server.ts index 9d6e6f2..e7bfb6f 100644 --- a/src/routes/api/system/files/content/+server.ts +++ b/src/routes/api/system/files/content/+server.ts @@ -8,8 +8,15 @@ import { isProtectedPath } from '$lib/server/fs-guard'; * GET /api/system/files/content * Read file content from Dockhand's local filesystem * - * Query params: - * - path: File path to read + * @openapi + * summary: Read a text file from Dockhand's local filesystem (max 10MB, protected paths denied) + * query: path:string! Absolute path of the file to read + * resp-200: {path:string!, content:string!, size:integer!, mtime:string!} + * resp-200-example: {"path":"/docker/stacks/myapp/compose.yaml","content":"services:\n app:\n image: nginx","size":42,"mtime":"2026-07-01T10:00:00.000Z"} + * resp-400: Path is missing, points to a directory, or the file exceeds 10MB + * resp-403: Permission denied, or the path is protected + * resp-404: File not found + * resp-500: Failed to read file */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/api/templates/+server.ts b/src/routes/api/templates/+server.ts index 9c340e1..8fed1bb 100644 --- a/src/routes/api/templates/+server.ts +++ b/src/routes/api/templates/+server.ts @@ -188,6 +188,15 @@ async function fetchSource(source: TemplateSource): Promise { } } +/** + * GET /api/templates - Aggregated app templates from enabled sources + * + * @openapi + * summary: Return the normalized app templates aggregated from all enabled template sources (server-side cached for 1 hour) + * resp-200: array<{id:string!, type:string!, title:string!, description:string!, logo:string!, categories:array!, source:string!, image:string, projectUrl:string}> + * resp-200-example: [{"id":"a1b2c3","type":"container","title":"Nginx","description":"Web server","logo":"https://example.com/nginx.png","categories":["web"],"source":"LinuxServer.io","image":"lscr.io/linuxserver/nginx:latest","projectUrl":"https://github.com/linuxserver/docker-nginx"}] + * resp-403: Permission denied + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('templates', 'view')) { diff --git a/src/routes/api/templates/compose/+server.ts b/src/routes/api/templates/compose/+server.ts index 296f430..5a5ad2c 100644 --- a/src/routes/api/templates/compose/+server.ts +++ b/src/routes/api/templates/compose/+server.ts @@ -79,6 +79,19 @@ async function fetchStackCompose(repository: { url: string; stackfile: string }) return await response.text(); } +/** + * POST /api/templates/compose - Generate/fetch a compose file for a template + * + * @openapi + * summary: Return the compose YAML for a template — generated for container templates, fetched from the repository for stack templates + * body: {template:{id:string, type:string, title:string, image:string, repository:{url:string, stackfile:string}}!} + * body-example: {"template":{"id":"a1b2c3","type":"container","title":"Nginx","image":"nginx:latest"}} + * resp-200: {compose:string!} + * resp-200-example: {"compose":"services:\n nginx:\n image: nginx:latest\n restart: unless-stopped\n"} + * resp-400: Template is missing from the request body + * resp-403: Permission denied + * resp-500: Failed to generate or fetch the compose file + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('templates', 'deploy')) { diff --git a/src/routes/api/templates/sources/+server.ts b/src/routes/api/templates/sources/+server.ts index 67a0e7c..84f74e6 100644 --- a/src/routes/api/templates/sources/+server.ts +++ b/src/routes/api/templates/sources/+server.ts @@ -3,6 +3,15 @@ import { json } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; import { getTemplateSources, updateTemplateSource, addTemplateSource, deleteTemplateSource } from '$lib/server/templates'; +/** + * GET /api/templates/sources - List configured template sources + * + * @openapi + * summary: List all configured template sources (built-in and custom) + * resp-200: array<{id:integer!, name:string!, url:string!, enabled:boolean!}> + * resp-200-example: [{"id":1,"name":"LinuxServer.io","url":"https://fleet.linuxserver.io/api/v1/images","enabled":true}] + * resp-403: Permission denied + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('templates', 'view')) { @@ -14,6 +23,18 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // Toggle enabled or update a source +/** + * PUT /api/templates/sources - Toggle or update a template source + * + * @openapi + * summary: Update a template source — toggle enabled, or change its name/URL (only provided fields are changed) + * body: {id:integer!, enabled:boolean, name:string, url:string} + * body-example: {"id":3,"enabled":false} + * resp-200: {ok:boolean!} + * resp-200-example: {"ok":true} + * resp-400: Missing id + * resp-403: Permission denied + */ export const PUT: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('templates', 'manage')) { @@ -33,6 +54,18 @@ export const PUT: RequestHandler = async ({ request, cookies }) => { }; // Add a custom source +/** + * POST /api/templates/sources - Add a custom template source + * + * @openapi + * summary: Add a custom template source with a name and URL + * body: {name:string!, url:string!} + * body-example: {"name":"My Templates","url":"https://example.com/templates.json"} + * resp-200: {id:integer!, name:string!, url:string!, enabled:boolean!} + * resp-200-example: {"id":4,"name":"My Templates","url":"https://example.com/templates.json","enabled":true} + * resp-400: Name and URL are required + * resp-403: Permission denied + */ export const POST: RequestHandler = async ({ request, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('templates', 'manage')) { @@ -49,6 +82,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => { }; // Delete a custom source +/** + * DELETE /api/templates/sources - Delete a custom template source + * + * @openapi + * summary: Delete a custom template source by ID + * query: id:integer! ID of the template source to delete (from GET /api/templates/sources) + * resp-200: {ok:boolean!} + * resp-200-example: {"ok":true} + * resp-400: Missing id + * resp-403: Permission denied + */ export const DELETE: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('templates', 'manage')) { diff --git a/src/routes/api/users/+server.ts b/src/routes/api/users/+server.ts index ff56670..419f26d 100644 --- a/src/routes/api/users/+server.ts +++ b/src/routes/api/users/+server.ts @@ -16,6 +16,14 @@ import { auditUser } from '$lib/server/audit'; // GET /api/users - List all users // Free for all - local users are needed for basic auth +/** + * @openapi + * summary: List all local/SSO users (any authenticated user may view — only mutations are RBAC-gated) + * resp-200: array<{id:integer!, username:string!, email:string, displayName:string, isAdmin:boolean!, isActive:boolean!, isSso:boolean!, authProvider:string!}> + * resp-200-example: [{"id":1,"username":"admin","email":"admin@example.com","displayName":"Admin","isAdmin":true,"isActive":true,"isSso":false,"authProvider":"local"}] + * resp-401: Not authenticated + * resp-500: Unexpected error while loading users + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -65,6 +73,19 @@ export const GET: RequestHandler = async ({ cookies }) => { // POST /api/users - Create a new user // Free for all - local users are needed for basic auth +/** + * @openapi + * summary: Create a local user (allowed without auth only during initial setup, before any admin exists) + * body: {username:string!, email:string, password:string!, displayName:string} + * body-example: {"username":"jdoe","email":"jdoe@example.com","password":"correct horse battery staple","displayName":"Jane Doe"} + * resp-201: {id:integer!, username:string!, email:string, isAdmin:boolean!} + * resp-201-desc: The first user ever created (or every user in Free edition) automatically gets the Admin role + * resp-201-example: {"id":1,"username":"jdoe","email":"jdoe@example.com","isAdmin":true} + * resp-400: Missing username/password, or password shorter than 8 characters + * resp-403: Permission denied (RBAC 'users:create' missing — only applies once an admin already exists) + * resp-409: Username already exists + * resp-500: Unexpected error while creating the user + */ export const POST: RequestHandler = async (event) => { const { request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/users/[id]/+server.ts b/src/routes/api/users/[id]/+server.ts index d3128aa..bd68957 100644 --- a/src/routes/api/users/[id]/+server.ts +++ b/src/routes/api/users/[id]/+server.ts @@ -20,6 +20,16 @@ import { invalidateTokenCacheForUser } from '$lib/server/api-tokens'; // GET /api/users/[id] - Get a specific user // Free for all - local users are needed for basic auth +/** + * @openapi + * summary: Get a single user by id (password hash is never returned; isAdmin is derived from role assignment) + * path: id:integer! Numeric id of the user (from GET /api/users) + * resp-200: {id:integer!, username:string!, email:string, displayName:string, mfaEnabled:boolean!, isAdmin:boolean!, isActive:boolean!, lastLogin:string, createdAt:string!, updatedAt:string!} + * resp-400: User id is required + * resp-401: Authentication required (auth is enabled and the caller is not authenticated) + * resp-404: User not found + * resp-500: Failed to read the user + */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -63,6 +73,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => { // PUT /api/users/[id] - Update a user // Free for all - local users are needed for basic auth +/** + * @openapi + * summary: Update a user (self-edit is always allowed; admin/active changes require admin; demoting or deactivating the last admin needs confirmDisableAuth) + * path: id:integer! Numeric id of the user (from GET /api/users) + * body: {username:string, email:string, displayName:string, isAdmin:boolean, isActive:boolean, password:string, confirmDisableAuth:boolean} + * body-example: {"displayName":"Jane Doe","email":"jane@example.com"} + * resp-200: {id:integer!, username:string!, email:string, displayName:string, mfaEnabled:boolean!, isAdmin:boolean!, isActive:boolean!, lastLogin:string, createdAt:string!, updatedAt:string!} + * resp-400: User id is required, or the new password is shorter than 8 characters + * resp-403: Permission denied (editing another user without users:edit) + * resp-404: User not found + * resp-409: This is the last admin user (confirmDisableAuth required), or the username already exists + * resp-500: Failed to update the user + */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); @@ -253,6 +276,18 @@ export const PUT: RequestHandler = async (event) => { // DELETE /api/users/[id] - Delete a user // Free for all - local users are needed for basic auth +/** + * @openapi + * summary: Delete a user by id; deleting the last admin while auth is enabled disables authentication and requires confirmDisableAuth + * path: id:integer! Numeric id of the user (from GET /api/users) + * query: confirmDisableAuth:boolean Set to true to confirm deleting the last admin (which disables authentication) + * resp-200: {success:boolean!, authDisabled:boolean} + * resp-400: User id is required + * resp-403: Permission denied (missing users:remove) + * resp-404: User not found + * resp-409: This is the last admin user — pass confirmDisableAuth=true to proceed + * resp-500: Failed to delete the user + */ export const DELETE: RequestHandler = async (event) => { const { params, url, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/users/[id]/mfa/+server.ts b/src/routes/api/users/[id]/mfa/+server.ts index 6faf0f8..1937d3a 100644 --- a/src/routes/api/users/[id]/mfa/+server.ts +++ b/src/routes/api/users/[id]/mfa/+server.ts @@ -10,6 +10,17 @@ import { auditUser } from '$lib/server/audit'; import { getUser } from '$lib/server/db'; // POST /api/users/[id]/mfa - Setup MFA (generate QR code) +/** + * @openapi + * summary: Set up or verify MFA for a user (action=setup regenerates a secret; action=verify confirms a code) + * path: id:integer The user id + * body: {action:string!, token:string} + * resp-400: Missing user id, missing/invalid MFA token, or an unknown action + * resp-403: Permission denied + * resp-404: User not found + * resp-409: MFA is already enabled (disable it before setting up again) + * resp-500: Failed to set up MFA + */ export const POST: RequestHandler = async (event) => { const { params, request, cookies } = event; const currentUser = await validateSession(cookies); @@ -85,6 +96,15 @@ export const POST: RequestHandler = async (event) => { }; // DELETE /api/users/[id]/mfa - Disable MFA +/** + * @openapi + * summary: Disable MFA for a user + * path: id:integer The user id + * resp-400: Missing user id + * resp-403: Permission denied + * resp-404: User not found + * resp-500: Failed to disable MFA + */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const currentUser = await validateSession(cookies); diff --git a/src/routes/api/users/[id]/roles/+server.ts b/src/routes/api/users/[id]/roles/+server.ts index 4fd8149..1fd71a6 100644 --- a/src/routes/api/users/[id]/roles/+server.ts +++ b/src/routes/api/users/[id]/roles/+server.ts @@ -13,6 +13,16 @@ import { auditUser } from '$lib/server/audit'; import { invalidateTokenCacheForUser } from '$lib/server/api-tokens'; // GET /api/users/[id]/roles - Get roles assigned to a user +/** + * @openapi + * summary: List the roles assigned to a user (enterprise only) + * path: id:integer! Numeric id of the user (from GET /api/users) + * resp-200: array<{roleId:integer!, name:string!, environmentId:integer}> + * resp-400: User id is required + * resp-403: Enterprise license required + * resp-404: User not found + * resp-500: Failed to read the user roles + */ export const GET: RequestHandler = async ({ params, cookies }) => { // Check enterprise license if (!(await isEnterprise())) { @@ -40,6 +50,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => { }; // POST /api/users/[id]/roles - Assign a role to a user +/** + * @openapi + * summary: Assign a role to a user, optionally scoped to an environment (enterprise, admin only) + * description: roleId from GET /api/roles. environmentId from GET /api/environments. + * path: id:integer! Numeric id of the user (from GET /api/users) + * body: {roleId:integer!, environmentId:integer} + * body-example: {"roleId":3,"environmentId":1} + * resp-201: The created user-role assignment + * resp-400: User id or role id is required + * resp-403: Enterprise license required, or admin access required + * resp-404: User not found + * resp-500: Failed to assign the role + */ export const POST: RequestHandler = async (event) => { const { params, request, cookies } = event; // Check enterprise license @@ -87,6 +110,19 @@ export const POST: RequestHandler = async (event) => { }; // DELETE /api/users/[id]/roles - Remove a role from a user +/** + * @openapi + * summary: Remove a role assignment from a user, optionally scoped to an environment (enterprise, admin only) + * description: roleId from GET /api/roles. environmentId from GET /api/environments. + * path: id:integer! Numeric id of the user (from GET /api/users) + * body: {roleId:integer!, environmentId:integer} + * body-example: {"roleId":3,"environmentId":1} + * resp-200: {success:boolean!} + * resp-400: User id or role id is required + * resp-403: Enterprise license required, or admin access required + * resp-404: Role assignment not found + * resp-500: Failed to remove the role + */ export const DELETE: RequestHandler = async (event) => { const { params, request, cookies } = event; // Check enterprise license diff --git a/src/routes/api/volumes/+server.ts b/src/routes/api/volumes/+server.ts index 4c45b87..9c6178d 100644 --- a/src/routes/api/volumes/+server.ts +++ b/src/routes/api/volumes/+server.ts @@ -5,6 +5,16 @@ import { authorize } from '$lib/server/authorize'; import { auditVolume } from '$lib/server/audit'; import { hasEnvironments } from '$lib/server/db'; +/** + * @openapi + * summary: List Docker volumes for an environment; returns an empty array when no environment is specified + * query: env:integer Environment ID to list volumes for (from GET /api/environments) + * resp-200: array<{Name:string!, Driver:string!, Mountpoint:string, Scope:string, CreatedAt:string}> + * resp-200-example: [{"Name":"web_data","Driver":"local","Mountpoint":"/var/lib/docker/volumes/web_data/_data","Scope":"local","CreatedAt":"2026-06-01T10:00:00Z"}] + * resp-403: Permission denied (requires volumes:view, or environment access denied on enterprise) + * resp-404: Environment not found + * resp-500: Failed to list volumes + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -40,6 +50,18 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; +/** + * @openapi + * summary: Create a Docker volume in an environment + * query: env:integer Environment ID to create the volume in (from GET /api/environments) + * body: {name:string!, driver:string, driverOpts:{}, labels:{}} + * body-example: {"name":"web_data","driver":"local","driverOpts":{},"labels":{"app":"web"}} + * resp-200: {success:boolean!, name:string!} + * resp-200-example: {"success":true,"name":"web_data"} + * resp-400: Volume name is required + * resp-403: Permission denied (requires volumes:create, or environment access denied on enterprise) + * resp-500: Failed to create volume + */ export const POST: RequestHandler = async (event) => { const { url, request, cookies } = event; const auth = await authorize(cookies); diff --git a/src/routes/api/volumes/[name]/+server.ts b/src/routes/api/volumes/[name]/+server.ts index 40d3ed7..dc04385 100644 --- a/src/routes/api/volumes/[name]/+server.ts +++ b/src/routes/api/volumes/[name]/+server.ts @@ -5,6 +5,15 @@ import { authorize } from '$lib/server/authorize'; import { auditVolume } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * @openapi + * summary: Inspect a single Docker volume by name (the name is validated as a Docker identifier) + * path: name:string! Docker volume name (from GET /api/volumes) + * query: env:integer Environment ID the volume belongs to (from GET /api/environments) + * resp-200: {Name:string!, Driver:string!, Mountpoint:string!, Scope:string, Labels:{}, Options:{}, CreatedAt:string} + * resp-200-example: {"Name":"web_data","Driver":"local","Mountpoint":"/var/lib/docker/volumes/web_data/_data","Scope":"local","Labels":{},"Options":{},"CreatedAt":"2026-06-01T10:00:00Z"} + * resp-403: Permission denied (requires volumes:inspect, or environment access denied on enterprise) + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.name, 'volume'); if (invalid) return invalid; @@ -35,6 +44,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { } }; +/** + * @openapi + * summary: Remove a Docker volume by name, optionally forcing removal + * path: name:string! Docker volume name (from GET /api/volumes) + * query: env:integer Environment ID the volume belongs to (from GET /api/environments) + * query: force:boolean Force removal of the volume + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-403: Permission denied (requires volumes:remove, or environment access denied on enterprise) + */ export const DELETE: RequestHandler = async (event) => { const { params, url, cookies } = event; const invalid = validateDockerIdParam(params.name, 'volume'); diff --git a/src/routes/api/volumes/[name]/browse/+server.ts b/src/routes/api/volumes/[name]/browse/+server.ts index 2686c77..d12d2c9 100644 --- a/src/routes/api/volumes/[name]/browse/+server.ts +++ b/src/routes/api/volumes/[name]/browse/+server.ts @@ -4,6 +4,18 @@ import { listVolumeDirectory, getVolumeUsage } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * @openapi + * summary: Browse a directory inside a Docker volume via a cached helper container; the volume is mounted read-only when in use by other containers + * path: name:string! Docker volume name (from GET /api/volumes) + * query: env:integer Environment ID the volume belongs to (from GET /api/environments) + * query: path:string Directory path inside the volume to list (defaults to "/") + * resp-200: {path:string!, entries:array<{name:string!, type:string!, size:integer!, permissions:string!, owner:string!, group:string!, modified:string!}>!, usage:array<{containerId:string!, containerName:string!, state:string!}>!, isInUse:boolean!, helperId:string!} + * resp-200-example: {"path":"/","entries":[{"name":"data","type":"directory","size":4096,"permissions":"drwxr-xr-x","owner":"root","group":"root","modified":"2026-06-01 10:00"}],"usage":[],"isInUse":false,"helperId":"abc123"} + * resp-403: Permission denied (requires volumes:inspect) or permission denied accessing the path + * resp-404: Directory not found + * resp-500: Failed to browse volume + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.name, 'volume'); if (invalid) return invalid; diff --git a/src/routes/api/volumes/[name]/browse/content/+server.ts b/src/routes/api/volumes/[name]/browse/content/+server.ts index 9215a67..9ed7350 100644 --- a/src/routes/api/volumes/[name]/browse/content/+server.ts +++ b/src/routes/api/volumes/[name]/browse/content/+server.ts @@ -7,6 +7,20 @@ import { validateDockerIdParam } from '$lib/server/docker-validation'; // Max file size for reading (1MB) const MAX_FILE_SIZE = 1024 * 1024; +/** + * @openapi + * summary: Read the content of a single file inside a Docker volume (files larger than 1MB are rejected) + * path: name:string! Docker volume name (from GET /api/volumes) + * query: path:string! File path inside the volume to read + * query: env:integer Environment ID the volume belongs to (from GET /api/environments) + * resp-200: {content:string!, path:string!} + * resp-200-example: {"content":"hello world\n","path":"/data/readme.txt"} + * resp-400: Path is required, or the path points to a directory + * resp-403: Permission denied (requires volumes:inspect) or permission denied reading the file + * resp-404: File not found + * resp-413: File is too large to view (max 1MB) + * resp-500: Failed to read file + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.name, 'volume'); if (invalid) return invalid; diff --git a/src/routes/api/volumes/[name]/browse/release/+server.ts b/src/routes/api/volumes/[name]/browse/release/+server.ts index f7bf3ad..2f83132 100644 --- a/src/routes/api/volumes/[name]/browse/release/+server.ts +++ b/src/routes/api/volumes/[name]/browse/release/+server.ts @@ -5,8 +5,17 @@ import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; /** - * Release the cached volume helper container when done browsing. - * This is called when the volume browser modal is closed. + * Release the cached volume helper container when done browsing (called when the + * volume browser modal is closed). + * + * @openapi + * summary: Release the cached helper container used to browse a Docker volume + * path: name:string! Docker volume name (from GET /api/volumes) + * query: env:integer Environment ID the volume belongs to (from GET /api/environments) + * resp-200: {success:boolean!} + * resp-200-example: {"success":true} + * resp-403: Permission denied (requires volumes:inspect) + * resp-500: Failed to release volume helper */ export const POST: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.name, 'volume'); diff --git a/src/routes/api/volumes/[name]/clone/+server.ts b/src/routes/api/volumes/[name]/clone/+server.ts index e462857..ca7b474 100644 --- a/src/routes/api/volumes/[name]/clone/+server.ts +++ b/src/routes/api/volumes/[name]/clone/+server.ts @@ -5,6 +5,19 @@ import { authorize } from '$lib/server/authorize'; import { auditVolume } from '$lib/server/audit'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * @openapi + * summary: Clone a Docker volume into a new named volume, copying the data with a temporary helper container and preserving driver/options/labels + * path: name:string! Source Docker volume name (from GET /api/volumes) + * query: env:integer Environment ID the volume belongs to (from GET /api/environments) + * body: {name:string!} + * body-example: {"name":"web_data_copy"} + * resp-200: {success:boolean!, name:string!} + * resp-200-example: {"success":true,"name":"web_data_copy"} + * resp-400: New volume name is required + * resp-403: Permission denied (requires volumes:create) + * resp-500: Failed to clone volume + */ export const POST: RequestHandler = async (event) => { const { params, url, request, cookies } = event; const invalid = validateDockerIdParam(params.name, 'volume'); diff --git a/src/routes/api/volumes/[name]/export/+server.ts b/src/routes/api/volumes/[name]/export/+server.ts index 892da63..5c36344 100644 --- a/src/routes/api/volumes/[name]/export/+server.ts +++ b/src/routes/api/volumes/[name]/export/+server.ts @@ -7,6 +7,18 @@ import { validateDockerIdParam } from '$lib/server/docker-validation'; import { extractFirstFileFromTar } from '$lib/server/tar-extract'; import { attachmentContentDisposition } from '$lib/server/content-disposition'; +/** + * @openapi + * summary: Export a Docker volume (or a sub-path) as a downloadable archive — streaming tar, gzip-compressed tar.gz, or raw single-file bytes + * path: name:string! Docker volume name (from GET /api/volumes) + * query: env:integer Environment ID the volume belongs to (from GET /api/environments) + * query: path:string Sub-path inside the volume to export (defaults to "/") + * query: format:string Archive format: "tar" (default), "tar.gz", or "raw" (single file) + * resp-200: Binary archive stream (Content-Disposition attachment); Content-Type is application/x-tar, application/gzip, or application/octet-stream depending on format + * resp-403: Permission denied (requires volumes:inspect) + * resp-404: Path not found + * resp-500: Failed to export volume + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.name, 'volume'); if (invalid) return invalid; diff --git a/src/routes/api/volumes/[name]/inspect/+server.ts b/src/routes/api/volumes/[name]/inspect/+server.ts index f68d16c..2137848 100644 --- a/src/routes/api/volumes/[name]/inspect/+server.ts +++ b/src/routes/api/volumes/[name]/inspect/+server.ts @@ -4,6 +4,16 @@ import { inspectVolume } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; +/** + * @openapi + * summary: Inspect a Docker volume by name, returning the raw Docker volume inspect object + * path: name:string! Docker volume name (from GET /api/volumes) + * query: env:integer Environment ID the volume belongs to (from GET /api/environments) + * resp-200: {Name:string!, Driver:string!, Mountpoint:string!, Scope:string, Labels:{}, Options:{}, CreatedAt:string} + * resp-200-example: {"Name":"web_data","Driver":"local","Mountpoint":"/var/lib/docker/volumes/web_data/_data","Scope":"local","Labels":{},"Options":{},"CreatedAt":"2026-06-01T10:00:00Z"} + * resp-403: Permission denied (requires volumes:inspect) + * resp-500: Failed to inspect volume + */ export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.name, 'volume'); if (invalid) return invalid; diff --git a/src/routes/api/vulnerabilities/+server.ts b/src/routes/api/vulnerabilities/+server.ts index 80e84dc..da93abc 100644 --- a/src/routes/api/vulnerabilities/+server.ts +++ b/src/routes/api/vulnerabilities/+server.ts @@ -8,6 +8,13 @@ import type { RequestHandler } from './$types'; * sorted server-side. Query: limit, offset, q, severity, image, container, * stack, sort, dir. Returns { findings, total } where `total` is the filtered * count (for the "X-Y of N" counter and infinite scroll). + * + * @openapi + * summary: A filtered, sorted page of aggregated vulnerability findings for an environment + * description: Accepts limit, offset, q, severity, image, container, stack, sort and dir query params (parsed centrally). Returns an empty page when no environment resolves. + * resp-200: {findings:array!, total:integer!} + * resp-200-desc: A page of findings plus the filtered total count; a permission failure returns the status from the access check + * resp-200-example: {"findings":[{"cve":"CVE-2024-0001","severity":"high","package":"openssl","imageName":"nginx:latest"}],"total":1} */ export const GET: RequestHandler = async ({ url, cookies }) => { const { envIdNum, denied } = await authorizeVulnAccess(cookies, url); diff --git a/src/routes/api/vulnerabilities/count/+server.ts b/src/routes/api/vulnerabilities/count/+server.ts index fad18d4..1b8930a 100644 --- a/src/routes/api/vulnerabilities/count/+server.ts +++ b/src/routes/api/vulnerabilities/count/+server.ts @@ -10,6 +10,15 @@ import { EMPTY_META } from '$lib/server/vulnerabilities-cache'; import { authorizeVulnAccess } from '$lib/server/vuln-access'; import type { RequestHandler } from './$types'; +/** + * GET /api/vulnerabilities/count - Vulnerability dashboard metadata + * + * @openapi + * summary: Return the total finding count, severity summary, and distinct image/container/stack filter values for an environment + * resp-200: {total:integer!, summary:object!, images:array!, containers:array!, stacks:array!} + * resp-200-desc: An empty metadata object is returned when no environment resolves; a permission failure returns the status from the access check + * resp-200-example: {"total":12,"summary":{"critical":1,"high":3,"medium":5,"low":3},"images":["nginx:latest"],"containers":["web"],"stacks":["frontend"]} + */ export const GET: RequestHandler = async ({ url, cookies }) => { const { envIdNum, denied } = await authorizeVulnAccess(cookies, url); if (denied) return json({ error: denied.message }, { status: denied.status }); diff --git a/src/routes/api/vulnerabilities/export/+server.ts b/src/routes/api/vulnerabilities/export/+server.ts index 373337c..b9a3a86 100644 --- a/src/routes/api/vulnerabilities/export/+server.ts +++ b/src/routes/api/vulnerabilities/export/+server.ts @@ -30,6 +30,12 @@ function toCSV(findings: Finding[]): string { /** * Export the aggregated vulnerability findings as json | csv. * Server-side rendering so the download matches the grid data exactly. + * + * @openapi + * summary: Export an environment's aggregated vulnerability findings as json or csv, matching the grid's filters and sort + * query: format:string Output format — json (default) or csv + * resp-200: The findings as a downloadable json or csv attachment + * resp-200-desc: A permission failure returns the status from the access check; 500 is returned on an export error */ export const GET: RequestHandler = async ({ url, cookies }) => { const { envIdNum, denied } = await authorizeVulnAccess(cookies, url); diff --git a/src/routes/api/vulnerabilities/scan-all/+server.ts b/src/routes/api/vulnerabilities/scan-all/+server.ts index e050514..a256b5c 100644 --- a/src/routes/api/vulnerabilities/scan-all/+server.ts +++ b/src/routes/api/vulnerabilities/scan-all/+server.ts @@ -11,6 +11,12 @@ import { createJobResponse } from '$lib/server/sse'; * their repo digest so they aren't silently skipped (#1286). * Reuses the per-image scan flow, sequentially, reporting N/total progress. * A single image failing does not abort the batch. + * + * @openapi + * summary: Scan every image in an environment for vulnerabilities, streaming per-image progress as Server-Sent Events + * query: env:integer ID of the environment whose images to scan (from GET /api/environments) + * resp-200: A Server-Sent Events stream of progress and per-image results, ending with a summary "result" event + * resp-403: Permission denied, or (enterprise) no access to this environment */ export const POST: RequestHandler = async ({ request, url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/audit/+server.ts b/src/routes/audit/+server.ts index 09a6b6f..c861823 100644 --- a/src/routes/audit/+server.ts +++ b/src/routes/audit/+server.ts @@ -3,6 +3,21 @@ import { authorize, enterpriseRequired } from '$lib/server/authorize'; import { getAuditLogs, getAuditLogUsers, type AuditLogFilters, type AuditEntityType, type AuditAction } from '$lib/server/db'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: List audit log entries with optional filters (Enterprise only) + * query: username:string + * query: entity_type:string + * query: action:string + * query: environment_id:integer + * query: from_date:string + * query: to_date:string + * query: limit:integer + * query: offset:integer + * resp-200: {logs:array<{id:integer!, userId:integer, username:string!, action:string!, entityType:string!, entityId:string, entityName:string, environmentId:integer, description:string, details:{}, ipAddress:string, userAgent:string, createdAt:string!}>!, total:integer!, limit:integer!, offset:integer!} + * resp-403: Enterprise license required, or permission denied to view the audit log + * resp-500: Failed to fetch audit logs + */ export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/audit/users/+server.ts b/src/routes/audit/users/+server.ts index e881583..0a9be52 100644 --- a/src/routes/audit/users/+server.ts +++ b/src/routes/audit/users/+server.ts @@ -3,6 +3,13 @@ import { authorize, enterpriseRequired } from '$lib/server/authorize'; import { getAuditLogUsers } from '$lib/server/db'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: List distinct usernames that appear in the audit log, for use as a filter dropdown (Enterprise only) + * resp-200: array + * resp-403: Enterprise license required, or permission denied to view the audit log + * resp-500: Failed to fetch users + */ export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); diff --git a/src/routes/metrics/+server.ts b/src/routes/metrics/+server.ts index 5b1bc81..8991990 100644 --- a/src/routes/metrics/+server.ts +++ b/src/routes/metrics/+server.ts @@ -13,6 +13,14 @@ import { authorize } from '$lib/server/authorize'; import { METRICS_ENABLED, renderMetrics, metricsContentType } from '$lib/server/metrics'; import type { RequestHandler } from './$types'; +/** + * @openapi + * summary: Prometheus metrics endpoint (disabled and 404s unless EXPORT_METRICS=true; public if app auth is disabled, otherwise requires a session or bearer token) + * resp-200: Prometheus text-exposition-format metrics body + * resp-401: Not authenticated (session or bearer token required when app auth is enabled) + * resp-404: Metrics export disabled (EXPORT_METRICS is not true) + * resp-500: Metrics collection failed + */ export const GET: RequestHandler = async ({ cookies }) => { if (!METRICS_ENABLED) { return new Response('Not found', { status: 404 });