From c8a24d224338dd9ebc102ffe3d602aff46570214 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sat, 13 Jun 2026 21:30:38 -0500 Subject: [PATCH 01/20] feat(api): agency-token security primitive - off-ladder scope + agencyGate (#73) The capability/target-restricted token model for the agency portal (#73 option B), proven before any endpoint sits on it: - 'agency' scope value is OFF the read/write/full ladder, so the existing tokenScopeGate rejects it on every public router by construction (auto-confinement, no new code). - api_token_targets join table: which playlists an agency token may act on. - agencyGate: THE single seam - agency-scope-only + (playlist in this token's allowlist AND in the bound workspace), one query enforcing target + cross-workspace isolation. - AGENCY_ROUTERS category in config/api-surface.js (mounted with agencyGate, not tokenScopeGate) - declared; router/mount land with the endpoints. Both bite-tested: spine (agency 403s on tokenScopeGate; read/write still pass) and the gate (non-designated/cross-workspace/non-agency/JWT -> 403; neutralizing the target check goes red). NARROW - not the general capability-scope system. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/config/api-surface.js | 11 ++++++++- server/db/database.js | 2 ++ server/db/schema.sql | 14 +++++++++++- server/middleware/apiToken.js | 26 ++++++++++++++++++++- server/test/agency-gate.test.js | 39 ++++++++++++++++++++++++++++++++ server/test/agency-scope.test.js | 32 ++++++++++++++++++++++++++ 6 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 server/test/agency-gate.test.js create mode 100644 server/test/agency-scope.test.js diff --git a/server/config/api-surface.js b/server/config/api-surface.js index 9af5c93..d67485d 100644 --- a/server/config/api-surface.js +++ b/server/config/api-surface.js @@ -48,4 +48,13 @@ const JWT_ONLY_ROUTERS = [ { path: '/api/tokens', mod: './routes/tokens', tenancy: true }, ]; -module.exports = { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS }; +// #73: AGENCY_ROUTERS - capability-restricted ('agency' scope) surface. Mounted with +// bearerAuth + resolveTenancy + agencyGate (NOT tokenScopeGate). An 'agency' token is +// OFF the read/write/full ladder, so tokenScopeGate rejects it on every PUBLIC_ROUTER - +// it can reach ONLY this router, and only its allowlisted playlists in its bound +// workspace (agencyGate enforces both). read/write/full tokens and JWTs are rejected here. +const AGENCY_ROUTERS = [ + { path: '/api/agency', mod: './routes/agency' }, +]; + +module.exports = { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS, AGENCY_ROUTERS }; diff --git a/server/db/database.js b/server/db/database.js index 192dc5f..6e121c5 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -193,6 +193,8 @@ const migrations = [ "ALTER TABLE users ADD COLUMN totp_last_step INTEGER NOT NULL DEFAULT 0", "CREATE TABLE IF NOT EXISTS totp_recovery_codes (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, code_hash TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), used_at INTEGER)", "CREATE INDEX IF NOT EXISTS idx_totp_recovery_user ON totp_recovery_codes(user_id)", + // #73: agency-token target allowlist (capability-restricted tokens). + "CREATE TABLE IF NOT EXISTS 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, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), PRIMARY KEY (token_id, playlist_id))", ]; // 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 de5d2f5..838ad64 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -530,12 +530,24 @@ CREATE TABLE IF NOT EXISTS api_tokens ( name TEXT NOT NULL, -- user-given label user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'full' + scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'full' | 'agency' created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), last_used_at INTEGER, revoked_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); + +-- #73: target allowlist for capability-restricted ('agency') tokens. An agency token +-- (scope='agency', OFF the read/write/full ladder so tokenScopeGate rejects it on every +-- other router) may act ONLY on the playlists listed here, enforced at the single +-- agencyGate seam. FK cascade both ways: revoke the token or delete the playlist and the +-- grant disappears. +CREATE TABLE IF NOT EXISTS 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, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (token_id, playlist_id) +); CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); -- ===================== SCHEMA MIGRATIONS ===================== diff --git a/server/middleware/apiToken.js b/server/middleware/apiToken.js index e747e27..a9ca1dc 100644 --- a/server/middleware/apiToken.js +++ b/server/middleware/apiToken.js @@ -112,7 +112,31 @@ function requireScope(need) { }; } +// #73: THE single seam for capability-restricted ('agency') tokens. Mounted on the +// AGENCY_ROUTER (config/api-surface.js) in place of tokenScopeGate. Two checks, no more: +// (1) only an agency token passes (a JWT or read/write/full token is rejected); +// (2) if the request targets a playlist, that playlist must be in THIS token's +// allowlist AND in the token's bound workspace - one query enforces both the +// target restriction and cross-workspace isolation. +// Every agency capability route passes through here, so the whole primitive is proven +// at one place. Removing the api_token_targets condition makes the bite-test go red. +function agencyGate(req, res, next) { + if (!req.viaToken || req.tokenScope !== 'agency') { + return res.status(403).json({ error: 'agency token required' }); + } + const playlistId = req.params.playlistId || (req.body && req.body.playlist_id); + if (playlistId) { + const ok = db.prepare(` + SELECT 1 FROM api_token_targets t + JOIN playlists p ON p.id = t.playlist_id + WHERE t.token_id = ? AND t.playlist_id = ? AND p.workspace_id = ? + `).get(req.apiToken.id, playlistId, req.jwtWorkspaceId); + if (!ok) return res.status(403).json({ error: 'playlist not in this agency token\'s allowlist' }); + } + next(); +} + module.exports = { - bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, + bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, agencyGate, hashToken, generateToken, displayPrefix, TOKEN_PREFIX, }; diff --git a/server/test/agency-gate.test.js b/server/test/agency-gate.test.js new file mode 100644 index 0000000..9a2e7dc --- /dev/null +++ b/server/test/agency-gate.test.js @@ -0,0 +1,39 @@ +'use strict'; + +// #73 THE SEAM: agencyGate is the single place capability+target restriction is enforced. +// Prove it confines before any endpoint is built behind it. Removing the api_token_targets +// condition in agencyGate makes "non-designated -> 403" go red (the bite). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); + +const mem = new Database(':memory:'); +mem.exec(` + CREATE TABLE api_token_targets (token_id TEXT, playlist_id TEXT, PRIMARY KEY(token_id, playlist_id)); + CREATE TABLE playlists (id TEXT PRIMARY KEY, workspace_id TEXT); + INSERT INTO playlists (id, workspace_id) VALUES ('plA','wsA'), ('plB','wsA'), ('plC','wsB'); + INSERT INTO api_token_targets (token_id, playlist_id) VALUES ('tok1','plA'), ('tok1','plC'); +`); // tok1 is allowlisted for plA (wsA, its bound ws) and plC (wsB, a DIFFERENT ws) +require.cache[require.resolve('../db/database')] = { + id: require.resolve('../db/database'), loaded: true, exports: { db: mem }, +}; +const { agencyGate } = require('../middleware/apiToken'); + +function gate(over = {}) { + const req = { viaToken: true, tokenScope: 'agency', apiToken: { id: 'tok1' }, jwtWorkspaceId: 'wsA', params: {}, body: {}, ...over }; + let status = 200, nexted = false; + const res = { status(s) { status = s; return this; }, json() { return this; } }; + agencyGate(req, res, () => { nexted = true; }); + return { status, nexted }; +} + +test('#73 agencyGate: only agency tokens, only allowlisted playlists, only the bound workspace', () => { + assert.equal(gate({ params: { playlistId: 'plA' } }).nexted, true, 'designated playlist in bound ws -> passes'); + assert.equal(gate({ params: { playlistId: 'plB' } }).status, 403, 'NON-designated playlist -> 403 (target restriction)'); + assert.equal(gate({ params: { playlistId: 'plC' } }).status, 403, 'designated but CROSS-workspace -> 403'); + assert.equal(gate({ tokenScope: 'write', params: { playlistId: 'plA' } }).status, 403, 'non-agency token -> 403'); + assert.equal(gate({ viaToken: false, params: { playlistId: 'plA' } }).status, 403, 'JWT -> 403'); + // body.playlist_id is honored too (create-item path), so the seam covers both routes + assert.equal(gate({ body: { playlist_id: 'plB' } }).status, 403, 'non-designated via body -> 403'); +}); diff --git a/server/test/agency-scope.test.js b/server/test/agency-scope.test.js new file mode 100644 index 0000000..1c6dcb5 --- /dev/null +++ b/server/test/agency-scope.test.js @@ -0,0 +1,32 @@ +'use strict'; + +// #73 SPINE: an 'agency' scope is OFF the read/write/full ladder, so the EXISTING +// tokenScopeGate rejects it on every router by construction (auto-confinement). This is +// the foundation the whole model rests on - prove it before building anything on top. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); + +// tokenScopeGate is pure (no db), but requiring the module loads db/database - inject one. +require.cache[require.resolve('../db/database')] = { + id: require.resolve('../db/database'), loaded: true, exports: { db: new Database(':memory:') }, +}; +const { tokenScopeGate } = require('../middleware/apiToken'); + +function run(scope, method) { + const req = { viaToken: true, tokenScope: scope, method }; + let status = 200, nexted = false; + const res = { status(s) { status = s; return this; }, json() { return this; } }; + tokenScopeGate(req, res, () => { nexted = true; }); + return { status, nexted }; +} + +test('#73 spine: agency scope auto-fails tokenScopeGate everywhere (off-ladder)', () => { + assert.equal(run('agency', 'GET').status, 403, 'agency cannot read on a normal router'); + assert.equal(run('agency', 'POST').status, 403, 'agency cannot write on a normal router'); + assert.equal(run('agency', 'GET').nexted, false, 'agency never reaches the handler'); + // Contrast: normal scopes still pass - the gate isn't just rejecting everything. + assert.equal(run('write', 'POST').nexted, true, 'write still passes write'); + assert.equal(run('read', 'GET').nexted, true, 'read still passes read'); +}); From a59b53cc25a25f7fe6ad65e862e429e45ee82019 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sat, 13 Jun 2026 22:48:42 -0500 Subject: [PATCH 02/20] refactor(content): extract the upload ingest into a shared lib (#73) routes/content.js POST / processing (thumbnail/dimensions/duration) + insert moved to lib/content-ingest.js so the agency router produces byte-identical first-class content. content.js POST / is now a thin caller; behavior-preserving - the 52 content regression tests (api/operator-permissions/config-paths) pass unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/lib/content-ingest.js | 77 ++++++++++++++++++++++++++++++++++++ server/routes/content.js | 58 ++------------------------- 2 files changed, 81 insertions(+), 54 deletions(-) create mode 100644 server/lib/content-ingest.js diff --git a/server/lib/content-ingest.js b/server/lib/content-ingest.js new file mode 100644 index 0000000..c4694e8 --- /dev/null +++ b/server/lib/content-ingest.js @@ -0,0 +1,77 @@ +'use strict'; + +// #73: shared content-ingest core. Extracted from routes/content.js POST / so the agency +// upload (routes/agency.js) produces BYTE-IDENTICAL first-class content (same thumbnail/ +// dimensions/duration/insert) - an agency asset is indistinguishable from a dashboard +// upload. routes/content.js POST / is now a thin caller; behavior is unchanged (its +// existing tests are the regression guard). + +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); +const config = require('../config'); +const { sanitizeString } = require('../middleware/sanitize'); + +// Multer takes file.originalname from the multipart header, bypassing sanitizeBody, so +// HTML-escape here (renders as text in every UI sink). .normalize('NFC') first: macOS +// sends NFD-decomposed names; Linux/renderers expect NFC. Single point - every filename +// storage site flows through here. +function safeFilename(name) { + return sanitizeString((name || '').normalize('NFC')); +} + +// Process a multer-uploaded file (thumbnail + dimensions + duration) and insert a content +// row. Returns the content row. Throws on a hard failure (the caller maps to 500); +// thumbnail/metadata failures are best-effort (logged, non-fatal) exactly as before. +async function ingestUploadedFile({ file, userId, workspaceId }) { + const id = uuidv4(); + const filepath = file.filename; + let width = null, height = null, durationSec = null, thumbnailPath = null; + + try { + if (file.mimetype.startsWith('image/')) { + const sharp = require('sharp'); + const metadata = await sharp(file.path).metadata(); + width = metadata.width; + height = metadata.height; + thumbnailPath = `thumb_${filepath}`; + await sharp(file.path) + .resize(config.thumbnailWidth) + .jpeg({ quality: 70 }) + .toFile(path.join(config.contentDir, thumbnailPath)); + } else if (file.mimetype.startsWith('video/')) { + try { + const { execFileSync } = require('child_process'); + const probe = execFileSync('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', file.path], + { timeout: 15000 } + ).toString(); + const info = JSON.parse(probe); + if (info.format?.duration) durationSec = parseFloat(info.format.duration); + const videoStream = info.streams?.find(s => s.codec_type === 'video'); + if (videoStream) { + width = videoStream.width; + height = videoStream.height; + } + thumbnailPath = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`; + try { + execFileSync('ffmpeg', ['-y', '-i', file.path, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbnailPath)], + { timeout: 15000 } + ); + } catch { thumbnailPath = null; } + } catch (e) { + console.warn('ffprobe failed:', e.message); + } + } + } catch (e) { + console.warn('Thumbnail/metadata generation failed:', e.message); + } + + db.prepare(` + INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, userId, workspaceId, safeFilename(file.originalname), filepath, file.mimetype, file.size, durationSec, thumbnailPath, width, height); + + return db.prepare('SELECT * FROM content WHERE id = ?').get(id); +} + +module.exports = { ingestUploadedFile, safeFilename }; diff --git a/server/routes/content.js b/server/routes/content.js index affe22e..7c51a09 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -11,6 +11,8 @@ const { sanitizeString } = require('../middleware/sanitize'); const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth'); // Phase 2.2b: workspace-aware access. Mirrors the pattern from devices.js. const { accessContext } = require('../lib/tenancy'); +// #73: the upload ingest (processing + insert) is now shared with the agency router. +const { ingestUploadedFile } = require('../lib/content-ingest'); // Multer captures file.originalname directly from the multipart filename header, // bypassing sanitizeBody. Apply the same HTML-escape here so a filename like @@ -91,60 +93,8 @@ router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => { if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before uploading.' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); - const id = uuidv4(); - const filepath = req.file.filename; - let width = null, height = null, durationSec = null, thumbnailPath = null; - - // Try to generate thumbnail, get dimensions, and detect duration - try { - if (req.file.mimetype.startsWith('image/')) { - const sharp = require('sharp'); - const metadata = await sharp(req.file.path).metadata(); - width = metadata.width; - height = metadata.height; - - // Generate thumbnail - thumbnailPath = `thumb_${filepath}`; - await sharp(req.file.path) - .resize(config.thumbnailWidth) - .jpeg({ quality: 70 }) - .toFile(path.join(config.contentDir, thumbnailPath)); - } else if (req.file.mimetype.startsWith('video/')) { - // Extract video duration and dimensions with ffprobe - try { - const { execFileSync } = require('child_process'); - // Use execFileSync (not execSync) to prevent shell injection - args are NOT passed through shell - const probe = execFileSync('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', req.file.path], - { timeout: 15000 } - ).toString(); - const info = JSON.parse(probe); - if (info.format?.duration) durationSec = parseFloat(info.format.duration); - const videoStream = info.streams?.find(s => s.codec_type === 'video'); - if (videoStream) { - width = videoStream.width; - height = videoStream.height; - } - // Generate video thumbnail at 2 second mark - thumbnailPath = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`; - try { - execFileSync('ffmpeg', ['-y', '-i', req.file.path, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbnailPath)], - { timeout: 15000 } - ); - } catch { thumbnailPath = null; } - } catch (e) { - console.warn('ffprobe failed:', e.message); - } - } - } catch (e) { - console.warn('Thumbnail/metadata generation failed:', e.message); - } - - db.prepare(` - INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, req.user.id, req.workspaceId, safeFilename(req.file.originalname), filepath, req.file.mimetype, req.file.size, durationSec, thumbnailPath, width, height); - - const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id); + // #73: shared ingest - identical processing + insert for dashboard and agency uploads. + const content = await ingestUploadedFile({ file: req.file, userId: req.user.id, workspaceId: req.workspaceId }); res.status(201).json(content); } catch (err) { console.error('Upload error:', err); From 40102b2b41f26f951dbdd607c487f6349d155015 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sat, 13 Jun 2026 22:48:42 -0500 Subject: [PATCH 03/20] feat(api): agency portal endpoints + router.param target seam (#73) The agency capability behind the proven off-ladder/agencyGate primitive: - agencyGate is now SCOPE-only at the mount; the per-target check is router.param ('playlistId') in routes/agency.js - it fires WITH the param before the handler, so no :playlistId route can skip it (drift-proof). A mount-level target check was silently bypassed (Express populates req.params only at route match); the integration bite-suite caught it - this is the fix. - routes/agency.js: POST /content (shared ingest) + POST /playlists/:id/items (date-bounded #74/#75 item; lands as draft so the admin's re-publish is the approval gate). - tokens.js: issue scope='agency' tokens bound to a non-empty in-workspace playlist allowlist (atomic); PUT /:id/targets re-designates (JWT-only -> can't self-widen). - server.js: AGENCY_ROUTERS mounted bearerAuth + resolveTenancy + agencyGate. Full bite-suite (test/agency.test.js) GREEN and re-proven to bite on the SHIPPING path: neutralizing the router.param check makes non-designated->403 go red. Four assertions at three seams: target (router.param), off-ladder (tokenScopeGate), can't-widen (tokens JWT-only), issuance cross-workspace (create validation). 139 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/middleware/apiToken.js | 25 +++------ server/routes/agency.js | 89 +++++++++++++++++++++++++++++++++ server/routes/tokens.js | 53 +++++++++++++++++--- server/server.js | 10 +++- server/test/agency-gate.test.js | 33 +++++------- server/test/agency.test.js | 80 +++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 46 deletions(-) create mode 100644 server/routes/agency.js create mode 100644 server/test/agency.test.js diff --git a/server/middleware/apiToken.js b/server/middleware/apiToken.js index a9ca1dc..74dbed8 100644 --- a/server/middleware/apiToken.js +++ b/server/middleware/apiToken.js @@ -112,27 +112,18 @@ function requireScope(need) { }; } -// #73: THE single seam for capability-restricted ('agency') tokens. Mounted on the -// AGENCY_ROUTER (config/api-surface.js) in place of tokenScopeGate. Two checks, no more: -// (1) only an agency token passes (a JWT or read/write/full token is rejected); -// (2) if the request targets a playlist, that playlist must be in THIS token's -// allowlist AND in the token's bound workspace - one query enforces both the -// target restriction and cross-workspace isolation. -// Every agency capability route passes through here, so the whole primitive is proven -// at one place. Removing the api_token_targets condition makes the bite-test go red. +// #73: mount seam for capability-restricted ('agency') tokens. SCOPE/off-ladder check ONLY: +// only an agency token reaches the agency router (a read/write/full token or a JWT is +// rejected). The PER-TARGET check CANNOT live here - Express doesn't populate req.params at +// app.use-level middleware (params land at route match, inside the router), so a mount-level +// target check is silently bypassed (the integration bite-suite caught exactly this). The +// target check is router.param('playlistId') in routes/agency.js - it fires WITH the param +// before the handler and can't be skipped by any :playlistId route. Two single-registration, +// drift-proof seams: scope (here) + target (router.param). function agencyGate(req, res, next) { if (!req.viaToken || req.tokenScope !== 'agency') { return res.status(403).json({ error: 'agency token required' }); } - const playlistId = req.params.playlistId || (req.body && req.body.playlist_id); - if (playlistId) { - const ok = db.prepare(` - SELECT 1 FROM api_token_targets t - JOIN playlists p ON p.id = t.playlist_id - WHERE t.token_id = ? AND t.playlist_id = ? AND p.workspace_id = ? - `).get(req.apiToken.id, playlistId, req.jwtWorkspaceId); - if (!ok) return res.status(403).json({ error: 'playlist not in this agency token\'s allowlist' }); - } next(); } diff --git a/server/routes/agency.js b/server/routes/agency.js new file mode 100644 index 0000000..8f04bc1 --- /dev/null +++ b/server/routes/agency.js @@ -0,0 +1,89 @@ +'use strict'; + +// #73: agency portal endpoints. Mounted behind bearerAuth + resolveTenancy + agencyGate +// (AGENCY_ROUTERS in config/api-surface.js). agencyGate has ALREADY proven, at one seam: +// the caller is an 'agency' token, and for any :playlistId the playlist is in THIS token's +// allowlist AND its bound workspace. So these handlers only add within-workspace content +// checks; router/target/cross-workspace confinement is proven upstream. + +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); +const upload = require('../middleware/upload'); +const { checkStorageLimit } = require('../middleware/subscription'); +const { ingestUploadedFile } = require('../lib/content-ingest'); + +const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +// #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 +// route without this triggering). One query enforces both the target allowlist and +// cross-workspace isolation. Neutralizing the `if (!ok)` return makes integration BITE 1 red. +router.param('playlistId', (req, res, next, playlistId) => { + const ok = db.prepare(` + SELECT 1 FROM api_token_targets t + JOIN playlists p ON p.id = t.playlist_id + WHERE t.token_id = ? AND t.playlist_id = ? AND p.workspace_id = ? + `).get(req.apiToken.id, playlistId, req.jwtWorkspaceId); + if (!ok) return res.status(403).json({ error: 'playlist not in this agency token\'s allowlist' }); + next(); +}); + +// Upload to the bound workspace via the SHARED ingest -> first-class content (identical +// thumbnail/dimensions/duration to a dashboard upload). +router.post('/content', checkStorageLimit, upload.single('file'), async (req, res) => { + try { + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + const content = await ingestUploadedFile({ file: req.file, userId: req.user.id, workspaceId: req.workspaceId }); + res.status(201).json(content); + } catch (e) { + console.error('agency upload error:', e.message); + res.status(500).json({ error: 'Upload failed' }); + } +}); + +// Add a date-bounded item to a DESIGNATED playlist (#74/#75 schedule block). The playlist +// is already gate-verified. Lands as DRAFT (markDraft) so the admin's re-publish is the +// approval gate for external-party content - same draft-on-change behavior as the dashboard. +router.post('/playlists/:playlistId/items', (req, res) => { + const { content_id } = req.body; + if (!content_id) return res.status(400).json({ error: 'content_id required' }); + + 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) + if (content.workspace_id && content.workspace_id !== req.workspaceId) { + return res.status(403).json({ error: 'Content is not in this workspace' }); + } + + 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' }); + } + duration_sec = duration_sec || content.duration_sec || 10; + + const sd = start_date ?? null, ed = end_date ?? null; + for (const [k, v] of [['start_date', sd], ['end_date', ed]]) { + if (v != null && !DATE_RE.test(v)) return res.status(400).json({ error: `${k} must be YYYY-MM-DD or null` }); + } + const dys = (Array.isArray(days) && days.length) ? days : [0, 1, 2, 3, 4, 5, 6]; + if (!dys.every(d => Number.isInteger(d) && d >= 0 && d <= 6)) return res.status(400).json({ error: 'days must be integers 0-6' }); + const st = start ?? '00:00', en = end ?? '24:00'; + if (!TIME_RE.test(st)) return res.status(400).json({ error: 'start must be HH:MM' }); + 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; + 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); + // items changed since last publish -> draft; admin re-publish approves it. + db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(req.params.playlistId); + + res.status(201).json({ id: itemId, playlist_id: req.params.playlistId, content_id, duration_sec, start_date: sd, end_date: ed }); +}); + +module.exports = router; diff --git a/server/routes/tokens.js b/server/routes/tokens.js index ea0a26d..20436c3 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -8,7 +8,9 @@ const { db } = require('../db/database'); const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken'); const { accessContext } = require('../lib/tenancy'); -const SCOPES = ['read', 'write', 'full']; +// #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. +const SCOPES = ['read', 'write', 'full', 'agency']; // List the caller's tokens in the active workspace. Never returns the secret/hash. router.get('/', (req, res) => { @@ -27,21 +29,38 @@ router.post('/', (req, res) => { const scope = req.body.scope || 'read'; if (!name) return res.status(400).json({ error: 'name is required' }); if (name.length > 100) return res.status(400).json({ error: 'name too long' }); - if (!SCOPES.includes(scope)) return res.status(400).json({ error: "scope must be 'read', 'write' or 'full'" }); + if (!SCOPES.includes(scope)) return res.status(400).json({ error: "scope must be 'read', 'write', 'full' or 'agency'" }); // The token runs with platform powers stripped (role forced to 'user'), so it must // bind to a workspace the owner reaches via membership/org - not platform act-as - // else apiTokenAuth+resolveTenancy would land it in no workspace at use time. if (!accessContext(req.user.id, 'user', req.workspace)) { return res.status(400).json({ error: 'You must be a member of this workspace to create a token here' }); } + // #73: an agency token is bound to a NON-EMPTY allowlist of playlists in THIS workspace. + // Validate up front so a bad target never leaves an orphan token behind. + let targetIds = []; + 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' }); + 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` }); + } + } const secret = generateToken(); const id = crypto.randomUUID(); - db.prepare(` - INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) - `).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope); + db.transaction(() => { + db.prepare(` + INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) + `).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope); + 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); + } + })(); // `token` is returned only here, never again. - res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId }); + res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId, target_playlist_ids: targetIds }); }); // Revoke one of the caller's own tokens (soft delete - takes effect on the next request). @@ -54,4 +73,24 @@ router.delete('/:id', (req, res) => { res.json({ success: true }); }); +// #73: re-designate an agency token's playlist allowlist (atomic replace). JWT-only (this +// whole router is JWT-only), so an agency token can never widen its OWN targets. +router.put('/:id/targets', (req, res) => { + const tok = db.prepare('SELECT id, scope, workspace_id FROM api_tokens WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); + if (!tok) return res.status(404).json({ error: 'Token not found' }); + if (tok.scope !== 'agency') return res.status(400).json({ error: 'only agency tokens have targets' }); + const ids = Array.isArray(req.body.target_playlist_ids) ? req.body.target_playlist_ids : []; + if (!ids.length) return res.status(400).json({ error: 'target_playlist_ids must be a non-empty array' }); + 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` }); + } + const ins = db.prepare('INSERT OR IGNORE INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)'); + db.transaction(() => { + db.prepare('DELETE FROM api_token_targets WHERE token_id = ?').run(tok.id); + for (const pid of ids) ins.run(tok.id, pid); + })(); + res.json({ id: tok.id, target_playlist_ids: ids }); +}); + module.exports = router; diff --git a/server/server.js b/server/server.js index 8ac7932..a270676 100644 --- a/server/server.js +++ b/server/server.js @@ -446,7 +446,7 @@ app.get('/api/content/:id/thumbnail', (req, res) => { const { requireAuth } = require('./middleware/auth'); const { resolveTenancy } = require('./lib/tenancy'); // Public API token front door (Phase 1). Attached ONLY to the public routers below. -const { bearerAuth, tokenScopeGate } = require('./middleware/apiToken'); +const { bearerAuth, tokenScopeGate, agencyGate } = require('./middleware/apiToken'); // activityLogger wraps res.json on every subsequent route to auto-log // successful POST/PUT/DELETE mutations. Mount it BEFORE the workspace routes @@ -464,7 +464,7 @@ app.use(activityLogger); // their jwt.verify and is unreachable (secure by exclusion). Tokens act as a workspace // member with platform powers stripped, so in-handler ELEVATED/PLATFORM checks (e.g. // GET /api/devices/unassigned) still deny. -const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS } = require('./config/api-surface'); +const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS, AGENCY_ROUTERS } = require('./config/api-surface'); // Public device-render endpoints + the memory-heavy preview limiter must be registered // BEFORE their parent router mount so the _skipAuth bypass / the limiter fire first. @@ -485,6 +485,12 @@ for (const r of JWT_ONLY_ROUTERS) { if (r.tenancy) app.use(r.path, requireAuth, resolveTenancy, require(r.mod)); else app.use(r.path, requireAuth, require(r.mod)); } +for (const r of AGENCY_ROUTERS) { + // #73: capability-restricted token surface. bearerAuth + resolveTenancy + agencyGate + // (NOT tokenScopeGate). 'agency' is off the read/write/full ladder, so these tokens + // reach ONLY here; agencyGate enforces the playlist allowlist + bound workspace. + app.use(r.path, bearerAuth, resolveTenancy, agencyGate, require(r.mod)); +} // Frontend version hash (changes when files are modified, triggers soft reload) const crypto = require('crypto'); diff --git a/server/test/agency-gate.test.js b/server/test/agency-gate.test.js index 9a2e7dc..76fc6d2 100644 --- a/server/test/agency-gate.test.js +++ b/server/test/agency-gate.test.js @@ -1,39 +1,32 @@ 'use strict'; -// #73 THE SEAM: agencyGate is the single place capability+target restriction is enforced. -// Prove it confines before any endpoint is built behind it. Removing the api_token_targets -// condition in agencyGate makes "non-designated -> 403" go red (the bite). +// #73 mount seam: agencyGate does SCOPE/off-ladder confinement ONLY (only an agency token +// reaches the agency router). The per-target check moved to router.param('playlistId') in +// routes/agency.js, because Express doesn't populate req.params at mount-level middleware - +// so the target restriction is proven on the REAL runtime path by test/agency.test.js +// (the integration bite-suite), not here. const { test } = require('node:test'); const assert = require('node:assert/strict'); const Database = require('better-sqlite3'); -const mem = new Database(':memory:'); -mem.exec(` - CREATE TABLE api_token_targets (token_id TEXT, playlist_id TEXT, PRIMARY KEY(token_id, playlist_id)); - CREATE TABLE playlists (id TEXT PRIMARY KEY, workspace_id TEXT); - INSERT INTO playlists (id, workspace_id) VALUES ('plA','wsA'), ('plB','wsA'), ('plC','wsB'); - INSERT INTO api_token_targets (token_id, playlist_id) VALUES ('tok1','plA'), ('tok1','plC'); -`); // tok1 is allowlisted for plA (wsA, its bound ws) and plC (wsB, a DIFFERENT ws) +// agencyGate needs no db now, but requiring the module loads db/database - inject a stub. require.cache[require.resolve('../db/database')] = { - id: require.resolve('../db/database'), loaded: true, exports: { db: mem }, + id: require.resolve('../db/database'), loaded: true, exports: { db: new Database(':memory:') }, }; const { agencyGate } = require('../middleware/apiToken'); function gate(over = {}) { - const req = { viaToken: true, tokenScope: 'agency', apiToken: { id: 'tok1' }, jwtWorkspaceId: 'wsA', params: {}, body: {}, ...over }; + const req = { viaToken: true, tokenScope: 'agency', ...over }; let status = 200, nexted = false; const res = { status(s) { status = s; return this; }, json() { return this; } }; agencyGate(req, res, () => { nexted = true; }); return { status, nexted }; } -test('#73 agencyGate: only agency tokens, only allowlisted playlists, only the bound workspace', () => { - assert.equal(gate({ params: { playlistId: 'plA' } }).nexted, true, 'designated playlist in bound ws -> passes'); - assert.equal(gate({ params: { playlistId: 'plB' } }).status, 403, 'NON-designated playlist -> 403 (target restriction)'); - assert.equal(gate({ params: { playlistId: 'plC' } }).status, 403, 'designated but CROSS-workspace -> 403'); - assert.equal(gate({ tokenScope: 'write', params: { playlistId: 'plA' } }).status, 403, 'non-agency token -> 403'); - assert.equal(gate({ viaToken: false, params: { playlistId: 'plA' } }).status, 403, 'JWT -> 403'); - // body.playlist_id is honored too (create-item path), so the seam covers both routes - assert.equal(gate({ body: { playlist_id: 'plB' } }).status, 403, 'non-designated via body -> 403'); +test('#73 agencyGate (mount seam): only agency tokens pass; non-agency + JWT rejected', () => { + assert.equal(gate().nexted, true, 'agency token passes the scope seam'); + assert.equal(gate({ tokenScope: 'write' }).status, 403, 'read/write/full token -> 403'); + assert.equal(gate({ tokenScope: 'full' }).status, 403, 'full token -> 403'); + assert.equal(gate({ viaToken: false }).status, 403, 'JWT (not a token) -> 403'); }); diff --git a/server/test/agency.test.js b/server/test/agency.test.js new file mode 100644 index 0000000..b2fb8c2 --- /dev/null +++ b/server/test/agency.test.js @@ -0,0 +1,80 @@ +'use strict'; + +// #73 FULL bite-suite for the agency-token primitive, end-to-end against a booted server: +// the happy path (upload -> date-bounded item on a DESIGNATED playlist) plus the four +// confinement assertions at their three seams (gate / off-ladder / JWT-only / issuance). + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); + +const PORT = 3992; +const BASE = `http://127.0.0.1:${PORT}`; +const DATA_DIR = path.join(os.tmpdir(), 'st-agency-' + crypto.randomBytes(4).toString('hex')); +let proc; + +before(async () => { + const logFd = fs.openSync(path.join(os.tmpdir(), 'st-agency.log'), 'w'); + proc = spawn('node', ['server.js'], { + cwd: path.join(__dirname, '..'), + env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, + stdio: ['ignore', logFd, logFd], + }); + for (let i = 0; i < 80; i++) { + try { const r = await fetch(BASE + '/api/status'); if (r.ok) break; } catch { /* not yet */ } + await new Promise(r => setTimeout(r, 250)); + } +}); +after(() => { try { proc.kill('SIGKILL'); } catch { /* ignore */ } }); + +async function jfetch(p, opts = {}) { + const res = await fetch(BASE + p, opts); + let body = null; try { body = await res.json(); } catch { /* non-JSON */ } + return { status: res.status, body }; +} +const jpost = (tok, o) => ({ method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(o || {}) }); +const reg = (o) => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(o) }); + +test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)', async () => { + const email = 'ag' + crypto.randomBytes(4).toString('hex') + '@x.local'; + const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token; + const pl1 = (await jfetch('/api/playlists', jpost(jwt, { name: 'Designated' }))).body; + const pl2 = (await jfetch('/api/playlists', jpost(jwt, { name: 'Off-limits' }))).body; + + // issue an agency token bound to pl1 ONLY + const tokRes = await jfetch('/api/tokens', jpost(jwt, { name: 'Agency', scope: 'agency', target_playlist_ids: [pl1.id] })); + assert.equal(tokRes.status, 201, 'agency token created'); + assert.deepEqual(tokRes.body.target_playlist_ids, [pl1.id]); + const atok = tokRes.body.token; + + // 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'); + const up = await fetch(BASE + '/api/agency/content', { method: 'POST', headers: { Authorization: 'Bearer ' + atok }, body: fd }); + assert.equal(up.status, 201, 'agency upload -> 201 (first-class content)'); + const content = await up.json(); + + // date-bounded item on the DESIGNATED playlist + const item = await jfetch(`/api/agency/playlists/${pl1.id}/items`, jpost(atok, { content_id: content.id, start_date: '2026-07-01', end_date: '2026-07-31' })); + assert.equal(item.status, 201, 'item on designated playlist -> 201'); + + // BITE 1 (gate): NON-designated playlist -> 403 + const blocked = await jfetch(`/api/agency/playlists/${pl2.id}/items`, jpost(atok, { content_id: content.id })); + assert.equal(blocked.status, 403, 'non-designated playlist -> 403'); + + // BITE 2 (off-ladder): agency token on a normal public router -> 403 + const dev = await jfetch('/api/devices', { headers: { Authorization: 'Bearer ' + atok } }); + assert.equal(dev.status, 403, 'agency token on /api/devices -> 403 (off-ladder, tokenScopeGate)'); + + // BITE 3 (JWT-only): can't reach /api/tokens to widen its OWN targets -> 401 + const widen = await jfetch(`/api/tokens/${tokRes.body.id}/targets`, jpost(atok, { target_playlist_ids: [pl1.id, pl2.id] })); + assert.equal(widen.status, 401, 'agency token cannot reach /api/tokens (JWT-only) -> 401'); + + // BITE 4 (issuance): an agency token can't be BOUND to an out-of-workspace/unknown playlist -> 400 + 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'); +}); From 6d152a5ccff35087f9172cf0f9257456d2c0fb3a Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 13:08:07 -0500 Subject: [PATCH 04/20] feat(api): GET /api/agency/playlists - a token's designated targets (#73) The portal needs to show an agency which playlists it may post to. New read surface on the security primitive, built with write-path rigor: the confinement query lives in lib/agency-targets.js (own token + bound workspace only) and is bite-tested four ways - own targets yes; another token's, outside the allowlist, and cross-workspace all NO; neutralizing the t.token_id filter makes it go red. Real-path wiring + the portal's graceful 401 trigger asserted in the integration suite. No :playlistId, so router.param doesn't apply - the query is the seam. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/lib/agency-targets.js | 20 +++++++++++++++++++ server/routes/agency.js | 8 ++++++++ server/test/agency-list.test.js | 35 +++++++++++++++++++++++++++++++++ server/test/agency.test.js | 10 ++++++++++ 4 files changed, 73 insertions(+) create mode 100644 server/lib/agency-targets.js create mode 100644 server/test/agency-list.test.js diff --git a/server/lib/agency-targets.js b/server/lib/agency-targets.js new file mode 100644 index 0000000..20af630 --- /dev/null +++ b/server/lib/agency-targets.js @@ -0,0 +1,20 @@ +'use strict'; + +// #73: the single query behind GET /api/agency/playlists. Returns ONLY this token's +// designated playlists, in its bound workspace. The WHERE clause IS the confinement and is +// the thing to bite-test: +// t.token_id = ? -> this token's targets, never another token's +// (JOIN api_token_targets) -> only allowlisted playlists, never one outside the allowlist +// p.workspace_id = ? -> only the bound workspace, never cross-workspace +// db is passed in (not module-required) so the confinement is unit-testable in isolation. +function listDesignatedPlaylists(db, tokenId, workspaceId) { + return db.prepare(` + SELECT p.id, p.name, p.status + FROM api_token_targets t + JOIN playlists p ON p.id = t.playlist_id + WHERE t.token_id = ? AND p.workspace_id = ? + ORDER BY p.name + `).all(tokenId, workspaceId); +} + +module.exports = { listDesignatedPlaylists }; diff --git a/server/routes/agency.js b/server/routes/agency.js index 8f04bc1..e0f1a24 100644 --- a/server/routes/agency.js +++ b/server/routes/agency.js @@ -13,10 +13,18 @@ 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 TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +// List the playlists THIS token may post to (so the portal can show them). No :playlistId, +// so router.param doesn't apply - the confinement is the query in lib/agency-targets.js +// (own token + bound workspace only). Bite-tested in test/agency-list.test.js. +router.get('/playlists', (req, res) => { + res.json(listDesignatedPlaylists(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-list.test.js b/server/test/agency-list.test.js new file mode 100644 index 0000000..76b5390 --- /dev/null +++ b/server/test/agency-list.test.js @@ -0,0 +1,35 @@ +'use strict'; + +// #73: GET /api/agency/playlists is a new READ surface on the security primitive, so prove +// it confines with write-path rigor. The query (lib/agency-targets.js) must return ONLY this +// token's designated, in-workspace playlists. Four ways it could leak, all asserted here; +// neutralizing the t.token_id filter makes it go red (the bite). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const { listDesignatedPlaylists } = require('../lib/agency-targets'); + +const db = new Database(':memory:'); +db.exec(` + CREATE TABLE api_token_targets (token_id TEXT, playlist_id TEXT, PRIMARY KEY(token_id, playlist_id)); + CREATE TABLE playlists (id TEXT PRIMARY KEY, name TEXT, status TEXT, workspace_id TEXT); + INSERT INTO playlists (id, name, status, workspace_id) VALUES + ('p1','One', 'published','wsA'), + ('p2','Two', 'published','wsA'), + ('p3','Three','published','wsA'), + ('pX','Cross','published','wsB'); + INSERT INTO api_token_targets (token_id, playlist_id) VALUES + ('tokA','p1'), -- own + in-workspace -> MUST appear + ('tokA','pX'), -- own but CROSS-workspace -> must NOT appear + ('tokB','p2'); -- ANOTHER token's -> must NOT appear for tokA + -- p3 is in wsA but designated to no one -> OUTSIDE the allowlist -> must NOT appear +`); + +test('#73 GET targets: returns ONLY this token\'s designated, in-workspace playlists', () => { + const a = listDesignatedPlaylists(db, 'tokA', 'wsA').map(r => r.id); + assert.deepEqual(a, ['p1'], + 'tokA sees ONLY p1 - not p2 (another token), not p3 (outside allowlist), not pX (cross-workspace)'); + const b = listDesignatedPlaylists(db, 'tokB', 'wsA').map(r => r.id); + assert.deepEqual(b, ['p2'], 'tokB sees ONLY p2'); +}); diff --git a/server/test/agency.test.js b/server/test/agency.test.js index b2fb8c2..c0ed3a5 100644 --- a/server/test/agency.test.js +++ b/server/test/agency.test.js @@ -51,6 +51,11 @@ test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)' assert.deepEqual(tokRes.body.target_playlist_ids, [pl1.id]); const atok = tokRes.body.token; + // GET targets (real path: agencyGate -> handler -> query): returns ONLY the designated pl1 + const mine = await jfetch('/api/agency/playlists', { headers: { Authorization: 'Bearer ' + atok } }); + 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)'); + // 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'); @@ -77,4 +82,9 @@ test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)' // BITE 4 (issuance): an agency token can't be BOUND to an out-of-workspace/unknown playlist -> 400 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'); + + // 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' } }); + assert.equal(bogus.status, 401, 'invalid agency key -> 401 (portal resets to the entry screen)'); }); From d59adfd10cde86423f9bd3a6bf2137753671325a Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 13:08:07 -0500 Subject: [PATCH 05/20] feat(ui): agency token designation in Settings (#73) Admin-facing. Extends the existing API-token UI: an 'agency' scope option reveals a playlist picker (the workspace's playlists); creating the token binds the checked ones as its allowlist (target_playlist_ids). The token list shows each agency token's designated playlists (tokens GET now returns targets for agency-scoped tokens). i18n keys added across all five locales (parity test). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/i18n/de.js | 6 ++++++ frontend/js/i18n/en.js | 6 ++++++ frontend/js/i18n/es.js | 6 ++++++ frontend/js/i18n/fr.js | 6 ++++++ frontend/js/i18n/pt.js | 6 ++++++ frontend/js/views/settings.js | 37 +++++++++++++++++++++++++++++++++-- server/routes/tokens.js | 5 +++++ 7 files changed, 70 insertions(+), 2 deletions(-) diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index d4f680a..704482b 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -357,6 +357,12 @@ export default { 'apitoken.scope_read': 'Nur Lesen', 'apitoken.scope_write': 'Lesen & Schreiben', 'apitoken.scope_full': 'Voll (inkl. Gerätebefehle)', + 'apitoken.scope_agency': 'Agentur (nur in gewählte Playlists hochladen)', + 'apitoken.agency_playlists_label': 'Playlists, in die dieser Agentur-Token posten darf', + 'apitoken.agency_playlists_hint': 'Der Token kann nur in diese Playlists hochladen und zeitlich begrenzte Elemente hinzufügen. Hinzufügungen landen als Entwurf zur Veröffentlichung durch dich.', + 'apitoken.agency_needs_playlists': 'Wähle mindestens eine Playlist für einen Agentur-Token.', + 'apitoken.agency_no_playlists': 'Erstelle zuerst eine Playlist – ein Agentur-Token muss auf eine zielen.', + 'apitoken.targets_label': 'Zugewiesen:', '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 7efdaa0..f07750f 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -393,6 +393,12 @@ export default { 'apitoken.scope_read': 'Read only', 'apitoken.scope_write': 'Read & write', 'apitoken.scope_full': 'Full (incl. device commands)', + 'apitoken.scope_agency': 'Agency (upload to chosen playlists only)', + 'apitoken.agency_playlists_label': 'Playlists this agency token may post to', + 'apitoken.agency_playlists_hint': 'The token can upload and add date-bounded items to these playlists only. Additions land as drafts for you to publish.', + 'apitoken.agency_needs_playlists': 'Select at least one playlist for an agency token.', + 'apitoken.agency_no_playlists': 'Create a playlist first — an agency token must target one.', + 'apitoken.targets_label': 'Designated:', '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 f722311..ce06355 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -356,6 +356,12 @@ export default { 'apitoken.scope_read': 'Solo lectura', 'apitoken.scope_write': 'Lectura y escritura', 'apitoken.scope_full': 'Completo (incl. comandos de dispositivo)', + 'apitoken.scope_agency': 'Agencia (subir solo a listas elegidas)', + 'apitoken.agency_playlists_label': 'Listas a las que este token de agencia puede publicar', + 'apitoken.agency_playlists_hint': 'El token solo puede subir y añadir elementos con fechas a estas listas. Las adiciones quedan como borrador para que las publiques.', + 'apitoken.agency_needs_playlists': 'Selecciona al menos una lista para un token de agencia.', + 'apitoken.agency_no_playlists': 'Crea una lista primero: un token de agencia debe apuntar a una.', + 'apitoken.targets_label': 'Designadas:', '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 e7e1c48..d361b67 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -357,6 +357,12 @@ export default { 'apitoken.scope_read': 'Lecture seule', 'apitoken.scope_write': 'Lecture et écriture', 'apitoken.scope_full': 'Complet (cmd. appareils incluses)', + 'apitoken.scope_agency': 'Agence (téléverser uniquement vers les listes choisies)', + 'apitoken.agency_playlists_label': 'Listes vers lesquelles ce jeton d\'agence peut publier', + 'apitoken.agency_playlists_hint': 'Le jeton peut uniquement téléverser et ajouter des éléments datés à ces listes. Les ajouts restent en brouillon pour que vous les publiiez.', + 'apitoken.agency_needs_playlists': 'Sélectionnez au moins une liste pour un jeton d\'agence.', + 'apitoken.agency_no_playlists': 'Créez d\'abord une liste : un jeton d\'agence doit en cibler une.', + 'apitoken.targets_label': 'Assignées :', '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 da40f03..641f53d 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -357,6 +357,12 @@ export default { 'apitoken.scope_read': 'Somente leitura', 'apitoken.scope_write': 'Leitura e escrita', 'apitoken.scope_full': 'Completo (incl. comandos de dispositivo)', + 'apitoken.scope_agency': 'Agência (enviar apenas para listas escolhidas)', + 'apitoken.agency_playlists_label': 'Listas às quais este token de agência pode publicar', + 'apitoken.agency_playlists_hint': 'O token só pode enviar e adicionar itens com datas a estas listas. As adições ficam como rascunho para você publicar.', + 'apitoken.agency_needs_playlists': 'Selecione pelo menos uma lista para um token de agência.', + 'apitoken.agency_no_playlists': 'Crie uma lista primeiro: um token de agência deve apontar para uma.', + 'apitoken.targets_label': 'Designadas:', '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 6e0fe02..caa5e87 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -74,10 +74,16 @@ export async function render(container) { + +

${t('settings.loading_users')}

@@ -329,6 +335,7 @@ export async function render(container) { read: t('apitoken.scope_read'), write: t('apitoken.scope_write'), full: t('apitoken.scope_full'), + agency: t('apitoken.scope_agency'), }[s] || s); async function loadTokens() { @@ -357,7 +364,10 @@ export async function render(container) { ${esc(tok.prefix)}… ${esc(tok.name || '')} - ${esc(scopeLabel(tok.scope))} + ${esc(scopeLabel(tok.scope))}${ + tok.scope === 'agency' && Array.isArray(tok.targets) + ? `
${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}
` + : ''} ${esc(fmtTokenDate(tok.created_at))} ${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')} @@ -388,13 +398,36 @@ export async function render(container) { loadTokens(); + // #73: agency scope reveals a playlist picker (the token's allowlist). Loaded lazily once. + const tokScopeSel = document.getElementById('tokScope'); + let agencyPlaylistsLoaded = false; + tokScopeSel?.addEventListener('change', async () => { + const picker = document.getElementById('agencyPlaylistPicker'); + const isAgency = tokScopeSel.value === 'agency'; + picker.style.display = isAgency ? 'block' : 'none'; + if (isAgency && !agencyPlaylistsLoaded) { + 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')}

`; + } + }); + document.getElementById('createTokenBtn')?.addEventListener('click', async () => { const name = document.getElementById('tokName').value.trim(); const scope = document.getElementById('tokScope').value; + const payload = { name, scope }; + if (scope === 'agency') { + 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; + } const btn = document.getElementById('createTokenBtn'); btn.disabled = true; try { - const r = await api.createToken({ name, scope }); + const r = await api.createToken(payload); const box = document.getElementById('tokenSecretBox'); box.style.display = 'block'; box.innerHTML = ` diff --git a/server/routes/tokens.js b/server/routes/tokens.js index 20436c3..673ea36 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -19,6 +19,11 @@ router.get('/', (req, res) => { SELECT id, prefix, name, scope, workspace_id, created_at, last_used_at, revoked_at FROM api_tokens WHERE user_id = ? AND workspace_id = ? ORDER BY created_at DESC `).all(req.user.id, req.workspaceId); + // #73: attach designated playlists for agency tokens so the admin sees the binding persist. + const targetsStmt = db.prepare('SELECT p.id, p.name FROM api_token_targets t JOIN playlists p ON p.id = t.playlist_id WHERE t.token_id = ? ORDER BY p.name'); + for (const r of rows) { + if (r.scope === 'agency') r.targets = targetsStmt.all(r.id); + } res.json(rows); }); From efd4d7826cbb9ef30bcf656976d263deb7bd61f9 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 13:08:07 -0500 Subject: [PATCH 06/20] feat(ui): standalone agency upload portal (#73) Agency-facing. A self-contained page at /agency (NOT the dashboard SPA - the agency has no JWT, only the token). Entry: paste access key -> sessionStorage (cleared on tab close, not localStorage) -> sent as Bearer. Flow: list designated playlists -> upload (shared ingest = first-class content) -> date-bounded item on a chosen playlist (lands as draft for admin re-publish). Graceful failure: any 401/403 resets to the entry screen with "key invalid, paste it again" - never a wall of 403s. Blast radius of a leaked key stays bounded by the narrow scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/agency.html | 75 +++++++++++++++++++++ frontend/js/agency-portal.js | 124 +++++++++++++++++++++++++++++++++++ server/server.js | 5 ++ 3 files changed, 204 insertions(+) create mode 100644 frontend/agency.html create mode 100644 frontend/js/agency-portal.js diff --git a/frontend/agency.html b/frontend/agency.html new file mode 100644 index 0000000..b2661e2 --- /dev/null +++ b/frontend/agency.html @@ -0,0 +1,75 @@ + + + + + + +Agency Upload Portal + + + +
+ +
+

Agency Upload Portal

+

Paste the access key your contact gave you. It stays in this browser tab only and is cleared when you close it.

+
+ + +
+
+ + + +
+ + + diff --git a/frontend/js/agency-portal.js b/frontend/js/agency-portal.js new file mode 100644 index 0000000..c4456df --- /dev/null +++ b/frontend/js/agency-portal.js @@ -0,0 +1,124 @@ +'use strict'; + +// #73 agency portal. Token-auth ONLY (never the dashboard JWT). The access key lives in +// sessionStorage (cleared on tab close — chosen over localStorage so it doesn't linger on a +// shared agency machine) and is sent as a Bearer header. Any 401/403 resets to the entry +// screen with a clear "key invalid" message — never a wall of 403s. The token is narrow +// (agency scope), so even if leaked its blast radius is upload + drafts to designated +// playlists, which the admin must publish. +(function () { + const KEY = 'agency_key'; + const $ = (id) => document.getElementById(id); + let uploadedContentId = null; + + const getKey = () => sessionStorage.getItem(KEY) || ''; + const setKey = (k) => sessionStorage.setItem(KEY, k); + const clearKey = () => sessionStorage.removeItem(KEY); + + function showEntry(msg) { + $('portal').classList.add('hidden'); + $('entry').classList.remove('hidden'); + const m = $('entryMsg'); + if (msg) { m.textContent = msg; m.style.display = 'block'; } else { m.style.display = 'none'; } + } + function showPortal() { + $('entry').classList.add('hidden'); + $('portal').classList.remove('hidden'); + } + function portalMsg(text, kind) { + const m = $('portalMsg'); + m.textContent = text || ''; + m.className = 'msg ' + (kind || 'ok'); + m.style.display = text ? 'block' : 'none'; + } + const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + // Fetch /api/agency/* with the bearer key. On 401/403 -> graceful reset to entry. + async function agencyFetch(path, opts = {}) { + const headers = Object.assign({}, opts.headers, { Authorization: 'Bearer ' + getKey() }); + const res = await fetch('/api/agency' + path, Object.assign({}, opts, { headers })); + if (res.status === 401 || res.status === 403) { + clearKey(); + showEntry('That access key is invalid, revoked, or expired. Paste it again.'); + throw new Error('auth'); + } + return res; + } + + async function loadPortal() { + let playlists; + try { + playlists = await (await agencyFetch('/playlists')).json(); + } catch (e) { return; } // agencyFetch already reset to entry on an auth failure + const sel = $('plSelect'); + sel.innerHTML = playlists.length + ? playlists.map(p => ``).join('') + : ''; + showPortal(); + portalMsg('', ''); + } + + // ---- entry ---- + $('enterBtn').addEventListener('click', () => { + const k = $('keyInput').value.trim(); + if (!k) return; + setKey(k); + $('keyInput').value = ''; + loadPortal(); + }); + $('keyInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('enterBtn').click(); }); + $('signOutBtn').addEventListener('click', () => { clearKey(); uploadedContentId = null; showEntry(''); }); + + // ---- upload ---- + $('fileInput').addEventListener('change', () => { $('uploadBtn').disabled = !$('fileInput').files.length; }); + $('uploadBtn').addEventListener('click', async () => { + const file = $('fileInput').files[0]; + if (!file) return; + $('uploadBtn').disabled = true; + portalMsg('Uploading…', 'ok'); + try { + const fd = new FormData(); + fd.append('file', file); + const res = await agencyFetch('/content', { method: 'POST', body: fd }); + if (!res.ok) { portalMsg('Upload failed. Try again.', 'err'); return; } + const content = await res.json(); + uploadedContentId = content.id; + $('uploadInfo').textContent = 'Uploaded: ' + (content.filename || content.id); + $('scheduleBtn').disabled = false; + portalMsg('Uploaded. Now schedule it below.', 'ok'); + } catch (e) { /* auth already handled */ } + finally { if (getKey()) $('uploadBtn').disabled = false; } + }); + + // ---- schedule ---- + $('scheduleBtn').addEventListener('click', async () => { + if (!uploadedContentId) return portalMsg('Upload a file first.', 'err'); + const playlistId = $('plSelect').value; + if (!playlistId) return portalMsg('No playlist available to schedule on.', 'err'); + const body = { content_id: uploadedContentId }; + if ($('startDate').value) body.start_date = $('startDate').value; + if ($('endDate').value) body.end_date = $('endDate').value; + $('scheduleBtn').disabled = true; + try { + const res = await agencyFetch('/playlists/' + encodeURIComponent(playlistId) + '/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const e = await res.json().catch(() => ({})); + portalMsg(e.error || 'Could not add to the playlist.', 'err'); + $('scheduleBtn').disabled = false; + return; + } + portalMsg('Added as a draft — your contact will publish it. You can upload another.', 'ok'); + uploadedContentId = null; + $('uploadInfo').textContent = ''; + $('fileInput').value = ''; + $('uploadBtn').disabled = true; + } catch (e) { /* auth already handled */ } + }); + + // ---- boot: a stored key is validated by the first /playlists call ---- + if (getKey()) loadPortal(); else showEntry(''); +})(); diff --git a/server/server.js b/server/server.js index a270676..b35645e 100644 --- a/server/server.js +++ b/server/server.js @@ -201,6 +201,11 @@ app.get('/openapi.yaml', (req, res) => { app.get('/docs', (req, res) => { res.sendFile(path.join(config.frontendDir, 'api-docs.html')); }); +// #73: the standalone agency portal (token-auth, NOT the JWT dashboard SPA). Served as its +// own page so the agency never touches the dashboard login. +app.get('/agency', (req, res) => { + res.sendFile(path.join(config.frontendDir, 'agency.html')); +}); // Serve frontend static files // JS/CSS/HTML: no-cache (always revalidate, uses ETag/304) From 79c453cd436f5171621c2080ff12cd5e125e0a23 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 13:48:17 -0500 Subject: [PATCH 07/20] refactor(playlists): extract publishPlaylist() shared fn (#73) POST /:id/publish snapshots items into published_snapshot (what devices consume) + pushes to devices. Extracted that into publishPlaylist(id, req) so the agency auto-publish path can call the IDENTICAL logic - a "published" playlist that wasn't snapshotted would be live on no screen. The manual endpoint now calls it; behavior-preserving (suite green). Co-Authored-By: Claude Opus 4.8 (1M context) --- server/routes/playlists.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/server/routes/playlists.js b/server/routes/playlists.js index 2544255..5d9e57c 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -121,6 +121,17 @@ function pushToDevices(playlistId, req) { } catch (e) { /* silent */ } } +// #73: the shared publish path - snapshot current items into published_snapshot (what +// devices actually consume) + push to devices. POST /:id/publish AND the agency +// auto-publish path both call this, so they can never drift (a "published" playlist that +// wasn't snapshotted would be live-on-no-screen). +function publishPlaylist(playlistId, req) { + const snapshotItems = buildSnapshotItems(playlistId); + db.prepare("UPDATE playlists SET status = 'published', published_snapshot = ?, updated_at = strftime('%s','now') WHERE id = ?") + .run(JSON.stringify(snapshotItems), playlistId); + pushToDevices(playlistId, req); +} + // Phase 2.2k: list scoped to caller's current workspace. No platform_admin // bypass - cross-workspace view comes from switch-workspace, matching the // precedent established across all other migrated routes. @@ -202,10 +213,7 @@ router.put('/:id', requirePlaylistWrite, (req, res) => { router.post('/:id/publish', requirePlaylistWrite, (req, res) => { // Snapshot shape (no pi.id) is intentional — published_snapshot is consumed // by devices and stored as JSON; row IDs there would be misleading. - const snapshotItems = buildSnapshotItems(req.params.id); - db.prepare("UPDATE playlists SET status = 'published', published_snapshot = ?, updated_at = strftime('%s','now') WHERE id = ?") - .run(JSON.stringify(snapshotItems), req.params.id); - pushToDevices(req.params.id, req); + publishPlaylist(req.params.id, req); // UI response shape must include pi.id so the post-publish render can wire // per-row delete/duration listeners. TODO: refactor to share this SELECT // with GET /:id (also duplicated in /discard and POST /:id/items/reorder). @@ -541,3 +549,4 @@ router.post('/:id/assign', requirePlaylistWrite, (req, res) => { }); module.exports = router; +module.exports.publishPlaylist = publishPlaylist; // #73: shared with the agency auto-publish path From 1f207c4278e147c4ec9793f4499e320fbfbdd77d Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 13:48:17 -0500 Subject: [PATCH 08/20] feat(api): per-agency-token auto-publish (#73) api_tokens.auto_publish (DEFAULT 0 = draft, the fail-safe). Admin sets it at token creation in the designate UI (checkbox, agency scope only). The agency endpoint reads it from the TOKEN ROW via req.apiToken (apiTokenAuth attaches it) - NEVER from req.body, so an agency can't opt itself out of approval. 0 -> markDraft; 1 -> the shared publishPlaylist path. Tests (integration): draft is the default; a draft token with auto_publish:true IN THE BODY still lands draft (body ignored); an auto-publish token goes live; manual publish still works (extraction regression). i18n across all 5 locales. 141 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/i18n/de.js | 3 +++ frontend/js/i18n/en.js | 3 +++ frontend/js/i18n/es.js | 3 +++ frontend/js/i18n/fr.js | 3 +++ frontend/js/i18n/pt.js | 3 +++ frontend/js/views/settings.js | 7 ++++++- server/db/database.js | 2 ++ server/db/schema.sql | 1 + server/middleware/apiToken.js | 4 +++- server/routes/agency.js | 15 +++++++++++--- server/routes/tokens.js | 13 +++++++----- server/test/agency.test.js | 38 +++++++++++++++++++++++++++++++++++ 12 files changed, 85 insertions(+), 10 deletions(-) diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 704482b..36f6bb1 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -363,6 +363,9 @@ export default { 'apitoken.agency_needs_playlists': 'Wähle mindestens eine Playlist für einen Agentur-Token.', 'apitoken.agency_no_playlists': 'Erstelle zuerst eine Playlist – ein Agentur-Token muss auf eine zielen.', 'apitoken.targets_label': 'Zugewiesen:', + '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.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 f07750f..4754cff 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -399,6 +399,9 @@ export default { 'apitoken.agency_needs_playlists': 'Select at least one playlist for an agency token.', 'apitoken.agency_no_playlists': 'Create a playlist first — an agency token must target one.', 'apitoken.targets_label': 'Designated:', + '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.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 ce06355..d4080b7 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -362,6 +362,9 @@ export default { 'apitoken.agency_needs_playlists': 'Selecciona al menos una lista para un token de agencia.', 'apitoken.agency_no_playlists': 'Crea una lista primero: un token de agencia debe apuntar a una.', 'apitoken.targets_label': 'Designadas:', + '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.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 d361b67..bec332b 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -363,6 +363,9 @@ export default { 'apitoken.agency_needs_playlists': 'Sélectionnez au moins une liste pour un jeton d\'agence.', 'apitoken.agency_no_playlists': 'Créez d\'abord une liste : un jeton d\'agence doit en cibler une.', 'apitoken.targets_label': 'Assignées :', + '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.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 641f53d..ffa7553 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -363,6 +363,9 @@ export default { 'apitoken.agency_needs_playlists': 'Selecione pelo menos uma lista para um token de agência.', 'apitoken.agency_no_playlists': 'Crie uma lista primeiro: um token de agência deve apontar para uma.', 'apitoken.targets_label': 'Designadas:', + '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.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 caa5e87..dcec920 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -83,6 +83,10 @@ export async function render(container) {

${t('apitoken.agency_playlists_hint')}

+ +

${t('apitoken.auto_publish_hint')}

${t('settings.loading_users')}

@@ -366,7 +370,7 @@ export async function render(container) { ${esc(tok.name || '')} ${esc(scopeLabel(tok.scope))}${ tok.scope === 'agency' && Array.isArray(tok.targets) - ? `
${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}
` + ? `
${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}
` : ''} ${esc(fmtTokenDate(tok.created_at))} ${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')} @@ -423,6 +427,7 @@ 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; + payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked; } const btn = document.getElementById('createTokenBtn'); btn.disabled = true; diff --git a/server/db/database.js b/server/db/database.js index 6e121c5..2b247e4 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -195,6 +195,8 @@ const migrations = [ "CREATE INDEX IF NOT EXISTS idx_totp_recovery_user ON totp_recovery_codes(user_id)", // #73: agency-token target allowlist (capability-restricted tokens). "CREATE TABLE IF NOT EXISTS 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, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), PRIMARY KEY (token_id, playlist_id))", + // #73: per-agency-token auto-publish (DEFAULT 0 = draft, the fail-safe). + "ALTER TABLE api_tokens ADD COLUMN auto_publish INTEGER NOT NULL DEFAULT 0", ]; // 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 838ad64..b67a107 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -531,6 +531,7 @@ CREATE TABLE IF NOT EXISTS api_tokens ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'full' | 'agency' + auto_publish INTEGER NOT NULL DEFAULT 0, -- #73: agency only. 0 = items land DRAFT (default, fail-safe); 1 = admin opted this agency out of approval created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), last_used_at INTEGER, revoked_at INTEGER diff --git a/server/middleware/apiToken.js b/server/middleware/apiToken.js index 74dbed8..9537556 100644 --- a/server/middleware/apiToken.js +++ b/server/middleware/apiToken.js @@ -69,7 +69,9 @@ function apiTokenAuth(req, res, next) { req.jwtWorkspaceId = row.workspace_id; // resolveTenancy scopes to the bound workspace req.viaToken = true; req.tokenScope = row.scope; - req.apiToken = { id: row.id, prefix: row.prefix, name: row.name, workspace_id: row.workspace_id }; + // #73: auto_publish read from the TOKEN ROW (admin-set), so the agency endpoint can + // never take it from the request body. `|| 0` keeps it fail-safe for any row predating it. + req.apiToken = { id: row.id, prefix: row.prefix, name: row.name, workspace_id: row.workspace_id, auto_publish: row.auto_publish || 0 }; touchLastUsed(row.id); next(); } diff --git a/server/routes/agency.js b/server/routes/agency.js index e0f1a24..0093bab 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 { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -88,10 +89,18 @@ router.post('/playlists/:playlistId/items', (req, res) => { .run(req.params.playlistId, content_id, 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); - // items changed since last publish -> draft; admin re-publish approves it. - db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(req.params.playlistId); + // #73: draft vs live is decided by the TOKEN's auto_publish (admin-set, read from + // req.apiToken - NEVER req.body, so the agency can't opt itself out of approval). Default + // 0 -> draft for admin re-publish. 1 -> the SHARED publishPlaylist path (snapshot + push). + let published = false; + if (req.apiToken.auto_publish) { + publishPlaylist(req.params.playlistId, req); + published = true; + } else { + db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(req.params.playlistId); + } - res.status(201).json({ id: itemId, playlist_id: req.params.playlistId, content_id, duration_sec, start_date: sd, end_date: ed }); + res.status(201).json({ id: itemId, playlist_id: req.params.playlistId, content_id, duration_sec, start_date: sd, end_date: ed, published }); }); module.exports = router; diff --git a/server/routes/tokens.js b/server/routes/tokens.js index 673ea36..3e3270b 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -16,7 +16,7 @@ const SCOPES = ['read', 'write', 'full', 'agency']; router.get('/', (req, res) => { if (!req.workspaceId) return res.status(403).json({ error: 'No active workspace' }); const rows = db.prepare(` - SELECT id, prefix, name, scope, workspace_id, created_at, last_used_at, revoked_at + SELECT id, prefix, name, scope, auto_publish, workspace_id, created_at, last_used_at, revoked_at FROM api_tokens WHERE user_id = ? AND workspace_id = ? ORDER BY created_at DESC `).all(req.user.id, req.workspaceId); // #73: attach designated playlists for agency tokens so the admin sees the binding persist. @@ -44,6 +44,9 @@ router.post('/', (req, res) => { // #73: an agency token is bound to a NON-EMPTY allowlist of playlists in THIS workspace. // Validate up front so a bad target never leaves an orphan token behind. let targetIds = []; + // 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; 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' }); @@ -56,16 +59,16 @@ router.post('/', (req, res) => { const id = crypto.randomUUID(); db.transaction(() => { db.prepare(` - INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) - `).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope); + INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, auto_publish, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) + `).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope, autoPublish); 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); } })(); // `token` is returned only here, never again. - res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId, target_playlist_ids: targetIds }); + res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId, target_playlist_ids: targetIds, auto_publish: !!autoPublish }); }); // Revoke one of the caller's own tokens (soft delete - takes effect on the next request). diff --git a/server/test/agency.test.js b/server/test/agency.test.js index c0ed3a5..3ba72bd 100644 --- a/server/test/agency.test.js +++ b/server/test/agency.test.js @@ -88,3 +88,41 @@ test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)' const bogus = await jfetch('/api/agency/playlists', { headers: { Authorization: 'Bearer st_bogus_invalid_key' } }); assert.equal(bogus.status, 401, 'invalid agency key -> 401 (portal resets to the entry screen)'); }); + +test('#73 auto-publish: the TOKEN flag decides draft vs live; the body can never override it', async () => { + const jwtAuth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok } }); + const email = 'ap' + crypto.randomBytes(4).toString('hex') + '@x.local'; + const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token; + const plD = (await jfetch('/api/playlists', jpost(jwt, { name: 'DraftTarget' }))).body; + const plA = (await jfetch('/api/playlists', jpost(jwt, { name: 'AutoTarget' }))).body; + + const draftTok = (await jfetch('/api/tokens', jpost(jwt, { name: 'DraftAgency', scope: 'agency', target_playlist_ids: [plD.id] }))).body; + assert.equal(draftTok.auto_publish, false, 'DEFAULT is draft (auto_publish false) - the fail-safe'); + const autoTok = (await jfetch('/api/tokens', jpost(jwt, { name: 'AutoAgency', scope: 'agency', target_playlist_ids: [plA.id], auto_publish: true }))).body; + assert.equal(autoTok.auto_publish, true, 'admin explicitly opted into auto-publish'); + + async function upload(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 cD = await upload(draftTok.token); + const cA = await upload(autoTok.token); + + // (a) DRAFT token + {auto_publish:true} IN THE BODY -> still draft (token flag wins, body ignored) + const addD = await jfetch(`/api/agency/playlists/${plD.id}/items`, jpost(draftTok.token, { content_id: cD.id, auto_publish: true })); + assert.equal(addD.status, 201); + assert.equal(addD.body.published, false, 'draft token does NOT publish even with auto_publish:true in the body'); + assert.equal((await jfetch(`/api/playlists/${plD.id}`, jwtAuth(jwt))).body.status, 'draft', 'playlist stays draft'); + + // (b) AUTO-PUBLISH token -> item goes live via the shared publishPlaylist path + const addA = await jfetch(`/api/agency/playlists/${plA.id}/items`, jpost(autoTok.token, { content_id: cA.id })); + assert.equal(addA.status, 201); + assert.equal(addA.body.published, true, 'auto-publish token publishes'); + assert.equal((await jfetch(`/api/playlists/${plA.id}`, jwtAuth(jwt))).body.status, 'published', 'playlist is published'); + + // (c) REGRESSION: the manual publish endpoint still works after the publishPlaylist extraction + const pub = await jfetch(`/api/playlists/${plD.id}/publish`, jpost(jwt, {})); + assert.equal(pub.status, 200, 'manual publish works post-extraction'); + assert.equal((await jfetch(`/api/playlists/${plD.id}`, jwtAuth(jwt))).body.status, 'published', 'manual publish sets status=published'); +}); From 986d94a7780314a71cc96b14f3c3684e81762d82 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 13:53:30 -0500 Subject: [PATCH 09/20] feat(api): GET /api/agency/layouts - device-free layout geometry (#73) So the agency can size/place content: returns the canvas size + zone positions/sizes for the layouts its designated playlists feed, marking which zones are theirs. DEVICE-FREE BY CONSTRUCTION - the query path is playlist_items.zone_id -> layout_zones -> layouts and never touches devices/groups/schedules, so device names/locations/IPs/topology are structurally absent, not filtered. Geometry only - no sibling-zone content. layout.name included (admin's canvas name); thumbnail_data omitted (could render other zones' content). Confinement query in lib/agency-layouts.js, bite-tested: own layout YES, a non-designated playlist's layout NO, response has NO device fields (asserted on a db where a location-named device exists), and neutralizing the t.token_id filter goes red. 142 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/lib/agency-layouts.js | 48 ++++++++++++++++++++++++++ server/routes/agency.js | 8 +++++ server/test/agency-layouts.test.js | 55 ++++++++++++++++++++++++++++++ server/test/agency.test.js | 6 ++++ 4 files changed, 117 insertions(+) create mode 100644 server/lib/agency-layouts.js create mode 100644 server/test/agency-layouts.test.js 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'); From c55ca60b56b6814db2c8fcfda0d83e2093314e89 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 13:59:37 -0500 Subject: [PATCH 10/20] feat(api): batched email digest for agency uploads (#73) Reuses the existing scheduler + sendEmail infra (no new scheduler). The agency endpoint enqueues one agency_notifications row per item added; a 15-min flush groups unsent rows per token+playlist+action and sends ONE digest per group to the workspace owner/admins + the playlist owner (deduped via UNION). Draft -> "added N items, awaiting approval"; published -> "updated ". Two robustness rules, both tested: - Queue never balloons when SMTP is off: the endpoint skips enqueue when !isConfigured(), and the flush drains-and-discards unsent rows as a backstop. - sent_at is stamped ONLY after a successful send, so a failed send retries next cycle instead of silently dropping. Wired into boot via startAgencyDigest(). 147 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/db/database.js | 3 ++ server/db/schema.sql | 15 ++++++ server/routes/agency.js | 8 +++ server/server.js | 4 ++ server/services/agency-digest.js | 84 +++++++++++++++++++++++++++++++ server/test/agency-digest.test.js | 75 +++++++++++++++++++++++++++ 6 files changed, 189 insertions(+) create mode 100644 server/services/agency-digest.js create mode 100644 server/test/agency-digest.test.js diff --git a/server/db/database.js b/server/db/database.js index 2b247e4..5845aca 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -197,6 +197,9 @@ const migrations = [ "CREATE TABLE IF NOT EXISTS 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, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), PRIMARY KEY (token_id, playlist_id))", // #73: per-agency-token auto-publish (DEFAULT 0 = draft, the fail-safe). "ALTER TABLE api_tokens ADD COLUMN auto_publish INTEGER NOT NULL DEFAULT 0", + // #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)", ]; // 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 b67a107..542984f 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -551,6 +551,21 @@ CREATE TABLE IF NOT EXISTS api_token_targets ( ); CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); +-- #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). +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, -- 'draft' | 'published' + content_id TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + sent_at INTEGER -- NULL = unsent +); +CREATE INDEX IF NOT EXISTS idx_agency_notifications_unsent ON agency_notifications(sent_at); + -- ===================== SCHEMA MIGRATIONS ===================== CREATE TABLE IF NOT EXISTS schema_migrations ( diff --git a/server/routes/agency.js b/server/routes/agency.js index 5ec26ec..8db6637 100644 --- a/server/routes/agency.js +++ b/server/routes/agency.js @@ -16,6 +16,7 @@ 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 { isConfigured } = require('../services/email'); // #73: gate digest enqueue on SMTP being set const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -108,6 +109,13 @@ router.post('/playlists/:playlistId/items', (req, res) => { db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(req.params.playlistId); } + // #73: enqueue a digest notification ONLY when email is configured, so the queue can't + // balloon on installs without SMTP. action reflects what actually happened (draft vs live). + if (isConfigured()) { + db.prepare('INSERT INTO agency_notifications (workspace_id, token_id, playlist_id, action, content_id) VALUES (?,?,?,?,?)') + .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 }); }); diff --git a/server/server.js b/server/server.js index b35645e..efaa1be 100644 --- a/server/server.js +++ b/server/server.js @@ -594,6 +594,10 @@ startAlertService(io); const { startActivationNudge } = require('./services/activationNudge'); startActivationNudge(); +// #73: agency-upload digest flush (batched draft/published notifications to admins + owner) +const { startAgencyDigest } = require('./services/agency-digest'); +startAgencyDigest(); + // Handle provisioning via WebSocket notification const { db } = require('./db/database'); const originalProvisionRoute = require('./routes/provisioning'); diff --git a/server/services/agency-digest.js b/server/services/agency-digest.js new file mode 100644 index 0000000..7f5658c --- /dev/null +++ b/server/services/agency-digest.js @@ -0,0 +1,84 @@ +'use strict'; + +// #73: batched digest of agency uploads. The agency endpoint enqueues a row per item added +// (ONLY when email is configured). This job flushes every 15 min: groups unsent rows per +// token+playlist+action, sends one email per group to the workspace owner/admins + the +// playlist owner (deduped), and stamps sent_at ONLY after a successful send. Two robustness +// rules: (1) never let the queue balloon when SMTP is off; (2) a failed send retries next +// cycle instead of silently dropping. + +const { db: defaultDb } = require('../db/database'); +const defaultEmail = require('./email'); + +const FLUSH_MS = 15 * 60 * 1000; // the digest window + +// Workspace owner/admins (via the org) + the playlist owner. UNION dedupes by email. +function resolveRecipients(db, workspaceId, playlistId) { + return db.prepare(` + SELECT u.email FROM organization_members om + JOIN workspaces w ON w.organization_id = om.organization_id + JOIN users u ON u.id = om.user_id + WHERE w.id = ? AND om.role IN ('org_owner', 'org_admin') AND u.email IS NOT NULL + UNION + SELECT u.email FROM playlists p + JOIN users u ON u.id = p.user_id + WHERE p.id = ? AND u.email IS NOT NULL + `).all(workspaceId, playlistId); +} + +function composeDigest(db, g) { + const agency = db.prepare('SELECT name FROM api_tokens WHERE id = ?').get(g.token_id)?.name || 'An agency'; + const playlist = db.prepare('SELECT name FROM playlists WHERE id = ?').get(g.playlist_id)?.name || 'a playlist'; + const n = g.n; + if (g.action === 'draft') { + return { + subject: `${agency} added ${n} item${n === 1 ? '' : 's'} to "${playlist}" — awaiting your approval`, + text: `${agency} added ${n} item${n === 1 ? '' : 's'} to the playlist "${playlist}".\n\nThey are saved as drafts and will NOT appear on screens until you publish the playlist.`, + }; + } + return { + subject: `${agency} updated "${playlist}"`, + text: `${agency} added ${n} item${n === 1 ? '' : 's'} to the playlist "${playlist}", now live (this token is set to auto-publish).`, + }; +} + +// Core flush - testable: pass a db and an email impl ({ isConfigured, sendEmail }). +async function flushAgencyDigests(db = defaultDb, email = defaultEmail) { + if (!email.isConfigured()) { + // SMTP off -> drain-and-discard so the queue can't grow unbounded on self-hosters + // who never set up email. (The endpoint also skips enqueue when off; this is the backstop.) + db.prepare('DELETE FROM agency_notifications WHERE sent_at IS NULL').run(); + return; + } + const groups = db.prepare(` + SELECT workspace_id, token_id, playlist_id, action, COUNT(*) AS n, GROUP_CONCAT(id) AS ids + FROM agency_notifications WHERE sent_at IS NULL + GROUP BY token_id, playlist_id, action + `).all(); + + for (const g of groups) { + try { + const recipients = resolveRecipients(db, g.workspace_id, g.playlist_id); + if (recipients.length) { + const { subject, text } = composeDigest(db, g); + for (const r of recipients) { + await email.sendEmail({ to: r.email, subject, text }); // throw -> caught below -> NOT stamped -> retried + } + } + // Stamp sent_at ONLY after every send for this group succeeded (or there were no + // recipients). A throw above skips this -> the rows stay unsent for the next cycle. + const now = Math.floor(Date.now() / 1000); + const stamp = db.prepare('UPDATE agency_notifications SET sent_at = ? WHERE id = ?'); + db.transaction(() => { for (const id of g.ids.split(',')) stamp.run(now, id); })(); + } catch (e) { + console.warn('agency digest: send failed, will retry next cycle:', e.message); + } + } +} + +function startAgencyDigest() { + setInterval(() => { flushAgencyDigests().catch(() => {}); }, FLUSH_MS); + console.log('Agency digest service started'); +} + +module.exports = { startAgencyDigest, flushAgencyDigests, resolveRecipients, composeDigest }; diff --git a/server/test/agency-digest.test.js b/server/test/agency-digest.test.js new file mode 100644 index 0000000..fc05734 --- /dev/null +++ b/server/test/agency-digest.test.js @@ -0,0 +1,75 @@ +'use strict'; + +// #73 email digest robustness. Proves the two rules the design hinges on: (1) the queue +// never balloons when SMTP is off (drain-and-discard); (2) sent_at is stamped ONLY after a +// successful send, so a failure retries next cycle instead of silently dropping. Plus +// recipient resolution (org owner/admins + playlist owner, deduped) and digest grouping. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const { flushAgencyDigests, resolveRecipients } = require('../services/agency-digest'); + +function freshDb() { + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE agency_notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT, token_id TEXT, playlist_id TEXT, action TEXT, content_id TEXT, created_at INTEGER, sent_at INTEGER); + CREATE TABLE organization_members (organization_id TEXT, user_id TEXT, role TEXT); + CREATE TABLE workspaces (id TEXT, organization_id TEXT); + CREATE TABLE users (id TEXT, email TEXT); + CREATE TABLE playlists (id TEXT, user_id TEXT, name TEXT); + CREATE TABLE api_tokens (id TEXT, name TEXT); + INSERT INTO workspaces VALUES ('ws1','org1'); + INSERT INTO users VALUES ('uOwner','owner@x'), ('uAdmin','admin@x'), ('uViewer','viewer@x'), ('uPlOwner','plowner@x'); + INSERT INTO organization_members VALUES ('org1','uOwner','org_owner'), ('org1','uAdmin','org_admin'), ('org1','uViewer','member'); + INSERT INTO playlists VALUES ('pl1','uPlOwner','Lobby'); + INSERT INTO api_tokens VALUES ('tok1','Acme Agency'); + `); + return db; +} +function enqueue(db, n, action = 'draft') { + const ins = db.prepare("INSERT INTO agency_notifications (workspace_id, token_id, playlist_id, action) VALUES ('ws1','tok1','pl1',?)"); + for (let i = 0; i < n; i++) ins.run(action); +} +const cfg = (sendEmail) => ({ isConfigured: () => true, sendEmail }); +const sink = () => { const sent = []; return { sent, sendEmail: async (m) => { sent.push(m); } }; }; + +test('#73 digest recipients: org owner + admins + playlist owner, deduped (NOT the viewer)', () => { + const emails = resolveRecipients(freshDb(), 'ws1', 'pl1').map(r => r.email).sort(); + assert.deepEqual(emails, ['admin@x', 'owner@x', 'plowner@x']); +}); + +test('#73 digest: 30 uploads -> ONE email per recipient (not 30), all rows stamped sent', async () => { + const db = freshDb(); + enqueue(db, 30, 'draft'); + const { sent, sendEmail } = sink(); + await flushAgencyDigests(db, cfg(sendEmail)); + assert.equal(sent.length, 3, '1 group x 3 recipients = 3 emails, not 30 per recipient'); + assert.match(sent[0].subject, /Acme Agency added 30 items to "Lobby"/); + assert.equal(db.prepare('SELECT COUNT(*) c FROM agency_notifications WHERE sent_at IS NULL').get().c, 0); +}); + +test('#73 digest: a failed send leaves rows UNSENT for retry (never silently dropped)', async () => { + const db = freshDb(); + enqueue(db, 5, 'draft'); + await flushAgencyDigests(db, cfg(async () => { throw new Error('smtp down'); })); + assert.equal(db.prepare('SELECT COUNT(*) c FROM agency_notifications WHERE sent_at IS NULL').get().c, 5, 'still unsent -> retried next cycle'); +}); + +test('#73 digest: SMTP off -> queue drained-and-discarded (never balloons)', async () => { + const db = freshDb(); + enqueue(db, 10, 'draft'); + await flushAgencyDigests(db, { isConfigured: () => false, sendEmail: async () => { throw new Error('must not send'); } }); + assert.equal(db.prepare('SELECT COUNT(*) c FROM agency_notifications').get().c, 0, 'drained when email is off'); +}); + +test('#73 digest: draft vs published produce different subjects, grouped per action', async () => { + const db = freshDb(); + enqueue(db, 2, 'draft'); + enqueue(db, 3, 'published'); + const { sent, sendEmail } = sink(); + await flushAgencyDigests(db, cfg(sendEmail)); + const subjects = sent.map(s => s.subject); + assert.ok(subjects.some(s => /awaiting your approval/.test(s)), 'draft digest mentions approval'); + assert.ok(subjects.some(s => /updated "Lobby"/.test(s)), 'published digest says updated'); +}); From 289d54f4fa93d9408f9e1078e3bd1acd91b250bf Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 14:57:27 -0500 Subject: [PATCH 11/20] feat(api): zone-grant confinement for agency tokens - FK-anchored (#73) Placement-as-grant, replacing the inferred auto-place idea. api_token_target_zones is an ADDITIVE second table (does NOT touch the proven api_token_targets), structurally anchored: a composite FK to api_token_targets(token_id, playlist_id) makes a zone grant orphan- impossible and cascade away when the playlist grant is revoked - "narrow" is structural, not conventional. zone_id FK -> layout_zones cascades on zone/layout delete. Confinement (lib/agency-targets.resolveGrantedZone, called in the item-add): grants exist -> the item MUST land in a granted zone (a body zone_id picks among grants, never escapes them); none -> whole-playlist/full-screen as before. The item-add stamps the granted zone_id. Bite-tested (6, all proven incl. neutralize->red on the confinement): granted YES; non- granted/cross-playlist/ambiguous blocked; orphan-grant rejected by the FK; cascade on playlist-grant revoke, on playlist delete, on zone/layout delete; and foreign_keys=ON asserted (a cascade that no-ops because FKs are off is the trap). 153 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/db/database.js | 3 + server/db/schema.sql | 16 +++++ server/lib/agency-targets.js | 24 ++++++- server/routes/agency.js | 18 +++-- server/test/agency-zone-grants.test.js | 91 ++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 server/test/agency-zone-grants.test.js 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); +}); From c5550f5bc923a286c04f9037096d5329611dd777 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 15:12:18 -0500 Subject: [PATCH 12/20] feat: agency zone-grant issuance UI + reactive placement card (#73) Issuance (on the proven seam): - tokens.js create + PUT /:id/targets accept per-playlist zone grants (target_zones), inserted into api_token_target_zones inside the same transaction as the playlist grants (FK requires the parent, so order matters and is correct). - Issuance validation (the mirror of runtime confinement): grantableZoneIds() - can grant ONLY a zone the playlist's layout actually feeds; can't grant one it doesn't have or one from another playlist's layout. Bite-tested. PUT re-designate stays atomic: delete parent rows -> zone grants cascade out (no manual child delete). - settings.js: checking a designated playlist reveals its grantable zones (GET /api/playlists/:id/zones, JWT); leave unchecked = whole-playlist. i18n across all 5 locales. Card: - GET /api/agency/playlists/:playlistId/layout (rides router.param - confined; a non- designated playlist -> 403, asserted). "Your zone" = the GRANTED zones. Retired the token-wide /layouts (the per-playlist card replaces the disconnected lump). - Portal card reacts to the playlist selector: pick a playlist -> its layout renders, the granted zone highlighted with px size, siblings as context. Full suite + agency bite-suite green (154). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/agency-portal.js | 36 ++++++++++++++++++++++++++ frontend/js/api.js | 1 + frontend/js/i18n/de.js | 2 ++ frontend/js/i18n/en.js | 2 ++ frontend/js/i18n/es.js | 2 ++ frontend/js/i18n/fr.js | 2 ++ frontend/js/i18n/pt.js | 2 ++ frontend/js/views/settings.js | 35 ++++++++++++++++++++++--- server/lib/agency-layouts.js | 7 ++--- server/lib/agency-targets.js | 17 +++++++++++- server/routes/agency.js | 17 ++++++++---- server/routes/playlists.js | 15 +++++++++++ server/routes/tokens.js | 35 ++++++++++++++++++++++++- server/test/agency-zone-grants.test.js | 13 +++++++++- server/test/agency.test.js | 16 +++++++++--- 15 files changed, 184 insertions(+), 18 deletions(-) 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' } }); From 400a438fff53d6886f4dad38ed360269e84cb843 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 15:52:11 -0500 Subject: [PATCH 13/20] revert: drop zone-binding, keep whole-playlist grants + size-guidance card (#73) Investigation found zone placement is a DEVICE property (device.layout_id), not a playlist property: a normal playlist has no derivable layout (zone_id is NULL unless set in the device-assignment flow), so a playlist-scoped zone grant can't reach the normal flow. The right model: placement belongs to the device (same playlist can be full-screen on one screen, a zone on another); the agency just gets whole-playlist grants + size-guidance. Removed the zone-grant machinery (security-adjacent dead surface is a liability, not dormant convenience): api_token_target_zones (schema + a DROP migration for the dev DB where the short-lived CREATE ran), resolveGrantedZone, grantableZoneIds, buildZoneGrantRows, the create/PUT zone validation, GET /api/playlists/:id/zones, getPlaylistZones, the settings zone-picker + its i18n, and the zone-grant bite-test. KEPT (model-agnostic, good): the reactive per-playlist size-guidance card - GET /api/agency/playlists/:playlistId/layout (router.param-confined) now reports the zones the playlist actually feeds (where/what-size content lands), or full-screen when it has no layout. Whole-playlist grants = today's working model. 147 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/api.js | 1 - frontend/js/i18n/de.js | 2 - frontend/js/i18n/en.js | 2 - frontend/js/i18n/es.js | 2 - frontend/js/i18n/fr.js | 2 - frontend/js/i18n/pt.js | 2 - frontend/js/views/settings.js | 35 +-------- server/db/database.js | 6 +- server/db/schema.sql | 16 ---- server/lib/agency-targets.js | 39 +--------- server/routes/agency.js | 35 +++------ server/routes/playlists.js | 15 ---- server/routes/tokens.js | 35 +-------- server/test/agency-zone-grants.test.js | 102 ------------------------- server/test/agency.test.js | 5 -- 15 files changed, 18 insertions(+), 281 deletions(-) delete mode 100644 server/test/agency-zone-grants.test.js diff --git a/frontend/js/api.js b/frontend/js/api.js index 82d7cad..e0ec520 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -137,7 +137,6 @@ 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 42d3fc6..36f6bb1 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -366,8 +366,6 @@ 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 60c1312..4754cff 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -402,8 +402,6 @@ 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 c775d50..d4080b7 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -365,8 +365,6 @@ 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 b669f59..bec332b 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -366,8 +366,6 @@ 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 433267e..ffa7553 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -366,8 +366,6 @@ 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 f30e7d9..dcec920 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -413,31 +413,9 @@ export async function render(container) { agencyPlaylistsLoaded = true; const list = document.getElementById('agencyPlaylistList'); const pls = await api.getPlaylists().catch(() => []); - 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')}
`; - })); + list.innerHTML = pls.length + ? pls.map(p => ``).join('') + : `

${t('apitoken.agency_no_playlists')}

`; } }); @@ -449,13 +427,6 @@ 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/db/database.js b/server/db/database.js index 73a7472..4780e37 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -200,9 +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)", + // #73: zone-binding was reverted (placement belongs to the device, not the playlist - see + // the agency-tokens history). Drop the table on DBs where the short-lived migration ran. + "DROP TABLE IF EXISTS api_token_target_zones", ]; // 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 66f5659..542984f 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -551,22 +551,6 @@ 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 c6cc223..20af630 100644 --- a/server/lib/agency-targets.js +++ b/server/lib/agency-targets.js @@ -17,41 +17,4 @@ function listDesignatedPlaylists(db, tokenId, workspaceId) { `).all(tokenId, workspaceId); } -// #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' }; -} - -// #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 }; +module.exports = { listDesignatedPlaylists }; diff --git a/server/routes/agency.js b/server/routes/agency.js index 6156ee2..c97c987 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, resolveGrantedZone } = require('../lib/agency-targets'); +const { listDesignatedPlaylists } = 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 @@ -28,18 +28,13 @@ router.get('/playlists', (req, res) => { res.json(listDesignatedPlaylists(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). +// Layout GEOMETRY for ONE designated playlist (the per-playlist size-guidance card): canvas +// size + zone positions/sizes, with feeds_zone_ids = the zones this playlist actually feeds +// (so the agency sees where/what-size their content lands). Returns [] when the playlist has +// no layout -> the card shows the full-screen message. Placement itself stays the admin's job +// (device-side). Has :playlistId, so router.param confines it. DEVICE-FREE (lib/agency-layouts.js). 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); + res.json(listLayoutGeometry(db, req.apiToken.id, req.jwtWorkspaceId, req.params.playlistId)); }); // #73 THE target seam. router.param fires for EVERY route with :playlistId, WITH the param, @@ -84,16 +79,6 @@ 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' }); @@ -111,8 +96,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, zone_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)') - .run(req.params.playlistId, content_id, z.zoneId, order, duration_sec).lastInsertRowid; + 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; 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 @@ -133,7 +118,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, zone_id: z.zoneId, duration_sec, start_date: sd, end_date: ed, published }); + res.status(201).json({ id: itemId, playlist_id: req.params.playlistId, content_id, duration_sec, start_date: sd, end_date: ed, published }); }); module.exports = router; diff --git a/server/routes/playlists.js b/server/routes/playlists.js index 65b3e82..5d9e57c 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -291,21 +291,6 @@ 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 f418093..3e3270b 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -7,26 +7,6 @@ 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. @@ -67,7 +47,6 @@ 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' }); @@ -75,10 +54,6 @@ 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(); @@ -90,8 +65,6 @@ 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. @@ -120,18 +93,12 @@ 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, zone_grants: zg.rows.length }); + res.json({ id: tok.id, target_playlist_ids: ids }); }); module.exports = router; diff --git a/server/test/agency-zone-grants.test.js b/server/test/agency-zone-grants.test.js deleted file mode 100644 index e411cc3..0000000 --- a/server/test/agency-zone-grants.test.js +++ /dev/null @@ -1,102 +0,0 @@ -'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, grantableZoneIds } = 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'); - 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 - `); - 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); -}); - -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 a54640d..ca857f9 100644 --- a/server/test/agency.test.js +++ b/server/test/agency.test.js @@ -92,11 +92,6 @@ 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' } }); From 6ea8100aeb01ed78ae8d17437b750eb1c7f33151 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 16:10:25 -0500 Subject: [PATCH 14/20] feat(ui): the size-guidance card container in the agency portal (#73) The #placementCard / #layoutView elements that agency-portal.js's reactive loadLayoutForPlaylist() renders into. Was built with the card logic but never folded into a commit; without it the size-guidance card has nowhere to render. Pure markup, no behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/agency.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/agency.html b/frontend/agency.html index b2661e2..1e8b826 100644 --- a/frontend/agency.html +++ b/frontend/agency.html @@ -68,6 +68,12 @@
+ + From 4c38536cc69c43cc0e8c0c3884dc07c145dda9a3 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 17:04:07 -0500 Subject: [PATCH 15/20] feat(ui): edit-designations for agency tokens (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → API Tokens: each agency token gets an "Edit playlists" control that opens the playlist picker pre-checked with the token's CURRENT designations (from the list GET's tok.targets), lets the admin add/remove, and calls the existing PUT /:id/targets to atomically re-designate. Reuses the creation picker pattern; common.save/cancel reused; edit_targets + targets_updated i18n across all 5 locales. No security-model change - the endpoint was already proven. Test (integration): PUT /:id/targets re-designates (add + remove) and the confinement follows the NEW set - a re-designated token reaches only its new playlists (router.param 403s the removed one). 148 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/api.js | 1 + frontend/js/i18n/de.js | 2 ++ frontend/js/i18n/en.js | 2 ++ frontend/js/i18n/es.js | 2 ++ frontend/js/i18n/fr.js | 2 ++ frontend/js/i18n/pt.js | 2 ++ frontend/js/views/settings.js | 34 +++++++++++++++++++++++++++++++++- server/test/agency.test.js | 24 ++++++++++++++++++++++++ 8 files changed, 68 insertions(+), 1 deletion(-) diff --git a/frontend/js/api.js b/frontend/js/api.js index e0ec520..790bc9e 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -160,6 +160,7 @@ export const api = { getTokens: () => request('/tokens'), createToken: (data) => request('/tokens', { method: 'POST', body: JSON.stringify(data) }), revokeToken: (id) => request('/tokens/' + id, { method: 'DELETE' }), + setTokenTargets: (id, target_playlist_ids) => request('/tokens/' + id + '/targets', { method: 'PUT', body: JSON.stringify({ target_playlist_ids }) }), // #73: re-designate agency token playlists // Current user getMe: () => request('/auth/me'), diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 36f6bb1..a12fa31 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -363,6 +363,8 @@ export default { 'apitoken.agency_needs_playlists': 'Wähle mindestens eine Playlist für einen Agentur-Token.', '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.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.', 'apitoken.auto_publish_on': 'Auto-Veröffentlichung an', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 4754cff..624ef0b 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -399,6 +399,8 @@ export default { 'apitoken.agency_needs_playlists': 'Select at least one playlist for an agency token.', 'apitoken.agency_no_playlists': 'Create a playlist first — an agency token must target one.', 'apitoken.targets_label': 'Designated:', + 'apitoken.edit_targets': 'Edit playlists', + '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.', 'apitoken.auto_publish_on': 'auto-publish on', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index d4080b7..53e5696 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -362,6 +362,8 @@ export default { 'apitoken.agency_needs_playlists': 'Selecciona al menos una lista para un token de agencia.', '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.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.', 'apitoken.auto_publish_on': 'publicación automática activada', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index bec332b..ff2d2bf 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -363,6 +363,8 @@ export default { 'apitoken.agency_needs_playlists': 'Sélectionnez au moins une liste pour un jeton d\'agence.', '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.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.', 'apitoken.auto_publish_on': 'publication automatique activée', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index ffa7553..6708436 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -363,6 +363,8 @@ export default { 'apitoken.agency_needs_playlists': 'Selecione pelo menos uma lista para um token de agência.', '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.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.', 'apitoken.auto_publish_on': 'publicação automática ativada', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index dcec920..5c8f93a 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -90,6 +90,7 @@ export async function render(container) {

${t('settings.loading_users')}

+ ${isAdmin ? ` @@ -377,7 +378,7 @@ export async function render(container) { ${tok.revoked_at ? `${t('apitoken.revoked')}` - : ``} + : `${tok.scope === 'agency' ? ` ` : ''}`} `).join('')} @@ -398,6 +399,37 @@ export async function render(container) { } }); }); + + // #73: edit an agency token's playlist designations -> PUT /:id/targets (atomic re-designate). + el.querySelectorAll('.edit-targets-btn').forEach(btn => btn.addEventListener('click', async () => { + const id = btn.dataset.id; + const current = new Set((btn.dataset.targets || '').split(',').filter(Boolean)); + const panel = document.getElementById('tokenEditPanel'); + const pls = await api.getPlaylists().catch(() => []); + panel.style.display = 'block'; + panel.innerHTML = ` +
+

${t('apitoken.edit_targets')}

+
+ ${pls.length + ? pls.map(p => ``).join('') + : `

${t('apitoken.agency_no_playlists')}

`} +
+ + +
`; + document.getElementById('saveTargetsBtn').onclick = async () => { + const ids = [...panel.querySelectorAll('.edit-pl:checked')].map(c => c.value); + if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error'); + try { + await api.setTokenTargets(id, ids); + showToast(t('apitoken.targets_updated'), 'success'); + panel.style.display = 'none'; + loadTokens(); + } catch (err) { showToast(err.message, 'error'); } + }; + document.getElementById('cancelTargetsBtn').onclick = () => { panel.style.display = 'none'; }; + })); } loadTokens(); diff --git a/server/test/agency.test.js b/server/test/agency.test.js index ca857f9..ce552ad 100644 --- a/server/test/agency.test.js +++ b/server/test/agency.test.js @@ -135,3 +135,27 @@ test('#73 auto-publish: the TOKEN flag decides draft vs live; the body can never assert.equal(pub.status, 200, 'manual publish works post-extraction'); assert.equal((await jfetch(`/api/playlists/${plD.id}`, jwtAuth(jwt))).body.status, 'published', 'manual publish sets status=published'); }); + +test('#73 edit-designations: PUT /:id/targets re-designates (add + remove); confinement follows', async () => { + const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok } }); + const email = 're' + crypto.randomBytes(4).toString('hex') + '@x.local'; + const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token; + const plA = (await jfetch('/api/playlists', jpost(jwt, { name: 'A' }))).body; + const plB = (await jfetch('/api/playlists', jpost(jwt, { name: 'B' }))).body; + const plC = (await jfetch('/api/playlists', jpost(jwt, { name: 'C' }))).body; + + const tokRes = await jfetch('/api/tokens', jpost(jwt, { name: 'EditMe', scope: 'agency', target_playlist_ids: [plA.id, plB.id] })); + const atok = tokRes.body.token, tokId = tokRes.body.id; + // initially A+B designated (200 = router.param lets it through), C not (403) + assert.equal((await jfetch(`/api/agency/playlists/${plA.id}/layout`, auth(atok))).status, 200, 'A reachable'); + assert.equal((await jfetch(`/api/agency/playlists/${plC.id}/layout`, auth(atok))).status, 403, 'C not yet designated'); + + // re-designate: drop A, keep B, add C + const put = await jfetch(`/api/tokens/${tokId}/targets`, { method: 'PUT', headers: { Authorization: 'Bearer ' + jwt, 'Content-Type': 'application/json' }, body: JSON.stringify({ target_playlist_ids: [plB.id, plC.id] }) }); + assert.equal(put.status, 200, 're-designate ok'); + + // confinement follows the NEW set: removed A -> 403, kept B -> 200, added C -> 200 + assert.equal((await jfetch(`/api/agency/playlists/${plA.id}/layout`, auth(atok))).status, 403, 'removed A -> 403'); + 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'); +}); From 57d78dd1fa1458bcba0b43b9fb356311355ed5aa Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 17:36:03 -0500 Subject: [PATCH 16/20] feat: full-screen-only guardrail for agency designations (#73) Agencies can only be designated FULL-SCREEN playlists (no item with zone_id) - a full-screen agency upload can't safely target a zone, so the ambiguous case is excluded rather than solved. Checked at THREE points: - Designation (tokens.js create + PUT /:id/targets) -> 400: reject a zoned target. - Upload (agency.js item-add) -> 409: block if the playlist BECAME zoned after designation. MANDATORY because auto-publish has no draft net - a full-screen playlist designated to an auto-publish token, then zone-assigned, would otherwise auto-publish a full-screen upload into a zoned playlist. The upload check is the only thing that catches it. - Picker (settings.js): zoned playlists greyed/disabled with the reason (GET /playlists now returns a zoned flag); backend reject is the guard if the UI is bypassed. i18n x5. isZonedPlaylist = EXISTS(playlist_items WHERE zone_id IS NOT NULL). Pure restriction - no zone structure, no api_token_target_zones. Bite-test (the exact sequence) GREEN and re-proven to bite: full-screen -> designate to an auto-publish token -> zone-assign the playlist -> agency upload is BLOCKED (409), not auto-published; neutralizing the upload check makes it go red. 149 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/i18n/de.js | 1 + frontend/js/i18n/en.js | 1 + frontend/js/i18n/es.js | 1 + frontend/js/i18n/fr.js | 1 + frontend/js/i18n/pt.js | 1 + frontend/js/views/settings.js | 8 ++++++-- server/lib/agency-targets.js | 11 ++++++++++- server/routes/agency.js | 10 +++++++++- server/routes/playlists.js | 3 ++- server/routes/tokens.js | 5 +++++ server/test/agency.test.js | 32 ++++++++++++++++++++++++++++++++ 11 files changed, 69 insertions(+), 5 deletions(-) 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.edit_targets')}

${pls.length - ? pls.map(p => ``).join('') + ? pls.map(p => p.zoned + ? `` + : ``).join('') : `

${t('apitoken.agency_no_playlists')}

`}
@@ -446,7 +448,9 @@ export async function render(container) { const list = document.getElementById('agencyPlaylistList'); const pls = await api.getPlaylists().catch(() => []); list.innerHTML = pls.length - ? pls.map(p => ``).join('') + ? pls.map(p => p.zoned + ? `` + : ``).join('') : `

${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'); +}); From 02859eb1aaf4a19723d55b7ca3c724b95078d530 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 17:44:42 -0500 Subject: [PATCH 17/20] feat(ui): surface the API docs link in Settings -> API Tokens (#73) A meaningful link to /docs right under the section header (where someone's creating a token), opening in a new tab (target=_blank rel=noopener) so it doesn't navigate them away from the token they're mid-creating. "New to the API? See the full documentation ->" across all 5 locales. /docs (Redoc) already existed; this just makes it discoverable. Confirmed /docs -> 200 Redoc and /openapi.yaml -> 200. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/i18n/de.js | 1 + frontend/js/i18n/en.js | 1 + frontend/js/i18n/es.js | 1 + frontend/js/i18n/fr.js | 1 + frontend/js/i18n/pt.js | 1 + frontend/js/views/settings.js | 3 ++- 6 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index b03a78d..232876d 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -353,6 +353,7 @@ export default { // API-Tokens 'apitoken.title': 'API-Tokens', 'apitoken.desc': 'Persönliche Zugriffstokens für die öffentliche API, beschränkt auf diesen Arbeitsbereich. Behandeln Sie sie wie Passwörter – wer das Token hat, kann hier in Ihrem Namen handeln.', + 'apitoken.docs_link': 'Neu bei der API? Zur vollständigen Dokumentation →', 'apitoken.name_placeholder': 'z. B. Agentur-Integration', 'apitoken.scope_read': 'Nur Lesen', 'apitoken.scope_write': 'Lesen & Schreiben', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 7af5440..654f3cd 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -389,6 +389,7 @@ export default { // API Tokens 'apitoken.title': 'API Tokens', 'apitoken.desc': 'Personal access tokens for the public API, scoped to this workspace. Treat them like passwords — anyone with the token can act as you here.', + 'apitoken.docs_link': 'New to the API? See the full documentation →', 'apitoken.name_placeholder': 'e.g. Agency integration', 'apitoken.scope_read': 'Read only', 'apitoken.scope_write': 'Read & write', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 4629874..1b66161 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -352,6 +352,7 @@ export default { // Tokens de API 'apitoken.title': 'Tokens de API', 'apitoken.desc': 'Tokens de acceso personal para la API pública, limitados a este espacio de trabajo. Trátalos como contraseñas: cualquiera que tenga el token puede actuar como tú aquí.', + 'apitoken.docs_link': '¿Nuevo en la API? Consulta la documentación completa →', 'apitoken.name_placeholder': 'p. ej. Integración de agencia', 'apitoken.scope_read': 'Solo lectura', 'apitoken.scope_write': 'Lectura y escritura', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 817bd4f..4b738c0 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -353,6 +353,7 @@ export default { // Jetons d'API 'apitoken.title': "Jetons d'API", 'apitoken.desc': "Jetons d'accès personnels pour l'API publique, limités à cet espace de travail. Traitez-les comme des mots de passe : toute personne disposant du jeton peut agir en votre nom ici.", + 'apitoken.docs_link': "Nouveau sur l'API ? Voir la documentation complète →", 'apitoken.name_placeholder': 'p. ex. Intégration agence', 'apitoken.scope_read': 'Lecture seule', 'apitoken.scope_write': 'Lecture et écriture', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index d8e15f2..447f1b8 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -353,6 +353,7 @@ export default { // Tokens de API 'apitoken.title': 'Tokens de API', 'apitoken.desc': 'Tokens de acesso pessoal para a API pública, restritos a este espaço de trabalho. Trate-os como senhas — qualquer pessoa com o token pode agir como você aqui.', + 'apitoken.docs_link': 'Novo na API? Veja a documentação completa →', 'apitoken.name_placeholder': 'ex.: Integração da agência', 'apitoken.scope_read': 'Somente leitura', 'apitoken.scope_write': 'Leitura e escrita', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index bf439bc..3749684 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -62,7 +62,8 @@ export async function render(container) {

${t('apitoken.title')}

-

${t('apitoken.desc')}

+

${t('apitoken.desc')}

+

${t('apitoken.docs_link')}

From ed45a9a23debed28ebf034dd824bd4539d5b7680 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 17:54:23 -0500 Subject: [PATCH 18/20] feat(ui): surface the agency portal handoff at token creation (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agency token is created, the once-shown secret box now also shows the Portal URL (window.location.origin + '/agency' — the real public host the admin is on, correct behind Cloudflare, config-free) and a COPYABLE INVITE: "Go to and paste this access key: ". The key lives in the invite TEXT, never in a URL — no magic link, because Cloudflare logs query strings and chat apps unfurl links (the key would leak on paste). Same exposure as the key field itself, just with the destination surfaced. The existing "won't see it again" warning now covers the invite too (it contains the key). i18n x5 (parity test). Skipped the optional per-row portal URL in the token list: it's the same /agency for every agency token, so per-row it's noise; the creation invite + the /docs link cover discovery. Confirmed: invite copy button copies the full "go here + paste key" text; /agency resolves (200); i18n parity + full suite green (149). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/i18n/de.js | 4 ++++ frontend/js/i18n/en.js | 4 ++++ frontend/js/i18n/es.js | 4 ++++ frontend/js/i18n/fr.js | 4 ++++ frontend/js/i18n/pt.js | 4 ++++ frontend/js/views/settings.js | 19 +++++++++++++++++++ 6 files changed, 39 insertions(+) diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 232876d..d41d4ee 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -354,6 +354,10 @@ export default { 'apitoken.title': 'API-Tokens', 'apitoken.desc': 'Persönliche Zugriffstokens für die öffentliche API, beschränkt auf diesen Arbeitsbereich. Behandeln Sie sie wie Passwörter – wer das Token hat, kann hier in Ihrem Namen handeln.', 'apitoken.docs_link': 'Neu bei der API? Zur vollständigen Dokumentation →', + 'apitoken.portal_url_label': 'Agentur-Portal-URL', + 'apitoken.invite_label': 'Einladung zum Kopieren — an die Agentur senden:', + 'apitoken.invite_text': 'Gehe zu {url} und füge diesen Zugriffsschlüssel ein: {key}', + 'apitoken.copy_invite': 'Einladung kopieren', 'apitoken.name_placeholder': 'z. B. Agentur-Integration', 'apitoken.scope_read': 'Nur Lesen', 'apitoken.scope_write': 'Lesen & Schreiben', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 654f3cd..871364d 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -390,6 +390,10 @@ export default { 'apitoken.title': 'API Tokens', 'apitoken.desc': 'Personal access tokens for the public API, scoped to this workspace. Treat them like passwords — anyone with the token can act as you here.', 'apitoken.docs_link': 'New to the API? See the full documentation →', + 'apitoken.portal_url_label': 'Agency portal URL', + 'apitoken.invite_label': 'Copyable invite — send this to the agency:', + 'apitoken.invite_text': 'Go to {url} and paste this access key: {key}', + 'apitoken.copy_invite': 'Copy invite', 'apitoken.name_placeholder': 'e.g. Agency integration', 'apitoken.scope_read': 'Read only', 'apitoken.scope_write': 'Read & write', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 1b66161..93c68bd 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -353,6 +353,10 @@ export default { 'apitoken.title': 'Tokens de API', 'apitoken.desc': 'Tokens de acceso personal para la API pública, limitados a este espacio de trabajo. Trátalos como contraseñas: cualquiera que tenga el token puede actuar como tú aquí.', 'apitoken.docs_link': '¿Nuevo en la API? Consulta la documentación completa →', + 'apitoken.portal_url_label': 'URL del portal de agencia', + 'apitoken.invite_label': 'Invitación para copiar — envíala a la agencia:', + 'apitoken.invite_text': 'Ve a {url} y pega esta clave de acceso: {key}', + 'apitoken.copy_invite': 'Copiar invitación', 'apitoken.name_placeholder': 'p. ej. Integración de agencia', 'apitoken.scope_read': 'Solo lectura', 'apitoken.scope_write': 'Lectura y escritura', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 4b738c0..232d511 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -354,6 +354,10 @@ export default { 'apitoken.title': "Jetons d'API", 'apitoken.desc': "Jetons d'accès personnels pour l'API publique, limités à cet espace de travail. Traitez-les comme des mots de passe : toute personne disposant du jeton peut agir en votre nom ici.", 'apitoken.docs_link': "Nouveau sur l'API ? Voir la documentation complète →", + 'apitoken.portal_url_label': 'URL du portail agence', + 'apitoken.invite_label': "Invitation à copier — envoyez-la à l'agence :", + 'apitoken.invite_text': "Allez sur {url} et collez cette clé d'accès : {key}", + 'apitoken.copy_invite': "Copier l'invitation", 'apitoken.name_placeholder': 'p. ex. Intégration agence', 'apitoken.scope_read': 'Lecture seule', 'apitoken.scope_write': 'Lecture et écriture', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index 447f1b8..ddbbe2a 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -354,6 +354,10 @@ export default { 'apitoken.title': 'Tokens de API', 'apitoken.desc': 'Tokens de acesso pessoal para a API pública, restritos a este espaço de trabalho. Trate-os como senhas — qualquer pessoa com o token pode agir como você aqui.', 'apitoken.docs_link': 'Novo na API? Veja a documentação completa →', + 'apitoken.portal_url_label': 'URL do portal da agência', + 'apitoken.invite_label': 'Convite para copiar — envie para a agência:', + 'apitoken.invite_text': 'Acesse {url} e cole esta chave de acesso: {key}', + 'apitoken.copy_invite': 'Copiar convite', 'apitoken.name_placeholder': 'ex.: Integração da agência', 'apitoken.scope_read': 'Somente leitura', 'apitoken.scope_write': 'Leitura e escrita', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index 3749684..3c8027a 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -472,6 +472,11 @@ export async function render(container) { const r = await api.createToken(payload); const box = document.getElementById('tokenSecretBox'); box.style.display = 'block'; + // #73: for agency tokens, surface the handoff (portal URL + a copyable invite). The key + // is in the invite TEXT, never in a URL (Cloudflare logs query strings + chat apps unfurl + // links). window.location.origin is the real public host the admin is on (correct behind CF). + const portalUrl = window.location.origin + '/agency'; + const inviteText = t('apitoken.invite_text', { url: portalUrl, key: r.token }); box.innerHTML = `

${t('apitoken.secret_title')}

@@ -480,6 +485,14 @@ export async function render(container) {
+ ${scope === 'agency' ? ` +
+ + + + + +
` : ''}
`; document.getElementById('copyTokenBtn')?.addEventListener('click', async () => { @@ -488,6 +501,12 @@ export async function render(container) { showToast(t('apitoken.copied'), 'success'); } catch { /* clipboard may be unavailable; the field is selectable */ } }); + document.getElementById('copyInviteBtn')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(inviteText); // full "go here + paste key" text + showToast(t('apitoken.copied'), 'success'); + } catch { /* field is selectable as a fallback */ } + }); document.getElementById('tokName').value = ''; showToast(t('apitoken.created_toast'), 'success'); loadTokens(); From 7f7dc80a8cf56fdbf1145275d4d8dbb0f432bbad Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 18:18:58 -0500 Subject: [PATCH 19/20] =?UTF-8?q?fix(content):=20YouTube=20preview=20153?= =?UTF-8?q?=20=E2=80=94=20drop=20enablejsapi/origin=20from=20the=20passive?= =?UTF-8?q?=20embed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The content-tab preview embedded a RAW iframe with enablejsapi=1 (baked into the stored /embed URL by /youtube) plus origin=window.location.origin — but the content tab loads the YouTube IFrame API zero times. enablejsapi=1 + origin tells YouTube's player to expect a postMessage handshake from a parent JS API that never exists here, which surfaces as "Video player configuration error" (153). Same-video proof: it plays on the device player (which loads iframe_api + uses YT.Player, so the handshake completes) and failed only on the content tab — so it was never a video/embeddability problem, purely the embed construction. Fix: the preview is passive (never drives playback via JS), so it must not declare the JS API — strip enablejsapi + origin, leaving a plain /embed/ID (the form that plays in a bare tab). Did NOT touch /youtube storage (the player extracts the videoId and ignores stored params, so the baked-in enablejsapi is harmless there). Retracts the earlier wrong "validate embeddability at add-time" diagnosis (never built — it would have rejected this embeddable video). Frontend-only; suite 149 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/views/content-library.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js index 9a0038e..2ae201f 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -635,7 +635,7 @@ function showPreview(content) {
${isYoutube - ? `` + ? `` : isVideo ? `` : `` From 46e4bc857935e1634ad7c3c8500faf21a00426cd Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 14 Jun 2026 20:12:57 -0500 Subject: [PATCH 20/20] =?UTF-8?q?fix(content):=20YouTube=20preview=20153?= =?UTF-8?q?=20=E2=80=94=20give=20the=20iframe=20a=20referrer=20(page=20is?= =?UTF-8?q?=20no-referrer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROOT CAUSE (hard evidence this time, from the response headers): the app sends Referrer-Policy: no-referrer globally (helmet default). A raw YouTube iframe then reaches youtube.com with NO Referer, so YouTube can't identify the embedding site and shows "Video player configuration error" (153). Confirmed by the three facts: the same /embed URL plays in a top-level tab (no embed check), plays in the device player (YT.Player loads iframe_api and validates via an ORIGIN postMessage handshake, which doesn't need Referer), and fails only as a raw iframe on a no-referrer page. The player's page is ALSO no-referrer, proving it's the embed method that saves it, not the headers. Fix: add referrerpolicy="strict-origin-when-cross-origin" to the preview iframe — overrides the page's no-referrer for just this element so YouTube receives our origin and validates the embed. Scoped (only the YouTube embed sends a referrer; only the origin, not the path), no JS API machinery needed for a passive preview, page-level no-referrer untouched. Supersedes the earlier enablejsapi/origin strip, which was inert (those params do nothing in a raw iframe with no IFrame API). Frontend-only; suite 149 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/views/content-library.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js index 2ae201f..1804c90 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -635,7 +635,7 @@ function showPreview(content) {
${isYoutube - ? `` + ? `` : isVideo ? `` : ``