diff --git a/frontend/agency.html b/frontend/agency.html new file mode 100644 index 0000000..1e8b826 --- /dev/null +++ b/frontend/agency.html @@ -0,0 +1,81 @@ + + + + + + +Agency Upload Portal + + + +
+ +
+

Agency Upload Portal

+

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

+
+ + +
+
+ + + +
+ + + diff --git a/frontend/js/agency-portal.js b/frontend/js/agency-portal.js new file mode 100644 index 0000000..ff089bc --- /dev/null +++ b/frontend/js/agency-portal.js @@ -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 => ``).join('') + : ''; + showPortal(); + portalMsg('', ''); + // #73: the placement card reacts to the playlist selector - "where does THIS playlist go?" + sel.onchange = () => loadLayoutForPlaylist(sel.value); + loadLayoutForPlaylist(sel.value); // initial selection + } + + // Visual placement guide for the SELECTED playlist: draw its layout to scale, highlight the + // GRANTED zone(s) with the px size to design for, show sibling zones as context (geometry + // only - no content, no device/screen data; the endpoint is device-free). + async function loadLayoutForPlaylist(playlistId) { + const card = $('placementCard'), view = $('layoutView'); + if (!playlistId) { card.style.display = 'none'; return; } + let layouts; + try { layouts = await (await agencyFetch('/playlists/' + encodeURIComponent(playlistId) + '/layout')).json(); } catch (e) { return; } + card.style.display = 'block'; + if (!layouts.length) { + view.innerHTML = '

This playlist plays full-screen — design for the full display.

'; + return; + } + view.innerHTML = layouts.map(l => { + const mine = new Set(l.feeds_zone_ids); + const aspect = (l.height / l.width) * 100; // padding-bottom % = aspect ratio + const zones = l.zones.map(z => { + const isMine = mine.has(z.id); + const wpx = Math.round(l.width * z.width_percent / 100); + const hpx = Math.round(l.height * z.height_percent / 100); + return `
` + + `${escapeHtml(z.name)}${isMine ? `
YOUR ZONE
${wpx}×${hpx}px` : ''}
`; + }).join(''); + return `
` + + `
${escapeHtml(l.name)} · ${l.width}×${l.height}
` + + `
` + + `
${zones}
`; + }).join(''); + } + + // ---- entry ---- + $('enterBtn').addEventListener('click', () => { + const k = $('keyInput').value.trim(); + if (!k) return; + setKey(k); + $('keyInput').value = ''; + loadPortal(); + }); + $('keyInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('enterBtn').click(); }); + $('signOutBtn').addEventListener('click', () => { clearKey(); uploadedContentId = null; showEntry(''); }); + + // ---- upload ---- + $('fileInput').addEventListener('change', () => { $('uploadBtn').disabled = !$('fileInput').files.length; }); + $('uploadBtn').addEventListener('click', async () => { + const file = $('fileInput').files[0]; + if (!file) return; + $('uploadBtn').disabled = true; + portalMsg('Uploading…', 'ok'); + try { + const fd = new FormData(); + fd.append('file', file); + const res = await agencyFetch('/content', { method: 'POST', body: fd }); + if (!res.ok) { portalMsg('Upload failed. Try again.', 'err'); return; } + const content = await res.json(); + uploadedContentId = content.id; + $('uploadInfo').textContent = 'Uploaded: ' + (content.filename || content.id); + $('scheduleBtn').disabled = false; + portalMsg('Uploaded. Now schedule it below.', 'ok'); + } catch (e) { /* auth already handled */ } + finally { if (getKey()) $('uploadBtn').disabled = false; } + }); + + // ---- schedule ---- + $('scheduleBtn').addEventListener('click', async () => { + if (!uploadedContentId) return portalMsg('Upload a file first.', 'err'); + const playlistId = $('plSelect').value; + if (!playlistId) return portalMsg('No playlist available to schedule on.', 'err'); + const body = { content_id: uploadedContentId }; + if ($('startDate').value) body.start_date = $('startDate').value; + if ($('endDate').value) body.end_date = $('endDate').value; + $('scheduleBtn').disabled = true; + try { + const res = await agencyFetch('/playlists/' + encodeURIComponent(playlistId) + '/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const e = await res.json().catch(() => ({})); + portalMsg(e.error || 'Could not add to the playlist.', 'err'); + $('scheduleBtn').disabled = false; + return; + } + portalMsg('Added as a draft — your contact will publish it. You can upload another.', 'ok'); + uploadedContentId = null; + $('uploadInfo').textContent = ''; + $('fileInput').value = ''; + $('uploadBtn').disabled = true; + } catch (e) { /* auth already handled */ } + }); + + // ---- boot: a stored key is validated by the first /playlists call ---- + if (getKey()) loadPortal(); else showEntry(''); +})(); diff --git a/frontend/js/api.js b/frontend/js/api.js index e0ec520..790bc9e 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -160,6 +160,7 @@ export const api = { getTokens: () => request('/tokens'), createToken: (data) => request('/tokens', { method: 'POST', body: JSON.stringify(data) }), revokeToken: (id) => request('/tokens/' + id, { method: 'DELETE' }), + setTokenTargets: (id, target_playlist_ids) => request('/tokens/' + id + '/targets', { method: 'PUT', body: JSON.stringify({ target_playlist_ids }) }), // #73: re-designate agency token playlists // Current user getMe: () => request('/auth/me'), diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index d4f680a..d41d4ee 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -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', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 7efdaa0..871364d 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index f722311..93c68bd 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -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', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index e7e1c48..232d511 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -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', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index da40f03..ddbbe2a 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -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', diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js index 9a0038e..1804c90 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -635,7 +635,7 @@ function showPreview(content) {
${isYoutube - ? `` + ? `` : isVideo ? `` : `` diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index 6e0fe02..3c8027a 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -62,7 +62,8 @@ export async function render(container) {

${t('apitoken.title')}

-

${t('apitoken.desc')}

+

${t('apitoken.desc')}

+

${t('apitoken.docs_link')}

@@ -74,12 +75,23 @@ export async function render(container) { +
+

${t('settings.loading_users')}

+
${isAdmin ? ` @@ -329,6 +341,7 @@ export async function render(container) { read: t('apitoken.scope_read'), write: t('apitoken.scope_write'), full: t('apitoken.scope_full'), + agency: t('apitoken.scope_agency'), }[s] || s); async function loadTokens() { @@ -357,13 +370,16 @@ export async function render(container) { ${esc(tok.prefix)}… ${esc(tok.name || '')} - ${esc(scopeLabel(tok.scope))} + ${esc(scopeLabel(tok.scope))}${ + tok.scope === 'agency' && Array.isArray(tok.targets) + ? `
${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}
` + : ''} ${esc(fmtTokenDate(tok.created_at))} ${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')} ${tok.revoked_at ? `${t('apitoken.revoked')}` - : ``} + : `${tok.scope === 'agency' ? ` ` : ''}`} `).join('')} @@ -384,19 +400,83 @@ export async function render(container) { } }); }); + + // #73: edit an agency token's playlist designations -> PUT /:id/targets (atomic re-designate). + el.querySelectorAll('.edit-targets-btn').forEach(btn => btn.addEventListener('click', async () => { + const id = btn.dataset.id; + const current = new Set((btn.dataset.targets || '').split(',').filter(Boolean)); + const panel = document.getElementById('tokenEditPanel'); + const pls = await api.getPlaylists().catch(() => []); + panel.style.display = 'block'; + panel.innerHTML = ` +
+

${t('apitoken.edit_targets')}

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

${t('apitoken.agency_no_playlists')}

`} +
+ + +
`; + document.getElementById('saveTargetsBtn').onclick = async () => { + const ids = [...panel.querySelectorAll('.edit-pl:checked')].map(c => c.value); + if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error'); + try { + await api.setTokenTargets(id, ids); + showToast(t('apitoken.targets_updated'), 'success'); + panel.style.display = 'none'; + loadTokens(); + } catch (err) { showToast(err.message, 'error'); } + }; + document.getElementById('cancelTargetsBtn').onclick = () => { panel.style.display = 'none'; }; + })); } loadTokens(); + // #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 + ? `` + : ``).join('') + : `

${t('apitoken.agency_no_playlists')}

`; + } + }); + document.getElementById('createTokenBtn')?.addEventListener('click', async () => { const name = document.getElementById('tokName').value.trim(); const scope = document.getElementById('tokScope').value; + const payload = { name, scope }; + if (scope === 'agency') { + const ids = [...document.querySelectorAll('#agencyPlaylistList .agency-pl:checked')].map(c => c.value); + if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error'); + payload.target_playlist_ids = ids; + payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked; + } const btn = document.getElementById('createTokenBtn'); btn.disabled = true; try { - const r = await api.createToken({ name, scope }); + const r = await api.createToken(payload); const box = document.getElementById('tokenSecretBox'); box.style.display = 'block'; + // #73: for agency tokens, surface the handoff (portal URL + a copyable invite). The key + // is in the invite TEXT, never in a URL (Cloudflare logs query strings + chat apps unfurl + // links). window.location.origin is the real public host the admin is on (correct behind CF). + const portalUrl = window.location.origin + '/agency'; + const inviteText = t('apitoken.invite_text', { url: portalUrl, key: r.token }); box.innerHTML = `

${t('apitoken.secret_title')}

@@ -405,6 +485,14 @@ export async function render(container) {
+ ${scope === 'agency' ? ` +
+ + + + + +
` : ''}
`; document.getElementById('copyTokenBtn')?.addEventListener('click', async () => { @@ -413,6 +501,12 @@ export async function render(container) { showToast(t('apitoken.copied'), 'success'); } catch { /* clipboard may be unavailable; the field is selectable */ } }); + document.getElementById('copyInviteBtn')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(inviteText); // full "go here + paste key" text + showToast(t('apitoken.copied'), 'success'); + } catch { /* field is selectable as a fallback */ } + }); document.getElementById('tokName').value = ''; showToast(t('apitoken.created_toast'), 'success'); loadTokens(); diff --git a/server/config/api-surface.js b/server/config/api-surface.js index 9af5c93..d67485d 100644 --- a/server/config/api-surface.js +++ b/server/config/api-surface.js @@ -48,4 +48,13 @@ const JWT_ONLY_ROUTERS = [ { path: '/api/tokens', mod: './routes/tokens', tenancy: true }, ]; -module.exports = { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS }; +// #73: AGENCY_ROUTERS - capability-restricted ('agency' scope) surface. Mounted with +// bearerAuth + resolveTenancy + agencyGate (NOT tokenScopeGate). An 'agency' token is +// OFF the read/write/full ladder, so tokenScopeGate rejects it on every PUBLIC_ROUTER - +// it can reach ONLY this router, and only its allowlisted playlists in its bound +// workspace (agencyGate enforces both). read/write/full tokens and JWTs are rejected here. +const AGENCY_ROUTERS = [ + { path: '/api/agency', mod: './routes/agency' }, +]; + +module.exports = { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS, AGENCY_ROUTERS }; diff --git a/server/db/database.js b/server/db/database.js index 192dc5f..4780e37 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -193,6 +193,16 @@ const migrations = [ "ALTER TABLE users ADD COLUMN totp_last_step INTEGER NOT NULL DEFAULT 0", "CREATE TABLE IF NOT EXISTS totp_recovery_codes (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, code_hash TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), used_at INTEGER)", "CREATE INDEX IF NOT EXISTS idx_totp_recovery_user ON totp_recovery_codes(user_id)", + // #73: agency-token target allowlist (capability-restricted tokens). + "CREATE TABLE IF NOT EXISTS api_token_targets (token_id TEXT NOT NULL REFERENCES api_tokens(id) ON DELETE CASCADE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), PRIMARY KEY (token_id, playlist_id))", + // #73: per-agency-token auto-publish (DEFAULT 0 = draft, the fail-safe). + "ALTER TABLE api_tokens ADD COLUMN auto_publish INTEGER NOT NULL DEFAULT 0", + // #73: agency-upload notification queue (batched digest). + "CREATE TABLE IF NOT EXISTS agency_notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT NOT NULL, token_id TEXT NOT NULL, playlist_id TEXT NOT NULL, action TEXT NOT NULL, content_id TEXT, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), sent_at INTEGER)", + "CREATE INDEX IF NOT EXISTS idx_agency_notifications_unsent ON agency_notifications(sent_at)", + // #73: zone-binding was reverted (placement belongs to the device, not the playlist - see + // the agency-tokens history). Drop the table on DBs where the short-lived migration ran. + "DROP TABLE IF EXISTS api_token_target_zones", ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/db/schema.sql b/server/db/schema.sql index de5d2f5..542984f 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -530,14 +530,42 @@ CREATE TABLE IF NOT EXISTS api_tokens ( name TEXT NOT NULL, -- user-given label user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'full' + scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'full' | 'agency' + auto_publish INTEGER NOT NULL DEFAULT 0, -- #73: agency only. 0 = items land DRAFT (default, fail-safe); 1 = admin opted this agency out of approval created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), last_used_at INTEGER, revoked_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); + +-- #73: target allowlist for capability-restricted ('agency') tokens. An agency token +-- (scope='agency', OFF the read/write/full ladder so tokenScopeGate rejects it on every +-- other router) may act ONLY on the playlists listed here, enforced at the single +-- agencyGate seam. FK cascade both ways: revoke the token or delete the playlist and the +-- grant disappears. +CREATE TABLE IF NOT EXISTS api_token_targets ( + token_id TEXT NOT NULL REFERENCES api_tokens(id) ON DELETE CASCADE, + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (token_id, playlist_id) +); CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); +-- #73: agency-upload notification queue. The agency endpoint enqueues one row per item added +-- (only when email is configured); a 15-min flush job groups per token+playlist+action and +-- sends one digest per group, stamping sent_at ONLY after a successful send (failed -> retry). +CREATE TABLE IF NOT EXISTS agency_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + token_id TEXT NOT NULL, + playlist_id TEXT NOT NULL, + action TEXT NOT NULL, -- 'draft' | 'published' + content_id TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + sent_at INTEGER -- NULL = unsent +); +CREATE INDEX IF NOT EXISTS idx_agency_notifications_unsent ON agency_notifications(sent_at); + -- ===================== SCHEMA MIGRATIONS ===================== CREATE TABLE IF NOT EXISTS schema_migrations ( diff --git a/server/lib/agency-layouts.js b/server/lib/agency-layouts.js new file mode 100644 index 0000000..d2e50f3 --- /dev/null +++ b/server/lib/agency-layouts.js @@ -0,0 +1,49 @@ +'use strict'; + +// #73: layout GEOMETRY for an agency token's designated playlists. DEVICE-FREE BY +// CONSTRUCTION: the only path used is playlist_items.zone_id -> layout_zones -> layouts. +// It never references devices / device_groups / schedules, so no fleet data (device names, +// locations, IPs, screen sizes, topology) can leak - it's structurally absent, not filtered. +// Confined to THIS token's designated playlists (t.token_id) in its bound workspace. +// Returns layout canvas size + ALL zones' geometry (no zone CONTENT) + which zones this +// token feeds. Bite-tested in test/agency-layouts.test.js. +function listLayoutGeometry(db, tokenId, workspaceId, playlistId = null) { + // Distinct layouts that this token's designated playlists feed (via their items' zones). + // Optional playlistId narrows to ONE designated playlist (the per-playlist card). + const layouts = db.prepare(` + SELECT DISTINCT l.id, l.name, l.width, l.height + FROM api_token_targets t + JOIN playlists p ON p.id = t.playlist_id AND p.workspace_id = ? + JOIN playlist_items pi ON pi.playlist_id = p.id AND pi.zone_id IS NOT NULL + JOIN layout_zones lz ON lz.id = pi.zone_id + JOIN layouts l ON l.id = lz.layout_id + WHERE t.token_id = ?${playlistId ? ' AND p.id = ?' : ''} + ORDER BY l.name + `).all(...(playlistId ? [workspaceId, tokenId, playlistId] : [workspaceId, tokenId])); + + // All zones of a layout - GEOMETRY ONLY (no content, no device data lives here anyway). + const zonesStmt = db.prepare(` + SELECT id, name, x_percent, y_percent, width_percent, height_percent, + z_index, zone_type, fit_mode, background_color, sort_order + FROM layout_zones WHERE layout_id = ? ORDER BY sort_order, z_index + `); + // Which zones of a given layout THIS token actually feeds. + const feedsStmt = db.prepare(` + SELECT DISTINCT pi.zone_id + FROM api_token_targets t + JOIN playlist_items pi ON pi.playlist_id = t.playlist_id AND pi.zone_id IS NOT NULL + JOIN layout_zones lz ON lz.id = pi.zone_id + WHERE t.token_id = ? AND lz.layout_id = ? + `); + + return layouts.map(l => ({ + id: l.id, + name: l.name, + width: l.width, + height: l.height, + zones: zonesStmt.all(l.id), + feeds_zone_ids: feedsStmt.all(tokenId, l.id).map(r => r.zone_id), + })); +} + +module.exports = { listLayoutGeometry }; diff --git a/server/lib/agency-targets.js b/server/lib/agency-targets.js new file mode 100644 index 0000000..697909b --- /dev/null +++ b/server/lib/agency-targets.js @@ -0,0 +1,29 @@ +'use strict'; + +// #73: the single query behind GET /api/agency/playlists. Returns ONLY this token's +// designated playlists, in its bound workspace. The WHERE clause IS the confinement and is +// the thing to bite-test: +// t.token_id = ? -> this token's targets, never another token's +// (JOIN api_token_targets) -> only allowlisted playlists, never one outside the allowlist +// p.workspace_id = ? -> only the bound workspace, never cross-workspace +// db is passed in (not module-required) so the confinement is unit-testable in isolation. +function listDesignatedPlaylists(db, tokenId, workspaceId) { + return db.prepare(` + SELECT p.id, p.name, p.status + FROM api_token_targets t + JOIN playlists p ON p.id = t.playlist_id + WHERE t.token_id = ? AND p.workspace_id = ? + ORDER BY p.name + `).all(tokenId, workspaceId); +} + +// #73 full-screen guardrail: a playlist is "zoned" if any item targets a layout zone. Agency +// uploads are full-screen and can't safely target a zone, so a zoned playlist can't be shared +// with an agency. Checked at BOTH designation (reject the grant) AND upload (block the add) - +// the upload check is mandatory because auto-publish has no draft step to catch a playlist +// that becomes zoned after designation. +function isZonedPlaylist(db, playlistId) { + return !!db.prepare('SELECT 1 FROM playlist_items WHERE playlist_id = ? AND zone_id IS NOT NULL LIMIT 1').get(playlistId); +} + +module.exports = { listDesignatedPlaylists, isZonedPlaylist }; diff --git a/server/lib/content-ingest.js b/server/lib/content-ingest.js new file mode 100644 index 0000000..c4694e8 --- /dev/null +++ b/server/lib/content-ingest.js @@ -0,0 +1,77 @@ +'use strict'; + +// #73: shared content-ingest core. Extracted from routes/content.js POST / so the agency +// upload (routes/agency.js) produces BYTE-IDENTICAL first-class content (same thumbnail/ +// dimensions/duration/insert) - an agency asset is indistinguishable from a dashboard +// upload. routes/content.js POST / is now a thin caller; behavior is unchanged (its +// existing tests are the regression guard). + +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); +const config = require('../config'); +const { sanitizeString } = require('../middleware/sanitize'); + +// Multer takes file.originalname from the multipart header, bypassing sanitizeBody, so +// HTML-escape here (renders as text in every UI sink). .normalize('NFC') first: macOS +// sends NFD-decomposed names; Linux/renderers expect NFC. Single point - every filename +// storage site flows through here. +function safeFilename(name) { + return sanitizeString((name || '').normalize('NFC')); +} + +// Process a multer-uploaded file (thumbnail + dimensions + duration) and insert a content +// row. Returns the content row. Throws on a hard failure (the caller maps to 500); +// thumbnail/metadata failures are best-effort (logged, non-fatal) exactly as before. +async function ingestUploadedFile({ file, userId, workspaceId }) { + const id = uuidv4(); + const filepath = file.filename; + let width = null, height = null, durationSec = null, thumbnailPath = null; + + try { + if (file.mimetype.startsWith('image/')) { + const sharp = require('sharp'); + const metadata = await sharp(file.path).metadata(); + width = metadata.width; + height = metadata.height; + thumbnailPath = `thumb_${filepath}`; + await sharp(file.path) + .resize(config.thumbnailWidth) + .jpeg({ quality: 70 }) + .toFile(path.join(config.contentDir, thumbnailPath)); + } else if (file.mimetype.startsWith('video/')) { + try { + const { execFileSync } = require('child_process'); + const probe = execFileSync('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', file.path], + { timeout: 15000 } + ).toString(); + const info = JSON.parse(probe); + if (info.format?.duration) durationSec = parseFloat(info.format.duration); + const videoStream = info.streams?.find(s => s.codec_type === 'video'); + if (videoStream) { + width = videoStream.width; + height = videoStream.height; + } + thumbnailPath = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`; + try { + execFileSync('ffmpeg', ['-y', '-i', file.path, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbnailPath)], + { timeout: 15000 } + ); + } catch { thumbnailPath = null; } + } catch (e) { + console.warn('ffprobe failed:', e.message); + } + } + } catch (e) { + console.warn('Thumbnail/metadata generation failed:', e.message); + } + + db.prepare(` + INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, userId, workspaceId, safeFilename(file.originalname), filepath, file.mimetype, file.size, durationSec, thumbnailPath, width, height); + + return db.prepare('SELECT * FROM content WHERE id = ?').get(id); +} + +module.exports = { ingestUploadedFile, safeFilename }; diff --git a/server/middleware/apiToken.js b/server/middleware/apiToken.js index e747e27..9537556 100644 --- a/server/middleware/apiToken.js +++ b/server/middleware/apiToken.js @@ -69,7 +69,9 @@ function apiTokenAuth(req, res, next) { req.jwtWorkspaceId = row.workspace_id; // resolveTenancy scopes to the bound workspace req.viaToken = true; req.tokenScope = row.scope; - req.apiToken = { id: row.id, prefix: row.prefix, name: row.name, workspace_id: row.workspace_id }; + // #73: auto_publish read from the TOKEN ROW (admin-set), so the agency endpoint can + // never take it from the request body. `|| 0` keeps it fail-safe for any row predating it. + req.apiToken = { id: row.id, prefix: row.prefix, name: row.name, workspace_id: row.workspace_id, auto_publish: row.auto_publish || 0 }; touchLastUsed(row.id); next(); } @@ -112,7 +114,22 @@ function requireScope(need) { }; } +// #73: mount seam for capability-restricted ('agency') tokens. SCOPE/off-ladder check ONLY: +// only an agency token reaches the agency router (a read/write/full token or a JWT is +// rejected). The PER-TARGET check CANNOT live here - Express doesn't populate req.params at +// app.use-level middleware (params land at route match, inside the router), so a mount-level +// target check is silently bypassed (the integration bite-suite caught exactly this). The +// target check is router.param('playlistId') in routes/agency.js - it fires WITH the param +// before the handler and can't be skipped by any :playlistId route. Two single-registration, +// drift-proof seams: scope (here) + target (router.param). +function agencyGate(req, res, next) { + if (!req.viaToken || req.tokenScope !== 'agency') { + return res.status(403).json({ error: 'agency token required' }); + } + next(); +} + module.exports = { - bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, + bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, agencyGate, hashToken, generateToken, displayPrefix, TOKEN_PREFIX, }; diff --git a/server/routes/agency.js b/server/routes/agency.js new file mode 100644 index 0000000..7c6d8ba --- /dev/null +++ b/server/routes/agency.js @@ -0,0 +1,132 @@ +'use strict'; + +// #73: agency portal endpoints. Mounted behind bearerAuth + resolveTenancy + agencyGate +// (AGENCY_ROUTERS in config/api-surface.js). agencyGate has ALREADY proven, at one seam: +// the caller is an 'agency' token, and for any :playlistId the playlist is in THIS token's +// allowlist AND its bound workspace. So these handlers only add within-workspace content +// checks; router/target/cross-workspace confinement is proven upstream. + +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); +const upload = require('../middleware/upload'); +const { checkStorageLimit } = require('../middleware/subscription'); +const { ingestUploadedFile } = require('../lib/content-ingest'); +const { listDesignatedPlaylists, isZonedPlaylist } = require('../lib/agency-targets'); +const { listLayoutGeometry } = require('../lib/agency-layouts'); +const { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish +const { isConfigured } = require('../services/email'); // #73: gate digest enqueue on SMTP being set + +const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +// List the playlists THIS token may post to (so the portal can show them). No :playlistId, +// so router.param doesn't apply - the confinement is the query in lib/agency-targets.js +// (own token + bound workspace only). Bite-tested in test/agency-list.test.js. +router.get('/playlists', (req, res) => { + res.json(listDesignatedPlaylists(db, req.apiToken.id, req.jwtWorkspaceId)); +}); + +// Layout GEOMETRY for ONE designated playlist (the per-playlist size-guidance card): canvas +// size + zone positions/sizes, with feeds_zone_ids = the zones this playlist actually feeds +// (so the agency sees where/what-size their content lands). Returns [] when the playlist has +// no layout -> the card shows the full-screen message. Placement itself stays the admin's job +// (device-side). Has :playlistId, so router.param confines it. DEVICE-FREE (lib/agency-layouts.js). +router.get('/playlists/:playlistId/layout', (req, res) => { + res.json(listLayoutGeometry(db, req.apiToken.id, req.jwtWorkspaceId, req.params.playlistId)); +}); + +// #73 THE target seam. router.param fires for EVERY route with :playlistId, WITH the param, +// BEFORE the handler - so no targeted route can skip the allowlist + bound-workspace check +// (the api-surface.js can't-drift property, at the param level: you cannot add a :playlistId +// route without this triggering). One query enforces both the target allowlist and +// cross-workspace isolation. Neutralizing the `if (!ok)` return makes integration BITE 1 red. +router.param('playlistId', (req, res, next, playlistId) => { + const ok = db.prepare(` + SELECT 1 FROM api_token_targets t + JOIN playlists p ON p.id = t.playlist_id + WHERE t.token_id = ? AND t.playlist_id = ? AND p.workspace_id = ? + `).get(req.apiToken.id, playlistId, req.jwtWorkspaceId); + if (!ok) return res.status(403).json({ error: 'playlist not in this agency token\'s allowlist' }); + next(); +}); + +// Upload to the bound workspace via the SHARED ingest -> first-class content (identical +// thumbnail/dimensions/duration to a dashboard upload). +router.post('/content', checkStorageLimit, upload.single('file'), async (req, res) => { + try { + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + const content = await ingestUploadedFile({ file: req.file, userId: req.user.id, workspaceId: req.workspaceId }); + res.status(201).json(content); + } catch (e) { + console.error('agency upload error:', e.message); + res.status(500).json({ error: 'Upload failed' }); + } +}); + +// Add a date-bounded item to a DESIGNATED playlist (#74/#75 schedule block). The playlist +// is already gate-verified. Lands as DRAFT (markDraft) so the admin's re-publish is the +// approval gate for external-party content - same draft-on-change behavior as the dashboard. +router.post('/playlists/:playlistId/items', (req, res) => { + const { content_id } = req.body; + if (!content_id) return res.status(400).json({ error: 'content_id required' }); + + // #73 full-screen guardrail, upload-time (MANDATORY because auto-publish has no draft net): + // if the designated playlist has BECOME zoned since designation, block the add - a full-screen + // agency upload can't target a zone. 409 (not 401/403) so the portal shows the message, not its + // "key invalid" reset. This runs BEFORE the draft/publish branch, so auto-publish can't slip through. + if (isZonedPlaylist(db, req.params.playlistId)) { + return res.status(409).json({ error: "This playlist can't accept uploads right now — it's been assigned to a zone on a screen. Ask your contact." }); + } + + const content = db.prepare('SELECT id, workspace_id, duration_sec FROM content WHERE id = ?').get(content_id); + if (!content) return res.status(404).json({ error: 'Content not found' }); + // cross-tenant guard: content must be in the token's bound workspace (or a template) + if (content.workspace_id && content.workspace_id !== req.workspaceId) { + return res.status(403).json({ error: 'Content is not in this workspace' }); + } + + let { duration_sec, days, start, end, start_date, end_date } = req.body; + if (duration_sec != null && (typeof duration_sec !== 'number' || duration_sec < 1)) { + return res.status(400).json({ error: 'duration_sec must be a positive integer' }); + } + duration_sec = duration_sec || content.duration_sec || 10; + + const sd = start_date ?? null, ed = end_date ?? null; + for (const [k, v] of [['start_date', sd], ['end_date', ed]]) { + if (v != null && !DATE_RE.test(v)) return res.status(400).json({ error: `${k} must be YYYY-MM-DD or null` }); + } + const dys = (Array.isArray(days) && days.length) ? days : [0, 1, 2, 3, 4, 5, 6]; + if (!dys.every(d => Number.isInteger(d) && d >= 0 && d <= 6)) return res.status(400).json({ error: 'days must be integers 0-6' }); + const st = start ?? '00:00', en = end ?? '24:00'; + if (!TIME_RE.test(st)) return res.status(400).json({ error: 'start must be HH:MM' }); + if (!(TIME_RE.test(en) || en === '24:00')) return res.status(400).json({ error: 'end must be HH:MM or 24:00' }); + + const order = db.prepare('SELECT COALESCE(MAX(sort_order),0)+1 AS n FROM playlist_items WHERE playlist_id = ?').get(req.params.playlistId).n; + const itemId = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)') + .run(req.params.playlistId, content_id, order, duration_sec).lastInsertRowid; + db.prepare('INSERT INTO playlist_item_schedules (id, playlist_item_id, active_days, start_time, end_time, start_date, end_date, sort_order) VALUES (?,?,?,?,?,?,?,0)') + .run(uuidv4(), itemId, dys.join(','), st, en, sd, ed); + // #73: draft vs live is decided by the TOKEN's auto_publish (admin-set, read from + // req.apiToken - NEVER req.body, so the agency can't opt itself out of approval). Default + // 0 -> draft for admin re-publish. 1 -> the SHARED publishPlaylist path (snapshot + push). + let published = false; + if (req.apiToken.auto_publish) { + publishPlaylist(req.params.playlistId, req); + published = true; + } else { + db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(req.params.playlistId); + } + + // #73: enqueue a digest notification ONLY when email is configured, so the queue can't + // balloon on installs without SMTP. action reflects what actually happened (draft vs live). + if (isConfigured()) { + db.prepare('INSERT INTO agency_notifications (workspace_id, token_id, playlist_id, action, content_id) VALUES (?,?,?,?,?)') + .run(req.workspaceId, req.apiToken.id, req.params.playlistId, published ? 'published' : 'draft', content_id); + } + + res.status(201).json({ id: itemId, playlist_id: req.params.playlistId, content_id, duration_sec, start_date: sd, end_date: ed, published }); +}); + +module.exports = router; diff --git a/server/routes/content.js b/server/routes/content.js index affe22e..7c51a09 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -11,6 +11,8 @@ const { sanitizeString } = require('../middleware/sanitize'); const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth'); // Phase 2.2b: workspace-aware access. Mirrors the pattern from devices.js. const { accessContext } = require('../lib/tenancy'); +// #73: the upload ingest (processing + insert) is now shared with the agency router. +const { ingestUploadedFile } = require('../lib/content-ingest'); // Multer captures file.originalname directly from the multipart filename header, // bypassing sanitizeBody. Apply the same HTML-escape here so a filename like @@ -91,60 +93,8 @@ router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => { if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before uploading.' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); - const id = uuidv4(); - const filepath = req.file.filename; - let width = null, height = null, durationSec = null, thumbnailPath = null; - - // Try to generate thumbnail, get dimensions, and detect duration - try { - if (req.file.mimetype.startsWith('image/')) { - const sharp = require('sharp'); - const metadata = await sharp(req.file.path).metadata(); - width = metadata.width; - height = metadata.height; - - // Generate thumbnail - thumbnailPath = `thumb_${filepath}`; - await sharp(req.file.path) - .resize(config.thumbnailWidth) - .jpeg({ quality: 70 }) - .toFile(path.join(config.contentDir, thumbnailPath)); - } else if (req.file.mimetype.startsWith('video/')) { - // Extract video duration and dimensions with ffprobe - try { - const { execFileSync } = require('child_process'); - // Use execFileSync (not execSync) to prevent shell injection - args are NOT passed through shell - const probe = execFileSync('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', req.file.path], - { timeout: 15000 } - ).toString(); - const info = JSON.parse(probe); - if (info.format?.duration) durationSec = parseFloat(info.format.duration); - const videoStream = info.streams?.find(s => s.codec_type === 'video'); - if (videoStream) { - width = videoStream.width; - height = videoStream.height; - } - // Generate video thumbnail at 2 second mark - thumbnailPath = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`; - try { - execFileSync('ffmpeg', ['-y', '-i', req.file.path, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbnailPath)], - { timeout: 15000 } - ); - } catch { thumbnailPath = null; } - } catch (e) { - console.warn('ffprobe failed:', e.message); - } - } - } catch (e) { - console.warn('Thumbnail/metadata generation failed:', e.message); - } - - db.prepare(` - INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, req.user.id, req.workspaceId, safeFilename(req.file.originalname), filepath, req.file.mimetype, req.file.size, durationSec, thumbnailPath, width, height); - - const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id); + // #73: shared ingest - identical processing + insert for dashboard and agency uploads. + const content = await ingestUploadedFile({ file: req.file, userId: req.user.id, workspaceId: req.workspaceId }); res.status(201).json(content); } catch (err) { console.error('Upload error:', err); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index 2544255..e1bdc69 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -121,13 +121,25 @@ function pushToDevices(playlistId, req) { } catch (e) { /* silent */ } } +// #73: the shared publish path - snapshot current items into published_snapshot (what +// devices actually consume) + push to devices. POST /:id/publish AND the agency +// auto-publish path both call this, so they can never drift (a "published" playlist that +// wasn't snapshotted would be live-on-no-screen). +function publishPlaylist(playlistId, req) { + const snapshotItems = buildSnapshotItems(playlistId); + db.prepare("UPDATE playlists SET status = 'published', published_snapshot = ?, updated_at = strftime('%s','now') WHERE id = ?") + .run(JSON.stringify(snapshotItems), playlistId); + pushToDevices(playlistId, req); +} + // Phase 2.2k: list scoped to caller's current workspace. No platform_admin // bypass - cross-workspace view comes from switch-workspace, matching the // precedent established across all other migrated routes. router.get('/', (req, res) => { if (!req.workspaceId) return res.json([]); const playlists = db.prepare(` - SELECT p.*, COUNT(DISTINCT pi.id) as item_count, COUNT(DISTINCT d.id) as display_count + SELECT p.*, COUNT(DISTINCT pi.id) as item_count, COUNT(DISTINCT d.id) as display_count, + EXISTS(SELECT 1 FROM playlist_items z WHERE z.playlist_id = p.id AND z.zone_id IS NOT NULL) as zoned FROM playlists p LEFT JOIN playlist_items pi ON p.id = pi.playlist_id LEFT JOIN devices d ON d.playlist_id = p.id @@ -202,10 +214,7 @@ router.put('/:id', requirePlaylistWrite, (req, res) => { router.post('/:id/publish', requirePlaylistWrite, (req, res) => { // Snapshot shape (no pi.id) is intentional — published_snapshot is consumed // by devices and stored as JSON; row IDs there would be misleading. - const snapshotItems = buildSnapshotItems(req.params.id); - db.prepare("UPDATE playlists SET status = 'published', published_snapshot = ?, updated_at = strftime('%s','now') WHERE id = ?") - .run(JSON.stringify(snapshotItems), req.params.id); - pushToDevices(req.params.id, req); + publishPlaylist(req.params.id, req); // UI response shape must include pi.id so the post-publish render can wire // per-row delete/duration listeners. TODO: refactor to share this SELECT // with GET /:id (also duplicated in /discard and POST /:id/items/reorder). @@ -541,3 +550,4 @@ router.post('/:id/assign', requirePlaylistWrite, (req, res) => { }); module.exports = router; +module.exports.publishPlaylist = publishPlaylist; // #73: shared with the agency auto-publish path diff --git a/server/routes/tokens.js b/server/routes/tokens.js index ea0a26d..2e04030 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -7,16 +7,24 @@ const crypto = require('crypto'); const { db } = require('../db/database'); const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken'); const { accessContext } = require('../lib/tenancy'); +const { isZonedPlaylist } = require('../lib/agency-targets'); // #73: full-screen-only guardrail -const SCOPES = ['read', 'write', 'full']; +// #73: 'agency' is OFF the read/write/full ladder (not in apiToken.js SCOPE_RANK), so a +// tokenScopeGate-mounted router rejects it; it reaches only the AGENCY_ROUTER via agencyGate. +const SCOPES = ['read', 'write', 'full', 'agency']; // List the caller's tokens in the active workspace. Never returns the secret/hash. router.get('/', (req, res) => { if (!req.workspaceId) return res.status(403).json({ error: 'No active workspace' }); const rows = db.prepare(` - SELECT id, prefix, name, scope, workspace_id, created_at, last_used_at, revoked_at + SELECT id, prefix, name, scope, auto_publish, workspace_id, created_at, last_used_at, revoked_at FROM api_tokens WHERE user_id = ? AND workspace_id = ? ORDER BY created_at DESC `).all(req.user.id, req.workspaceId); + // #73: attach designated playlists for agency tokens so the admin sees the binding persist. + const targetsStmt = db.prepare('SELECT p.id, p.name FROM api_token_targets t JOIN playlists p ON p.id = t.playlist_id WHERE t.token_id = ? ORDER BY p.name'); + for (const r of rows) { + if (r.scope === 'agency') r.targets = targetsStmt.all(r.id); + } res.json(rows); }); @@ -27,21 +35,43 @@ router.post('/', (req, res) => { const scope = req.body.scope || 'read'; if (!name) return res.status(400).json({ error: 'name is required' }); if (name.length > 100) return res.status(400).json({ error: 'name too long' }); - if (!SCOPES.includes(scope)) return res.status(400).json({ error: "scope must be 'read', 'write' or 'full'" }); + if (!SCOPES.includes(scope)) return res.status(400).json({ error: "scope must be 'read', 'write', 'full' or 'agency'" }); // The token runs with platform powers stripped (role forced to 'user'), so it must // bind to a workspace the owner reaches via membership/org - not platform act-as - // else apiTokenAuth+resolveTenancy would land it in no workspace at use time. if (!accessContext(req.user.id, 'user', req.workspace)) { return res.status(400).json({ error: 'You must be a member of this workspace to create a token here' }); } + // #73: an agency token is bound to a NON-EMPTY allowlist of playlists in THIS workspace. + // Validate up front so a bad target never leaves an orphan token behind. + let targetIds = []; + // auto_publish is meaningful ONLY for agency scope and is the admin's explicit opt-OUT of + // approval. Anything but agency-scope + literal true -> 0 (draft, the fail-safe default). + const autoPublish = (scope === 'agency' && req.body.auto_publish === true) ? 1 : 0; + if (scope === 'agency') { + targetIds = Array.isArray(req.body.target_playlist_ids) ? req.body.target_playlist_ids : []; + if (!targetIds.length) return res.status(400).json({ error: 'an agency token requires target_playlist_ids' }); + const inWs = db.prepare('SELECT id FROM playlists WHERE id = ? AND workspace_id = ?'); + for (const pid of targetIds) { + if (!inWs.get(pid, req.workspaceId)) return res.status(400).json({ error: `playlist ${pid} is not in this workspace` }); + // #73: agencies get FULL-SCREEN playlists only - a zoned playlist can't take full-screen uploads. + if (isZonedPlaylist(db, pid)) return res.status(400).json({ error: 'A selected playlist is assigned to a zone on a screen — agency uploads play full-screen, so it can\'t be shared with an agency. Use a full-screen playlist.' }); + } + } const secret = generateToken(); const id = crypto.randomUUID(); - db.prepare(` - INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) - `).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope); + db.transaction(() => { + db.prepare(` + INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, auto_publish, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) + `).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope, autoPublish); + if (scope === 'agency') { + const ins = db.prepare('INSERT INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)'); + for (const pid of targetIds) ins.run(id, pid); + } + })(); // `token` is returned only here, never again. - res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId }); + res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId, target_playlist_ids: targetIds, auto_publish: !!autoPublish }); }); // Revoke one of the caller's own tokens (soft delete - takes effect on the next request). @@ -54,4 +84,26 @@ router.delete('/:id', (req, res) => { res.json({ success: true }); }); +// #73: re-designate an agency token's playlist allowlist (atomic replace). JWT-only (this +// whole router is JWT-only), so an agency token can never widen its OWN targets. +router.put('/:id/targets', (req, res) => { + const tok = db.prepare('SELECT id, scope, workspace_id FROM api_tokens WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); + if (!tok) return res.status(404).json({ error: 'Token not found' }); + if (tok.scope !== 'agency') return res.status(400).json({ error: 'only agency tokens have targets' }); + const ids = Array.isArray(req.body.target_playlist_ids) ? req.body.target_playlist_ids : []; + if (!ids.length) return res.status(400).json({ error: 'target_playlist_ids must be a non-empty array' }); + const inWs = db.prepare('SELECT id FROM playlists WHERE id = ? AND workspace_id = ?'); + for (const pid of ids) { + if (!inWs.get(pid, tok.workspace_id)) return res.status(400).json({ error: `playlist ${pid} is not in this token's workspace` }); + // #73: full-screen-only - a zoned playlist can't be (re-)designated to an agency. + if (isZonedPlaylist(db, pid)) return res.status(400).json({ error: 'A selected playlist is assigned to a zone on a screen — agency uploads play full-screen, so it can\'t be shared with an agency. Use a full-screen playlist.' }); + } + const ins = db.prepare('INSERT OR IGNORE INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)'); + db.transaction(() => { + db.prepare('DELETE FROM api_token_targets WHERE token_id = ?').run(tok.id); + for (const pid of ids) ins.run(tok.id, pid); + })(); + res.json({ id: tok.id, target_playlist_ids: ids }); +}); + module.exports = router; diff --git a/server/server.js b/server/server.js index 8ac7932..efaa1be 100644 --- a/server/server.js +++ b/server/server.js @@ -201,6 +201,11 @@ app.get('/openapi.yaml', (req, res) => { app.get('/docs', (req, res) => { res.sendFile(path.join(config.frontendDir, 'api-docs.html')); }); +// #73: the standalone agency portal (token-auth, NOT the JWT dashboard SPA). Served as its +// own page so the agency never touches the dashboard login. +app.get('/agency', (req, res) => { + res.sendFile(path.join(config.frontendDir, 'agency.html')); +}); // Serve frontend static files // JS/CSS/HTML: no-cache (always revalidate, uses ETag/304) @@ -446,7 +451,7 @@ app.get('/api/content/:id/thumbnail', (req, res) => { const { requireAuth } = require('./middleware/auth'); const { resolveTenancy } = require('./lib/tenancy'); // Public API token front door (Phase 1). Attached ONLY to the public routers below. -const { bearerAuth, tokenScopeGate } = require('./middleware/apiToken'); +const { bearerAuth, tokenScopeGate, agencyGate } = require('./middleware/apiToken'); // activityLogger wraps res.json on every subsequent route to auto-log // successful POST/PUT/DELETE mutations. Mount it BEFORE the workspace routes @@ -464,7 +469,7 @@ app.use(activityLogger); // their jwt.verify and is unreachable (secure by exclusion). Tokens act as a workspace // member with platform powers stripped, so in-handler ELEVATED/PLATFORM checks (e.g. // GET /api/devices/unassigned) still deny. -const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS } = require('./config/api-surface'); +const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS, AGENCY_ROUTERS } = require('./config/api-surface'); // Public device-render endpoints + the memory-heavy preview limiter must be registered // BEFORE their parent router mount so the _skipAuth bypass / the limiter fire first. @@ -485,6 +490,12 @@ for (const r of JWT_ONLY_ROUTERS) { if (r.tenancy) app.use(r.path, requireAuth, resolveTenancy, require(r.mod)); else app.use(r.path, requireAuth, require(r.mod)); } +for (const r of AGENCY_ROUTERS) { + // #73: capability-restricted token surface. bearerAuth + resolveTenancy + agencyGate + // (NOT tokenScopeGate). 'agency' is off the read/write/full ladder, so these tokens + // reach ONLY here; agencyGate enforces the playlist allowlist + bound workspace. + app.use(r.path, bearerAuth, resolveTenancy, agencyGate, require(r.mod)); +} // Frontend version hash (changes when files are modified, triggers soft reload) const crypto = require('crypto'); @@ -583,6 +594,10 @@ startAlertService(io); const { startActivationNudge } = require('./services/activationNudge'); startActivationNudge(); +// #73: agency-upload digest flush (batched draft/published notifications to admins + owner) +const { startAgencyDigest } = require('./services/agency-digest'); +startAgencyDigest(); + // Handle provisioning via WebSocket notification const { db } = require('./db/database'); const originalProvisionRoute = require('./routes/provisioning'); diff --git a/server/services/agency-digest.js b/server/services/agency-digest.js new file mode 100644 index 0000000..7f5658c --- /dev/null +++ b/server/services/agency-digest.js @@ -0,0 +1,84 @@ +'use strict'; + +// #73: batched digest of agency uploads. The agency endpoint enqueues a row per item added +// (ONLY when email is configured). This job flushes every 15 min: groups unsent rows per +// token+playlist+action, sends one email per group to the workspace owner/admins + the +// playlist owner (deduped), and stamps sent_at ONLY after a successful send. Two robustness +// rules: (1) never let the queue balloon when SMTP is off; (2) a failed send retries next +// cycle instead of silently dropping. + +const { db: defaultDb } = require('../db/database'); +const defaultEmail = require('./email'); + +const FLUSH_MS = 15 * 60 * 1000; // the digest window + +// Workspace owner/admins (via the org) + the playlist owner. UNION dedupes by email. +function resolveRecipients(db, workspaceId, playlistId) { + return db.prepare(` + SELECT u.email FROM organization_members om + JOIN workspaces w ON w.organization_id = om.organization_id + JOIN users u ON u.id = om.user_id + WHERE w.id = ? AND om.role IN ('org_owner', 'org_admin') AND u.email IS NOT NULL + UNION + SELECT u.email FROM playlists p + JOIN users u ON u.id = p.user_id + WHERE p.id = ? AND u.email IS NOT NULL + `).all(workspaceId, playlistId); +} + +function composeDigest(db, g) { + const agency = db.prepare('SELECT name FROM api_tokens WHERE id = ?').get(g.token_id)?.name || 'An agency'; + const playlist = db.prepare('SELECT name FROM playlists WHERE id = ?').get(g.playlist_id)?.name || 'a playlist'; + const n = g.n; + if (g.action === 'draft') { + return { + subject: `${agency} added ${n} item${n === 1 ? '' : 's'} to "${playlist}" — awaiting your approval`, + text: `${agency} added ${n} item${n === 1 ? '' : 's'} to the playlist "${playlist}".\n\nThey are saved as drafts and will NOT appear on screens until you publish the playlist.`, + }; + } + return { + subject: `${agency} updated "${playlist}"`, + text: `${agency} added ${n} item${n === 1 ? '' : 's'} to the playlist "${playlist}", now live (this token is set to auto-publish).`, + }; +} + +// Core flush - testable: pass a db and an email impl ({ isConfigured, sendEmail }). +async function flushAgencyDigests(db = defaultDb, email = defaultEmail) { + if (!email.isConfigured()) { + // SMTP off -> drain-and-discard so the queue can't grow unbounded on self-hosters + // who never set up email. (The endpoint also skips enqueue when off; this is the backstop.) + db.prepare('DELETE FROM agency_notifications WHERE sent_at IS NULL').run(); + return; + } + const groups = db.prepare(` + SELECT workspace_id, token_id, playlist_id, action, COUNT(*) AS n, GROUP_CONCAT(id) AS ids + FROM agency_notifications WHERE sent_at IS NULL + GROUP BY token_id, playlist_id, action + `).all(); + + for (const g of groups) { + try { + const recipients = resolveRecipients(db, g.workspace_id, g.playlist_id); + if (recipients.length) { + const { subject, text } = composeDigest(db, g); + for (const r of recipients) { + await email.sendEmail({ to: r.email, subject, text }); // throw -> caught below -> NOT stamped -> retried + } + } + // Stamp sent_at ONLY after every send for this group succeeded (or there were no + // recipients). A throw above skips this -> the rows stay unsent for the next cycle. + const now = Math.floor(Date.now() / 1000); + const stamp = db.prepare('UPDATE agency_notifications SET sent_at = ? WHERE id = ?'); + db.transaction(() => { for (const id of g.ids.split(',')) stamp.run(now, id); })(); + } catch (e) { + console.warn('agency digest: send failed, will retry next cycle:', e.message); + } + } +} + +function startAgencyDigest() { + setInterval(() => { flushAgencyDigests().catch(() => {}); }, FLUSH_MS); + console.log('Agency digest service started'); +} + +module.exports = { startAgencyDigest, flushAgencyDigests, resolveRecipients, composeDigest }; diff --git a/server/test/agency-digest.test.js b/server/test/agency-digest.test.js new file mode 100644 index 0000000..fc05734 --- /dev/null +++ b/server/test/agency-digest.test.js @@ -0,0 +1,75 @@ +'use strict'; + +// #73 email digest robustness. Proves the two rules the design hinges on: (1) the queue +// never balloons when SMTP is off (drain-and-discard); (2) sent_at is stamped ONLY after a +// successful send, so a failure retries next cycle instead of silently dropping. Plus +// recipient resolution (org owner/admins + playlist owner, deduped) and digest grouping. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const { flushAgencyDigests, resolveRecipients } = require('../services/agency-digest'); + +function freshDb() { + const db = new Database(':memory:'); + db.exec(` + CREATE TABLE agency_notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT, token_id TEXT, playlist_id TEXT, action TEXT, content_id TEXT, created_at INTEGER, sent_at INTEGER); + CREATE TABLE organization_members (organization_id TEXT, user_id TEXT, role TEXT); + CREATE TABLE workspaces (id TEXT, organization_id TEXT); + CREATE TABLE users (id TEXT, email TEXT); + CREATE TABLE playlists (id TEXT, user_id TEXT, name TEXT); + CREATE TABLE api_tokens (id TEXT, name TEXT); + INSERT INTO workspaces VALUES ('ws1','org1'); + INSERT INTO users VALUES ('uOwner','owner@x'), ('uAdmin','admin@x'), ('uViewer','viewer@x'), ('uPlOwner','plowner@x'); + INSERT INTO organization_members VALUES ('org1','uOwner','org_owner'), ('org1','uAdmin','org_admin'), ('org1','uViewer','member'); + INSERT INTO playlists VALUES ('pl1','uPlOwner','Lobby'); + INSERT INTO api_tokens VALUES ('tok1','Acme Agency'); + `); + return db; +} +function enqueue(db, n, action = 'draft') { + const ins = db.prepare("INSERT INTO agency_notifications (workspace_id, token_id, playlist_id, action) VALUES ('ws1','tok1','pl1',?)"); + for (let i = 0; i < n; i++) ins.run(action); +} +const cfg = (sendEmail) => ({ isConfigured: () => true, sendEmail }); +const sink = () => { const sent = []; return { sent, sendEmail: async (m) => { sent.push(m); } }; }; + +test('#73 digest recipients: org owner + admins + playlist owner, deduped (NOT the viewer)', () => { + const emails = resolveRecipients(freshDb(), 'ws1', 'pl1').map(r => r.email).sort(); + assert.deepEqual(emails, ['admin@x', 'owner@x', 'plowner@x']); +}); + +test('#73 digest: 30 uploads -> ONE email per recipient (not 30), all rows stamped sent', async () => { + const db = freshDb(); + enqueue(db, 30, 'draft'); + const { sent, sendEmail } = sink(); + await flushAgencyDigests(db, cfg(sendEmail)); + assert.equal(sent.length, 3, '1 group x 3 recipients = 3 emails, not 30 per recipient'); + assert.match(sent[0].subject, /Acme Agency added 30 items to "Lobby"/); + assert.equal(db.prepare('SELECT COUNT(*) c FROM agency_notifications WHERE sent_at IS NULL').get().c, 0); +}); + +test('#73 digest: a failed send leaves rows UNSENT for retry (never silently dropped)', async () => { + const db = freshDb(); + enqueue(db, 5, 'draft'); + await flushAgencyDigests(db, cfg(async () => { throw new Error('smtp down'); })); + assert.equal(db.prepare('SELECT COUNT(*) c FROM agency_notifications WHERE sent_at IS NULL').get().c, 5, 'still unsent -> retried next cycle'); +}); + +test('#73 digest: SMTP off -> queue drained-and-discarded (never balloons)', async () => { + const db = freshDb(); + enqueue(db, 10, 'draft'); + await flushAgencyDigests(db, { isConfigured: () => false, sendEmail: async () => { throw new Error('must not send'); } }); + assert.equal(db.prepare('SELECT COUNT(*) c FROM agency_notifications').get().c, 0, 'drained when email is off'); +}); + +test('#73 digest: draft vs published produce different subjects, grouped per action', async () => { + const db = freshDb(); + enqueue(db, 2, 'draft'); + enqueue(db, 3, 'published'); + const { sent, sendEmail } = sink(); + await flushAgencyDigests(db, cfg(sendEmail)); + const subjects = sent.map(s => s.subject); + assert.ok(subjects.some(s => /awaiting your approval/.test(s)), 'draft digest mentions approval'); + assert.ok(subjects.some(s => /updated "Lobby"/.test(s)), 'published digest says updated'); +}); diff --git a/server/test/agency-gate.test.js b/server/test/agency-gate.test.js new file mode 100644 index 0000000..76fc6d2 --- /dev/null +++ b/server/test/agency-gate.test.js @@ -0,0 +1,32 @@ +'use strict'; + +// #73 mount seam: agencyGate does SCOPE/off-ladder confinement ONLY (only an agency token +// reaches the agency router). The per-target check moved to router.param('playlistId') in +// routes/agency.js, because Express doesn't populate req.params at mount-level middleware - +// so the target restriction is proven on the REAL runtime path by test/agency.test.js +// (the integration bite-suite), not here. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); + +// agencyGate needs no db now, but requiring the module loads db/database - inject a stub. +require.cache[require.resolve('../db/database')] = { + id: require.resolve('../db/database'), loaded: true, exports: { db: new Database(':memory:') }, +}; +const { agencyGate } = require('../middleware/apiToken'); + +function gate(over = {}) { + const req = { viaToken: true, tokenScope: 'agency', ...over }; + let status = 200, nexted = false; + const res = { status(s) { status = s; return this; }, json() { return this; } }; + agencyGate(req, res, () => { nexted = true; }); + return { status, nexted }; +} + +test('#73 agencyGate (mount seam): only agency tokens pass; non-agency + JWT rejected', () => { + assert.equal(gate().nexted, true, 'agency token passes the scope seam'); + assert.equal(gate({ tokenScope: 'write' }).status, 403, 'read/write/full token -> 403'); + assert.equal(gate({ tokenScope: 'full' }).status, 403, 'full token -> 403'); + assert.equal(gate({ viaToken: false }).status, 403, 'JWT (not a token) -> 403'); +}); diff --git a/server/test/agency-layouts.test.js b/server/test/agency-layouts.test.js new file mode 100644 index 0000000..4001453 --- /dev/null +++ b/server/test/agency-layouts.test.js @@ -0,0 +1,55 @@ +'use strict'; + +// #73: GET /api/agency/layouts is a read surface on the primitive, so prove it confines with +// the same rigor as the playlists list. The query (lib/agency-layouts.js) is DEVICE-FREE: +// designated playlist -> item zone -> layout. Asserted: own layout YES, a non-designated +// playlist's layout NO, and the response carries NO device fields (structurally absent - the +// device row exists in the db but is never queried). Neutralizing the t.token_id filter -> red. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const { listLayoutGeometry } = require('../lib/agency-layouts'); + +const db = new Database(':memory:'); +db.exec(` + CREATE TABLE api_token_targets (token_id TEXT, playlist_id TEXT); + CREATE TABLE playlists (id TEXT, workspace_id TEXT); + CREATE TABLE playlist_items (id INTEGER PRIMARY KEY, playlist_id TEXT, zone_id TEXT); + CREATE TABLE layouts (id TEXT, name TEXT, width INTEGER, height INTEGER); + CREATE TABLE layout_zones (id TEXT, layout_id TEXT, name TEXT, x_percent REAL, y_percent REAL, + width_percent REAL, height_percent REAL, z_index INTEGER, zone_type TEXT, fit_mode TEXT, + background_color TEXT, sort_order INTEGER); + CREATE TABLE devices (id TEXT, name TEXT, layout_id TEXT, playlist_id TEXT, ip_address TEXT); + INSERT INTO layouts VALUES ('L1','Lobby',1920,1080), ('L2','Cafe',1080,1920); + INSERT INTO layout_zones VALUES + ('z1','L1','Main',0,0,70,100,0,'content','contain','#000000',0), + ('z2','L1','Sidebar',70,0,30,100,1,'content','contain','#111111',1), + ('z3','L2','Full',0,0,100,100,0,'content','cover','#000000',0); + INSERT INTO playlists VALUES ('plA','wsA'), ('plB','wsA'); + INSERT INTO playlist_items VALUES (1,'plA','z1'), (2,'plB','z3'); + INSERT INTO api_token_targets VALUES ('tokA','plA'), ('tokB','plB'); + -- a device referencing L1/plA with a location-y name + IP. The device-free query must + -- NEVER surface any of this. + INSERT INTO devices VALUES ('d1','Lobby Screen — North Wall','L1','plA','10.0.0.5'); +`); + +test('#73 layout geometry: own layout only, all zones geometry, theirs marked, NO device data', () => { + const a = listLayoutGeometry(db, 'tokA', 'wsA'); + assert.equal(a.length, 1, 'tokA sees ONLY L1 (its designated playlist feeds it), not L2'); + assert.equal(a[0].id, 'L1'); + assert.deepEqual({ name: a[0].name, width: a[0].width, height: a[0].height }, { name: 'Lobby', width: 1920, height: 1080 }); + assert.deepEqual(a[0].zones.map(z => z.id), ['z1', 'z2'], 'all zones of the canvas (geometry), incl. the sibling'); + assert.deepEqual(a[0].feeds_zone_ids, ['z1'], 'only z1 is marked as this token\'s zone (z2 is geometry only)'); + + // NO device data anywhere in the response - structurally absent (the device row exists). + const blob = JSON.stringify(a); + for (const leak of ['d1', 'North Wall', '10.0.0.5', 'ip_address', 'device']) { + assert.ok(!blob.includes(leak), `response must not contain "${leak}"`); + } + // zone objects expose only geometry keys, nothing fleet. + assert.deepEqual(Object.keys(a[0].zones[0]).sort(), + ['background_color', 'fit_mode', 'height_percent', 'id', 'name', 'sort_order', 'width_percent', 'x_percent', 'y_percent', 'z_index', 'zone_type'].sort()); + + assert.deepEqual(listLayoutGeometry(db, 'tokB', 'wsA').map(l => l.id), ['L2'], 'tokB sees ONLY L2'); +}); diff --git a/server/test/agency-list.test.js b/server/test/agency-list.test.js new file mode 100644 index 0000000..76b5390 --- /dev/null +++ b/server/test/agency-list.test.js @@ -0,0 +1,35 @@ +'use strict'; + +// #73: GET /api/agency/playlists is a new READ surface on the security primitive, so prove +// it confines with write-path rigor. The query (lib/agency-targets.js) must return ONLY this +// token's designated, in-workspace playlists. Four ways it could leak, all asserted here; +// neutralizing the t.token_id filter makes it go red (the bite). + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); +const { listDesignatedPlaylists } = require('../lib/agency-targets'); + +const db = new Database(':memory:'); +db.exec(` + CREATE TABLE api_token_targets (token_id TEXT, playlist_id TEXT, PRIMARY KEY(token_id, playlist_id)); + CREATE TABLE playlists (id TEXT PRIMARY KEY, name TEXT, status TEXT, workspace_id TEXT); + INSERT INTO playlists (id, name, status, workspace_id) VALUES + ('p1','One', 'published','wsA'), + ('p2','Two', 'published','wsA'), + ('p3','Three','published','wsA'), + ('pX','Cross','published','wsB'); + INSERT INTO api_token_targets (token_id, playlist_id) VALUES + ('tokA','p1'), -- own + in-workspace -> MUST appear + ('tokA','pX'), -- own but CROSS-workspace -> must NOT appear + ('tokB','p2'); -- ANOTHER token's -> must NOT appear for tokA + -- p3 is in wsA but designated to no one -> OUTSIDE the allowlist -> must NOT appear +`); + +test('#73 GET targets: returns ONLY this token\'s designated, in-workspace playlists', () => { + const a = listDesignatedPlaylists(db, 'tokA', 'wsA').map(r => r.id); + assert.deepEqual(a, ['p1'], + 'tokA sees ONLY p1 - not p2 (another token), not p3 (outside allowlist), not pX (cross-workspace)'); + const b = listDesignatedPlaylists(db, 'tokB', 'wsA').map(r => r.id); + assert.deepEqual(b, ['p2'], 'tokB sees ONLY p2'); +}); diff --git a/server/test/agency-scope.test.js b/server/test/agency-scope.test.js new file mode 100644 index 0000000..1c6dcb5 --- /dev/null +++ b/server/test/agency-scope.test.js @@ -0,0 +1,32 @@ +'use strict'; + +// #73 SPINE: an 'agency' scope is OFF the read/write/full ladder, so the EXISTING +// tokenScopeGate rejects it on every router by construction (auto-confinement). This is +// the foundation the whole model rests on - prove it before building anything on top. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); + +// tokenScopeGate is pure (no db), but requiring the module loads db/database - inject one. +require.cache[require.resolve('../db/database')] = { + id: require.resolve('../db/database'), loaded: true, exports: { db: new Database(':memory:') }, +}; +const { tokenScopeGate } = require('../middleware/apiToken'); + +function run(scope, method) { + const req = { viaToken: true, tokenScope: scope, method }; + let status = 200, nexted = false; + const res = { status(s) { status = s; return this; }, json() { return this; } }; + tokenScopeGate(req, res, () => { nexted = true; }); + return { status, nexted }; +} + +test('#73 spine: agency scope auto-fails tokenScopeGate everywhere (off-ladder)', () => { + assert.equal(run('agency', 'GET').status, 403, 'agency cannot read on a normal router'); + assert.equal(run('agency', 'POST').status, 403, 'agency cannot write on a normal router'); + assert.equal(run('agency', 'GET').nexted, false, 'agency never reaches the handler'); + // Contrast: normal scopes still pass - the gate isn't just rejecting everything. + assert.equal(run('write', 'POST').nexted, true, 'write still passes write'); + assert.equal(run('read', 'GET').nexted, true, 'read still passes read'); +}); diff --git a/server/test/agency.test.js b/server/test/agency.test.js new file mode 100644 index 0000000..3bdb18d --- /dev/null +++ b/server/test/agency.test.js @@ -0,0 +1,193 @@ +'use strict'; + +// #73 FULL bite-suite for the agency-token primitive, end-to-end against a booted server: +// the happy path (upload -> date-bounded item on a DESIGNATED playlist) plus the four +// confinement assertions at their three seams (gate / off-ladder / JWT-only / issuance). + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); + +const PORT = 3992; +const BASE = `http://127.0.0.1:${PORT}`; +const DATA_DIR = path.join(os.tmpdir(), 'st-agency-' + crypto.randomBytes(4).toString('hex')); +let proc; + +before(async () => { + const logFd = fs.openSync(path.join(os.tmpdir(), 'st-agency.log'), 'w'); + proc = spawn('node', ['server.js'], { + cwd: path.join(__dirname, '..'), + env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, + stdio: ['ignore', logFd, logFd], + }); + for (let i = 0; i < 80; i++) { + try { const r = await fetch(BASE + '/api/status'); if (r.ok) break; } catch { /* not yet */ } + await new Promise(r => setTimeout(r, 250)); + } +}); +after(() => { try { proc.kill('SIGKILL'); } catch { /* ignore */ } }); + +async function jfetch(p, opts = {}) { + const res = await fetch(BASE + p, opts); + let body = null; try { body = await res.json(); } catch { /* non-JSON */ } + return { status: res.status, body }; +} +const jpost = (tok, o) => ({ method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(o || {}) }); +const reg = (o) => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(o) }); + +test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)', async () => { + const email = 'ag' + crypto.randomBytes(4).toString('hex') + '@x.local'; + const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token; + const pl1 = (await jfetch('/api/playlists', jpost(jwt, { name: 'Designated' }))).body; + const pl2 = (await jfetch('/api/playlists', jpost(jwt, { name: 'Off-limits' }))).body; + + // issue an agency token bound to pl1 ONLY + const tokRes = await jfetch('/api/tokens', jpost(jwt, { name: 'Agency', scope: 'agency', target_playlist_ids: [pl1.id] })); + assert.equal(tokRes.status, 201, 'agency token created'); + assert.deepEqual(tokRes.body.target_playlist_ids, [pl1.id]); + const atok = tokRes.body.token; + + // GET targets (real path: agencyGate -> handler -> query): returns ONLY the designated pl1 + const mine = await jfetch('/api/agency/playlists', { headers: { Authorization: 'Bearer ' + atok } }); + assert.equal(mine.status, 200, 'agency can list its targets'); + assert.deepEqual(mine.body.map(p => p.id), [pl1.id], 'GET /agency/playlists returns ONLY the designated playlist (not pl2)'); + + // GET per-playlist layout (real path through router.param): 200 + array, never device fields; + // a NON-designated playlist's layout -> 403 (router.param confines it) + const lay = await jfetch(`/api/agency/playlists/${pl1.id}/layout`, { headers: { Authorization: 'Bearer ' + atok } }); + assert.equal(lay.status, 200, 'agency can read its designated playlist layout'); + assert.ok(Array.isArray(lay.body), 'layout is an array'); + assert.ok(!JSON.stringify(lay.body).includes('device'), 'layout response carries no device data'); + const layX = await jfetch(`/api/agency/playlists/${pl2.id}/layout`, { headers: { Authorization: 'Bearer ' + atok } }); + assert.equal(layX.status, 403, 'layout of a NON-designated playlist -> 403 (router.param)'); + + // HAPPY PATH: upload via the agency token (shared ingest -> first-class content) + const fd = new FormData(); + fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png'); + const up = await fetch(BASE + '/api/agency/content', { method: 'POST', headers: { Authorization: 'Bearer ' + atok }, body: fd }); + assert.equal(up.status, 201, 'agency upload -> 201 (first-class content)'); + const content = await up.json(); + + // date-bounded item on the DESIGNATED playlist + const item = await jfetch(`/api/agency/playlists/${pl1.id}/items`, jpost(atok, { content_id: content.id, start_date: '2026-07-01', end_date: '2026-07-31' })); + assert.equal(item.status, 201, 'item on designated playlist -> 201'); + + // BITE 1 (gate): NON-designated playlist -> 403 + const blocked = await jfetch(`/api/agency/playlists/${pl2.id}/items`, jpost(atok, { content_id: content.id })); + assert.equal(blocked.status, 403, 'non-designated playlist -> 403'); + + // BITE 2 (off-ladder): agency token on a normal public router -> 403 + const dev = await jfetch('/api/devices', { headers: { Authorization: 'Bearer ' + atok } }); + assert.equal(dev.status, 403, 'agency token on /api/devices -> 403 (off-ladder, tokenScopeGate)'); + + // BITE 3 (JWT-only): can't reach /api/tokens to widen its OWN targets -> 401 + const widen = await jfetch(`/api/tokens/${tokRes.body.id}/targets`, jpost(atok, { target_playlist_ids: [pl1.id, pl2.id] })); + assert.equal(widen.status, 401, 'agency token cannot reach /api/tokens (JWT-only) -> 401'); + + // BITE 4 (issuance): an agency token can't be BOUND to an out-of-workspace/unknown playlist -> 400 + const badTok = await jfetch('/api/tokens', jpost(jwt, { name: 'Bad', scope: 'agency', target_playlist_ids: ['nonexistent'] })); + assert.equal(badTok.status, 400, 'cannot bind an out-of-workspace target at issuance'); + + // Portal graceful-failure trigger: an invalid/revoked key -> 401, which the portal catches + // to show "paste it again" (never a wall of 403s). + const bogus = await jfetch('/api/agency/playlists', { headers: { Authorization: 'Bearer st_bogus_invalid_key' } }); + assert.equal(bogus.status, 401, 'invalid agency key -> 401 (portal resets to the entry screen)'); +}); + +test('#73 auto-publish: the TOKEN flag decides draft vs live; the body can never override it', async () => { + const jwtAuth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok } }); + const email = 'ap' + crypto.randomBytes(4).toString('hex') + '@x.local'; + const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token; + const plD = (await jfetch('/api/playlists', jpost(jwt, { name: 'DraftTarget' }))).body; + const plA = (await jfetch('/api/playlists', jpost(jwt, { name: 'AutoTarget' }))).body; + + const draftTok = (await jfetch('/api/tokens', jpost(jwt, { name: 'DraftAgency', scope: 'agency', target_playlist_ids: [plD.id] }))).body; + assert.equal(draftTok.auto_publish, false, 'DEFAULT is draft (auto_publish false) - the fail-safe'); + const autoTok = (await jfetch('/api/tokens', jpost(jwt, { name: 'AutoAgency', scope: 'agency', target_playlist_ids: [plA.id], auto_publish: true }))).body; + assert.equal(autoTok.auto_publish, true, 'admin explicitly opted into auto-publish'); + + async function upload(tok) { + const fd = new FormData(); + fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png'); + return (await fetch(BASE + '/api/agency/content', { method: 'POST', headers: { Authorization: 'Bearer ' + tok }, body: fd })).json(); + } + const cD = await upload(draftTok.token); + const cA = await upload(autoTok.token); + + // (a) DRAFT token + {auto_publish:true} IN THE BODY -> still draft (token flag wins, body ignored) + const addD = await jfetch(`/api/agency/playlists/${plD.id}/items`, jpost(draftTok.token, { content_id: cD.id, auto_publish: true })); + assert.equal(addD.status, 201); + assert.equal(addD.body.published, false, 'draft token does NOT publish even with auto_publish:true in the body'); + assert.equal((await jfetch(`/api/playlists/${plD.id}`, jwtAuth(jwt))).body.status, 'draft', 'playlist stays draft'); + + // (b) AUTO-PUBLISH token -> item goes live via the shared publishPlaylist path + const addA = await jfetch(`/api/agency/playlists/${plA.id}/items`, jpost(autoTok.token, { content_id: cA.id })); + assert.equal(addA.status, 201); + assert.equal(addA.body.published, true, 'auto-publish token publishes'); + assert.equal((await jfetch(`/api/playlists/${plA.id}`, jwtAuth(jwt))).body.status, 'published', 'playlist is published'); + + // (c) REGRESSION: the manual publish endpoint still works after the publishPlaylist extraction + const pub = await jfetch(`/api/playlists/${plD.id}/publish`, jpost(jwt, {})); + assert.equal(pub.status, 200, 'manual publish works post-extraction'); + assert.equal((await jfetch(`/api/playlists/${plD.id}`, jwtAuth(jwt))).body.status, 'published', 'manual publish sets status=published'); +}); + +test('#73 edit-designations: PUT /:id/targets re-designates (add + remove); confinement follows', async () => { + const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok } }); + const email = 're' + crypto.randomBytes(4).toString('hex') + '@x.local'; + const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token; + const plA = (await jfetch('/api/playlists', jpost(jwt, { name: 'A' }))).body; + const plB = (await jfetch('/api/playlists', jpost(jwt, { name: 'B' }))).body; + const plC = (await jfetch('/api/playlists', jpost(jwt, { name: 'C' }))).body; + + const tokRes = await jfetch('/api/tokens', jpost(jwt, { name: 'EditMe', scope: 'agency', target_playlist_ids: [plA.id, plB.id] })); + const atok = tokRes.body.token, tokId = tokRes.body.id; + // initially A+B designated (200 = router.param lets it through), C not (403) + assert.equal((await jfetch(`/api/agency/playlists/${plA.id}/layout`, auth(atok))).status, 200, 'A reachable'); + assert.equal((await jfetch(`/api/agency/playlists/${plC.id}/layout`, auth(atok))).status, 403, 'C not yet designated'); + + // re-designate: drop A, keep B, add C + const put = await jfetch(`/api/tokens/${tokId}/targets`, { method: 'PUT', headers: { Authorization: 'Bearer ' + jwt, 'Content-Type': 'application/json' }, body: JSON.stringify({ target_playlist_ids: [plB.id, plC.id] }) }); + assert.equal(put.status, 200, 're-designate ok'); + + // confinement follows the NEW set: removed A -> 403, kept B -> 200, added C -> 200 + assert.equal((await jfetch(`/api/agency/playlists/${plA.id}/layout`, auth(atok))).status, 403, 'removed A -> 403'); + assert.equal((await jfetch(`/api/agency/playlists/${plB.id}/layout`, auth(atok))).status, 200, 'kept B -> 200'); + assert.equal((await jfetch(`/api/agency/playlists/${plC.id}/layout`, auth(atok))).status, 200, 'added C -> 200'); +}); + +test('#73 full-screen guardrail holds at UPLOAD time too (auto-publish has no draft net)', async () => { + const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok } }); + const upload = async (tok) => { + const fd = new FormData(); + fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png'); + return (await fetch(BASE + '/api/agency/content', { method: 'POST', headers: { Authorization: 'Bearer ' + tok }, body: fd })).json(); + }; + const email = 'fs' + crypto.randomBytes(4).toString('hex') + '@x.local'; + const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token; + const plFS = (await jfetch('/api/playlists', jpost(jwt, { name: 'FullScreen' }))).body; + + // (1) full-screen playlist -> AUTO-PUBLISH token designation SUCCEEDS (safe at designation) + const tokRes = await jfetch('/api/tokens', jpost(jwt, { name: 'AP', scope: 'agency', target_playlist_ids: [plFS.id], auto_publish: true })); + assert.equal(tokRes.status, 201, 'full-screen designation OK'); + const atok = tokRes.body.token; + + // (2) zone the playlist AFTER designation: a layout+zone, then a zone-targeted item via JWT + const lid = (await jfetch('/api/layouts', jpost(jwt, { name: 'Z', zones: [{ name: 'Main', x_percent: 0, y_percent: 0, width_percent: 70, height_percent: 100 }] }))).body.id; + const zoneId = (await jfetch(`/api/layouts/${lid}`, auth(jwt))).body.zones[0].id; + const c1 = await upload(atok); + assert.equal((await jfetch(`/api/playlists/${plFS.id}/items`, jpost(jwt, { content_id: c1.id, zone_id: zoneId }))).status, 201, 'playlist is now zoned'); + + // (3) THE BITE: agency upload to the now-zoned playlist is BLOCKED (409), NOT auto-published into the zone + const c2 = await upload(atok); + const add = await jfetch(`/api/agency/playlists/${plFS.id}/items`, jpost(atok, { content_id: c2.id })); + assert.equal(add.status, 409, 'upload to a now-zoned playlist blocked (auto-publish cannot slip it into the zone)'); + + // (4) and an already-zoned playlist is rejected at DESIGNATION too + const reDesig = await jfetch('/api/tokens', jpost(jwt, { name: 'AP2', scope: 'agency', target_playlist_ids: [plFS.id] })); + assert.equal(reDesig.status, 400, 'already-zoned playlist rejected at designation'); +});