Files
Maestro/spec.yaml
T
Admin ea12039af8 [docs](maestro): specify rev 9, the data plane, web apps, and the catalog
Bring spec.yaml in line with the implemented surface: the retired PKI/federation routes, the ungated /api/{service} data plane and its WebSocket upgrade, the app catalog and public downloads, the hub SPA mount, and the web app link resolution and container fields.
2026-10-19 02:40:26 +01:00

1850 lines
64 KiB
YAML

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)://<request host>:<published
port>`. 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://<service>.<domain>. Must contain the
literal <service> placeholder.
example: "https://<service>.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/<literal>/<x>, 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 <service> 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 <dataRoot>/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 (<dataRoot>/maestro/config-signing.key,
NOT the retired instance CA): iss=dcntrlized-maestro, aud=sub=<service>,
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