mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
feat(api): per-agency-token auto-publish (#73)
api_tokens.auto_publish (DEFAULT 0 = draft, the fail-safe). Admin sets it at token creation in the designate UI (checkbox, agency scope only). The agency endpoint reads it from the TOKEN ROW via req.apiToken (apiTokenAuth attaches it) - NEVER from req.body, so an agency can't opt itself out of approval. 0 -> markDraft; 1 -> the shared publishPlaylist path. Tests (integration): draft is the default; a draft token with auto_publish:true IN THE BODY still lands draft (body ignored); an auto-publish token goes live; manual publish still works (extraction regression). i18n across all 5 locales. 141 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79c453cd43
commit
1f207c4278
|
|
@ -363,6 +363,9 @@ export default {
|
||||||
'apitoken.agency_needs_playlists': 'Wähle mindestens eine Playlist für einen Agentur-Token.',
|
'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.agency_no_playlists': 'Erstelle zuerst eine Playlist – ein Agentur-Token muss auf eine zielen.',
|
||||||
'apitoken.targets_label': 'Zugewiesen:',
|
'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.create': 'Token erstellen',
|
||||||
'apitoken.none': 'Noch keine Tokens.',
|
'apitoken.none': 'Noch keine Tokens.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -399,6 +399,9 @@ export default {
|
||||||
'apitoken.agency_needs_playlists': 'Select at least one playlist for an agency token.',
|
'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.agency_no_playlists': 'Create a playlist first — an agency token must target one.',
|
||||||
'apitoken.targets_label': 'Designated:',
|
'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.create': 'Create token',
|
||||||
'apitoken.none': 'No tokens yet.',
|
'apitoken.none': 'No tokens yet.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,9 @@ export default {
|
||||||
'apitoken.agency_needs_playlists': 'Selecciona al menos una lista para un token de agencia.',
|
'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.agency_no_playlists': 'Crea una lista primero: un token de agencia debe apuntar a una.',
|
||||||
'apitoken.targets_label': 'Designadas:',
|
'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.create': 'Crear token',
|
||||||
'apitoken.none': 'Aún no hay tokens.',
|
'apitoken.none': 'Aún no hay tokens.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,9 @@ export default {
|
||||||
'apitoken.agency_needs_playlists': 'Sélectionnez au moins une liste pour un jeton d\'agence.',
|
'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.agency_no_playlists': 'Créez d\'abord une liste : un jeton d\'agence doit en cibler une.',
|
||||||
'apitoken.targets_label': 'Assignées :',
|
'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.create': 'Créer un jeton',
|
||||||
'apitoken.none': 'Aucun jeton pour le moment.',
|
'apitoken.none': 'Aucun jeton pour le moment.',
|
||||||
'apitoken.col_token': 'Jeton',
|
'apitoken.col_token': 'Jeton',
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,9 @@ export default {
|
||||||
'apitoken.agency_needs_playlists': 'Selecione pelo menos uma lista para um token de agência.',
|
'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.agency_no_playlists': 'Crie uma lista primeiro: um token de agência deve apontar para uma.',
|
||||||
'apitoken.targets_label': 'Designadas:',
|
'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.create': 'Criar token',
|
||||||
'apitoken.none': 'Ainda não há tokens.',
|
'apitoken.none': 'Ainda não há tokens.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,10 @@ export async function render(container) {
|
||||||
<label style="display:block;font-weight:500;margin-bottom:4px">${t('apitoken.agency_playlists_label')}</label>
|
<label style="display:block;font-weight:500;margin-bottom:4px">${t('apitoken.agency_playlists_label')}</label>
|
||||||
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.agency_playlists_hint')}</p>
|
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.agency_playlists_hint')}</p>
|
||||||
<div id="agencyPlaylistList" style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto"></div>
|
<div id="agencyPlaylistList" style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto"></div>
|
||||||
|
<label style="display:flex;gap:8px;align-items:center;margin-top:12px;font-weight:500">
|
||||||
|
<input type="checkbox" id="tokAutoPublish"> ${t('apitoken.auto_publish_label')}
|
||||||
|
</label>
|
||||||
|
<p style="color:var(--text-muted);font-size:12px;margin:4px 0 0">${t('apitoken.auto_publish_hint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="tokenSecretBox" style="display:none"></div>
|
<div id="tokenSecretBox" style="display:none"></div>
|
||||||
<div id="tokenList"><p style="color:var(--text-muted);font-size:13px">${t('settings.loading_users')}</p></div>
|
<div id="tokenList"><p style="color:var(--text-muted);font-size:13px">${t('settings.loading_users')}</p></div>
|
||||||
|
|
@ -366,7 +370,7 @@ export async function render(container) {
|
||||||
<td style="padding:10px 12px">${esc(tok.name || '')}</td>
|
<td style="padding:10px 12px">${esc(tok.name || '')}</td>
|
||||||
<td style="padding:10px 12px">${esc(scopeLabel(tok.scope))}${
|
<td style="padding:10px 12px">${esc(scopeLabel(tok.scope))}${
|
||||||
tok.scope === 'agency' && Array.isArray(tok.targets)
|
tok.scope === 'agency' && Array.isArray(tok.targets)
|
||||||
? `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}</div>`
|
? `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}</div>`
|
||||||
: ''}</td>
|
: ''}</td>
|
||||||
<td style="padding:10px 12px">${esc(fmtTokenDate(tok.created_at))}</td>
|
<td style="padding:10px 12px">${esc(fmtTokenDate(tok.created_at))}</td>
|
||||||
<td style="padding:10px 12px">${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')}</td>
|
<td style="padding:10px 12px">${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')}</td>
|
||||||
|
|
@ -423,6 +427,7 @@ export async function render(container) {
|
||||||
const ids = [...document.querySelectorAll('#agencyPlaylistList .agency-pl:checked')].map(c => c.value);
|
const ids = [...document.querySelectorAll('#agencyPlaylistList .agency-pl:checked')].map(c => c.value);
|
||||||
if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error');
|
if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error');
|
||||||
payload.target_playlist_ids = ids;
|
payload.target_playlist_ids = ids;
|
||||||
|
payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked;
|
||||||
}
|
}
|
||||||
const btn = document.getElementById('createTokenBtn');
|
const btn = document.getElementById('createTokenBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,8 @@ const migrations = [
|
||||||
"CREATE INDEX IF NOT EXISTS idx_totp_recovery_user ON totp_recovery_codes(user_id)",
|
"CREATE INDEX IF NOT EXISTS idx_totp_recovery_user ON totp_recovery_codes(user_id)",
|
||||||
// #73: agency-token target allowlist (capability-restricted tokens).
|
// #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))",
|
"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"
|
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
||||||
// error means the column is already present (expected on a migrated DB) - benign.
|
// error means the column is already present (expected on a migrated DB) - benign.
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,7 @@ CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
workspace_id TEXT NOT NULL REFERENCES workspaces(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'
|
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')),
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
last_used_at INTEGER,
|
last_used_at INTEGER,
|
||||||
revoked_at INTEGER
|
revoked_at INTEGER
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,9 @@ function apiTokenAuth(req, res, next) {
|
||||||
req.jwtWorkspaceId = row.workspace_id; // resolveTenancy scopes to the bound workspace
|
req.jwtWorkspaceId = row.workspace_id; // resolveTenancy scopes to the bound workspace
|
||||||
req.viaToken = true;
|
req.viaToken = true;
|
||||||
req.tokenScope = row.scope;
|
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);
|
touchLastUsed(row.id);
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const upload = require('../middleware/upload');
|
||||||
const { checkStorageLimit } = require('../middleware/subscription');
|
const { checkStorageLimit } = require('../middleware/subscription');
|
||||||
const { ingestUploadedFile } = require('../lib/content-ingest');
|
const { ingestUploadedFile } = require('../lib/content-ingest');
|
||||||
const { listDesignatedPlaylists } = require('../lib/agency-targets');
|
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 TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
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;
|
.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)')
|
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);
|
.run(uuidv4(), itemId, dys.join(','), st, en, sd, ed);
|
||||||
// items changed since last publish -> draft; admin re-publish approves it.
|
// #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);
|
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;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const SCOPES = ['read', 'write', 'full', 'agency'];
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
if (!req.workspaceId) return res.status(403).json({ error: 'No active workspace' });
|
if (!req.workspaceId) return res.status(403).json({ error: 'No active workspace' });
|
||||||
const rows = db.prepare(`
|
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
|
FROM api_tokens WHERE user_id = ? AND workspace_id = ? ORDER BY created_at DESC
|
||||||
`).all(req.user.id, req.workspaceId);
|
`).all(req.user.id, req.workspaceId);
|
||||||
// #73: attach designated playlists for agency tokens so the admin sees the binding persist.
|
// #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.
|
// #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.
|
// Validate up front so a bad target never leaves an orphan token behind.
|
||||||
let targetIds = [];
|
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') {
|
if (scope === 'agency') {
|
||||||
targetIds = Array.isArray(req.body.target_playlist_ids) ? req.body.target_playlist_ids : [];
|
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' });
|
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();
|
const id = crypto.randomUUID();
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, created_at)
|
INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, auto_publish, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'))
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s','now'))
|
||||||
`).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope);
|
`).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope, autoPublish);
|
||||||
if (scope === 'agency') {
|
if (scope === 'agency') {
|
||||||
const ins = db.prepare('INSERT INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)');
|
const ins = db.prepare('INSERT INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)');
|
||||||
for (const pid of targetIds) ins.run(id, pid);
|
for (const pid of targetIds) ins.run(id, pid);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
// `token` is returned only here, never again.
|
// `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).
|
// Revoke one of the caller's own tokens (soft delete - takes effect on the next request).
|
||||||
|
|
|
||||||
|
|
@ -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' } });
|
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)');
|
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');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue