diff --git a/frontend/js/agency-portal.js b/frontend/js/agency-portal.js index c4456df..ff089bc 100644 --- a/frontend/js/agency-portal.js +++ b/frontend/js/agency-portal.js @@ -56,6 +56,42 @@ : ''; showPortal(); portalMsg('', ''); + // #73: the placement card reacts to the playlist selector - "where does THIS playlist go?" + sel.onchange = () => loadLayoutForPlaylist(sel.value); + loadLayoutForPlaylist(sel.value); // initial selection + } + + // Visual placement guide for the SELECTED playlist: draw its layout to scale, highlight the + // GRANTED zone(s) with the px size to design for, show sibling zones as context (geometry + // only - no content, no device/screen data; the endpoint is device-free). + async function loadLayoutForPlaylist(playlistId) { + const card = $('placementCard'), view = $('layoutView'); + if (!playlistId) { card.style.display = 'none'; return; } + let layouts; + try { layouts = await (await agencyFetch('/playlists/' + encodeURIComponent(playlistId) + '/layout')).json(); } catch (e) { return; } + card.style.display = 'block'; + if (!layouts.length) { + view.innerHTML = '

This playlist plays full-screen — design for the full display.

'; + return; + } + view.innerHTML = layouts.map(l => { + const mine = new Set(l.feeds_zone_ids); + const aspect = (l.height / l.width) * 100; // padding-bottom % = aspect ratio + const zones = l.zones.map(z => { + const isMine = mine.has(z.id); + const wpx = Math.round(l.width * z.width_percent / 100); + const hpx = Math.round(l.height * z.height_percent / 100); + return `
` + + `${escapeHtml(z.name)}${isMine ? `
YOUR ZONE
${wpx}×${hpx}px` : ''}
`; + }).join(''); + return `
` + + `
${escapeHtml(l.name)} · ${l.width}×${l.height}
` + + `
` + + `
${zones}
`; + }).join(''); } // ---- entry ---- diff --git a/frontend/js/api.js b/frontend/js/api.js index e0ec520..82d7cad 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -137,6 +137,7 @@ export const api = { // Playlists getPlaylists: () => request('/playlists'), + getPlaylistZones: (id) => request('/playlists/' + id + '/zones'), // #73: grantable zones for the agency designate UI createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }), getPlaylist: (id) => request(`/playlists/${id}`), updatePlaylist: (id, data) => request(`/playlists/${id}`, { method: 'PUT', body: JSON.stringify(data) }), diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 36f6bb1..42d3fc6 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -366,6 +366,8 @@ export default { '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.', 'apitoken.auto_publish_on': 'Auto-Veröffentlichung an', + 'apitoken.zone_grant_hint': 'Bestimmte Zonen freigeben, oder leer lassen für die ganze Playlist:', + 'apitoken.zone_grant_fullscreen': 'Vollbild – keine Zonen freizugeben.', 'apitoken.create': 'Token erstellen', 'apitoken.none': 'Noch keine Tokens.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 4754cff..60c1312 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -402,6 +402,8 @@ export default { '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.', 'apitoken.auto_publish_on': 'auto-publish on', + 'apitoken.zone_grant_hint': 'Grant specific zones, or leave unchecked for the whole playlist:', + 'apitoken.zone_grant_fullscreen': 'Full-screen — no zones to grant.', 'apitoken.create': 'Create token', 'apitoken.none': 'No tokens yet.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index d4080b7..c775d50 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -365,6 +365,8 @@ export default { '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.', 'apitoken.auto_publish_on': 'publicación automática activada', + 'apitoken.zone_grant_hint': 'Concede zonas específicas, o deja sin marcar para toda la lista:', + 'apitoken.zone_grant_fullscreen': 'Pantalla completa: no hay zonas que conceder.', 'apitoken.create': 'Crear token', 'apitoken.none': 'Aún no hay tokens.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index bec332b..b669f59 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -366,6 +366,8 @@ export default { '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.', 'apitoken.auto_publish_on': 'publication automatique activée', + 'apitoken.zone_grant_hint': 'Accordez des zones spécifiques, ou laissez décoché pour toute la liste :', + 'apitoken.zone_grant_fullscreen': 'Plein écran — aucune zone à accorder.', 'apitoken.create': 'Créer un jeton', 'apitoken.none': 'Aucun jeton pour le moment.', 'apitoken.col_token': 'Jeton', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index ffa7553..433267e 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -366,6 +366,8 @@ export default { '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.', 'apitoken.auto_publish_on': 'publicação automática ativada', + 'apitoken.zone_grant_hint': 'Conceda zonas específicas, ou deixe sem marcar para a lista inteira:', + 'apitoken.zone_grant_fullscreen': 'Tela cheia — sem zonas para conceder.', 'apitoken.create': 'Criar token', 'apitoken.none': 'Ainda não há tokens.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index dcec920..f30e7d9 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -413,9 +413,31 @@ export async function render(container) { agencyPlaylistsLoaded = true; const list = document.getElementById('agencyPlaylistList'); const pls = await api.getPlaylists().catch(() => []); - list.innerHTML = pls.length - ? pls.map(p => ``).join('') - : `

${t('apitoken.agency_no_playlists')}

`; + if (!pls.length) { + list.innerHTML = `

${t('apitoken.agency_no_playlists')}

`; + return; + } + list.innerHTML = pls.map(p => ` +
+ + +
`).join(''); + // #73: checking a playlist reveals its grantable zones (lazy-loaded). Leaving them all + // unchecked = whole-playlist (full-screen). Zones offered come from the playlist's layout. + list.querySelectorAll('.agency-pl').forEach(cb => cb.addEventListener('change', async () => { + const box = list.querySelector(`.agency-zones[data-pl="${cb.value}"]`); + if (!cb.checked) { box.style.display = 'none'; return; } + box.style.display = 'block'; + if (box.dataset.loaded) return; + box.dataset.loaded = '1'; + const zones = await api.getPlaylistZones(cb.value).catch(() => []); + box.innerHTML = zones.length + ? `
${t('apitoken.zone_grant_hint')}
` + zones.map(z => { + const wpx = Math.round(z.layout_width * z.width_percent / 100), hpx = Math.round(z.layout_height * z.height_percent / 100); + return ``; + }).join('') + : `
${t('apitoken.zone_grant_fullscreen')}
`; + })); } }); @@ -427,6 +449,13 @@ export async function render(container) { const ids = [...document.querySelectorAll('#agencyPlaylistList .agency-pl:checked')].map(c => c.value); if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error'); payload.target_playlist_ids = ids; + // #73: per-playlist zone grants (a playlist with no checked zones = whole-playlist) + const target_zones = {}; + for (const pid of ids) { + const zoneIds = [...document.querySelectorAll(`.agency-zone[data-pl="${pid}"]:checked`)].map(c => c.value); + if (zoneIds.length) target_zones[pid] = zoneIds; + } + if (Object.keys(target_zones).length) payload.target_zones = target_zones; payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked; } const btn = document.getElementById('createTokenBtn'); diff --git a/server/lib/agency-layouts.js b/server/lib/agency-layouts.js index 45e8ba8..d2e50f3 100644 --- a/server/lib/agency-layouts.js +++ b/server/lib/agency-layouts.js @@ -7,8 +7,9 @@ // Confined to THIS token's designated playlists (t.token_id) in its bound workspace. // Returns layout canvas size + ALL zones' geometry (no zone CONTENT) + which zones this // token feeds. Bite-tested in test/agency-layouts.test.js. -function listLayoutGeometry(db, tokenId, workspaceId) { +function listLayoutGeometry(db, tokenId, workspaceId, playlistId = null) { // Distinct layouts that this token's designated playlists feed (via their items' zones). + // Optional playlistId narrows to ONE designated playlist (the per-playlist card). const layouts = db.prepare(` SELECT DISTINCT l.id, l.name, l.width, l.height FROM api_token_targets t @@ -16,9 +17,9 @@ function listLayoutGeometry(db, tokenId, workspaceId) { JOIN playlist_items pi ON pi.playlist_id = p.id AND pi.zone_id IS NOT NULL JOIN layout_zones lz ON lz.id = pi.zone_id JOIN layouts l ON l.id = lz.layout_id - WHERE t.token_id = ? + WHERE t.token_id = ?${playlistId ? ' AND p.id = ?' : ''} ORDER BY l.name - `).all(workspaceId, tokenId); + `).all(...(playlistId ? [workspaceId, tokenId, playlistId] : [workspaceId, tokenId])); // All zones of a layout - GEOMETRY ONLY (no content, no device data lives here anyway). const zonesStmt = db.prepare(` diff --git a/server/lib/agency-targets.js b/server/lib/agency-targets.js index 7d11917..c6cc223 100644 --- a/server/lib/agency-targets.js +++ b/server/lib/agency-targets.js @@ -39,4 +39,19 @@ function resolveGrantedZone(db, tokenId, playlistId, requestedZoneId) { return { ok: false, reason: 'ambiguous' }; } -module.exports = { listDesignatedPlaylists, resolveGrantedZone }; +// #73 issuance-side mirror of the runtime confinement: the set of zone_ids an admin may +// grant for a playlist = all zones of the layout(s) that playlist feeds (its items' +// zones -> their layouts -> all zones of those layouts). Token-less (used at create time, +// before the token exists). A zone the playlist's layout doesn't have -> not in this set -> +// rejected at issuance, the same boundary resolveGrantedZone enforces at runtime. +function grantableZoneIds(db, playlistId) { + return new Set(db.prepare(` + SELECT DISTINCT lz_all.id + FROM playlist_items pi + JOIN layout_zones lz ON lz.id = pi.zone_id + JOIN layout_zones lz_all ON lz_all.layout_id = lz.layout_id + WHERE pi.playlist_id = ? + `).all(playlistId).map(r => r.id)); +} + +module.exports = { listDesignatedPlaylists, resolveGrantedZone, grantableZoneIds }; diff --git a/server/routes/agency.js b/server/routes/agency.js index 9fabb98..6156ee2 100644 --- a/server/routes/agency.js +++ b/server/routes/agency.js @@ -28,11 +28,18 @@ router.get('/playlists', (req, res) => { res.json(listDesignatedPlaylists(db, req.apiToken.id, req.jwtWorkspaceId)); }); -// Layout GEOMETRY (canvas size + zone positions/sizes + which zones are this token's) so the -// agency can size/place content. DEVICE-FREE (lib/agency-layouts.js): never touches the fleet -// tables, so no device names/locations/topology can leak. Bite-tested in agency-layouts.test.js. -router.get('/layouts', (req, res) => { - res.json(listLayoutGeometry(db, req.apiToken.id, req.jwtWorkspaceId)); +// Layout GEOMETRY for ONE designated playlist (the per-playlist card): canvas size + zone +// positions/sizes, with "your zone" = the GRANTED zones (placement = grant). Has :playlistId, +// so router.param confines it to a granted playlist. DEVICE-FREE (lib/agency-layouts.js) - no +// device names/locations/topology. Bite-tested in agency-layouts.test.js (the geometry) + +// router.param (the confinement). +router.get('/playlists/:playlistId/layout', (req, res) => { + const layouts = listLayoutGeometry(db, req.apiToken.id, req.jwtWorkspaceId, req.params.playlistId); + const granted = new Set(db.prepare('SELECT zone_id FROM api_token_target_zones WHERE token_id = ? AND playlist_id = ?') + .all(req.apiToken.id, req.params.playlistId).map(r => r.zone_id)); + // "your zone" = the granted zones, not the item-feed zones (placement is the grant) + for (const l of layouts) l.feeds_zone_ids = l.zones.filter(z => granted.has(z.id)).map(z => z.id); + res.json(layouts); }); // #73 THE target seam. router.param fires for EVERY route with :playlistId, WITH the param, diff --git a/server/routes/playlists.js b/server/routes/playlists.js index 5d9e57c..65b3e82 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -291,6 +291,21 @@ router.delete('/:id', requirePlaylistWrite, (req, res) => { // --- Playlist Items --- // List items +// #73: the zones of the layout(s) this playlist feeds - for the agency-token designate UI to +// offer grantable zones. Geometry only (matches the agency layout view's safe surface). +router.get('/:id/zones', requirePlaylistRead, (req, res) => { + res.json(db.prepare(` + SELECT DISTINCT lz_all.id, lz_all.name, lz_all.width_percent, lz_all.height_percent, + l.name AS layout_name, l.width AS layout_width, l.height AS layout_height + FROM playlist_items pi + JOIN layout_zones lz ON lz.id = pi.zone_id + JOIN layout_zones lz_all ON lz_all.layout_id = lz.layout_id + JOIN layouts l ON l.id = lz_all.layout_id + WHERE pi.playlist_id = ? + ORDER BY lz_all.sort_order, lz_all.z_index + `).all(req.params.id)); +}); + router.get('/:id/items', requirePlaylistRead, (req, res) => { const items = db.prepare(` SELECT pi.*, diff --git a/server/routes/tokens.js b/server/routes/tokens.js index 3e3270b..f418093 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -7,6 +7,26 @@ const crypto = require('crypto'); const { db } = require('../db/database'); const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken'); const { accessContext } = require('../lib/tenancy'); +const { grantableZoneIds } = require('../lib/agency-targets'); + +// #73: validate per-playlist zone grants and return the (playlist_id, zone_id) rows to insert. +// target_zones is { playlist_id: [zone_id,...] }. Each playlist must be a granted target, and +// each zone must be one the playlist's layout actually feeds (grantableZoneIds) - the issuance- +// side mirror of the runtime confinement. Returns { error } on the first violation, else { rows }. +function buildZoneGrantRows(target_zones, targetIdSet) { + const rows = []; + if (!target_zones || typeof target_zones !== 'object') return { rows }; + for (const [playlistId, zoneIds] of Object.entries(target_zones)) { + if (!targetIdSet.has(playlistId)) return { error: `zone grant references playlist ${playlistId} which is not a designated target` }; + if (!Array.isArray(zoneIds)) return { error: `zones for ${playlistId} must be an array` }; + const grantable = grantableZoneIds(db, playlistId); + for (const zid of zoneIds) { + if (!grantable.has(zid)) return { error: `zone ${zid} is not in playlist ${playlistId}'s layout` }; + rows.push({ playlistId, zoneId: zid }); + } + } + return { rows }; +} // #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. @@ -47,6 +67,7 @@ router.post('/', (req, res) => { // auto_publish is meaningful ONLY for agency scope and is the admin's explicit opt-OUT of // approval. Anything but agency-scope + literal true -> 0 (draft, the fail-safe default). const autoPublish = (scope === 'agency' && req.body.auto_publish === true) ? 1 : 0; + let zoneRows = []; if (scope === 'agency') { targetIds = Array.isArray(req.body.target_playlist_ids) ? req.body.target_playlist_ids : []; if (!targetIds.length) return res.status(400).json({ error: 'an agency token requires target_playlist_ids' }); @@ -54,6 +75,10 @@ router.post('/', (req, res) => { 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: optional zone grants - validated against each playlist's layout zones up front. + const zg = buildZoneGrantRows(req.body.target_zones, new Set(targetIds)); + if (zg.error) return res.status(400).json({ error: zg.error }); + zoneRows = zg.rows; } const secret = generateToken(); const id = crypto.randomUUID(); @@ -65,6 +90,8 @@ router.post('/', (req, res) => { if (scope === 'agency') { const ins = db.prepare('INSERT INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)'); for (const pid of targetIds) ins.run(id, pid); + const insZ = db.prepare('INSERT INTO api_token_target_zones (token_id, playlist_id, zone_id) VALUES (?, ?, ?)'); + for (const r of zoneRows) insZ.run(id, r.playlistId, r.zoneId); // FK requires the playlist grant above } })(); // `token` is returned only here, never again. @@ -93,12 +120,18 @@ router.put('/:id/targets', (req, res) => { 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: optional zone grants, validated against each playlist's layout zones. + const zg = buildZoneGrantRows(req.body.target_zones, new Set(ids)); + if (zg.error) return res.status(400).json({ error: zg.error }); const ins = db.prepare('INSERT OR IGNORE INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)'); + const insZ = db.prepare('INSERT INTO api_token_target_zones (token_id, playlist_id, zone_id) VALUES (?, ?, ?)'); db.transaction(() => { + // delete the PARENT rows; the FK cascade clears the old zone grants (no manual child delete) db.prepare('DELETE FROM api_token_targets WHERE token_id = ?').run(tok.id); for (const pid of ids) ins.run(tok.id, pid); + for (const r of zg.rows) insZ.run(tok.id, r.playlistId, r.zoneId); })(); - res.json({ id: tok.id, target_playlist_ids: ids }); + res.json({ id: tok.id, target_playlist_ids: ids, zone_grants: zg.rows.length }); }); module.exports = router; diff --git a/server/test/agency-zone-grants.test.js b/server/test/agency-zone-grants.test.js index 9f5b937..e411cc3 100644 --- a/server/test/agency-zone-grants.test.js +++ b/server/test/agency-zone-grants.test.js @@ -8,7 +8,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const Database = require('better-sqlite3'); -const { resolveGrantedZone } = require('../lib/agency-targets'); +const { resolveGrantedZone, grantableZoneIds } = require('../lib/agency-targets'); function freshDb() { const db = new Database(':memory:'); @@ -32,6 +32,8 @@ function freshDb() { INSERT INTO playlists VALUES ('plA','wsA'), ('plB','wsA'); INSERT INTO layouts VALUES ('L1'), ('L2'); INSERT INTO layout_zones VALUES ('zA1','L1'), ('zA2','L1'), ('zB1','L2'); + CREATE TABLE playlist_items (id INTEGER PRIMARY KEY, playlist_id TEXT, zone_id TEXT); + INSERT INTO playlist_items VALUES (1,'plA','zA1'), (2,'plB','zB1'); -- plA feeds L1, plB feeds L2 INSERT INTO api_token_targets VALUES ('tok1','plA'), ('tok1','plB'); INSERT INTO api_token_target_zones VALUES ('tok1','plA','zA1', 0); -- plA narrowed to zA1; plB has none `); @@ -89,3 +91,12 @@ test('#73 cascade: deleting a zone (or its layout) drops the grant referencing i db.prepare("DELETE FROM layouts WHERE id='L1'").run(); // -> layout_zones zA1/zA2 cascade -> zone grants cascade assert.equal(db.prepare("SELECT COUNT(*) c FROM api_token_target_zones WHERE zone_id='zA1'").get().c, 0); }); + +test('#73 ISSUANCE validation: can only grant a zone the playlist\'s layout feeds', () => { + const db = freshDb(); + // plA feeds L1, so its layout's zones (zA1, zA2) are grantable - and nothing else + assert.deepEqual([...grantableZoneIds(db, 'plA')].sort(), ['zA1', 'zA2']); + // zB1 belongs to L2 (plB's layout) - NOT grantable for plA (no cross-playlist-layout grant) + assert.equal(grantableZoneIds(db, 'plA').has('zB1'), false); + assert.deepEqual([...grantableZoneIds(db, 'plB')], ['zB1']); +}); diff --git a/server/test/agency.test.js b/server/test/agency.test.js index 9cdecf6..a54640d 100644 --- a/server/test/agency.test.js +++ b/server/test/agency.test.js @@ -56,11 +56,14 @@ test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)' assert.equal(mine.status, 200, 'agency can list its targets'); assert.deepEqual(mine.body.map(p => p.id), [pl1.id], 'GET /agency/playlists returns ONLY the designated playlist (not pl2)'); - // GET layouts (real path through agencyGate): 200 + an array, and never any device fields - const lay = await jfetch('/api/agency/layouts', { headers: { Authorization: 'Bearer ' + atok } }); - assert.equal(lay.status, 200, 'agency can read layout geometry'); - assert.ok(Array.isArray(lay.body), 'layouts is an array'); + // GET per-playlist layout (real path through router.param): 200 + array, never device fields; + // a NON-designated playlist's layout -> 403 (router.param confines it) + const lay = await jfetch(`/api/agency/playlists/${pl1.id}/layout`, { headers: { Authorization: 'Bearer ' + atok } }); + assert.equal(lay.status, 200, 'agency can read its designated playlist layout'); + assert.ok(Array.isArray(lay.body), 'layout is an array'); assert.ok(!JSON.stringify(lay.body).includes('device'), 'layout response carries no device data'); + const layX = await jfetch(`/api/agency/playlists/${pl2.id}/layout`, { headers: { Authorization: 'Bearer ' + atok } }); + assert.equal(layX.status, 403, 'layout of a NON-designated playlist -> 403 (router.param)'); // HAPPY PATH: upload via the agency token (shared ingest -> first-class content) const fd = new FormData(); @@ -89,6 +92,11 @@ test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)' const badTok = await jfetch('/api/tokens', jpost(jwt, { name: 'Bad', scope: 'agency', target_playlist_ids: ['nonexistent'] })); assert.equal(badTok.status, 400, 'cannot bind an out-of-workspace target at issuance'); + // BITE 5 (issuance, zone): can't grant a zone the playlist's layout doesn't feed -> 400 + // (pl1 has no zone-targeted items, so NO zone is grantable for it) + const badZone = await jfetch('/api/tokens', jpost(jwt, { name: 'BadZone', scope: 'agency', target_playlist_ids: [pl1.id], target_zones: { [pl1.id]: ['nope-zone'] } })); + assert.equal(badZone.status, 400, 'cannot grant a zone the playlist\'s layout does not feed'); + // Portal graceful-failure trigger: an invalid/revoked key -> 401, which the portal catches // to show "paste it again" (never a wall of 403s). const bogus = await jfetch('/api/agency/playlists', { headers: { Authorization: 'Bearer st_bogus_invalid_key' } });