diff --git a/server/db/database.js b/server/db/database.js index 5845aca..73a7472 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -200,6 +200,9 @@ const migrations = [ // #73: agency-upload notification queue (batched digest). "CREATE TABLE IF NOT EXISTS agency_notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT NOT NULL, token_id TEXT NOT NULL, playlist_id TEXT NOT NULL, action TEXT NOT NULL, content_id TEXT, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), sent_at INTEGER)", "CREATE INDEX IF NOT EXISTS idx_agency_notifications_unsent ON agency_notifications(sent_at)", + // #73: zone refinement of a playlist grant - FK-anchored to api_token_targets (orphan- + // impossible + cascades on playlist-grant revoke). Additive; does NOT touch api_token_targets. + "CREATE TABLE IF NOT EXISTS api_token_target_zones (token_id TEXT NOT NULL, playlist_id TEXT NOT NULL, zone_id TEXT NOT NULL REFERENCES layout_zones(id) ON DELETE CASCADE, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), PRIMARY KEY (token_id, playlist_id, zone_id), FOREIGN KEY (token_id, playlist_id) REFERENCES api_token_targets(token_id, playlist_id) ON DELETE CASCADE)", ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/db/schema.sql b/server/db/schema.sql index 542984f..66f5659 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -551,6 +551,22 @@ CREATE TABLE IF NOT EXISTS api_token_targets ( ); CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); +-- #73: OPTIONAL zone refinement of a playlist grant. A row here narrows the agency to a +-- specific zone WITHIN an already-granted playlist. STRUCTURALLY anchored: the composite FK +-- to api_token_targets(token_id, playlist_id) means a zone grant CANNOT exist without its +-- playlist grant (orphan-impossible) and CASCADES away when the playlist grant is revoked - +-- that's what makes "narrow" structural, not conventional. zone_id FK -> layout_zones so a +-- deleted zone/layout drops the grant too. No rows for a (token,playlist) = whole-playlist +-- (full-screen), as before. (Requires PRAGMA foreign_keys=ON, set in db/database.js.) +CREATE TABLE IF NOT EXISTS api_token_target_zones ( + token_id TEXT NOT NULL, + playlist_id TEXT NOT NULL, + zone_id TEXT NOT NULL REFERENCES layout_zones(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (token_id, playlist_id, zone_id), + FOREIGN KEY (token_id, playlist_id) REFERENCES api_token_targets(token_id, playlist_id) ON DELETE CASCADE +); + -- #73: agency-upload notification queue. The agency endpoint enqueues one row per item added -- (only when email is configured); a 15-min flush job groups per token+playlist+action and -- sends one digest per group, stamping sent_at ONLY after a successful send (failed -> retry). diff --git a/server/lib/agency-targets.js b/server/lib/agency-targets.js index 20af630..7d11917 100644 --- a/server/lib/agency-targets.js +++ b/server/lib/agency-targets.js @@ -17,4 +17,26 @@ function listDesignatedPlaylists(db, tokenId, workspaceId) { `).all(tokenId, workspaceId); } -module.exports = { listDesignatedPlaylists }; +// #73: resolve which zone an agency item-add lands in, enforcing the zone grants. The grant +// is the boundary; a body-supplied zone can pick WITHIN it but never escape it. +// - No zone grants for (token, playlist) -> whole-playlist/full-screen (zone_id NULL); a +// body zone_id is ignored (placement isn't agency-driven when nothing's granted). +// - Zone grants exist -> the item MUST land in a GRANTED zone: +// requested zone that IS granted -> use it (agency picks among its grants); +// requested zone NOT granted -> { ok:false, reason:'forbidden' } (403); +// no request, exactly one grant -> auto-place into it; +// no request, multiple grants -> { ok:false, reason:'ambiguous' } (must pick). +function resolveGrantedZone(db, tokenId, playlistId, requestedZoneId) { + const grants = db.prepare('SELECT zone_id FROM api_token_target_zones WHERE token_id = ? AND playlist_id = ?') + .all(tokenId, playlistId).map(r => r.zone_id); + if (!grants.length) return { ok: true, zoneId: null }; + if (requestedZoneId) { + return grants.includes(requestedZoneId) + ? { ok: true, zoneId: requestedZoneId } + : { ok: false, reason: 'forbidden' }; + } + if (grants.length === 1) return { ok: true, zoneId: grants[0] }; + return { ok: false, reason: 'ambiguous' }; +} + +module.exports = { listDesignatedPlaylists, resolveGrantedZone }; diff --git a/server/routes/agency.js b/server/routes/agency.js index 8db6637..9fabb98 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, resolveGrantedZone } = 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 @@ -77,6 +77,16 @@ router.post('/playlists/:playlistId/items', (req, res) => { return res.status(403).json({ error: 'Content is not in this workspace' }); } + // #73: zone placement IS the grant. If this token has zone grants for the playlist, the + // item MUST land in a granted zone (a body zone_id picks among grants, never escapes them); + // if none, whole-playlist/full-screen. Same FK-anchored api_token_targets seam. + const z = resolveGrantedZone(db, req.apiToken.id, req.params.playlistId, req.body.zone_id); + if (!z.ok) { + return z.reason === 'ambiguous' + ? res.status(400).json({ error: 'This playlist has multiple granted zones — specify zone_id.' }) + : res.status(403).json({ error: 'That zone is not granted to this token for this playlist.' }); + } + let { duration_sec, days, start, end, start_date, end_date } = req.body; if (duration_sec != null && (typeof duration_sec !== 'number' || duration_sec < 1)) { return res.status(400).json({ error: 'duration_sec must be a positive integer' }); @@ -94,8 +104,8 @@ router.post('/playlists/:playlistId/items', (req, res) => { if (!(TIME_RE.test(en) || en === '24:00')) return res.status(400).json({ error: 'end must be HH:MM or 24:00' }); const order = db.prepare('SELECT COALESCE(MAX(sort_order),0)+1 AS n FROM playlist_items WHERE playlist_id = ?').get(req.params.playlistId).n; - const itemId = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)') - .run(req.params.playlistId, content_id, order, duration_sec).lastInsertRowid; + const itemId = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, zone_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)') + .run(req.params.playlistId, content_id, z.zoneId, order, duration_sec).lastInsertRowid; db.prepare('INSERT INTO playlist_item_schedules (id, playlist_item_id, active_days, start_time, end_time, start_date, end_date, sort_order) VALUES (?,?,?,?,?,?,?,0)') .run(uuidv4(), itemId, dys.join(','), st, en, sd, ed); // #73: draft vs live is decided by the TOKEN's auto_publish (admin-set, read from @@ -116,7 +126,7 @@ router.post('/playlists/:playlistId/items', (req, res) => { .run(req.workspaceId, req.apiToken.id, req.params.playlistId, published ? 'published' : 'draft', content_id); } - res.status(201).json({ id: itemId, playlist_id: req.params.playlistId, content_id, duration_sec, start_date: sd, end_date: ed, published }); + res.status(201).json({ id: itemId, playlist_id: req.params.playlistId, content_id, zone_id: z.zoneId, duration_sec, start_date: sd, end_date: ed, published }); }); module.exports = router; diff --git a/server/test/agency-zone-grants.test.js b/server/test/agency-zone-grants.test.js new file mode 100644 index 0000000..9f5b937 --- /dev/null +++ b/server/test/agency-zone-grants.test.js @@ -0,0 +1,91 @@ +'use strict'; + +// #73 zone-grant security model. Proves the structural narrow guarantee BEFORE any UI rides +// on it: zone grants confine placement, are FK-anchored to the playlist grant (orphan- +// impossible), and cascade away with it. The whole thing depends on PRAGMA foreign_keys=ON, +// so this test asserts that too (a cascade that silently no-ops because FKs are off is the trap). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const { resolveGrantedZone } = require('../lib/agency-targets'); + +function freshDb() { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); // mirrors db/database.js:13 + db.exec(` + CREATE TABLE api_tokens (id TEXT PRIMARY KEY); + CREATE TABLE playlists (id TEXT PRIMARY KEY, workspace_id TEXT); + CREATE TABLE layouts (id TEXT PRIMARY KEY); + CREATE TABLE layout_zones (id TEXT PRIMARY KEY, layout_id TEXT REFERENCES layouts(id) ON DELETE CASCADE); + CREATE TABLE api_token_targets ( + token_id TEXT NOT NULL REFERENCES api_tokens(id) ON DELETE CASCADE, + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + PRIMARY KEY (token_id, playlist_id)); + CREATE TABLE api_token_target_zones ( + token_id TEXT NOT NULL, playlist_id TEXT NOT NULL, + zone_id TEXT NOT NULL REFERENCES layout_zones(id) ON DELETE CASCADE, + created_at INTEGER, + PRIMARY KEY (token_id, playlist_id, zone_id), + FOREIGN KEY (token_id, playlist_id) REFERENCES api_token_targets(token_id, playlist_id) ON DELETE CASCADE); + INSERT INTO api_tokens VALUES ('tok1'); + 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'); + 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 + `); + return db; +} + +test('#73 foreign_keys is ON (the cascade/FK guarantees are real, not silent no-ops)', () => { + assert.equal(freshDb().pragma('foreign_keys', { simple: true }), 1); +}); + +test('#73 zone confinement: granted YES, non-granted/cross-playlist/ambiguous all blocked', () => { + const db = freshDb(); + // granted zone within a designated playlist -> YES + assert.deepEqual(resolveGrantedZone(db, 'tok1', 'plA', 'zA1'), { ok: true, zoneId: 'zA1' }); + // NON-granted zone within the SAME designated playlist -> blocked (the refinement bites) + assert.equal(resolveGrantedZone(db, 'tok1', 'plA', 'zA2').ok, false); + // a zone from a DIFFERENT playlist's layout -> blocked (no cross-playlist) + assert.equal(resolveGrantedZone(db, 'tok1', 'plA', 'zB1').ok, false); + // no requested zone, exactly one grant -> auto-place into it + assert.deepEqual(resolveGrantedZone(db, 'tok1', 'plA', null), { ok: true, zoneId: 'zA1' }); + // playlist with NO zone grants -> whole-playlist (full-screen); a body zone is IGNORED + assert.deepEqual(resolveGrantedZone(db, 'tok1', 'plB', null), { ok: true, zoneId: null }); + assert.deepEqual(resolveGrantedZone(db, 'tok1', 'plB', 'zB1'), { ok: true, zoneId: null }); + // multiple grants, no pick -> must specify + db.prepare("INSERT INTO api_token_target_zones VALUES ('tok1','plA','zA2',0)").run(); + assert.equal(resolveGrantedZone(db, 'tok1', 'plA', null).reason, 'ambiguous'); + assert.deepEqual(resolveGrantedZone(db, 'tok1', 'plA', 'zA2'), { ok: true, zoneId: 'zA2' }); // picks among grants +}); + +test('#73 orphan-grant is IMPOSSIBLE: a zone grant cannot exist without its playlist grant', () => { + const db = freshDb(); + // (tok1, plC) is NOT in api_token_targets -> the composite FK must reject the zone grant + assert.throws( + () => db.prepare("INSERT INTO api_token_target_zones VALUES ('tok1','plC','zA1',0)").run(), + /FOREIGN KEY/i, + 'inserting a zone grant without its playlist grant must be rejected by the FK'); +}); + +test('#73 cascade: revoking the playlist grant removes its zone grants (structural, not manual)', () => { + const db = freshDb(); + db.prepare("DELETE FROM api_token_targets WHERE token_id='tok1' AND playlist_id='plA'").run(); + assert.equal(db.prepare("SELECT COUNT(*) c FROM api_token_target_zones WHERE playlist_id='plA'").get().c, 0, + 'zone grants cascade out when the parent playlist grant is deleted'); +}); + +test('#73 cascade chain: deleting a playlist removes BOTH the playlist grant and the zone grants', () => { + const db = freshDb(); + db.prepare("DELETE FROM playlists WHERE id='plA'").run(); + assert.equal(db.prepare("SELECT COUNT(*) c FROM api_token_targets WHERE playlist_id='plA'").get().c, 0, 'playlist grant gone'); + assert.equal(db.prepare("SELECT COUNT(*) c FROM api_token_target_zones WHERE playlist_id='plA'").get().c, 0, 'zone grants gone (no orphans)'); +}); + +test('#73 cascade: deleting a zone (or its layout) drops the grant referencing it', () => { + const db = freshDb(); + 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); +});