diff --git a/server/lib/agency-layouts.js b/server/lib/agency-layouts.js new file mode 100644 index 0000000..45e8ba8 --- /dev/null +++ b/server/lib/agency-layouts.js @@ -0,0 +1,48 @@ +'use strict'; + +// #73: layout GEOMETRY for an agency token's designated playlists. DEVICE-FREE BY +// CONSTRUCTION: the only path used is playlist_items.zone_id -> layout_zones -> layouts. +// It never references devices / device_groups / schedules, so no fleet data (device names, +// locations, IPs, screen sizes, topology) can leak - it's structurally absent, not filtered. +// 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) { + // Distinct layouts that this token's designated playlists feed (via their items' zones). + const layouts = db.prepare(` + SELECT DISTINCT l.id, l.name, l.width, l.height + FROM api_token_targets t + JOIN playlists p ON p.id = t.playlist_id AND p.workspace_id = ? + 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 = ? + ORDER BY l.name + `).all(workspaceId, tokenId); + + // All zones of a layout - GEOMETRY ONLY (no content, no device data lives here anyway). + const zonesStmt = db.prepare(` + SELECT id, name, x_percent, y_percent, width_percent, height_percent, + z_index, zone_type, fit_mode, background_color, sort_order + FROM layout_zones WHERE layout_id = ? ORDER BY sort_order, z_index + `); + // Which zones of a given layout THIS token actually feeds. + const feedsStmt = db.prepare(` + SELECT DISTINCT pi.zone_id + FROM api_token_targets t + JOIN playlist_items pi ON pi.playlist_id = t.playlist_id AND pi.zone_id IS NOT NULL + JOIN layout_zones lz ON lz.id = pi.zone_id + WHERE t.token_id = ? AND lz.layout_id = ? + `); + + return layouts.map(l => ({ + id: l.id, + name: l.name, + width: l.width, + height: l.height, + zones: zonesStmt.all(l.id), + feeds_zone_ids: feedsStmt.all(tokenId, l.id).map(r => r.zone_id), + })); +} + +module.exports = { listLayoutGeometry }; diff --git a/server/routes/agency.js b/server/routes/agency.js index 0093bab..5ec26ec 100644 --- a/server/routes/agency.js +++ b/server/routes/agency.js @@ -14,6 +14,7 @@ const upload = require('../middleware/upload'); const { checkStorageLimit } = require('../middleware/subscription'); const { ingestUploadedFile } = require('../lib/content-ingest'); const { listDesignatedPlaylists } = require('../lib/agency-targets'); +const { listLayoutGeometry } = require('../lib/agency-layouts'); const { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; @@ -26,6 +27,13 @@ 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)); +}); + // #73 THE target seam. router.param fires for EVERY route with :playlistId, WITH the param, // BEFORE the handler - so no targeted route can skip the allowlist + bound-workspace check // (the api-surface.js can't-drift property, at the param level: you cannot add a :playlistId diff --git a/server/test/agency-layouts.test.js b/server/test/agency-layouts.test.js new file mode 100644 index 0000000..4001453 --- /dev/null +++ b/server/test/agency-layouts.test.js @@ -0,0 +1,55 @@ +'use strict'; + +// #73: GET /api/agency/layouts is a read surface on the primitive, so prove it confines with +// the same rigor as the playlists list. The query (lib/agency-layouts.js) is DEVICE-FREE: +// designated playlist -> item zone -> layout. Asserted: own layout YES, a non-designated +// playlist's layout NO, and the response carries NO device fields (structurally absent - the +// device row exists in the db but is never queried). Neutralizing the t.token_id filter -> red. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const { listLayoutGeometry } = require('../lib/agency-layouts'); + +const db = new Database(':memory:'); +db.exec(` + CREATE TABLE api_token_targets (token_id TEXT, playlist_id TEXT); + CREATE TABLE playlists (id TEXT, workspace_id TEXT); + CREATE TABLE playlist_items (id INTEGER PRIMARY KEY, playlist_id TEXT, zone_id TEXT); + CREATE TABLE layouts (id TEXT, name TEXT, width INTEGER, height INTEGER); + CREATE TABLE layout_zones (id TEXT, layout_id TEXT, name TEXT, x_percent REAL, y_percent REAL, + width_percent REAL, height_percent REAL, z_index INTEGER, zone_type TEXT, fit_mode TEXT, + background_color TEXT, sort_order INTEGER); + CREATE TABLE devices (id TEXT, name TEXT, layout_id TEXT, playlist_id TEXT, ip_address TEXT); + INSERT INTO layouts VALUES ('L1','Lobby',1920,1080), ('L2','Cafe',1080,1920); + INSERT INTO layout_zones VALUES + ('z1','L1','Main',0,0,70,100,0,'content','contain','#000000',0), + ('z2','L1','Sidebar',70,0,30,100,1,'content','contain','#111111',1), + ('z3','L2','Full',0,0,100,100,0,'content','cover','#000000',0); + INSERT INTO playlists VALUES ('plA','wsA'), ('plB','wsA'); + INSERT INTO playlist_items VALUES (1,'plA','z1'), (2,'plB','z3'); + INSERT INTO api_token_targets VALUES ('tokA','plA'), ('tokB','plB'); + -- a device referencing L1/plA with a location-y name + IP. The device-free query must + -- NEVER surface any of this. + INSERT INTO devices VALUES ('d1','Lobby Screen — North Wall','L1','plA','10.0.0.5'); +`); + +test('#73 layout geometry: own layout only, all zones geometry, theirs marked, NO device data', () => { + const a = listLayoutGeometry(db, 'tokA', 'wsA'); + assert.equal(a.length, 1, 'tokA sees ONLY L1 (its designated playlist feeds it), not L2'); + assert.equal(a[0].id, 'L1'); + assert.deepEqual({ name: a[0].name, width: a[0].width, height: a[0].height }, { name: 'Lobby', width: 1920, height: 1080 }); + assert.deepEqual(a[0].zones.map(z => z.id), ['z1', 'z2'], 'all zones of the canvas (geometry), incl. the sibling'); + assert.deepEqual(a[0].feeds_zone_ids, ['z1'], 'only z1 is marked as this token\'s zone (z2 is geometry only)'); + + // NO device data anywhere in the response - structurally absent (the device row exists). + const blob = JSON.stringify(a); + for (const leak of ['d1', 'North Wall', '10.0.0.5', 'ip_address', 'device']) { + assert.ok(!blob.includes(leak), `response must not contain "${leak}"`); + } + // zone objects expose only geometry keys, nothing fleet. + assert.deepEqual(Object.keys(a[0].zones[0]).sort(), + ['background_color', 'fit_mode', 'height_percent', 'id', 'name', 'sort_order', 'width_percent', 'x_percent', 'y_percent', 'z_index', 'zone_type'].sort()); + + assert.deepEqual(listLayoutGeometry(db, 'tokB', 'wsA').map(l => l.id), ['L2'], 'tokB sees ONLY L2'); +}); diff --git a/server/test/agency.test.js b/server/test/agency.test.js index 3ba72bd..9cdecf6 100644 --- a/server/test/agency.test.js +++ b/server/test/agency.test.js @@ -56,6 +56,12 @@ 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'); + assert.ok(!JSON.stringify(lay.body).includes('device'), 'layout response carries no device data'); + // HAPPY PATH: upload via the agency token (shared ingest -> first-class content) const fd = new FormData(); fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png');