diff --git a/spec.yaml b/spec.yaml index 838e14b..1e2241b 100644 --- a/spec.yaml +++ b/spec.yaml @@ -1,8 +1,8 @@ openapi: 3.0.3 info: title: Dcntrlized Maestro API - version: 1.0.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/* and the deliberately public GET /api/instance (hub-app theme) 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). + 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: @@ -189,7 +189,11 @@ components: TrustedInstance: type: object - description: A remote instance this instance trusts (federation store row). + 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: @@ -211,8 +215,9 @@ components: type: object description: | The unauthenticated GET /api/instance response (hub-app theme). With - DCNTRLIZED_PUBLIC_PROFILE=false only `instance` is returned. Absent - federation store → trusted_instances:null; present-but-empty → []. + 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: @@ -224,9 +229,165 @@ components: $ref: '#/components/schemas/PublicService' trusted_instances: type: array - nullable: true + 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 @@ -235,6 +396,13 @@ components: 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" @@ -254,6 +422,18 @@ components: 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: @@ -268,6 +448,37 @@ components: 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 @@ -316,6 +527,12 @@ components: 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: @@ -342,6 +559,8 @@ components: 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: @@ -365,6 +584,24 @@ components: 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: >- @@ -431,6 +668,13 @@ components: 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 @@ -670,8 +914,10 @@ paths: 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), and which remote instances it trusts. - DCNTRLIZED_PUBLIC_PROFILE=false omits services + trusted_instances. + 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 @@ -803,12 +1049,15 @@ paths: /api/ws: get: - summary: WebSocket endpoint for live container events + 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). + 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 @@ -817,119 +1066,681 @@ paths: '428': description: Setup required - /api/peers: + /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: List pending authorizations and the approved peer trust store - tags: ["security"] - description: > - Security theme M2 §3. Returns the shared auth service's first-contact - authorization requests (services whose certificate is valid but has - not been approved by the instance owner yet) and the approved keys. - Maestro is only the UI; the auth service owns the enforcement. + 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: Combined pending + approved trust state - content: - application/json: - schema: - type: object - properties: - pending: - type: array - items: - $ref: '#/components/schemas/PendingAuthorization' - authorized: - type: array - items: - $ref: '#/components/schemas/AuthorizedKey' + 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: Internal server error (auth management client unavailable) + 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: Failed to reach the shared auth service + 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. - /api/peers/attestation: - get: - summary: Read the auth service's public signing key statement - tags: ["security"] - description: > - The auth service's RSA public key, its kid, and the sha256 fingerprint - peers should pin after first contact (TOFU, D3). + + 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: Auth public key attestation + 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: - $ref: '#/components/schemas/Attestation' - '502': - description: Failed to reach the shared auth service - - /api/peers/approve: + 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: Approve a peer's fingerprint in the auth service trust store - tags: ["security"] - description: > - Forwards the instance owner's approval for a service's identity - fingerprint. After this call the service's requests to /auth/* proceed - normally (enforced by the auth service itself). + 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/PeerAction' + $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: - '200': - description: Peer approved + '201': + description: App created (hosted artifact or link) + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogApp' '400': - description: Missing service or fingerprint + 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: Failed to reach the shared auth service + description: Artifact fetch failed (ERR_ARTIFACT_FETCH) - /api/peers/revoke: - post: - summary: Revoke a peer's fingerprint (returns it to the no-op path) - tags: ["security"] - description: > - Forwards the instance owner's revocation. Calls from that fingerprint - become uniform 403 no-ops again immediately (the SSH authorized_keys - "delete the line" action). + /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/PeerAction' + $ref: '#/components/schemas/CatalogAppEdit' responses: '200': - description: Peer revoked + description: Updated app + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogApp' '400': - description: Missing service or fingerprint - '502': - description: Failed to reach the shared auth service + 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/peers/reject: - post: - summary: Reject a pending peer authorization request - tags: ["security"] - description: > - Denies a first-contact authorization request without authorizing it. - The auth service drops the request from the pending list and audits - the decision; the peer stays on the no-op path (a later contact simply - re-records a fresh pending request). + /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/PeerAction' + $ref: '#/components/schemas/WebLinksConfig' responses: '200': - description: Pending request rejected + description: Saved sources + content: + application/json: + schema: + $ref: '#/components/schemas/WebLinksConfig' '400': - description: Missing service or fingerprint - '502': - description: Failed to reach the shared auth service + description: Invalid base URL or template (ERR_INVALID_PAYLOAD) + '401': + description: No owner session (ERR_UNAUTHENTICATED) /api/services/{service}/config: parameters: @@ -989,10 +1800,13 @@ paths: 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 always targets the service's dedicated - Maestro peer listener (org.dcntrlized.peer_port label, default 9443) - over https with Maestro's own mTLS identity — CN=maestro is how the - service's /config gate authorizes the caller. + 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: @@ -1022,9 +1836,8 @@ paths: 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 - https + Maestro mTLS identity edge, to the service's peer port, 9443 by - default) so the running process drops the value immediately - instead of waiting for a restart. + 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