openapi: 3.0.3 info: title: Dcntrlized Maestro API version: 2.2.0 description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them. Every /api/* route except /api/auth/*, the deliberately public GET /api/instance (hub-app theme) and the public catalog download/icon routes requires an instance-owner session (cookies issued by the auth endpoints); in setup mode gated routes answer 428 ERR_SETUP_REQUIRED, otherwise 401 ERR_UNAUTHENTICATED (an unreadable owner store fails closed with 500 ERR_INTERNAL). Mutating requests from browser origins are additionally checked against the request host (CSRF). **Single data plane (user ruling, 2026-10-18).** Requests to a service are expected to be routed through Maestro and may be unauthenticated; services own their own auth (usually via dcntrlized-auth). Every service namespace an app needs is reachable under `/api/{service}/…` — `action`, `auth` and `ws` — without an owner session, and an instance therefore needs exactly ONE open port. Direct service addressing is not a supported deployment. Contract rev 9 removed the PKI/mTLS identity layer and the federation/peer trust surfaces — all internal edges are plaintext HTTP on a single 8080 listener, /api/peers* and /api/instances* are gone, and runtime config delivery is authorized by a dedicated ES256 /config token (see /api/services/{service}/config). components: schemas: ActionRequest: type: object properties: payload: type: object description: Action-specific parameters ActionResponse: type: object properties: status: type: string data: type: object SuccessResponse: type: object properties: status: type: string example: "success" message: type: string ApiError: type: object description: The uniform API error shape (onboarding theme M1). properties: error: type: object properties: code: type: string example: "ERR_UNAUTHENTICATED" AuthStatus: type: object description: The instance-owner gate state; the SPA bootstraps from it. properties: setup_required: type: boolean description: True until the first owner account exists. authenticated: type: boolean username: type: string Credentials: type: object required: [username, password] properties: username: type: string description: "3-64 chars: lowercase letters, digits and . _ - ; must start alphanumeric." password: type: string minLength: 10 SessionResponse: type: object description: >- Login/setup/rotation result. The access and refresh tokens ride as HttpOnly SameSite=Lax cookies (maestro_access / maestro_refresh). properties: username: type: string role: type: string example: "admin" access_ttl_s: type: integer example: 900 PasswordChange: type: object required: [current_password, new_password] properties: current_password: type: string new_password: type: string minLength: 10 PendingAuthorization: type: object description: A first-contact authorization request awaiting the instance owner (M2 §3). properties: service: type: string example: "keepnote" fingerprint: type: string example: "sha256:aa:bb:cc:dd" first_seen: type: string last_seen: type: string AuthorizedKey: type: object description: An approved peer row of the auth service's trust store. properties: service: type: string fingerprint: type: string approved_by: type: string approved_at: type: string revoked_at: type: string nullable: true PeerAction: type: object required: [service, fingerprint] properties: service: type: string description: The service name (cert CN / org.dcntrlized.service). fingerprint: type: string description: The SHA256 public-key fingerprint (sha256:…) shown to the owner. Attestation: type: object description: The auth service's public signing key statement (D3). properties: public_key: type: string fingerprint: type: string example: "sha256:…" kid: type: string issued_at: type: string InstanceIdentity: type: object description: The instance's public self-description (hub-app M1). required: [name] properties: name: type: string example: "home-node" title: type: string description: type: string fingerprint: type: string description: Instance CA public-key SHA256 (lib/pki); omitted when no CA is provisioned. example: "sha256:…" PublicService: type: object description: | Deliberately reduced view of a running service — no container id, docker name, private IP, or port mapping leaves the instance (hub-app D3). required: [service_name, status, image] properties: service_name: type: string title: type: string description: type: string status: type: string version: type: string capabilities: type: array items: type: string has_auth: type: boolean image: type: string TrustedInstance: type: object description: | Legacy remote-instance row kept for source compatibility with the hub-app profile. Contract rev 9 retired federation, so the public profile's trusted_instances array is always empty; the type is never populated with live rows. required: [name, url, fingerprint, status] properties: name: type: string url: type: string fingerprint: type: string example: "sha256:…" status: type: string example: "authorized" scopes: type: array items: type: string InstancePublicProfile: type: object description: | The unauthenticated GET /api/instance response (hub-app theme). With DCNTRLIZED_PUBLIC_PROFILE=false only `instance` is returned. After the rev-9 federation removal trusted_instances is always [] (never null); `instance.fingerprint` is derived from the persisted instance key. required: [instance] properties: instance: $ref: '#/components/schemas/InstanceIdentity' services: type: array nullable: true items: $ref: '#/components/schemas/PublicService' trusted_instances: type: array description: Always empty after the rev-9 federation removal. items: $ref: '#/components/schemas/TrustedInstance' apps: type: array nullable: true description: >- Operator-authored app catalog (app-catalog theme A3): the public projection with absolute download/icon URLs and no added_by. Empty catalog → []; the whole key is omitted when DCNTRLIZED_PUBLIC_PROFILE=false. items: $ref: '#/components/schemas/CatalogApp' CatalogArtifact: type: object description: The installable artifact of a catalog app (A4). required: [filename, size_bytes, sha256, kind] properties: filename: type: string example: "keepnote-1.4.2.apk" size_bytes: type: integer format: int64 sha256: type: string description: Lowercase hex sha256 of the artifact bytes. content_type: type: string example: "application/vnd.android.package-archive" kind: type: string enum: [hosted, link] description: hosted = Maestro stores/serves a copy; link = 302 to source_url. download_url: type: string description: Absolute URL of this instance's artifact endpoint. signer_sha256: type: string CatalogApp: type: object description: >- One app-catalog record (app-catalog theme). id is stable across edits ("a_" + first 12 hex of sha256(package)); added_by appears only on the owner-gated view; internal paths never serialize (A7). required: [id, package, name, version_name, artifact, added_at, updated_at] properties: id: type: string example: "a_3f9c1d2ab7e4" package: type: string example: "org.dcntrlized.keepnote" name: type: string summary: type: string description: type: string category: type: string version_name: type: string version_code: type: integer min_android: type: integer icon_url: type: string artifact: $ref: '#/components/schemas/CatalogArtifact' source_url: type: string description: Optional project/source page (not the artifact). added_at: type: string updated_at: type: string added_by: type: string description: Owner username; owner-gated responses only, never public. CatalogAppAdd: type: object description: Body of POST /api/catalog/apps (app-catalog theme). required: [package, name, version_name, artifact_url] properties: package: type: string name: type: string summary: type: string description: type: string category: type: string version_name: type: string version_code: type: integer min_android: type: integer icon_url: type: string source_url: type: string artifact_url: type: string description: Absolute http(s) URL Maestro fetches (or links, with link_only). filename: type: string description: Optional override; defaults to the URL basename. content_type: type: string sha256: type: string description: Optional expected checksum; mismatch → 400 and nothing stored. signer_sha256: type: string size_bytes: type: integer format: int64 description: Operator-reported size for link_only entries. link_only: type: boolean description: Skip the fetch, require sha256, store kind="link". CatalogAppEdit: type: object description: >- Body of PUT /api/catalog/apps/{id}: a full replacement of the editable metadata (omitted optional fields clear; name/version_name required; package immutable). properties: package: type: string name: type: string summary: type: string description: type: string category: type: string version_name: type: string version_code: type: integer min_android: type: integer icon_url: type: string source_url: type: string signer_sha256: type: string RegistryContainer: type: object required: [name, image] properties: name: type: string example: "keepnote" service_name: type: string description: >- The service identity declared by the image's org.dcntrlized.service label. A container package without it is not a Maestro service and is not listed. example: "KeepNote" version: type: string example: "0.0.1" image: type: string example: "192.168.122.183:3000/dcntrlized/keepnote:dev" digest: type: string title: type: string example: "KeepNote" vendor: type: string author: type: string description: type: string source: type: string description: >- The image's declared source repository (OCI org.opencontainers.image.source), served verbatim for display. Untrusted: it is never a link target by itself. source_href: type: string description: >- `source` projected through the http/https allowlist (same validator as `web.url`: http/https exactly, no userinfo, no control chars, non-empty host). Present only when `source` is safe to link; clients must render `source` as plain text when it is absent and must never build an href from `source` themselves. revision: type: string licenses: type: string url: type: string documentation: type: string created_at: type: string hidden: type: boolean description: Hidden infrastructure containers (e.g. the shared dcntrlized-db) are excluded from the service grid. example: true web: $ref: '#/components/schemas/WebApp' WebApp: type: object description: >- The service's Web App capability (contract rev 8.2 §16.2/§16.3). On GET /api/containers `href` is resolved SERVER-SIDE from the frozen precedence: manifest `url` -> per-service operator override -> global base-domain template -> derived `http(s)://:`. A present-but-invalid source is skipped, and a service whose block yields no valid href omits `web` entirely. Clients must only ever open `href`, never build a URL from service strings. required: [enabled] properties: enabled: type: boolean description: Whether the service advertises a web app at all. port: type: integer description: The container port the web app listens on. url: type: string description: >- The base URL declared by the service manifest. Untrusted: it is only echoed for diagnostics and is never a link target by itself. href: type: string description: >- The server-resolved absolute http(s) link target. Present only when a valid target exists; absent from registry-listing records. PortMapping: type: object properties: host: type: integer guest: type: integer IPAddress: type: object properties: version: type: string enum: [v4, v6] address: type: string Network: type: object properties: name: type: string ipAddress: $ref: '#/components/schemas/IPAddress' mode: type: string enum: [bridge, host, none, overlay, ipvlan, macvlan] ports: type: array items: $ref: '#/components/schemas/PortMapping' Container: type: object properties: id: type: string name: type: string service_name: type: string role: type: string example: "db" hidden: type: boolean description: Hidden infrastructure containers (e.g. the shared dcntrlized-db) are excluded from the service grid. external: type: boolean description: >- True when the container was started outside Maestro (e.g. a repo's own docker-compose.debug.yaml). The console may stop it but must not start, uninstall, update or reconfigure it. capabilities: type: array items: type: string example: "database" description: Capabilities declared by the service manifest (e.g. database). has_auth: type: boolean description: >- Whether the service consumes the shared auth service (manifest has_auth). True for dcntrlized-auth itself and for auth-consuming services. public_key: type: string description: >- The auth service's RS256 public key (manifest public_key); the hook for a future Maestro dashboard auth that validates dcntrlized-auth tokens. config: type: array description: >- Operator-configurable fields the service declares in its manifest config block (Service Config & Secrets theme). Each element is a single-key object: the key names the field, the value is its spec. items: $ref: '#/components/schemas/ServiceConfigDeclaration' web: $ref: '#/components/schemas/WebApp' docker_name: type: string image: type: string version: type: string status: type: string created_at: type: string network: $ref: '#/components/schemas/Network' title: type: string vendor: type: string author: type: string description: type: string source: type: string WebLinksConfig: type: object description: >- Operator-controlled URL sources for the Web App capability (contract rev 8.2 §16.2): a global base-domain template and per-service overrides. properties: base_domain_template: type: string description: >- Global template, e.g. https://.. Must contain the literal placeholder. example: "https://.home.example" overrides: type: object additionalProperties: type: string description: Per-service base URL, keyed by service name. ServiceConfigField: type: object description: >- The spec of one operator-configurable field declared by a service's manifest config block (Service Config & Secrets theme). The manifest is a pure schema — it never carries a value. required: [type, key] properties: type: type: string enum: [cleartext, password] description: cleartext is read-write; password is write-only and encrypted at rest. description: type: string description: Optional human-readable explanation shown in the UI. key: type: string description: The container env var name Maestro injects the value under. ServiceConfigDeclaration: type: object description: >- A single-key config declaration inside a service manifest's config array. The key names the field; the value is the field spec. additionalProperties: $ref: '#/components/schemas/ServiceConfigField' minProperties: 1 maxProperties: 1 ServiceConfigEntry: type: object description: >- The read-only merged view of one config field served by GET /api/services/{service}/config: the schema plus the operator's set state. Password values are never included; a cleartext value (read-write) is. required: [field, key, type, set] properties: field: type: string description: The manifest config field name (the config array element key). key: type: string description: The container env var name. type: type: string enum: [cleartext, password] description: type: string set: type: boolean description: Whether a value is stored. The only thing a password row reveals. value: type: string description: The stored value of a cleartext field; never present for passwords. ServiceConfigValue: type: object description: Request body for setting a config value (PUT .../config/{key}). required: [value] properties: value: type: string paths: /api/actions/{containerId}/{actionName}: description: >- LEGACY owner-gated console route, kept for compatibility. Apps must use /api/{service}/action/{actionName}, which reaches the same service without an owner session. This route is behind the session gate only because commit 2ffdf77 wrapped a service data-plane route in the operator gate; it was never console traffic, and it must not acquire that history's successors. parameters: - name: actionName in: path required: true description: The name of the specific action to execute schema: type: string - name: containerId in: path required: true description: The ID or name of the target container/service. Accepts a full or partial container ID, a container name, or the service name (matched case-insensitively). schema: type: string get: summary: Retrieve data or status from a service action tags: ["actions"] description: Forwards the request (method, headers, query string, and body) to the target container's /action/{actionName} endpoint. The target service is responsible for authentication; any client-supplied auth headers are forwarded as-is. responses: '200': description: Request successful content: application/json: schema: $ref: '#/components/schemas/ActionResponse' '400': description: Bad Request - Invalid input parameters '401': description: Unauthorized - the target service rejected the request '404': description: Action or container not found '500': description: Failed to forward the request to the container post: summary: Execute a state-changing action in the service tags: ["actions"] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ActionRequest' responses: '200': description: Action executed successfully content: application/json: schema: $ref: '#/components/schemas/ActionResponse' '400': description: Bad Request - Invalid input parameters '401': description: Unauthorized - the target service rejected the request '404': description: Action or container not found '500': description: Failed to forward the request to the container put: summary: Update configuration for a specific action tags: ["actions"] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ActionRequest' responses: '200': description: Configuration updated '400': description: Bad Request - Invalid input parameters '401': description: Unauthorized - the target service rejected the request '404': description: Action or container not found '500': description: Failed to forward the request to the container delete: tags: ["actions"] summary: Reset or remove an action's state requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ActionRequest' responses: '200': description: Action state reset '400': description: Bad Request - Invalid input parameters '401': description: Unauthorized - the target service rejected the request '404': description: Action or container not found '500': description: Failed to forward the request to the container /api/containers: get: summary: List the services Maestro manages tags: ["containers"] responses: '200': description: List of services content: application/json: schema: type: array items: $ref: '#/components/schemas/Container' '500': description: Internal server error /api/containers/install: post: summary: Install a service container (downloads the compose file and pulls the image) tags: ["containers"] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RegistryContainer' responses: '200': description: Container downloaded and image pulled successfully content: application/json: schema: $ref: '#/components/schemas/SuccessResponse' '400': description: Invalid JSON payload or missing required fields (name, image) '500': description: Failed to install container /api/containers/uninstall: post: summary: Uninstall a service container and remove it from the cache tags: ["containers"] requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: type: string responses: '200': description: Container uninstalled successfully content: application/json: schema: $ref: '#/components/schemas/SuccessResponse' '400': description: Invalid JSON payload or missing required field (name) '500': description: Failed to uninstall container /api/containers/start: post: summary: Start a previously installed service container tags: ["containers"] requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: type: string responses: '200': description: Container started successfully content: application/json: schema: $ref: '#/components/schemas/SuccessResponse' '400': description: Invalid JSON payload or missing required field (name) '500': description: Failed to start container /api/containers/stop: post: summary: Stop a running service container tags: ["containers"] requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: type: string responses: '200': description: Container stopped successfully content: application/json: schema: $ref: '#/components/schemas/SuccessResponse' '400': description: Invalid JSON payload or missing required field (name) '500': description: Failed to stop container /api/registry: get: summary: List the services available in the Dcntrlized registry tags: ["registry"] responses: '200': description: List of registry services content: application/json: schema: type: array items: $ref: '#/components/schemas/RegistryContainer' '500': description: Internal server error '502': description: Failed to connect to upstream registry /api/instance: get: summary: Public instance profile (unauthenticated) tags: ["instance"] security: [] description: >- The one read surface the owner gate deliberately allows through (hub-app theme M1/D2): what this instance is, which non-hidden services it runs (public fields only), which remote instances it trusts, and the operator-authored app catalog it offers (public projection, absolute download/icon URLs). DCNTRLIZED_PUBLIC_PROFILE=false omits services, trusted_instances and apps. responses: '200': description: The instance public profile content: application/json: schema: $ref: '#/components/schemas/InstancePublicProfile' /api/auth/status: get: summary: Instance-owner gate state (public) tags: ["auth"] description: >- The SPA bootstraps from this endpoint. All other /api/* routes are gated by the instance-owner session (onboarding theme M1): in setup mode they answer 428 ERR_SETUP_REQUIRED, otherwise 401 ERR_UNAUTHENTICATED without a valid session; an unreadable owner store fails closed with 500 ERR_INTERNAL everywhere. responses: '200': description: Gate state content: application/json: schema: $ref: '#/components/schemas/AuthStatus' '500': description: Internal server error /api/auth/setup: post: summary: Create the first owner account (admin) tags: ["auth"] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/Credentials' responses: '200': description: Owner created; session cookies issued content: application/json: schema: $ref: '#/components/schemas/SessionResponse' '400': description: Invalid credentials (ERR_INVALID_REQUEST / ERR_INVALID_CREDENTIALS) '409': description: Setup already completed (ERR_SETUP_COMPLETED) '429': description: Rate limited (ERR_RATE_LIMITED) '500': description: Owner store unreadable/unwritable (ERR_INTERNAL) /api/auth/login: post: summary: Log in as the instance owner tags: ["auth"] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/Credentials' responses: '200': description: Session cookies issued content: application/json: schema: $ref: '#/components/schemas/SessionResponse' '400': description: Invalid JSON payload '401': description: Unknown user or wrong password (ERR_INVALID_CREDENTIALS) '429': description: Rate limited (ERR_RATE_LIMITED) with Retry-After '500': description: Owner store unreadable (ERR_INTERNAL) /api/auth/refresh: post: summary: Rotate the access/refresh pair tags: ["auth"] description: >- Uses the maestro_refresh cookie. Reuse of an already-rotated jti is a theft signal and revokes every session of the user (ERR_SESSION_REVOKED). responses: '200': description: Fresh pair issued content: application/json: schema: $ref: '#/components/schemas/SessionResponse' '401': description: Missing/invalid/replayed refresh cookie /api/auth/logout: post: summary: Revoke the current refresh token and clear cookies tags: ["auth"] responses: '200': description: Logged out (idempotent, bodyless-safe) /api/auth/change-password: post: summary: Change the owner's password (session required) tags: ["auth"] description: Revokes every existing session of the user on success. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PasswordChange' responses: '200': description: Password changed; all sessions revoked '400': description: Invalid payload or wrong current password '401': description: Not authenticated '429': description: Rate limited (ERR_RATE_LIMITED) '500': description: Owner store unreadable/unwritable (ERR_INTERNAL) /api/ws: get: summary: WebSocket endpoint for live container events (internal, owner-gated) tags: ["containers"] description: >- Establishes a WebSocket connection that streams container lifecycle events as JSON. The upgrade is gated by the instance-owner session; anonymous handshakes close with 401 (428 in setup mode). This is Maestro's own internal broadcast channel, not the service data plane: everything a *service* emits or consumes over a socket goes through /api/{service}/ws instead. responses: '101': description: Switching Protocols - WebSocket upgrade '401': description: Not authenticated '428': description: Setup required /api/{service}/action/{actionName}: parameters: - name: service in: path required: true description: >- The service reference: full or partial container id, container name, image or the org.dcntrlized.service label (matched case-insensitively by Maestro's shared resolver). schema: type: string - name: actionName in: path required: true description: The action to run, forwarded verbatim as the service's /action/{actionName}. schema: type: string get: summary: Read through a service action without an owner session tags: ["services"] description: Same contract as POST — the method, query string and headers are forwarded unchanged. responses: '200': description: Mirrored from the service (status, body and headers unchanged). '4XX': description: >- Mirrored from the service, including its own error envelope — the service is the gate on this surface. '404': description: ERR_NOT_FOUND - unknown service (no installed container matches the reference). '409': description: ERR_NOT_RUNNING - the service container exists but is stopped. '500': description: >- ERR_INTERNAL - Maestro could not resolve the service (Docker daemon unavailable, no network address, no service port). Plumbing failure, deliberately NOT a 502. '502': description: ERR_UPSTREAM_UNREACHABLE - the service did not answer. post: summary: Run a service action (the app data-plane entry point) tags: ["services"] description: >- Forwards the request (method, body, query string and application headers) to the target service's `/action/{actionName}` and mirrors the answer. This is the surface apps use: Maestro is the instance's single data plane, so an instance needs exactly one open port and a service needs no public hostname. DELIBERATELY NOT OWNER-GATED. Apps and phones hold no Maestro owner session — that cookie belongs to the instance operator and must never be shipped in an app. The service is the gate: it decides whether a request needs a token and answers 401/403 itself, normally after validating a token issued through dcntrlized-auth. Maestro never inspects credentials; it is a dumb pipe. Forwarded: the query string VERBATIM and every application header except Host, Content-Length, the hop-by-hop set, Cookie and Authorization. Cookie/Authorization are excluded on purpose: a Maestro session cookie must never ride into a service, and services authenticate with their own tokens (X-Access-Token), which are forwarded untouched. The service's response is mirrored byte-for-byte — status, body, Content-Type, redirects included (never followed) and its own error envelope untouched. Only Maestro's own failures use the JSON envelope {"error":{"code","message"}}. Any HTTP method is relayed identically (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS); see the one below for a method-specific summary. responses: '200': description: Mirrored from the service (status, body and headers unchanged). '4XX': description: >- Mirrored from the service, including its own error envelope — the service is the gate on this surface. '404': description: ERR_NOT_FOUND - unknown service (no installed container matches the reference). '409': description: ERR_NOT_RUNNING - the service container exists but is stopped. '500': description: >- ERR_INTERNAL - Maestro could not resolve the service (Docker daemon unavailable, no network address, no service port). Plumbing failure, deliberately NOT a 502. '502': description: ERR_UPSTREAM_UNREACHABLE - the service did not answer. put: summary: Update through a service action tags: ["services"] description: Same contract as POST — the method, body and query are forwarded unchanged. responses: '200': description: Mirrored from the service (status, body and headers unchanged). '4XX': description: >- Mirrored from the service, including its own error envelope — the service is the gate on this surface. '404': description: ERR_NOT_FOUND - unknown service (no installed container matches the reference). '409': description: ERR_NOT_RUNNING - the service container exists but is stopped. '500': description: >- ERR_INTERNAL - Maestro could not resolve the service (Docker daemon unavailable, no network address, no service port). Plumbing failure, deliberately NOT a 502. '502': description: ERR_UPSTREAM_UNREACHABLE - the service did not answer. delete: summary: Delete through a service action tags: ["services"] description: Same contract as POST — the method, body and query are forwarded unchanged. responses: '200': description: Mirrored from the service (status, body and headers unchanged). '4XX': description: >- Mirrored from the service, including its own error envelope — the service is the gate on this surface. '404': description: ERR_NOT_FOUND - unknown service (no installed container matches the reference). '409': description: ERR_NOT_RUNNING - the service container exists but is stopped. '500': description: >- ERR_INTERNAL - Maestro could not resolve the service (Docker daemon unavailable, no network address, no service port). Plumbing failure, deliberately NOT a 502. '502': description: ERR_UPSTREAM_UNREACHABLE - the service did not answer. /api/{service}/auth/{path...}: parameters: - name: service in: path required: true description: >- The service reference: full or partial container id, container name, image or the org.dcntrlized.service label (matched case-insensitively by Maestro's shared resolver). schema: type: string - name: path in: path required: true description: >- The service's auth path, forwarded verbatim: /api/alarm/auth/login reaches the alarm service's /auth/login. A prefix, not a fixed list — a service adding an auth sub-path needs no Maestro change. schema: type: string get: summary: Call a service's auth endpoint (session, profile, refresh) tags: ["services"] description: >- Forwards the request to the target service's /auth/{path...} and mirrors the answer. The phone clients call /auth/login, /auth/register, /auth/logout, /auth/me and /auth/refresh directly, so under the single-origin model those must be reachable through Maestro without an owner session. Raw body semantics: the JSON body is forwarded untouched (services decode {username,password[,email]} themselves), the response is mirrored byte-for-byte, and Maestro never wraps, unwraps or inspects credentials. Cookies issued by the service are NOT mirrored back (Maestro's origin is the console's; a service token travels in the body or in its own X-Access-Token header). DELIBERATELY NOT OWNER-GATED, same reason as the action surface: the caller is an app, the service is the gate. responses: '200': description: Mirrored from the service. '4XX': description: Mirrored from the service (invalid credentials are its 401, not Maestro's). '404': description: ERR_NOT_FOUND - unknown service. '409': description: ERR_NOT_RUNNING - the service container exists but is stopped. '500': description: ERR_INTERNAL - Maestro could not resolve the service (never a 502). '502': description: ERR_UPSTREAM_UNREACHABLE - the service did not answer. post: summary: Authenticate against a service (login, register, logout, refresh) tags: ["services"] description: >- Same contract as GET; the body and method are forwarded unchanged. This is the call a phone makes before it holds any token at all, which is why the surface cannot be owner-gated. responses: '200': description: Mirrored from the service. '4XX': description: Mirrored from the service. '404': description: ERR_NOT_FOUND - unknown service. '500': description: ERR_INTERNAL - Maestro could not resolve the service. '502': description: ERR_UPSTREAM_UNREACHABLE - the service did not answer. /api/{service}/ws: parameters: - name: service in: path required: true description: >- The service reference: full or partial container id, container name, image or the org.dcntrlized.service label (matched case-insensitively by Maestro's shared resolver). schema: type: string get: summary: Proxy a WebSocket to a service's socket namespace (service data plane) tags: ["services"] description: >- Dials the target service's own socket (its /ws namespace) and pumps frames verbatim in both directions. Part of the instance's single data plane (see /api/{service}/action/{actionName} for the model): Maestro resolves the service container on dcntrlized_net and then acts as a dumb pipe. DELIBERATELY NOT OWNER-GATED. The callers are phones, which hold no Maestro owner session (that cookie belongs to the instance operator and must never be shipped in an app). The service is the gate: it validates the access token itself — normally via the X-Access-Token header forwarded by this proxy, or the ?accessToken= query parameter — and refuses the upgrade with 401 before accepting a single frame. Maestro never inspects the token. Forwarded: the query string VERBATIM (?alertId, ?token, ?accessToken …, never re-encoded) and the X-Access-Token, Origin, User-Agent, Accept-Language and X-Requested-With headers. Not forwarded: Host, Connection/Upgrade, Sec-WebSocket-*, Content-Length, Authorization and cookies (a Maestro session cookie must never ride into a service). Framing is end-to-end: ping/pong control frames are relayed to the other hop instead of being answered by Maestro (so a service's own ping/pong liveness accounting is truthful), close codes and reasons are propagated unchanged, and no read/write deadline is imposed on the socket — an idle alarm socket lives until one side closes it. responses: '101': description: >- Switching Protocols — the service accepted the socket. Statuses below are answered before the upgrade, while the response is still modifiable. '400': description: >- ERR_INVALID_REQUEST - the request is not a WebSocket upgrade ("this endpoint only accepts WebSocket upgrades"). '401': description: >- ERR_UNAUTHORIZED - mirrored from the service: it rejected the handshake (missing or invalid access token / alert token). Body is the service's own, never reformatted. '403': description: >- Mirrored from the service (token present but not authorized). Body is the service's own. '404': description: >- ERR_NOT_FOUND - unknown service; no installed container matches the reference. '409': description: >- ERR_NOT_RUNNING - the service container exists but is not running. '500': description: >- ERR_INTERNAL - Maestro could not resolve the service (Docker daemon unavailable, no network address, no service port). Plumbing failure, deliberately NOT a 502. '502': description: >- ERR_UPSTREAM_UNREACHABLE - the service's socket could not be reached (dial failed, connect refused, handshake timeout). /api/{service}/ws/{path...}: parameters: - name: service in: path required: true description: >- The service reference: full or partial container id, container name, image or the org.dcntrlized.service label (matched case-insensitively by Maestro's shared resolver). schema: type: string - name: path in: path required: false description: >- Sub-path below the service's /ws namespace, forwarded verbatim: a service that mounts its socket at /ws/alarm is reachable as /api/alarm/ws/alarm. schema: type: string get: summary: Proxy a WebSocket to a sub-path of a service's socket namespace tags: ["services"] description: >- Same contract, framing and status codes as /api/{service}/ws. Scope is deliberately narrow: only /ws and /ws/... on the service are reachable through this path, so /config and /_manifest can never be tunnelled through it (/auth/* has its own allowlisted namespace at /api/{service}/auth/{path...}). Dot segments and unusual characters in {path...} are rejected (404). Implementation note for future readers (this bit is not visible in the URL space): NO pattern of this surface can be registered directly next to /api/actions/{containerId}/{actionName} — this one and the action form both match /api/actions//, neither is more specific, and the mux panics at boot. The whole surface (action, auth, ws) is therefore dispatched by a single fallback registered on /api/, which the mux only reaches when no concrete route matched. That fallback is also what answers an unknown /api/* path, and it does so with the uniform envelope {"error":{"code":"ERR_NOT_FOUND","message": "unknown API path"}} — never plain text, because clients unwrap the envelope. responses: '101': description: Switching Protocols - WebSocket upgrade '400': description: ERR_INVALID_REQUEST - not a WebSocket upgrade. '401': description: Mirrored from the service (invalid access token). '404': description: >- ERR_NOT_FOUND - unknown service, a sub-path outside the service's /ws namespace, or (from the fallback) an unknown /api/* path. '409': description: ERR_NOT_RUNNING - the service is not running. '500': description: ERR_INTERNAL - Maestro could not resolve the service. '502': description: >- ERR_UPSTREAM_UNREACHABLE - the service's socket could not be reached. /api/catalog/apps: get: summary: List the operator-authored app catalog (owner only) tags: ["catalog"] description: >- Every catalog record with absolute download/icon URLs and the private added_by field (app-catalog theme A1/A7). Insertion order is stable and an empty catalog is []. responses: '200': description: Catalog entries content: application/json: schema: type: array items: $ref: '#/components/schemas/CatalogApp' '401': description: ERR_UNAUTHENTICATED - no valid owner session '428': description: ERR_SETUP_REQUIRED - owner setup not completed post: summary: Add an app from an artifact URL (owner only) tags: ["catalog"] description: >- Fetches artifact_url (redirects followed, 60s timeout, 512 MiB cap), computes size_bytes + sha256 and hosts the bytes, then answers 201. A supplied sha256 that does not match stores nothing (400 ERR_CHECKSUM_MISMATCH). link_only:true skips the fetch, requires the operator sha256 and stores kind:"link". Fetch failure → 502 ERR_ARTIFACT_FETCH; duplicate package → 409 ERR_APP_EXISTS. Also accepts multipart/form-data with the same flat snake_case fields plus the artifact in the part named `file`, so a new app can be published from a local file in one step. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CatalogAppAdd' multipart/form-data: schema: type: object required: [package, name, version_name] properties: file: type: string format: binary description: The artifact to host; when present artifact_url is not required. package: type: string name: type: string version_name: type: string summary: type: string description: type: string category: type: string version_code: type: integer min_android: type: integer icon_url: type: string source_url: type: string filename: type: string content_type: type: string sha256: type: string signer_sha256: type: string link_only: type: boolean responses: '201': description: App created (hosted artifact or link) content: application/json: schema: $ref: '#/components/schemas/CatalogApp' '400': description: Invalid payload or checksum mismatch (ERR_INVALID_PAYLOAD / ERR_CHECKSUM_MISMATCH) '401': description: ERR_UNAUTHENTICATED - no valid owner session '409': description: Duplicate package (ERR_APP_EXISTS) '428': description: ERR_SETUP_REQUIRED - owner setup not completed '502': description: Artifact fetch failed (ERR_ARTIFACT_FETCH) /api/catalog/apps/{id}: parameters: - name: id in: path required: true description: The stable app id ("a_" + sha256(package)[:12]). schema: type: string put: summary: Edit an app's metadata (owner only) tags: ["catalog"] description: >- Full replacement of the editable metadata: present fields are applied, optional fields omitted (or null/empty) are cleared, and name + version_name are required. package and id are immutable (a changed package → 400 ERR_INVALID_PAYLOAD). Unknown id → 404 ERR_NOT_FOUND. The artifact is replaced through its own endpoint. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CatalogAppEdit' responses: '200': description: Updated app content: application/json: schema: $ref: '#/components/schemas/CatalogApp' '400': description: Invalid payload or immutable package (ERR_INVALID_PAYLOAD) '401': description: ERR_UNAUTHENTICATED - no valid owner session '404': description: Unknown app (ERR_NOT_FOUND) '428': description: ERR_SETUP_REQUIRED - owner setup not completed delete: summary: Remove an app and its hosted bytes (owner only) tags: ["catalog"] description: >- Drops the record, the hosted artifact copy and the uploaded icon. Unknown id → 404 ERR_NOT_FOUND. responses: '200': description: App removed '401': description: ERR_UNAUTHENTICATED - no valid owner session '404': description: Unknown app (ERR_NOT_FOUND) '428': description: ERR_SETUP_REQUIRED - owner setup not completed /api/catalog/apps/{id}/artifact: parameters: - name: id in: path required: true schema: type: string put: summary: Replace an app's artifact from a multipart upload (owner only) tags: ["catalog"] description: >- multipart/form-data with a `file` part. Recomputes size_bytes + sha256, switches kind to "hosted", and prunes the previous copy. 512 MiB cap; unknown id → 404. requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary responses: '200': description: Updated app content: application/json: schema: $ref: '#/components/schemas/CatalogApp' '400': description: Missing/invalid upload or size cap (ERR_INVALID_PAYLOAD) '404': description: Unknown app (ERR_NOT_FOUND) /api/catalog/apps/{id}/icon: parameters: - name: id in: path required: true schema: type: string put: summary: Replace an app's uploaded icon (owner only) tags: ["catalog"] description: >- multipart/form-data with a `file` part; png, jpeg or webp, at most 512 KiB. The icon is then served at /api/catalog/icons/{id}. requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary responses: '200': description: Updated app content: application/json: schema: $ref: '#/components/schemas/CatalogApp' '400': description: Missing/invalid image or size cap (ERR_INVALID_PAYLOAD) '404': description: Unknown app (ERR_NOT_FOUND) /api/catalog/artifacts/{id}/{filename}: parameters: - name: id in: path required: true schema: type: string - name: filename in: path required: true schema: type: string get: summary: Download a catalog artifact (unauthenticated) tags: ["catalog"] security: [] description: >- Exact artifact bytes with Content-Type, Content-Length and X-Content-SHA256, plus Access-Control-Allow-Origin: * for browser clients. kind:"link" entries answer 302 to the operator URL. Unknown entries and DCNTRLIZED_PUBLIC_PROFILE=false answer 404 ERR_NOT_FOUND. responses: '200': description: Artifact bytes content: application/octet-stream: schema: type: string format: binary '302': description: Redirect to the operator-supplied URL (kind=link) '404': description: Unknown artifact or catalog not public (ERR_NOT_FOUND) /api/catalog/icons/{id}: parameters: - name: id in: path required: true schema: type: string get: summary: Fetch an app's uploaded icon (unauthenticated) tags: ["catalog"] security: [] description: >- Uploaded icon bytes, or 404 ERR_NOT_FOUND when the app has no uploaded icon or DCNTRLIZED_PUBLIC_PROFILE=false. responses: '200': description: Icon bytes content: image/png: schema: type: string format: binary '404': description: Unknown icon or catalog not public (ERR_NOT_FOUND) /api/web-links: get: summary: Read the operator Web App URL sources (owner) tags: ["containers"] description: >- The owner-controlled base-domain template and per-service overrides that Maestro layers between a service's manifest web.url and the derived request-host fallback (contract rev 8.2 §16.2). The global template can also be supplied via DCNTRLIZED_WEB_BASE_DOMAIN, which wins over the persisted value. responses: '200': description: Current sources content: application/json: schema: $ref: '#/components/schemas/WebLinksConfig' '401': description: No owner session (ERR_UNAUTHENTICATED) put: summary: Replace the operator Web App URL sources (owner) tags: ["containers"] description: >- Full replacement: omitted fields clear. The template must contain the literal placeholder; every value must be an http(s) base URL with a host, no userinfo and no control characters. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/WebLinksConfig' responses: '200': description: Saved sources content: application/json: schema: $ref: '#/components/schemas/WebLinksConfig' '400': description: Invalid base URL or template (ERR_INVALID_PAYLOAD) '401': description: No owner session (ERR_UNAUTHENTICATED) /api/services/{service}/config: parameters: - name: service in: path required: true description: >- The service (matched case-insensitively against the install record / container label, as in /api/containers). schema: type: string get: summary: Merge a service's declared config schema with its stored values tags: ["service-config"] description: >- Owner-gated (Service Config & Secrets theme M1). Lists each field the service's manifest config block declares, merged with the operator's set state. Password fields are only ever reported as set:false/true; cleartext fields (read-write) carry their stored value. responses: '200': description: The merged per-field view content: application/json: schema: type: array items: $ref: '#/components/schemas/ServiceConfigEntry' '401': description: ERR_UNAUTHENTICATED - no valid owner session '404': description: ERR_NOT_FOUND - unknown service '428': description: ERR_SETUP_REQUIRED - owner setup not completed /api/services/{service}/config/{key}: parameters: - name: service in: path required: true schema: type: string - name: key in: path required: true description: The manifest config field name (the config array element key). schema: type: string put: summary: Set a service config value tags: ["service-config"] description: >- Owner-gated (Service Config & Secrets theme M1). Stores the value for the field declared by the service manifest. Cleartext fields are stored plaintext (read-write); password fields are AES-256-GCM encrypted at rest, keyed by /maestro/secrets.key, and never returned again — the response is only a ServiceConfigEntry with set:true. The value is injected as the field's env key at start, and, when the service is running and exposes POST /config (D6 write-back), delivered to it at runtime. Delivery targets the service's single plaintext 8080 listener and carries an ES256 Bearer token minted with Maestro's DEDICATED config-signing keypair (/maestro/config-signing.key, NOT the retired instance CA): iss=dcntrlized-maestro, aud=sub=, scope="config", 60 s TTL. The service verifies it with the DCNTRLIZED_CONFIG_PUBKEY (base64 DER SPKI) Maestro injects and can never mint tokens itself. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ServiceConfigValue' responses: '200': description: Value stored content: application/json: schema: $ref: '#/components/schemas/ServiceConfigEntry' '400': description: ERR_INVALID_PAYLOAD - unparseable body or empty password '401': description: ERR_UNAUTHENTICATED - no valid owner session '404': description: ERR_NOT_FOUND - unknown service or config field '428': description: ERR_SETUP_REQUIRED - owner setup not completed delete: summary: Clear a service config value tags: ["service-config"] description: >- Owner-gated (Service Config & Secrets theme M1). Removes the stored value; on the next start the compose file's own default applies. When the service is running and exposes POST /config (D6 write-back), a best-effort clear {field, action:"clear"} is delivered at runtime (same plaintext 8080 listener + ES256 Bearer token) so the running process drops the value immediately instead of waiting for a restart. responses: '200': description: Value cleared '401': description: ERR_UNAUTHENTICATED - no valid owner session '404': description: ERR_NOT_FOUND - unknown service or config field '428': description: ERR_SETUP_REQUIRED - owner setup not completed