docs(api): document /api/pip and the assignments muted field (#109/#129)

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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-18 17:36:12 -05:00
parent 71f8948bdb
commit 5f83fc20d3
2 changed files with 86 additions and 1 deletions

View file

@ -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:

View file

@ -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}'`);
}