diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index a12fa31..b03a78d 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -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.', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 624ef0b..7af5440 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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.', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 53e5696..4629874 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -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.', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index ff2d2bf..817bd4f 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -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.', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index 6708436..d8e15f2 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -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.', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index 5c8f93a..bf439bc 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -412,7 +412,9 @@ export async function render(container) {
${t('apitoken.agency_no_playlists')}
`}${t('apitoken.agency_no_playlists')}
`; } }); diff --git a/server/lib/agency-targets.js b/server/lib/agency-targets.js index 20af630..697909b 100644 --- a/server/lib/agency-targets.js +++ b/server/lib/agency-targets.js @@ -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 }; diff --git a/server/routes/agency.js b/server/routes/agency.js index c97c987..7c6d8ba 100644 --- a/server/routes/agency.js +++ b/server/routes/agency.js @@ -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) diff --git a/server/routes/playlists.js b/server/routes/playlists.js index 5d9e57c..e1bdc69 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -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 diff --git a/server/routes/tokens.js b/server/routes/tokens.js index 3e3270b..2e04030 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -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(() => { diff --git a/server/test/agency.test.js b/server/test/agency.test.js index ce552ad..3bdb18d 100644 --- a/server/test/agency.test.js +++ b/server/test/agency.test.js @@ -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'); +});