`
+ + `${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('')
- : `
`;
+ }));
}
});
@@ -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' } });