mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
Merge feat/agency-tokens: agency upload portal (#73), full-screen guardrail, YouTube preview referrer fix
20 commits: agency-token security primitive + portal + designate UI + auto-publish + email digest + size-guidance card + edit-designations + full-screen-only guardrail; zone-binding reverted; API docs link; portal handoff at creation; YouTube content-preview 153 fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
31be2ffe8c
81
frontend/agency.html
Normal file
81
frontend/agency.html
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Agency Upload Portal</title>
|
||||
<style>
|
||||
:root { --bg:#0f1115; --card:#1a1d24; --border:#2a2f3a; --text:#e8eaed; --muted:#9aa0aa; --accent:#4f8cff; --danger:#ff5c5c; --ok:#3ddc84; --radius:10px; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
|
||||
.wrap { max-width:640px; margin:0 auto; padding:32px 20px; }
|
||||
.card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:24px; margin-bottom:20px; }
|
||||
h1 { font-size:22px; margin:0 0 4px; }
|
||||
h2 { font-size:16px; margin:0 0 12px; }
|
||||
.sub { color:var(--muted); font-size:13px; margin:0 0 16px; }
|
||||
label { display:block; font-size:13px; margin:12px 0 4px; color:var(--muted); }
|
||||
input, select { width:100%; padding:10px 12px; background:#0d0f13; border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:14px; }
|
||||
button { padding:10px 16px; background:var(--accent); color:#fff; border:0; border-radius:8px; font-size:14px; cursor:pointer; }
|
||||
button:disabled { opacity:.45; cursor:default; }
|
||||
button.secondary { background:transparent; border:1px solid var(--border); color:var(--muted); }
|
||||
.row { display:flex; gap:12px; }
|
||||
.row > * { flex:1; }
|
||||
.msg { padding:10px 12px; border-radius:8px; font-size:13px; margin:12px 0; display:none; }
|
||||
.msg.err { background:rgba(255,92,92,.12); color:var(--danger); border:1px solid rgba(255,92,92,.3); }
|
||||
.msg.ok { background:rgba(61,220,132,.12); color:var(--ok); border:1px solid rgba(61,220,132,.3); }
|
||||
.hidden { display:none; }
|
||||
.topbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; }
|
||||
.pill { font-size:12px; color:var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<!-- ENTRY -->
|
||||
<div id="entry" class="card">
|
||||
<h1>Agency Upload Portal</h1>
|
||||
<p class="sub">Paste the access key your contact gave you. It stays in this browser tab only and is cleared when you close it.</p>
|
||||
<div id="entryMsg" class="msg err"></div>
|
||||
<label for="keyInput">Access key</label>
|
||||
<input id="keyInput" type="password" placeholder="st_…" autocomplete="off" spellcheck="false">
|
||||
<div style="margin-top:16px"><button id="enterBtn">Continue</button></div>
|
||||
</div>
|
||||
|
||||
<!-- PORTAL -->
|
||||
<div id="portal" class="hidden">
|
||||
<div class="topbar">
|
||||
<h1>Agency Upload Portal</h1>
|
||||
<button class="secondary" id="signOutBtn">Sign out</button>
|
||||
</div>
|
||||
<div id="portalMsg" class="msg"></div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. Upload content</h2>
|
||||
<p class="sub">An image or video — added to the workspace library, full quality.</p>
|
||||
<input id="fileInput" type="file" accept="image/*,video/*">
|
||||
<div style="margin-top:12px"><button id="uploadBtn" disabled>Upload</button></div>
|
||||
<div id="uploadInfo" class="pill" style="margin-top:8px"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2. Schedule it on a playlist</h2>
|
||||
<p class="sub">Pick one of your designated playlists and the dates it should run. It's added as a <strong>draft</strong> for your contact to publish.</p>
|
||||
<label for="plSelect">Playlist</label>
|
||||
<select id="plSelect"></select>
|
||||
<div class="row">
|
||||
<div><label for="startDate">Start date (optional)</label><input id="startDate" type="date"></div>
|
||||
<div><label for="endDate">End date (optional)</label><input id="endDate" type="date"></div>
|
||||
</div>
|
||||
<div style="margin-top:16px"><button id="scheduleBtn" disabled>Add to playlist</button></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="placementCard" style="display:none">
|
||||
<h2>Where your content appears</h2>
|
||||
<p class="sub">Your zone is highlighted on each screen layout below, with the size to design for. Other zones are shown for context only.</p>
|
||||
<div id="layoutView"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/agency-portal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
160
frontend/js/agency-portal.js
Normal file
160
frontend/js/agency-portal.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
'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 => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join('')
|
||||
: '<option value="">No playlists designated — ask your contact</option>';
|
||||
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 = '<p class="pill">This playlist plays full-screen — design for the full display.</p>';
|
||||
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 `<div style="position:absolute;left:${z.x_percent}%;top:${z.y_percent}%;width:${z.width_percent}%;height:${z.height_percent}%;`
|
||||
+ `border:2px solid ${isMine ? 'var(--accent)' : 'var(--border)'};box-sizing:border-box;`
|
||||
+ `background:${isMine ? 'rgba(79,140,255,.20)' : 'transparent'};display:flex;align-items:center;justify-content:center;`
|
||||
+ `text-align:center;overflow:hidden;font-size:11px;color:${isMine ? '#fff' : 'var(--muted)'}">`
|
||||
+ `<span>${escapeHtml(z.name)}${isMine ? `<br><strong>YOUR ZONE</strong><br>${wpx}×${hpx}px` : ''}</span></div>`;
|
||||
}).join('');
|
||||
return `<div style="margin-bottom:16px">`
|
||||
+ `<div class="pill" style="margin-bottom:6px">${escapeHtml(l.name)} · ${l.width}×${l.height}</div>`
|
||||
+ `<div style="position:relative;width:100%;padding-bottom:${aspect}%;background:#0d0f13;border:1px solid var(--border);border-radius:6px">`
|
||||
+ `<div style="position:absolute;inset:0">${zones}</div></div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ---- 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('');
|
||||
})();
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -353,10 +353,27 @@ 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.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',
|
||||
'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.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.',
|
||||
'apitoken.auto_publish_on': 'Auto-Veröffentlichung an',
|
||||
'apitoken.create': 'Token erstellen',
|
||||
'apitoken.none': 'Noch keine Tokens.',
|
||||
'apitoken.col_token': 'Token',
|
||||
|
|
|
|||
|
|
@ -389,10 +389,27 @@ 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.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',
|
||||
'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.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.',
|
||||
'apitoken.auto_publish_on': 'auto-publish on',
|
||||
'apitoken.create': 'Create token',
|
||||
'apitoken.none': 'No tokens yet.',
|
||||
'apitoken.col_token': 'Token',
|
||||
|
|
|
|||
|
|
@ -352,10 +352,27 @@ 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.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',
|
||||
'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.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.',
|
||||
'apitoken.auto_publish_on': 'publicación automática activada',
|
||||
'apitoken.create': 'Crear token',
|
||||
'apitoken.none': 'Aún no hay tokens.',
|
||||
'apitoken.col_token': 'Token',
|
||||
|
|
|
|||
|
|
@ -353,10 +353,27 @@ 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.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',
|
||||
'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.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.',
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -353,10 +353,27 @@ 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.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',
|
||||
'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.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.',
|
||||
'apitoken.auto_publish_on': 'publicação automática ativada',
|
||||
'apitoken.create': 'Criar token',
|
||||
'apitoken.none': 'Ainda não há tokens.',
|
||||
'apitoken.col_token': 'Token',
|
||||
|
|
|
|||
|
|
@ -635,7 +635,7 @@ function showPreview(content) {
|
|||
<button style="position:absolute;top:8px;right:8px;z-index:1;background:rgba(0,0,0,0.7);border:none;color:white;width:32px;height:32px;border-radius:50%;font-size:18px;cursor:pointer" id="closePreview">×</button>
|
||||
<div style="max-width:80vw;max-height:80vh">
|
||||
${isYoutube
|
||||
? `<iframe src="${(() => { try { const u = new URL(src); if (!u.searchParams.has('mute')) u.searchParams.set('mute','1'); if (!u.searchParams.has('enablejsapi')) u.searchParams.set('enablejsapi','1'); if (!u.searchParams.has('origin')) u.searchParams.set('origin', window.location.origin); return u.toString(); } catch { return src; } })()}" style="width:80vw;height:45vw;max-height:80vh;display:block;border:none" allow="autoplay;encrypted-media" allowfullscreen></iframe>`
|
||||
? `<iframe referrerpolicy="strict-origin-when-cross-origin" src="${(() => { /* #YT153 ROOT CAUSE: the dashboard sends Referrer-Policy: no-referrer (helmet default), so a raw YouTube iframe reaches youtube.com with NO Referer -> YouTube can't identify the embedding site -> "Video player configuration error" (153). referrerpolicy on THIS iframe overrides the page policy to send just our origin, which YouTube uses to validate the embed. (The device player dodges no-referrer differently: YT.Player's iframe_api origin postMessage handshake, which doesn't rely on Referer.) The enablejsapi/origin URL params are inert in a raw iframe (no API loaded), so they're dropped. */ try { const u = new URL(src); u.searchParams.set('mute', '1'); u.searchParams.delete('enablejsapi'); u.searchParams.delete('origin'); return u.toString(); } catch { return src; } })()}" style="width:80vw;height:45vw;max-height:80vh;display:block;border:none" allow="autoplay;encrypted-media" allowfullscreen></iframe>`
|
||||
: isVideo
|
||||
? `<video src="${esc(src)}" controls autoplay style="max-width:80vw;max-height:80vh;display:block"></video>`
|
||||
: `<img src="${esc(src)}" style="max-width:80vw;max-height:80vh;display:block">`
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ export async function render(container) {
|
|||
|
||||
<div class="settings-section">
|
||||
<h3>${t('apitoken.title')}</h3>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:16px">${t('apitoken.desc')}</p>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.desc')}</p>
|
||||
<p style="font-size:13px;margin-bottom:16px"><a href="/docs" target="_blank" rel="noopener" style="color:var(--accent)">${t('apitoken.docs_link')}</a></p>
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-bottom:16px">
|
||||
<div class="form-group" style="margin-bottom:0;flex:1;min-width:180px">
|
||||
<label>${t('apitoken.col_name')}</label>
|
||||
|
|
@ -74,12 +75,23 @@ export async function render(container) {
|
|||
<option value="read">${esc(t('apitoken.scope_read'))}</option>
|
||||
<option value="write">${esc(t('apitoken.scope_write'))}</option>
|
||||
<option value="full">${esc(t('apitoken.scope_full'))}</option>
|
||||
<option value="agency">${esc(t('apitoken.scope_agency'))}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="createTokenBtn">${t('apitoken.create')}</button>
|
||||
</div>
|
||||
<div id="agencyPlaylistPicker" style="display:none;margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-secondary)">
|
||||
<label style="display:block;font-weight:500;margin-bottom:4px">${t('apitoken.agency_playlists_label')}</label>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.agency_playlists_hint')}</p>
|
||||
<div id="agencyPlaylistList" style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto"></div>
|
||||
<label style="display:flex;gap:8px;align-items:center;margin-top:12px;font-weight:500">
|
||||
<input type="checkbox" id="tokAutoPublish"> ${t('apitoken.auto_publish_label')}
|
||||
</label>
|
||||
<p style="color:var(--text-muted);font-size:12px;margin:4px 0 0">${t('apitoken.auto_publish_hint')}</p>
|
||||
</div>
|
||||
<div id="tokenSecretBox" style="display:none"></div>
|
||||
<div id="tokenList"><p style="color:var(--text-muted);font-size:13px">${t('settings.loading_users')}</p></div>
|
||||
<div id="tokenEditPanel" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
${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) {
|
|||
<tr style="border-bottom:1px solid var(--border)${tok.revoked_at ? ';opacity:0.55' : ''}">
|
||||
<td style="padding:10px 12px;font-family:monospace">${esc(tok.prefix)}…</td>
|
||||
<td style="padding:10px 12px">${esc(tok.name || '')}</td>
|
||||
<td style="padding:10px 12px">${esc(scopeLabel(tok.scope))}</td>
|
||||
<td style="padding:10px 12px">${esc(scopeLabel(tok.scope))}${
|
||||
tok.scope === 'agency' && Array.isArray(tok.targets)
|
||||
? `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}</div>`
|
||||
: ''}</td>
|
||||
<td style="padding:10px 12px">${esc(fmtTokenDate(tok.created_at))}</td>
|
||||
<td style="padding:10px 12px">${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')}</td>
|
||||
<td style="padding:10px 12px;white-space:nowrap;text-align:right">
|
||||
${tok.revoked_at
|
||||
? `<span style="color:var(--text-muted);font-size:12px">${t('apitoken.revoked')}</span>`
|
||||
: `<button class="btn btn-secondary btn-sm revoke-token-btn" data-id="${esc(String(tok.id))}">${t('apitoken.revoke')}</button>`}
|
||||
: `${tok.scope === 'agency' ? `<button class="btn btn-secondary btn-sm edit-targets-btn" data-id="${esc(String(tok.id))}" data-targets="${esc((tok.targets || []).map(p => p.id).join(','))}">${t('apitoken.edit_targets')}</button> ` : ''}<button class="btn btn-secondary btn-sm revoke-token-btn" data-id="${esc(String(tok.id))}">${t('apitoken.revoke')}</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`).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 = `
|
||||
<div style="border:1px solid var(--accent);border-radius:var(--radius);padding:16px;margin-top:12px">
|
||||
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.edit_targets')}</h4>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto;margin-bottom:12px">
|
||||
${pls.length
|
||||
? pls.map(p => p.zoned
|
||||
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
|
||||
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="edit-pl" value="${esc(String(p.id))}"${current.has(String(p.id)) ? ' checked' : ''}> ${esc(p.name)}</label>`).join('')
|
||||
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`}
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="saveTargetsBtn">${t('common.save')}</button>
|
||||
<button class="btn btn-secondary btn-sm" id="cancelTargetsBtn">${t('common.cancel')}</button>
|
||||
</div>`;
|
||||
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
|
||||
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
|
||||
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>`).join('')
|
||||
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
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 = `
|
||||
<div style="background:var(--bg-secondary);border:1px solid var(--accent);border-radius:var(--radius);padding:16px;margin-bottom:16px">
|
||||
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.secret_title')}</h4>
|
||||
|
|
@ -405,6 +485,14 @@ export async function render(container) {
|
|||
<input type="text" class="input" readonly value="${esc(r.token)}" style="font-family:monospace;flex:1" onclick="this.select()">
|
||||
<button class="btn btn-secondary btn-sm" id="copyTokenBtn">${t('apitoken.copy')}</button>
|
||||
</div>
|
||||
${scope === 'agency' ? `
|
||||
<div style="margin-top:12px;border-top:1px solid var(--border);padding-top:12px">
|
||||
<label style="font-size:12px;color:var(--text-muted)">${t('apitoken.portal_url_label')}</label>
|
||||
<input type="text" class="input" readonly value="${esc(portalUrl)}" style="width:100%;margin-top:4px" onclick="this.select()">
|
||||
<label style="font-size:12px;color:var(--text-muted);display:block;margin-top:10px">${t('apitoken.invite_label')}</label>
|
||||
<textarea class="input" readonly rows="2" style="width:100%;margin-top:4px;font-size:13px;font-family:inherit" onclick="this.select()">${esc(inviteText)}</textarea>
|
||||
<button class="btn btn-secondary btn-sm" id="copyInviteBtn" style="margin-top:8px">${t('apitoken.copy_invite')}</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
49
server/lib/agency-layouts.js
Normal file
49
server/lib/agency-layouts.js
Normal file
|
|
@ -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 };
|
||||
29
server/lib/agency-targets.js
Normal file
29
server/lib/agency-targets.js
Normal file
|
|
@ -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 };
|
||||
77
server/lib/content-ingest.js
Normal file
77
server/lib/content-ingest.js
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
132
server/routes/agency.js
Normal file
132
server/routes/agency.js
Normal file
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
84
server/services/agency-digest.js
Normal file
84
server/services/agency-digest.js
Normal file
|
|
@ -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 };
|
||||
75
server/test/agency-digest.test.js
Normal file
75
server/test/agency-digest.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
32
server/test/agency-gate.test.js
Normal file
32
server/test/agency-gate.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
55
server/test/agency-layouts.test.js
Normal file
55
server/test/agency-layouts.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
35
server/test/agency-list.test.js
Normal file
35
server/test/agency-list.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
32
server/test/agency-scope.test.js
Normal file
32
server/test/agency-scope.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
193
server/test/agency.test.js
Normal file
193
server/test/agency.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in a new issue