diff --git a/frontend/agency.html b/frontend/agency.html
new file mode 100644
index 0000000..1e8b826
--- /dev/null
+++ b/frontend/agency.html
@@ -0,0 +1,81 @@
+
+
+
+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 `
${isYoutube
- ? `
`
+ ? `
`
: isVideo
? `
`
: `
`
diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js
index 6e0fe02..3c8027a 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')}
${t('apitoken.col_name')}
@@ -74,12 +75,23 @@ export async function render(container) {
${esc(t('apitoken.scope_read'))}
${esc(t('apitoken.scope_write'))}
${esc(t('apitoken.scope_full'))}
+ ${esc(t('apitoken.scope_agency'))}
${t('apitoken.create')}
+
+
${t('apitoken.agency_playlists_label')}
+
${t('apitoken.agency_playlists_hint')}
+
+
+ ${t('apitoken.auto_publish_label')}
+
+
${t('apitoken.auto_publish_hint')}
+
${t('settings.loading_users')}
+
${isAdmin ? `
@@ -329,6 +341,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,13 +370,16 @@ 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(', ') : '—'}${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')}
${tok.revoked_at
? `${t('apitoken.revoked')} `
- : `${t('apitoken.revoke')} `}
+ : `${tok.scope === 'agency' ? `${t('apitoken.edit_targets')} ` : ''}${t('apitoken.revoke')} `}
`).join('')}
@@ -384,19 +400,83 @@ 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')}
+
+
${t('common.save')}
+
${t('common.cancel')}
+
`;
+ 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();
+ // #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 => p.zoned
+ ? `
${esc(p.name)} — ${esc(t('apitoken.zoned_playlist_reason'))} `
+ : `
${esc(p.name)}`).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;
+ payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked;
+ }
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';
+ // #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')}
@@ -405,6 +485,14 @@ export async function render(container) {
${t('apitoken.copy')}
+ ${scope === 'agency' ? `
+
+ ${t('apitoken.portal_url_label')}
+
+ ${t('apitoken.invite_label')}
+
+ ${t('apitoken.copy_invite')}
+
` : ''}
`;
document.getElementById('copyTokenBtn')?.addEventListener('click', async () => {
@@ -413,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();
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..4780e37 100644
--- a/server/db/database.js
+++ b/server/db/database.js
@@ -193,6 +193,16 @@ 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))",
+ // #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)",
+ // #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 de5d2f5..542984f 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -530,14 +530,42 @@ 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'
+ 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
);
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);
+-- #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/lib/agency-layouts.js b/server/lib/agency-layouts.js
new file mode 100644
index 0000000..d2e50f3
--- /dev/null
+++ b/server/lib/agency-layouts.js
@@ -0,0 +1,49 @@
+'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, 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
+ 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 = ?${playlistId ? ' AND p.id = ?' : ''}
+ ORDER BY l.name
+ `).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(`
+ 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/lib/agency-targets.js b/server/lib/agency-targets.js
new file mode 100644
index 0000000..697909b
--- /dev/null
+++ b/server/lib/agency-targets.js
@@ -0,0 +1,29 @@
+'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);
+}
+
+// #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/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/middleware/apiToken.js b/server/middleware/apiToken.js
index e747e27..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();
}
@@ -112,7 +114,22 @@ function requireScope(need) {
};
}
+// #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' });
+ }
+ next();
+}
+
module.exports = {
- bearerAuth, apiTokenAuth, tokenScopeGate, requireScope,
+ bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, agencyGate,
hashToken, generateToken, displayPrefix, TOKEN_PREFIX,
};
diff --git a/server/routes/agency.js b/server/routes/agency.js
new file mode 100644
index 0000000..7c6d8ba
--- /dev/null
+++ b/server/routes/agency.js
@@ -0,0 +1,132 @@
+'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 { 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
+
+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));
+});
+
+// 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) => {
+ 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,
+// 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' });
+
+ // #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)
+ 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);
+ // #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);
+ }
+
+ // #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 });
+});
+
+module.exports = router;
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);
diff --git a/server/routes/playlists.js b/server/routes/playlists.js
index 2544255..e1bdc69 100644
--- a/server/routes/playlists.js
+++ b/server/routes/playlists.js
@@ -121,13 +121,25 @@ 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.
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
@@ -202,10 +214,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 +550,4 @@ router.post('/:id/assign', requirePlaylistWrite, (req, res) => {
});
module.exports = router;
+module.exports.publishPlaylist = publishPlaylist; // #73: shared with the agency auto-publish path
diff --git a/server/routes/tokens.js b/server/routes/tokens.js
index ea0a26d..2e04030 100644
--- a/server/routes/tokens.js
+++ b/server/routes/tokens.js
@@ -7,16 +7,24 @@ 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
-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) => {
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.
+ 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);
});
@@ -27,21 +35,43 @@ 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 = [];
+ // 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' });
+ 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();
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, 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 });
+ 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).
@@ -54,4 +84,26 @@ 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` });
+ // #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(() => {
+ 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..efaa1be 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)
@@ -446,7 +451,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 +469,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 +490,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');
@@ -583,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');
+});
diff --git a/server/test/agency-gate.test.js b/server/test/agency-gate.test.js
new file mode 100644
index 0000000..76fc6d2
--- /dev/null
+++ b/server/test/agency-gate.test.js
@@ -0,0 +1,32 @@
+'use strict';
+
+// #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');
+
+// 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: new Database(':memory:') },
+};
+const { agencyGate } = require('../middleware/apiToken');
+
+function gate(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 (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-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-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-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');
+});
diff --git a/server/test/agency.test.js b/server/test/agency.test.js
new file mode 100644
index 0000000..3bdb18d
--- /dev/null
+++ b/server/test/agency.test.js
@@ -0,0 +1,193 @@
+'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;
+
+ // 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)');
+
+ // 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();
+ 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');
+
+ // 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)');
+});
+
+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');
+});
+
+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');
+});
+
+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');
+});