feat: full-screen-only guardrail for agency designations (#73)

Agencies can only be designated FULL-SCREEN playlists (no item with zone_id) - a full-screen
agency upload can't safely target a zone, so the ambiguous case is excluded rather than
solved. Checked at THREE points:
- Designation (tokens.js create + PUT /:id/targets) -> 400: reject a zoned target.
- Upload (agency.js item-add) -> 409: block if the playlist BECAME zoned after designation.
  MANDATORY because auto-publish has no draft net - a full-screen playlist designated to an
  auto-publish token, then zone-assigned, would otherwise auto-publish a full-screen upload
  into a zoned playlist. The upload check is the only thing that catches it.
- Picker (settings.js): zoned playlists greyed/disabled with the reason (GET /playlists now
  returns a zoned flag); backend reject is the guard if the UI is bypassed. i18n x5.

isZonedPlaylist = EXISTS(playlist_items WHERE zone_id IS NOT NULL). Pure restriction - no
zone structure, no api_token_target_zones.

Bite-test (the exact sequence) GREEN and re-proven to bite: full-screen -> designate to an
auto-publish token -> zone-assign the playlist -> agency upload is BLOCKED (409), not
auto-published; neutralizing the upload check makes it go red. 149 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-14 17:36:03 -05:00
parent 4c38536cc6
commit 57d78dd1fa
11 changed files with 69 additions and 5 deletions

View file

@ -364,6 +364,7 @@ export default {
'apitoken.agency_no_playlists': 'Erstelle zuerst eine Playlist ein Agentur-Token muss auf eine zielen.',
'apitoken.targets_label': 'Zugewiesen:',
'apitoken.edit_targets': 'Playlists bearbeiten',
'apitoken.zoned_playlist_reason': 'Einer Zone zugewiesen — Agenturen brauchen eine Vollbild-Playlist',
'apitoken.targets_updated': 'Zuweisungen aktualisiert',
'apitoken.auto_publish_label': 'Automatisch veröffentlichen (meine Freigabe überspringen)',
'apitoken.auto_publish_hint': 'Aus (Standard): Hinzufügungen warten als Entwurf auf deine Veröffentlichung. An: sie gehen sofort live nur für Agenturen, denen du voll vertraust.',

View file

@ -400,6 +400,7 @@ export default {
'apitoken.agency_no_playlists': 'Create a playlist first — an agency token must target one.',
'apitoken.targets_label': 'Designated:',
'apitoken.edit_targets': 'Edit playlists',
'apitoken.zoned_playlist_reason': 'Assigned to a zone — agencies need a full-screen playlist',
'apitoken.targets_updated': 'Designations updated',
'apitoken.auto_publish_label': 'Auto-publish (skip my approval)',
'apitoken.auto_publish_hint': 'Off (default): additions wait as drafts for you to publish. On: they go live immediately — only for agencies you fully trust.',

View file

@ -363,6 +363,7 @@ export default {
'apitoken.agency_no_playlists': 'Crea una lista primero: un token de agencia debe apuntar a una.',
'apitoken.targets_label': 'Designadas:',
'apitoken.edit_targets': 'Editar listas',
'apitoken.zoned_playlist_reason': 'Asignada a una zona — las agencias necesitan una lista de pantalla completa',
'apitoken.targets_updated': 'Designaciones actualizadas',
'apitoken.auto_publish_label': 'Publicación automática (omitir mi aprobación)',
'apitoken.auto_publish_hint': 'Desactivado (predeterminado): las adiciones esperan como borradores para que las publiques. Activado: se publican de inmediato, solo para agencias de plena confianza.',

View file

@ -364,6 +364,7 @@ export default {
'apitoken.agency_no_playlists': 'Créez d\'abord une liste : un jeton d\'agence doit en cibler une.',
'apitoken.targets_label': 'Assignées :',
'apitoken.edit_targets': 'Modifier les listes',
'apitoken.zoned_playlist_reason': 'Affectée à une zone — les agences ont besoin d\'une liste plein écran',
'apitoken.targets_updated': 'Désignations mises à jour',
'apitoken.auto_publish_label': 'Publication automatique (ignorer mon approbation)',
'apitoken.auto_publish_hint': 'Désactivé (par défaut) : les ajouts attendent en brouillon votre publication. Activé : ils sont diffusés immédiatement, uniquement pour les agences de pleine confiance.',

View file

@ -364,6 +364,7 @@ export default {
'apitoken.agency_no_playlists': 'Crie uma lista primeiro: um token de agência deve apontar para uma.',
'apitoken.targets_label': 'Designadas:',
'apitoken.edit_targets': 'Editar listas',
'apitoken.zoned_playlist_reason': 'Atribuída a uma zona — agências precisam de uma lista de tela cheia',
'apitoken.targets_updated': 'Designações atualizadas',
'apitoken.auto_publish_label': 'Publicação automática (ignorar minha aprovação)',
'apitoken.auto_publish_hint': 'Desativado (padrão): as adições aguardam como rascunho para você publicar. Ativado: vão ao ar imediatamente, apenas para agências de total confiança.',

View file

@ -412,7 +412,9 @@ export async function render(container) {
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.edit_targets')}</h4>
<div style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto;margin-bottom:12px">
${pls.length
? pls.map(p => `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="edit-pl" value="${esc(String(p.id))}"${current.has(String(p.id)) ? ' checked' : ''}> ${esc(p.name)}</label>`).join('')
? pls.map(p => p.zoned
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="edit-pl" value="${esc(String(p.id))}"${current.has(String(p.id)) ? ' checked' : ''}> ${esc(p.name)}</label>`).join('')
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`}
</div>
<button class="btn btn-primary btn-sm" id="saveTargetsBtn">${t('common.save')}</button>
@ -446,7 +448,9 @@ export async function render(container) {
const list = document.getElementById('agencyPlaylistList');
const pls = await api.getPlaylists().catch(() => []);
list.innerHTML = pls.length
? pls.map(p => `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>`).join('')
? pls.map(p => p.zoned
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>`).join('')
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`;
}
});

View file

@ -17,4 +17,13 @@ function listDesignatedPlaylists(db, tokenId, workspaceId) {
`).all(tokenId, workspaceId);
}
module.exports = { listDesignatedPlaylists };
// #73 full-screen guardrail: a playlist is "zoned" if any item targets a layout zone. Agency
// uploads are full-screen and can't safely target a zone, so a zoned playlist can't be shared
// with an agency. Checked at BOTH designation (reject the grant) AND upload (block the add) -
// the upload check is mandatory because auto-publish has no draft step to catch a playlist
// that becomes zoned after designation.
function isZonedPlaylist(db, playlistId) {
return !!db.prepare('SELECT 1 FROM playlist_items WHERE playlist_id = ? AND zone_id IS NOT NULL LIMIT 1').get(playlistId);
}
module.exports = { listDesignatedPlaylists, isZonedPlaylist };

View file

@ -13,7 +13,7 @@ const { db } = require('../db/database');
const upload = require('../middleware/upload');
const { checkStorageLimit } = require('../middleware/subscription');
const { ingestUploadedFile } = require('../lib/content-ingest');
const { listDesignatedPlaylists } = require('../lib/agency-targets');
const { listDesignatedPlaylists, isZonedPlaylist } = require('../lib/agency-targets');
const { listLayoutGeometry } = require('../lib/agency-layouts');
const { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish
const { isConfigured } = require('../services/email'); // #73: gate digest enqueue on SMTP being set
@ -72,6 +72,14 @@ router.post('/playlists/:playlistId/items', (req, res) => {
const { content_id } = req.body;
if (!content_id) return res.status(400).json({ error: 'content_id required' });
// #73 full-screen guardrail, upload-time (MANDATORY because auto-publish has no draft net):
// if the designated playlist has BECOME zoned since designation, block the add - a full-screen
// agency upload can't target a zone. 409 (not 401/403) so the portal shows the message, not its
// "key invalid" reset. This runs BEFORE the draft/publish branch, so auto-publish can't slip through.
if (isZonedPlaylist(db, req.params.playlistId)) {
return res.status(409).json({ error: "This playlist can't accept uploads right now — it's been assigned to a zone on a screen. Ask your contact." });
}
const content = db.prepare('SELECT id, workspace_id, duration_sec FROM content WHERE id = ?').get(content_id);
if (!content) return res.status(404).json({ error: 'Content not found' });
// cross-tenant guard: content must be in the token's bound workspace (or a template)

View file

@ -138,7 +138,8 @@ function publishPlaylist(playlistId, req) {
router.get('/', (req, res) => {
if (!req.workspaceId) return res.json([]);
const playlists = db.prepare(`
SELECT p.*, COUNT(DISTINCT pi.id) as item_count, COUNT(DISTINCT d.id) as display_count
SELECT p.*, COUNT(DISTINCT pi.id) as item_count, COUNT(DISTINCT d.id) as display_count,
EXISTS(SELECT 1 FROM playlist_items z WHERE z.playlist_id = p.id AND z.zone_id IS NOT NULL) as zoned
FROM playlists p
LEFT JOIN playlist_items pi ON p.id = pi.playlist_id
LEFT JOIN devices d ON d.playlist_id = p.id

View file

@ -7,6 +7,7 @@ const crypto = require('crypto');
const { db } = require('../db/database');
const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken');
const { accessContext } = require('../lib/tenancy');
const { isZonedPlaylist } = require('../lib/agency-targets'); // #73: full-screen-only guardrail
// #73: 'agency' is OFF the read/write/full ladder (not in apiToken.js SCOPE_RANK), so a
// tokenScopeGate-mounted router rejects it; it reaches only the AGENCY_ROUTER via agencyGate.
@ -53,6 +54,8 @@ router.post('/', (req, res) => {
const inWs = db.prepare('SELECT id FROM playlists WHERE id = ? AND workspace_id = ?');
for (const pid of targetIds) {
if (!inWs.get(pid, req.workspaceId)) return res.status(400).json({ error: `playlist ${pid} is not in this workspace` });
// #73: agencies get FULL-SCREEN playlists only - a zoned playlist can't take full-screen uploads.
if (isZonedPlaylist(db, pid)) return res.status(400).json({ error: 'A selected playlist is assigned to a zone on a screen — agency uploads play full-screen, so it can\'t be shared with an agency. Use a full-screen playlist.' });
}
}
const secret = generateToken();
@ -92,6 +95,8 @@ router.put('/:id/targets', (req, res) => {
const inWs = db.prepare('SELECT id FROM playlists WHERE id = ? AND workspace_id = ?');
for (const pid of ids) {
if (!inWs.get(pid, tok.workspace_id)) return res.status(400).json({ error: `playlist ${pid} is not in this token's workspace` });
// #73: full-screen-only - a zoned playlist can't be (re-)designated to an agency.
if (isZonedPlaylist(db, pid)) return res.status(400).json({ error: 'A selected playlist is assigned to a zone on a screen — agency uploads play full-screen, so it can\'t be shared with an agency. Use a full-screen playlist.' });
}
const ins = db.prepare('INSERT OR IGNORE INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)');
db.transaction(() => {

View file

@ -159,3 +159,35 @@ test('#73 edit-designations: PUT /:id/targets re-designates (add + remove); conf
assert.equal((await jfetch(`/api/agency/playlists/${plB.id}/layout`, auth(atok))).status, 200, 'kept B -> 200');
assert.equal((await jfetch(`/api/agency/playlists/${plC.id}/layout`, auth(atok))).status, 200, 'added C -> 200');
});
test('#73 full-screen guardrail holds at UPLOAD time too (auto-publish has no draft net)', async () => {
const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok } });
const upload = async (tok) => {
const fd = new FormData();
fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png');
return (await fetch(BASE + '/api/agency/content', { method: 'POST', headers: { Authorization: 'Bearer ' + tok }, body: fd })).json();
};
const email = 'fs' + crypto.randomBytes(4).toString('hex') + '@x.local';
const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token;
const plFS = (await jfetch('/api/playlists', jpost(jwt, { name: 'FullScreen' }))).body;
// (1) full-screen playlist -> AUTO-PUBLISH token designation SUCCEEDS (safe at designation)
const tokRes = await jfetch('/api/tokens', jpost(jwt, { name: 'AP', scope: 'agency', target_playlist_ids: [plFS.id], auto_publish: true }));
assert.equal(tokRes.status, 201, 'full-screen designation OK');
const atok = tokRes.body.token;
// (2) zone the playlist AFTER designation: a layout+zone, then a zone-targeted item via JWT
const lid = (await jfetch('/api/layouts', jpost(jwt, { name: 'Z', zones: [{ name: 'Main', x_percent: 0, y_percent: 0, width_percent: 70, height_percent: 100 }] }))).body.id;
const zoneId = (await jfetch(`/api/layouts/${lid}`, auth(jwt))).body.zones[0].id;
const c1 = await upload(atok);
assert.equal((await jfetch(`/api/playlists/${plFS.id}/items`, jpost(jwt, { content_id: c1.id, zone_id: zoneId }))).status, 201, 'playlist is now zoned');
// (3) THE BITE: agency upload to the now-zoned playlist is BLOCKED (409), NOT auto-published into the zone
const c2 = await upload(atok);
const add = await jfetch(`/api/agency/playlists/${plFS.id}/items`, jpost(atok, { content_id: c2.id }));
assert.equal(add.status, 409, 'upload to a now-zoned playlist blocked (auto-publish cannot slip it into the zone)');
// (4) and an already-zoned playlist is rejected at DESIGNATION too
const reDesig = await jfetch('/api/tokens', jpost(jwt, { name: 'AP2', scope: 'agency', target_playlist_ids: [plFS.id] }));
assert.equal(reDesig.status, 400, 'already-zoned playlist rejected at designation');
});