mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
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:
parent
71f8948bdb
commit
5f83fc20d3
|
|
@ -53,6 +53,8 @@ tags:
|
||||||
description: Activity log.
|
description: Activity log.
|
||||||
- name: kiosk
|
- name: kiosk
|
||||||
description: Interactive kiosk pages and their render output.
|
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:
|
security:
|
||||||
- ApiToken: []
|
- ApiToken: []
|
||||||
|
|
@ -819,6 +821,12 @@ paths:
|
||||||
sort_order: { type: integer }
|
sort_order: { type: integer }
|
||||||
duration_sec: { type: integer }
|
duration_sec: { type: integer }
|
||||||
zone_id: { type: [string, "null"] }
|
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:
|
responses:
|
||||||
'200': { description: Updated item. }
|
'200': { description: Updated item. }
|
||||||
delete:
|
delete:
|
||||||
|
|
@ -1228,6 +1236,80 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
'200': { description: '{ success, sent, offline, total, results }.' }
|
'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
|
||||||
/schedules:
|
/schedules:
|
||||||
get:
|
get:
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,10 @@ test('openapi: every operation x-required-scope matches the method-based enforce
|
||||||
for (const [m, op] of Object.entries(ops)) {
|
for (const [m, op] of Object.entries(ops)) {
|
||||||
if (!METHODS.includes(m) || !op || typeof op !== 'object') continue;
|
if (!METHODS.includes(m) || !op || typeof op !== 'object') continue;
|
||||||
if (Array.isArray(op.security) && op.security.length === 0) continue; // unauthenticated render
|
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) {
|
if (op['x-required-scope'] !== expected) {
|
||||||
mismatches.push(`${m.toUpperCase()} ${p}: spec='${op['x-required-scope']}' enforcement='${expected}'`);
|
mismatches.push(`${m.toUpperCase()} ${p}: spec='${op['x-required-scope']}' enforcement='${expected}'`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue