diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 704482b..36f6bb1 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -363,6 +363,9 @@ export default { 'apitoken.agency_needs_playlists': 'Wähle mindestens eine Playlist für einen Agentur-Token.', 'apitoken.agency_no_playlists': 'Erstelle zuerst eine Playlist – ein Agentur-Token muss auf eine zielen.', 'apitoken.targets_label': 'Zugewiesen:', + 'apitoken.auto_publish_label': 'Automatisch veröffentlichen (meine Freigabe überspringen)', + 'apitoken.auto_publish_hint': 'Aus (Standard): Hinzufügungen warten als Entwurf auf deine Veröffentlichung. An: sie gehen sofort live – nur für Agenturen, denen du voll vertraust.', + 'apitoken.auto_publish_on': 'Auto-Veröffentlichung an', 'apitoken.create': 'Token erstellen', 'apitoken.none': 'Noch keine Tokens.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index f07750f..4754cff 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -399,6 +399,9 @@ export default { 'apitoken.agency_needs_playlists': 'Select at least one playlist for an agency token.', 'apitoken.agency_no_playlists': 'Create a playlist first — an agency token must target one.', 'apitoken.targets_label': 'Designated:', + 'apitoken.auto_publish_label': 'Auto-publish (skip my approval)', + 'apitoken.auto_publish_hint': 'Off (default): additions wait as drafts for you to publish. On: they go live immediately — only for agencies you fully trust.', + 'apitoken.auto_publish_on': 'auto-publish on', 'apitoken.create': 'Create token', 'apitoken.none': 'No tokens yet.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index ce06355..d4080b7 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -362,6 +362,9 @@ export default { 'apitoken.agency_needs_playlists': 'Selecciona al menos una lista para un token de agencia.', 'apitoken.agency_no_playlists': 'Crea una lista primero: un token de agencia debe apuntar a una.', 'apitoken.targets_label': 'Designadas:', + 'apitoken.auto_publish_label': 'Publicación automática (omitir mi aprobación)', + 'apitoken.auto_publish_hint': 'Desactivado (predeterminado): las adiciones esperan como borradores para que las publiques. Activado: se publican de inmediato, solo para agencias de plena confianza.', + 'apitoken.auto_publish_on': 'publicación automática activada', 'apitoken.create': 'Crear token', 'apitoken.none': 'Aún no hay tokens.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index d361b67..bec332b 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -363,6 +363,9 @@ export default { 'apitoken.agency_needs_playlists': 'Sélectionnez au moins une liste pour un jeton d\'agence.', 'apitoken.agency_no_playlists': 'Créez d\'abord une liste : un jeton d\'agence doit en cibler une.', 'apitoken.targets_label': 'Assignées :', + 'apitoken.auto_publish_label': 'Publication automatique (ignorer mon approbation)', + 'apitoken.auto_publish_hint': 'Désactivé (par défaut) : les ajouts attendent en brouillon votre publication. Activé : ils sont diffusés immédiatement, uniquement pour les agences de pleine confiance.', + 'apitoken.auto_publish_on': 'publication automatique activée', 'apitoken.create': 'Créer un jeton', 'apitoken.none': 'Aucun jeton pour le moment.', 'apitoken.col_token': 'Jeton', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index 641f53d..ffa7553 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -363,6 +363,9 @@ export default { 'apitoken.agency_needs_playlists': 'Selecione pelo menos uma lista para um token de agência.', 'apitoken.agency_no_playlists': 'Crie uma lista primeiro: um token de agência deve apontar para uma.', 'apitoken.targets_label': 'Designadas:', + 'apitoken.auto_publish_label': 'Publicação automática (ignorar minha aprovação)', + 'apitoken.auto_publish_hint': 'Desativado (padrão): as adições aguardam como rascunho para você publicar. Ativado: vão ao ar imediatamente, apenas para agências de total confiança.', + 'apitoken.auto_publish_on': 'publicação automática ativada', 'apitoken.create': 'Criar token', 'apitoken.none': 'Ainda não há tokens.', 'apitoken.col_token': 'Token', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index caa5e87..dcec920 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -83,6 +83,10 @@ export async function render(container) {

${t('apitoken.agency_playlists_hint')}

+ +

${t('apitoken.auto_publish_hint')}

${t('settings.loading_users')}

@@ -366,7 +370,7 @@ export async function render(container) { ${esc(tok.name || '')} ${esc(scopeLabel(tok.scope))}${ tok.scope === 'agency' && Array.isArray(tok.targets) - ? `
${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}
` + ? `
${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}
` : ''} ${esc(fmtTokenDate(tok.created_at))} ${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')} @@ -423,6 +427,7 @@ export async function render(container) { const ids = [...document.querySelectorAll('#agencyPlaylistList .agency-pl:checked')].map(c => c.value); if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error'); payload.target_playlist_ids = ids; + payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked; } const btn = document.getElementById('createTokenBtn'); btn.disabled = true; diff --git a/server/db/database.js b/server/db/database.js index 6e121c5..2b247e4 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -195,6 +195,8 @@ const migrations = [ "CREATE INDEX IF NOT EXISTS idx_totp_recovery_user ON totp_recovery_codes(user_id)", // #73: agency-token target allowlist (capability-restricted tokens). "CREATE TABLE IF NOT EXISTS api_token_targets (token_id TEXT NOT NULL REFERENCES api_tokens(id) ON DELETE CASCADE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), PRIMARY KEY (token_id, playlist_id))", + // #73: per-agency-token auto-publish (DEFAULT 0 = draft, the fail-safe). + "ALTER TABLE api_tokens ADD COLUMN auto_publish INTEGER NOT NULL DEFAULT 0", ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/db/schema.sql b/server/db/schema.sql index 838ad64..b67a107 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -531,6 +531,7 @@ CREATE TABLE IF NOT EXISTS api_tokens ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'full' | 'agency' + auto_publish INTEGER NOT NULL DEFAULT 0, -- #73: agency only. 0 = items land DRAFT (default, fail-safe); 1 = admin opted this agency out of approval created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), last_used_at INTEGER, revoked_at INTEGER diff --git a/server/middleware/apiToken.js b/server/middleware/apiToken.js index 74dbed8..9537556 100644 --- a/server/middleware/apiToken.js +++ b/server/middleware/apiToken.js @@ -69,7 +69,9 @@ function apiTokenAuth(req, res, next) { req.jwtWorkspaceId = row.workspace_id; // resolveTenancy scopes to the bound workspace req.viaToken = true; req.tokenScope = row.scope; - req.apiToken = { id: row.id, prefix: row.prefix, name: row.name, workspace_id: row.workspace_id }; + // #73: auto_publish read from the TOKEN ROW (admin-set), so the agency endpoint can + // never take it from the request body. `|| 0` keeps it fail-safe for any row predating it. + req.apiToken = { id: row.id, prefix: row.prefix, name: row.name, workspace_id: row.workspace_id, auto_publish: row.auto_publish || 0 }; touchLastUsed(row.id); next(); } diff --git a/server/routes/agency.js b/server/routes/agency.js index e0f1a24..0093bab 100644 --- a/server/routes/agency.js +++ b/server/routes/agency.js @@ -14,6 +14,7 @@ const upload = require('../middleware/upload'); const { checkStorageLimit } = require('../middleware/subscription'); const { ingestUploadedFile } = require('../lib/content-ingest'); const { listDesignatedPlaylists } = require('../lib/agency-targets'); +const { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -88,10 +89,18 @@ router.post('/playlists/:playlistId/items', (req, res) => { .run(req.params.playlistId, content_id, order, duration_sec).lastInsertRowid; db.prepare('INSERT INTO playlist_item_schedules (id, playlist_item_id, active_days, start_time, end_time, start_date, end_date, sort_order) VALUES (?,?,?,?,?,?,?,0)') .run(uuidv4(), itemId, dys.join(','), st, en, sd, ed); - // items changed since last publish -> draft; admin re-publish approves it. - db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(req.params.playlistId); + // #73: draft vs live is decided by the TOKEN's auto_publish (admin-set, read from + // req.apiToken - NEVER req.body, so the agency can't opt itself out of approval). Default + // 0 -> draft for admin re-publish. 1 -> the SHARED publishPlaylist path (snapshot + push). + let published = false; + if (req.apiToken.auto_publish) { + publishPlaylist(req.params.playlistId, req); + published = true; + } else { + db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(req.params.playlistId); + } - res.status(201).json({ id: itemId, playlist_id: req.params.playlistId, content_id, duration_sec, start_date: sd, end_date: ed }); + res.status(201).json({ id: itemId, playlist_id: req.params.playlistId, content_id, duration_sec, start_date: sd, end_date: ed, published }); }); module.exports = router; diff --git a/server/routes/tokens.js b/server/routes/tokens.js index 673ea36..3e3270b 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -16,7 +16,7 @@ const SCOPES = ['read', 'write', 'full', 'agency']; router.get('/', (req, res) => { if (!req.workspaceId) return res.status(403).json({ error: 'No active workspace' }); const rows = db.prepare(` - SELECT id, prefix, name, scope, workspace_id, created_at, last_used_at, revoked_at + SELECT id, prefix, name, scope, auto_publish, workspace_id, created_at, last_used_at, revoked_at FROM api_tokens WHERE user_id = ? AND workspace_id = ? ORDER BY created_at DESC `).all(req.user.id, req.workspaceId); // #73: attach designated playlists for agency tokens so the admin sees the binding persist. @@ -44,6 +44,9 @@ router.post('/', (req, res) => { // #73: an agency token is bound to a NON-EMPTY allowlist of playlists in THIS workspace. // Validate up front so a bad target never leaves an orphan token behind. let targetIds = []; + // auto_publish is meaningful ONLY for agency scope and is the admin's explicit opt-OUT of + // approval. Anything but agency-scope + literal true -> 0 (draft, the fail-safe default). + const autoPublish = (scope === 'agency' && req.body.auto_publish === true) ? 1 : 0; if (scope === 'agency') { targetIds = Array.isArray(req.body.target_playlist_ids) ? req.body.target_playlist_ids : []; if (!targetIds.length) return res.status(400).json({ error: 'an agency token requires target_playlist_ids' }); @@ -56,16 +59,16 @@ router.post('/', (req, res) => { const id = crypto.randomUUID(); db.transaction(() => { db.prepare(` - INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) - `).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope); + INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, auto_publish, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) + `).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope, autoPublish); if (scope === 'agency') { const ins = db.prepare('INSERT INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)'); for (const pid of targetIds) ins.run(id, pid); } })(); // `token` is returned only here, never again. - res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId, target_playlist_ids: targetIds }); + res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId, target_playlist_ids: targetIds, auto_publish: !!autoPublish }); }); // Revoke one of the caller's own tokens (soft delete - takes effect on the next request). diff --git a/server/test/agency.test.js b/server/test/agency.test.js index c0ed3a5..3ba72bd 100644 --- a/server/test/agency.test.js +++ b/server/test/agency.test.js @@ -88,3 +88,41 @@ test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)' const bogus = await jfetch('/api/agency/playlists', { headers: { Authorization: 'Bearer st_bogus_invalid_key' } }); assert.equal(bogus.status, 401, 'invalid agency key -> 401 (portal resets to the entry screen)'); }); + +test('#73 auto-publish: the TOKEN flag decides draft vs live; the body can never override it', async () => { + const jwtAuth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok } }); + const email = 'ap' + crypto.randomBytes(4).toString('hex') + '@x.local'; + const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token; + const plD = (await jfetch('/api/playlists', jpost(jwt, { name: 'DraftTarget' }))).body; + const plA = (await jfetch('/api/playlists', jpost(jwt, { name: 'AutoTarget' }))).body; + + const draftTok = (await jfetch('/api/tokens', jpost(jwt, { name: 'DraftAgency', scope: 'agency', target_playlist_ids: [plD.id] }))).body; + assert.equal(draftTok.auto_publish, false, 'DEFAULT is draft (auto_publish false) - the fail-safe'); + const autoTok = (await jfetch('/api/tokens', jpost(jwt, { name: 'AutoAgency', scope: 'agency', target_playlist_ids: [plA.id], auto_publish: true }))).body; + assert.equal(autoTok.auto_publish, true, 'admin explicitly opted into auto-publish'); + + async function upload(tok) { + const fd = new FormData(); + fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png'); + return (await fetch(BASE + '/api/agency/content', { method: 'POST', headers: { Authorization: 'Bearer ' + tok }, body: fd })).json(); + } + const cD = await upload(draftTok.token); + const cA = await upload(autoTok.token); + + // (a) DRAFT token + {auto_publish:true} IN THE BODY -> still draft (token flag wins, body ignored) + const addD = await jfetch(`/api/agency/playlists/${plD.id}/items`, jpost(draftTok.token, { content_id: cD.id, auto_publish: true })); + assert.equal(addD.status, 201); + assert.equal(addD.body.published, false, 'draft token does NOT publish even with auto_publish:true in the body'); + assert.equal((await jfetch(`/api/playlists/${plD.id}`, jwtAuth(jwt))).body.status, 'draft', 'playlist stays draft'); + + // (b) AUTO-PUBLISH token -> item goes live via the shared publishPlaylist path + const addA = await jfetch(`/api/agency/playlists/${plA.id}/items`, jpost(autoTok.token, { content_id: cA.id })); + assert.equal(addA.status, 201); + assert.equal(addA.body.published, true, 'auto-publish token publishes'); + assert.equal((await jfetch(`/api/playlists/${plA.id}`, jwtAuth(jwt))).body.status, 'published', 'playlist is published'); + + // (c) REGRESSION: the manual publish endpoint still works after the publishPlaylist extraction + const pub = await jfetch(`/api/playlists/${plD.id}/publish`, jpost(jwt, {})); + assert.equal(pub.status, 200, 'manual publish works post-extraction'); + assert.equal((await jfetch(`/api/playlists/${plD.id}`, jwtAuth(jwt))).body.status, 'published', 'manual publish sets status=published'); +});