OpenAPI spec at /api/docs with a Scalar viewer, opt-in via FEAT_API_DOCS

This commit is contained in:
jarek committed 2026-08-17 09:50:34 +02:00
1 parent ba8670af66
commit da26f7f764
251 files changed
+32497 -66

No files matched your search

+8 -2
View File
@@ -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"
+14 -2
View File
@@ -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
File diff suppressed because it is too large. Load diff
+7
View File
@@ -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';
+4 -3
View File
@@ -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();
+6 -2
View File
@@ -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<number | null>(null);
@@ -130,8 +134,8 @@
<title>Dockhand - Docker Management</title>
</svelte:head>
{#if isLoginPage}
<!-- Login page: no sidebar, no header -->
{#if noSidebar}
<!-- Login page / OpenAPI docs viewer: no sidebar, no header -->
{@render children?.()}
<Toaster richColors position="bottom-right" />
{:else}
+24
View File
@@ -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);
@@ -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<string>
* 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);
@@ -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);
+9
View File
@@ -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);
+19
View File
@@ -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);
+6
View File
@@ -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);
+14
View File
@@ -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);
+8
View File
@@ -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<string>
* 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);
+21
View File
@@ -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);
+35
View File
@@ -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);
@@ -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);
+14 -1
View File
@@ -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
+7 -1
View File
@@ -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 {
+21
View File
@@ -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);
+35
View File
@@ -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);
@@ -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()) {
@@ -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
@@ -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
+6
View File
@@ -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: [] });
+8 -1
View File
@@ -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();
+20
View File
@@ -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);
+20
View File
@@ -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;
@@ -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;
+7
View File
@@ -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 {
@@ -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);
+24
View File
@@ -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<string>, stopBeforeBackup:boolean, schedule:string, retention:{keepLast:integer, keepDaily:integer, keepWeekly:integer, keepMonthly:integer, keepYearly:integer}, options:{}, tags:array<string>}
* 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);
@@ -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<string>, stopBeforeBackup:boolean, schedule:string, retention:{keepLast:integer, keepDaily:integer, keepWeekly:integer, keepMonthly:integer, keepYearly:integer}, options:{}, tags:array<string>}
* 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);
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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;
@@ -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);
@@ -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');
@@ -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);
@@ -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);
@@ -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');
+12
View File
@@ -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<string>, 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);
@@ -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');
@@ -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;
@@ -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');
@@ -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);
@@ -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');
@@ -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');
@@ -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');
@@ -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');
@@ -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');
@@ -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');
+12 -2
View File
@@ -126,8 +126,18 @@ async function processWithConcurrency<T>(
}
/**
* 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);
+8
View File
@@ -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<string>}>
* resp-200-example: [{"version":"1.0.39","date":"2026-06-01","changes":["Fixed image export streaming"]}]
*/
export const GET: RequestHandler = async () => {
return json(changelog);
};
+20
View File
@@ -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);
@@ -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);
+20
View File
@@ -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<string>, env:array<string>, labels:{}, cmd:array<string>, entrypoint:array<string>, workingDir:string, restartPolicy:string, restartMaxRetries:integer, networkMode:string, additionalNetworks:array<string>, networkAliases:array<string>, 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);
+26
View File
@@ -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');
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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<string>!, errors:array<string>}
* 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;
@@ -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;
@@ -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;
@@ -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;
@@ -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');
@@ -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');
@@ -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');
@@ -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<string>!, 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;
@@ -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');
@@ -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;
@@ -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');
@@ -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<string>!, Processes:array<array<string>>!, 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;
@@ -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');
@@ -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<string>, BlkioDeviceReadBps:array<string>, BlkioDeviceWriteBps:array<string>, BlkioDeviceReadIOps:array<string>, BlkioDeviceWriteIOps:array<string>, PidsLimit:integer} (unsupported fields are silently dropped)
* body-example: {"Memory":536870912,"PidsLimit":512}
* resp-200: {success:boolean!, warnings:array<string>}
* 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');
@@ -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');
@@ -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<string>!, 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;
@@ -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<string>!}
* 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;
@@ -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);
@@ -78,6 +78,18 @@ function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T
]);
}
/**
* GET /api/containers/stats - Get resource stats for all running containers
*
* @openapi
* summary: Return a CPU/memory/network/block-IO stats snapshot for every running container in an environment (requires the 'view' permission)
* description: Returns an empty array when no environment is configured or specified. With `debug=<name>` 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);
@@ -65,6 +65,13 @@ function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T
});
}
/**
* @openapi
* summary: Stream live CPU/memory/network/block-IO stats for all running containers in an environment (one snapshot round, then closes)
* query: env:integer Environment id — an immediate "done" event is sent if omitted or no environments are configured (from GET /api/environments)
* resp-200: 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)
* resp-403: Permission denied
*/
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
@@ -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);
+10
View File
@@ -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);
@@ -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')) {
+11
View File
@@ -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 });
+8
View File
@@ -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]
+45
View File
@@ -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 });
};
+9
View File
@@ -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 {};
};
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
let container: HTMLDivElement;
onMount(() => {
if (!browser) return;
// Self-hosted assets only (static/scalar/, copied from the
// @scalar/api-reference package by `npm run generate:openapi` — see
// scripts/generate-openapi.ts copyScalarAssets()). No CDN, works
// offline / behind an internal-only Dockhand deployment.
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.onload = () => resolve();
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.body.appendChild(s);
});
}
(async () => {
await loadScript('/scalar/standalone.js');
// @ts-expect-error — window.Scalar is a global set by the script above
window.Scalar.createApiReference(container, {
url: '/api/docs',
// Explicitly disabled: the default points at Scalar's hosted
// proxy.scalar.com, which we never want to reach (self-hosted,
// no external fetch — CSP would block it anyway, and the
// Dockhand API is same-origin so no proxy is needed).
proxyUrl: ''
});
})();
});
</script>
<svelte:head>
<title>Dockhand API Docs</title>
</svelte:head>
<div bind:this={container}></div>
<style>
:global(body) {
margin: 0;
}
</style>
+21
View File
@@ -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);
@@ -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<string>, 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<string>, 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);
@@ -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'))) {
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff. Show more