From 5f83fc20d3622224efe8a082ba9e36896df2e7ef Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Thu, 18 Jun 2026 17:36:12 -0500 Subject: [PATCH] docs(api): document /api/pip and the assignments muted field (#109/#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PiP endpoints and the per-item mute field shipped without OpenAPI coverage. - openapi.yaml: add POST /pip (show), DELETE /pip + POST /pip/clear (clear), all x-required-scope: full; add the `muted` boolean to PUT /assignments/{id}; add a `pip` tag. - openapi-contract.test.js: the scope heuristic only treated `command` paths as full-scope, so a full-scope non-command route (/pip) would fail it — extend it to recognize /pip. Docs-only as far as the running build goes (no route/behavior change). Lands on main; not in the frozen v1.9.1-beta4 tag — ships in the next tag. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/openapi.yaml | 82 ++++++++++++++++++++++++++++ server/test/openapi-contract.test.js | 5 +- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f4ce24f..23062e6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -53,6 +53,8 @@ tags: description: Activity log. - name: kiosk description: Interactive kiosk pages and their render output. + - name: pip + description: Picture-in-picture / overlay push (image or web on top of the running playlist). security: - ApiToken: [] @@ -819,6 +821,12 @@ paths: sort_order: { type: integer } duration_sec: { type: integer } zone_id: { type: [string, "null"] } + muted: + type: boolean + description: > + Mute this item's audio. Applied to playing devices in real time + (device:mute-changed) and persisted into the published snapshot, so it + also takes effect on the next playlist load. (#129) responses: '200': { description: Updated item. } delete: @@ -1228,6 +1236,80 @@ paths: responses: '200': { description: '{ success, sent, offline, total, results }.' } + # ---------------------------------------------------------------------- pip + /pip: + post: + tags: [pip] + summary: Show a picture-in-picture overlay on a device or group + description: | + Pushes an image or web overlay onto a device (or every device in a group) on top + of the running playlist, in real time (device:pip-show). The player fetches `uri` + itself (same trust model as remote_url content; the server does not proxy it). A + `web` overlay renders an arbitrary page in an iframe, so this requires scope: full. (#109) + x-required-scope: full + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [device_id, type, uri] + properties: + device_id: { type: string, description: A device id OR a group id (expanded to its members). } + type: { type: string, enum: [image, web] } + uri: { type: string, description: Absolute http(s) URL the player fetches directly. } + position: { type: string, enum: [top-left, top-right, bottom-left, bottom-right, center], default: top-right } + width: { type: integer, description: 'px (40-3840)', default: 480 } + height: { type: integer, description: 'px (40-3840)', default: 360 } + duration: { type: integer, description: 'seconds; 0 = until cleared (0-86400)', default: 0 } + title: { type: string } + title_color: { type: string, description: '#RRGGBB' } + background_color: { type: string, description: '#RRGGBB (transparency via opacity)' } + opacity: { type: number, description: '0-1', default: 1 } + border_radius: { type: integer, description: 'px (0-512)', default: 0 } + close_button: { type: boolean } + responses: + '200': { description: '{ success, pip_id, target, sent, offline, total, results }.' } + '400': { description: Validation error (type / uri scheme / position / numeric bounds / color). } + '404': { description: Device or group not found in this workspace. } + delete: + tags: [pip] + summary: Clear a picture-in-picture overlay + description: 'Clears the overlay on a device or group (device:pip-clear). Requires scope: full. (#109)' + x-required-scope: full + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [device_id] + properties: + device_id: { type: string, description: A device id OR a group id. } + pip_id: { type: string, description: Omit to clear whatever overlay is showing. } + responses: + '200': { description: '{ success, target, sent, offline, total, results }.' } + '404': { description: Device or group not found in this workspace. } + /pip/clear: + post: + tags: [pip] + summary: Clear a picture-in-picture overlay (alias for DELETE /pip) + description: 'Same as DELETE /pip; provided for clients that prefer POST. Requires scope: full. (#109)' + x-required-scope: full + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [device_id] + properties: + device_id: { type: string, description: A device id OR a group id. } + pip_id: { type: string, description: Omit to clear whatever overlay is showing. } + responses: + '200': { description: '{ success, target, sent, offline, total, results }.' } + '404': { description: Device or group not found in this workspace. } + # ---------------------------------------------------------------- schedules /schedules: get: diff --git a/server/test/openapi-contract.test.js b/server/test/openapi-contract.test.js index a2603c3..ad71a3d 100644 --- a/server/test/openapi-contract.test.js +++ b/server/test/openapi-contract.test.js @@ -32,7 +32,10 @@ test('openapi: every operation x-required-scope matches the method-based enforce for (const [m, op] of Object.entries(ops)) { if (!METHODS.includes(m) || !op || typeof op !== 'object') continue; if (Array.isArray(op.security) && op.security.length === 0) continue; // unauthenticated render - const expected = (m === 'get' || m === 'head') ? 'read' : (p.includes('command') ? 'full' : 'write'); + // Operational/fleet-affecting routes require 'full' even though they aren't GETs: + // the group command route, and #109 PiP (push an arbitrary web overlay to devices). + const isFullScope = p.includes('command') || p === '/pip' || p.startsWith('/pip/'); + const expected = (m === 'get' || m === 'head') ? 'read' : (isFullScope ? 'full' : 'write'); if (op['x-required-scope'] !== expected) { mismatches.push(`${m.toUpperCase()} ${p}: spec='${op['x-required-scope']}' enforcement='${expected}'`); }