feat(agency): per-token upload folder — auto-created, subtree-confined (#158) (#171)

Agency-portal uploads previously all landed at the workspace library root, unsorted.
Instead of the issue's whole-workspace folder dropdown (which would leak every folder
name to an external party), bind ONE folder per agency token — admin-controlled and
agency-invisible — and scope the portal picker strictly to that folder's own subtree
(Hybrid-C). Fully backwards-compatible: no bound folder -> root, exactly as before.

Model / multi-workspace: an agency token is bound to ONE workspace at issuance, so the
token key IS that workspace's private link and the bound folder lives in that workspace.
An admin with N workspaces mints one token per workspace (each with its own auto-folder).
No workspace-switcher in the portal — the token is the tenant boundary.

Backend:
- api_tokens.upload_folder_id (additive; ON DELETE SET NULL -> deleting the folder falls
  back to root).
- lib/agency-targets.folderSubtree(): recursive-CTE helper = the SINGLE confinement source
  shared by GET /api/agency/folders AND the POST /api/agency/content target check, so the
  set the agency can SEE and the set it may WRITE to can never drift. Workspace-guarded at
  the anchor row; descendants inherit the workspace (folders.js forbids cross-ws parents).
- routes/agency.js: GET /folders (bound subtree only); POST /content defaults to the bound
  folder and 403s any folder_id outside the subtree.
- routes/tokens.js: create auto-creates "Agency — <name>" (or binds a picked folder,
  validated same-workspace, respecting the 100-folder cap) inside the token tx; new
  PUT /:id/upload-folder to rebind; listing surfaces the bound folder name.
- middleware/apiToken.js + lib/content-ingest.js: upload_folder_id onto req.apiToken; ingest
  writes folder_id.

Frontend:
- Agency portal: folder <select> shown only when a real subfolder choice exists (identifies
  the "Main folder" root client-side without learning the token's folder id).
- Settings: folder pick at token creation, bound-folder display, rebind modal.
- i18n: 7 new apitoken.* keys across all 5 locales.

Tests (429/429):
- test/agency-folder.test.js: 5 folderSubtree confinement bites (subtree in, siblings out,
  workspace guard, null -> root).
- test/agency.test.js (+1 e2e): auto-create, default-to-bound, in-subtree pick lands there,
  sibling -> 403, admin-pick, unknown-pick -> 400, rebind-to-root.

Closes #158.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-12 21:23:25 -05:00 committed by GitHub
parent ef91f644a7
commit 2f3dd80881
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 331 additions and 24 deletions

View file

@ -53,6 +53,10 @@
<h2>1. Upload content</h2>
<p class="sub">An image or video — added to the workspace library, full quality.</p>
<input id="fileInput" type="file" accept="image/*,video/*">
<div id="folderRow" style="display:none;margin-top:12px">
<label for="folderSelect">Folder</label>
<select id="folderSelect"></select>
</div>
<div style="margin-top:12px"><button id="uploadBtn" disabled>Upload</button></div>
<div id="uploadInfo" class="pill" style="margin-top:8px"></div>
</div>

View file

@ -59,6 +59,25 @@
// #73: the placement card reacts to the playlist selector - "where does THIS playlist go?"
sel.onchange = () => loadLayoutForPlaylist(sel.value);
loadLayoutForPlaylist(sel.value); // initial selection
loadFolders();
}
// #158 (Hybrid-C): the folders this token may drop into = its bound folder + descendants.
// Only offer the picker when there's a real choice (a subfolder exists); with just the bound
// folder, uploads default to it server-side and the picker stays hidden. The bound "root" is
// the one node whose parent isn't in the returned set, so we can label it "Main folder"
// (default, value="") and list the descendants under it — without the portal ever learning
// the token's folder id.
async function loadFolders() {
const row = $('folderRow'), sel = $('folderSelect');
let folders;
try { folders = await (await agencyFetch('/folders')).json(); } catch (e) { return; }
const ids = new Set(folders.map(f => f.id));
const descendants = folders.filter(f => f.parent_id && ids.has(f.parent_id)); // exclude the root
if (!descendants.length) { row.style.display = 'none'; return; }
sel.innerHTML = '<option value="">Main folder</option>'
+ descendants.map(f => `<option value="${escapeHtml(f.id)}">${escapeHtml(f.name)}</option>`).join('');
row.style.display = 'block';
}
// Visual placement guide for the SELECTED playlist: draw its layout to scale, highlight the
@ -115,6 +134,8 @@
try {
const fd = new FormData();
fd.append('file', file);
const folderId = $('folderSelect') && $('folderSelect').value;
if (folderId) fd.append('folder_id', folderId); // #158: a subfolder of the bound folder; empty = the bound default
const res = await agencyFetch('/content', { method: 'POST', body: fd });
if (!res.ok) { portalMsg('Upload failed. Try again.', 'err'); return; }
const content = await res.json();

View file

@ -180,6 +180,7 @@ export const api = {
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
setTokenUploadFolder: (id, upload_folder_id) => request('/tokens/' + id + '/upload-folder', { method: 'PUT', body: JSON.stringify({ upload_folder_id }) }), // #158: rebind agency token upload folder (null = root)
// Current user
getMe: () => request('/auth/me'),

View file

@ -382,6 +382,13 @@ export default {
'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.agency_folder_label': 'Upload-Ordner',
'apitoken.agency_folder_hint': 'Wohin die Uploads dieser Agentur gelangen. Automatisch belassen, um einen nach dem Token benannten Ordner zu erstellen; die Agentur kann dessen Unterordner ansteuern, aber nichts anderes.',
'apitoken.agency_folder_auto': 'Automatisch erstellen (Agency — <Name>)',
'apitoken.folder_label': 'Ordner:',
'apitoken.folder_root': 'Bibliotheks-Stammverzeichnis',
'apitoken.edit_folder': 'Ordner',
'apitoken.folder_updated': 'Upload-Ordner aktualisiert',
'apitoken.create': 'Token erstellen',
'apitoken.none': 'Noch keine Tokens.',
'apitoken.col_token': 'Token',

View file

@ -524,6 +524,13 @@ export default {
'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.agency_folder_label': 'Upload folder',
'apitoken.agency_folder_hint': "Where this agency's uploads land. Leave on auto to create a folder named after the token; the agency can target subfolders of it but nothing else.",
'apitoken.agency_folder_auto': 'Create one automatically (Agency — <name>)',
'apitoken.folder_label': 'Folder:',
'apitoken.folder_root': 'Library root',
'apitoken.edit_folder': 'Folder',
'apitoken.folder_updated': 'Upload folder updated',
'apitoken.create': 'Create token',
'apitoken.none': 'No tokens yet.',
'apitoken.col_token': 'Token',

View file

@ -381,6 +381,13 @@ export default {
'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.agency_folder_label': 'Carpeta de subida',
'apitoken.agency_folder_hint': 'Dónde se guardan las subidas de esta agencia. Deja en automático para crear una carpeta con el nombre del token; la agencia puede usar subcarpetas de ella, pero nada más.',
'apitoken.agency_folder_auto': 'Crear una automáticamente (Agency — <nombre>)',
'apitoken.folder_label': 'Carpeta:',
'apitoken.folder_root': 'Raíz de la biblioteca',
'apitoken.edit_folder': 'Carpeta',
'apitoken.folder_updated': 'Carpeta de subida actualizada',
'apitoken.create': 'Crear token',
'apitoken.none': 'Aún no hay tokens.',
'apitoken.col_token': 'Token',

View file

@ -382,6 +382,13 @@ export default {
'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.agency_folder_label': 'Dossier de téléversement',
'apitoken.agency_folder_hint': "Où atterrissent les téléversements de cette agence. Laissez sur automatique pour créer un dossier nommé d'après le jeton ; l'agence peut cibler ses sous-dossiers, mais rien d'autre.",
'apitoken.agency_folder_auto': 'En créer un automatiquement (Agency — <nom>)',
'apitoken.folder_label': 'Dossier :',
'apitoken.folder_root': 'Racine de la bibliothèque',
'apitoken.edit_folder': 'Dossier',
'apitoken.folder_updated': 'Dossier de téléversement mis à jour',
'apitoken.create': 'Créer un jeton',
'apitoken.none': 'Aucun jeton pour le moment.',
'apitoken.col_token': 'Jeton',

View file

@ -382,6 +382,13 @@ export default {
'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.agency_folder_label': 'Pasta de upload',
'apitoken.agency_folder_hint': 'Onde os uploads desta agência ficam. Deixe em automático para criar uma pasta com o nome do token; a agência pode usar subpastas dela, mas nada além disso.',
'apitoken.agency_folder_auto': 'Criar uma automaticamente (Agency — <nome>)',
'apitoken.folder_label': 'Pasta:',
'apitoken.folder_root': 'Raiz da biblioteca',
'apitoken.edit_folder': 'Pasta',
'apitoken.folder_updated': 'Pasta de upload atualizada',
'apitoken.create': 'Criar token',
'apitoken.none': 'Ainda não há tokens.',
'apitoken.col_token': 'Token',

View file

@ -88,6 +88,9 @@ export async function render(container) {
<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>
<label style="display:block;font-weight:500;margin-top:12px;margin-bottom:4px">${t('apitoken.agency_folder_label')}</label>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.agency_folder_hint')}</p>
<select id="tokUploadFolder" class="input" style="width:100%"><option value="">${t('apitoken.agency_folder_auto')}</option></select>
</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>
@ -372,14 +375,14 @@ export async function render(container) {
<td style="padding:10px 12px">${esc(tok.name || '')}</td>
<td style="padding:10px 12px">${esc(scopeLabel(tok.scope))}${
tok.scope === 'agency' && Array.isArray(tok.targets)
? `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}</div>`
? `<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><div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.folder_label')} ${tok.upload_folder ? esc(tok.upload_folder) : esc(t('apitoken.folder_root'))}</div>`
: ''}</td>
<td style="padding:10px 12px">${esc(fmtTokenDate(tok.created_at))}</td>
<td style="padding:10px 12px">${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')}</td>
<td style="padding:10px 12px;white-space:nowrap;text-align:right">
${tok.revoked_at
? `<span style="color:var(--text-muted);font-size:12px">${t('apitoken.revoked')}</span>`
: `${tok.scope === 'agency' ? `<button class="btn btn-secondary btn-sm edit-targets-btn" data-id="${esc(String(tok.id))}" data-targets="${esc((tok.targets || []).map(p => p.id).join(','))}">${t('apitoken.edit_targets')}</button> ` : ''}<button class="btn btn-secondary btn-sm revoke-token-btn" data-id="${esc(String(tok.id))}">${t('apitoken.revoke')}</button>`}
: `${tok.scope === 'agency' ? `<button class="btn btn-secondary btn-sm edit-targets-btn" data-id="${esc(String(tok.id))}" data-targets="${esc((tok.targets || []).map(p => p.id).join(','))}">${t('apitoken.edit_targets')}</button> <button class="btn btn-secondary btn-sm edit-folder-btn" data-id="${esc(String(tok.id))}" data-folder="${esc(String(tok.upload_folder_id || ''))}">${t('apitoken.edit_folder')}</button> ` : ''}<button class="btn btn-secondary btn-sm revoke-token-btn" data-id="${esc(String(tok.id))}">${t('apitoken.revoke')}</button>`}
</td>
</tr>
`).join('')}
@ -433,6 +436,35 @@ export async function render(container) {
};
document.getElementById('cancelTargetsBtn').onclick = () => { panel.style.display = 'none'; };
}));
// #158: rebind an agency token's upload folder -> PUT /:id/upload-folder (null = root).
el.querySelectorAll('.edit-folder-btn').forEach(btn => btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const current = btn.dataset.folder || '';
const panel = document.getElementById('tokenEditPanel');
const folders = await api.getFolders().catch(() => []);
panel.style.display = 'block';
panel.innerHTML = `
<div style="border:1px solid var(--accent);border-radius:var(--radius);padding:16px;margin-top:12px">
<h4 style="font-size:14px;margin-bottom:8px">${t('apitoken.edit_folder')}</h4>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.agency_folder_hint')}</p>
<select id="rebindFolder" class="input" style="width:100%;margin-bottom:12px">
<option value="">${t('apitoken.folder_root')}</option>
${folders.map(f => `<option value="${esc(String(f.id))}"${String(f.id) === current ? ' selected' : ''}>${esc(f.name)}</option>`).join('')}
</select>
<button class="btn btn-primary btn-sm" id="saveFolderBtn">${t('common.save')}</button>
<button class="btn btn-secondary btn-sm" id="cancelFolderBtn">${t('common.cancel')}</button>
</div>`;
document.getElementById('saveFolderBtn').onclick = async () => {
try {
await api.setTokenUploadFolder(id, document.getElementById('rebindFolder').value || null);
showToast(t('apitoken.folder_updated'), 'success');
panel.style.display = 'none';
loadTokens();
} catch (err) { showToast(err.message, 'error'); }
};
document.getElementById('cancelFolderBtn').onclick = () => { panel.style.display = 'none'; };
}));
}
loadTokens();
@ -453,6 +485,10 @@ export async function render(container) {
? `<label style="display:flex;gap:8px;align-items:center;font-size:13px;opacity:.5"><input type="checkbox" disabled> ${esc(p.name)} <span style="font-size:11px;color:var(--text-muted)">— ${esc(t('apitoken.zoned_playlist_reason'))}</span></label>`
: `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>`).join('')
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`;
// #158: offer existing folders to bind, or leave on the auto-create default.
const folders = await api.getFolders().catch(() => []);
const fsel = document.getElementById('tokUploadFolder');
if (fsel && folders.length) fsel.insertAdjacentHTML('beforeend', folders.map(f => `<option value="${esc(String(f.id))}">${esc(f.name)}</option>`).join(''));
}
});
@ -465,6 +501,9 @@ export async function render(container) {
if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error');
payload.target_playlist_ids = ids;
payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked;
// #158: blank = auto-create "Agency — <name>"; a value binds that existing folder.
const fv = document.getElementById('tokUploadFolder')?.value;
if (fv) payload.upload_folder_id = fv;
}
const btn = document.getElementById('createTokenBtn');
btn.disabled = true;

View file

@ -221,6 +221,9 @@ const migrations = [
"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",
// #158: agency uploads land in this bound folder (and its subtree). NULL = root (pre-#158
// tokens, or admin unbound). ON DELETE SET NULL so deleting the folder falls back to root.
"ALTER TABLE api_tokens ADD COLUMN upload_folder_id TEXT REFERENCES content_folders(id) ON DELETE SET NULL",
// #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)",

View file

@ -26,4 +26,26 @@ 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 };
// #158 (Hybrid-C): the folder subtree an agency token may upload into = its bound
// upload_folder_id PLUS every descendant. This one recursive query IS the confinement,
// used by BOTH GET /api/agency/folders (the portal dropdown) and POST /api/agency/content
// (the upload target check) — so the list the agency sees and the set it may write to can
// never drift apart. The anchor row is workspace-guarded; descendants inherit the workspace
// because folders.js forbids a cross-workspace parent, so the whole subtree stays in-workspace.
// Returns [] for a null/foreign/absent root (legacy or root-bound token -> uploads go to root,
// no dropdown). rootFolderId included in the result (an agency can upload to the folder itself).
function folderSubtree(db, rootFolderId, workspaceId) {
if (!rootFolderId) return [];
return db.prepare(`
WITH RECURSIVE sub(id) AS (
SELECT id FROM content_folders WHERE id = ? AND workspace_id = ?
UNION
SELECT cf.id FROM content_folders cf JOIN sub ON cf.parent_id = sub.id
)
SELECT cf.id, cf.name, cf.parent_id
FROM content_folders cf JOIN sub ON cf.id = sub.id
ORDER BY cf.name COLLATE NOCASE
`).all(rootFolderId, workspaceId);
}
module.exports = { listDesignatedPlaylists, isZonedPlaylist, folderSubtree };

View file

@ -23,7 +23,7 @@ function safeFilename(name) {
// 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 }) {
async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }) {
const id = uuidv4();
const filepath = file.filename;
let width = null, height = null, durationSec = null, thumbnailPath = null;
@ -67,9 +67,9 @@ async function ingestUploadedFile({ file, userId, workspaceId }) {
}
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);
INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height, folder_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, userId, workspaceId, safeFilename(file.originalname), filepath, file.mimetype, file.size, durationSec, thumbnailPath, width, height, folderId || null);
return db.prepare('SELECT * FROM content WHERE id = ?').get(id);
}

View file

@ -71,7 +71,9 @@ function apiTokenAuth(req, res, next) {
req.tokenScope = row.scope;
// #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 };
// #158: upload_folder_id (admin-set) confines agency uploads to a folder subtree; read from
// the token row, never the request body. `|| null` keeps it fail-safe for pre-#158 rows.
req.apiToken = { id: row.id, prefix: row.prefix, name: row.name, workspace_id: row.workspace_id, auto_publish: row.auto_publish || 0, upload_folder_id: row.upload_folder_id || null };
touchLastUsed(row.id);
next();
}

View file

@ -13,7 +13,7 @@ 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 { listDesignatedPlaylists, isZonedPlaylist, folderSubtree } = 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
@ -52,12 +52,31 @@ router.param('playlistId', (req, res, next, playlistId) => {
next();
});
// #158: the folders THIS token may drop uploads into = its bound upload_folder_id + descendants
// (Hybrid-C). No bound folder -> [] (portal shows no picker, uploads go to root). The subtree
// query in lib/agency-targets.js is the confinement, shared with the upload check below so the
// list and the writable set can't drift. No :playlistId, so router.param doesn't apply.
router.get('/folders', (req, res) => {
res.json(folderSubtree(db, req.apiToken.upload_folder_id, req.workspaceId));
});
// Upload to the bound workspace via the SHARED ingest -> first-class content (identical
// thumbnail/dimensions/duration to a dashboard upload).
// thumbnail/dimensions/duration to a dashboard upload). #158: the file lands in the token's
// bound folder by default; the agency may target a SUBFOLDER of it via folder_id, but nothing
// outside that subtree (folder_id read from the multipart body, then confined to folderSubtree).
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 });
// Default target = the bound folder (or root if none bound). A supplied folder_id must be
// WITHIN the bound subtree — never a sibling, a parent, or another workspace's folder.
let folderId = req.apiToken.upload_folder_id || null;
const requested = req.body && req.body.folder_id;
if (requested) {
const allowed = new Set(folderSubtree(db, req.apiToken.upload_folder_id, req.workspaceId).map(f => f.id));
if (!allowed.has(requested)) return res.status(403).json({ error: 'folder is not in this agency token\'s upload area' });
folderId = requested;
}
const content = await ingestUploadedFile({ file: req.file, userId: req.user.id, workspaceId: req.workspaceId, folderId });
res.status(201).json(content);
} catch (e) {
console.error('agency upload error:', e.message);

View file

@ -15,17 +15,46 @@ const { isPlatformRole } = require('../middleware/auth'); // #146: billing
// #146: 'billing:read' is likewise off-ladder — reaches only /api/billing via requireBillingRead.
const SCOPES = ['read', 'write', 'full', 'agency', 'billing:read'];
// #158: per-workspace folder cap (mirrors folders.js) — auto-creating an agency folder must
// respect the same ceiling so a token-mint can't blow past it.
const MAX_FOLDERS_PER_WORKSPACE = 100;
// #158: resolve the folder an agency token uploads into. Either the admin PICKED an existing
// folder (must live in THIS workspace) or we AUTO-CREATE one named after the token. Returns the
// folder id, or throws { status, error } for a bad pick / folder-cap hit. Runs inside the token
// transaction so an auto-created folder and the token commit atomically.
function resolveAgencyUploadFolder(req, tokenName, pickedId) {
if (pickedId) {
const f = db.prepare('SELECT id, workspace_id FROM content_folders WHERE id = ?').get(pickedId);
if (!f || f.workspace_id !== req.workspaceId) throw { status: 400, error: 'upload_folder_id is not a folder in this workspace' };
return pickedId;
}
if (!isPlatformRole(req.user.role)) {
const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE workspace_id = ?').get(req.workspaceId);
if (count >= MAX_FOLDERS_PER_WORKSPACE) throw { status: 429, error: `Folder limit reached (${MAX_FOLDERS_PER_WORKSPACE}). Pick an existing folder for this agency token or delete unused folders.` };
}
const id = crypto.randomUUID();
db.prepare('INSERT INTO content_folders (id, user_id, workspace_id, parent_id, name) VALUES (?, ?, ?, NULL, ?)')
.run(id, req.user.id, req.workspaceId, `Agency — ${tokenName}`.slice(0, 100));
return id;
}
// 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, auto_publish, workspace_id, created_at, last_used_at, revoked_at
SELECT id, prefix, name, scope, auto_publish, upload_folder_id, 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');
// #158: attach the bound upload folder's name (may be null = root, or dangling after delete).
const folderStmt = db.prepare('SELECT name FROM content_folders WHERE id = ?');
for (const r of rows) {
if (r.scope === 'agency') r.targets = targetsStmt.all(r.id);
if (r.scope === 'agency') {
r.targets = targetsStmt.all(r.id);
r.upload_folder = r.upload_folder_id ? (folderStmt.get(r.upload_folder_id)?.name || null) : null;
}
}
res.json(rows);
});
@ -70,18 +99,27 @@ router.post('/', (req, res) => {
}
const secret = generateToken();
const id = crypto.randomUUID();
let uploadFolderId = null;
try {
db.transaction(() => {
// #158: agency uploads land in a bound folder — admin-picked (upload_folder_id) or
// auto-created "Agency — <name>". Resolved inside the tx so folder + token commit together.
if (scope === 'agency') uploadFolderId = resolveAgencyUploadFolder(req, name, req.body.upload_folder_id || null);
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);
INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, auto_publish, upload_folder_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s','now'))
`).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope, autoPublish, uploadFolderId);
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);
}
})();
} catch (e) {
if (e && e.status) return res.status(e.status).json({ error: e.error });
throw e;
}
// `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, auto_publish: !!autoPublish });
res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId, target_playlist_ids: targetIds, auto_publish: !!autoPublish, upload_folder_id: uploadFolderId });
});
// Revoke one of the caller's own tokens (soft delete - takes effect on the next request).
@ -116,4 +154,20 @@ router.put('/:id/targets', (req, res) => {
res.json({ id: tok.id, target_playlist_ids: ids });
});
// #158: rebind an agency token's upload folder (admin can move where an agency's uploads land,
// or unbind to root). JWT-only, like the rest of this router. upload_folder_id: a folder in the
// token's workspace, or null = root. Does NOT auto-create — clearing is explicit here.
router.put('/:id/upload-folder', (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 an upload folder' });
const folderId = req.body.upload_folder_id || null;
if (folderId) {
const f = db.prepare('SELECT id, workspace_id FROM content_folders WHERE id = ?').get(folderId);
if (!f || f.workspace_id !== tok.workspace_id) return res.status(400).json({ error: 'upload_folder_id is not a folder in this token\'s workspace' });
}
db.prepare('UPDATE api_tokens SET upload_folder_id = ? WHERE id = ?').run(folderId, tok.id);
res.json({ id: tok.id, upload_folder_id: folderId });
});
module.exports = router;

View file

@ -0,0 +1,53 @@
'use strict';
// #158 (Hybrid-C): an agency token uploads into a bound folder + its descendants and NOTHING
// else. folderSubtree() in lib/agency-targets.js IS that confinement — it backs both the portal
// dropdown (GET /api/agency/folders) and the upload target check (POST /api/agency/content), so
// if it over-returns, the agency can both SEE and WRITE outside its area. Every way it could
// leak is asserted here; the workspace guard on the anchor row and the parent-join recursion are
// the two lines that make these bites go red.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
const { folderSubtree } = require('../lib/agency-targets');
const db = new Database(':memory:');
db.exec(`
CREATE TABLE content_folders (id TEXT PRIMARY KEY, parent_id TEXT, name TEXT, workspace_id TEXT);
INSERT INTO content_folders (id, parent_id, name, workspace_id) VALUES
('root', NULL, 'Agency — Acme', 'wsA'), -- the bound folder
('sub1', 'root', 'Campaign Q1', 'wsA'), -- child -> in
('sub2', 'root', 'Campaign Q2', 'wsA'), -- child -> in
('deep', 'sub1', 'Drafts', 'wsA'), -- grandchild -> in
('sibling',NULL, 'Internal', 'wsA'), -- other root in same ws -> OUT (sibling leak)
('sibkid', 'sibling','Confidential', 'wsA'), -- under the sibling -> OUT
('foreign',NULL, 'Other tenant', 'wsB'); -- another workspace -> OUT
`);
const ids = (root, ws) => folderSubtree(db, root, ws).map(r => r.id).sort();
test('#158 folderSubtree: bound folder + all descendants, nothing else', () => {
assert.deepEqual(ids('root', 'wsA'), ['deep', 'root', 'sub1', 'sub2'],
'root sees itself + children + grandchild, NOT the sibling tree');
});
test('#158 folderSubtree: a deeper bound folder is confined to its own subtree', () => {
assert.deepEqual(ids('sub1', 'wsA'), ['deep', 'sub1'], 'sub1 sees itself + its child only');
assert.deepEqual(ids('sub2', 'wsA'), ['sub2'], 'a leaf folder sees only itself');
});
test('#158 folderSubtree: workspace guard — a foreign-workspace anchor returns nothing', () => {
assert.deepEqual(ids('root', 'wsB'), [], 'root anchored to the wrong workspace -> empty (no cross-ws upload area)');
assert.deepEqual(ids('foreign', 'wsA'), [], 'a wsB folder claimed under wsA -> empty');
});
test('#158 folderSubtree: no bound folder -> root uploads, empty subtree', () => {
assert.deepEqual(folderSubtree(db, null, 'wsA'), [], 'null bound folder -> [] (uploads default to library root)');
});
test('#158 folderSubtree: the sibling subtree is never reachable from root', () => {
const got = ids('root', 'wsA');
assert.ok(!got.includes('sibling') && !got.includes('sibkid'),
'neither the sibling folder nor its child may appear in the bound subtree');
});

View file

@ -191,3 +191,57 @@ test('#73 full-screen guardrail holds at UPLOAD time too (auto-publish has no dr
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');
});
test('#158 agency upload folder: auto-create, pick, subtree confinement, rebind', async () => {
const email = 'af' + crypto.randomBytes(4).toString('hex') + '@x.local';
const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token;
const jwtAuth = { headers: { Authorization: 'Bearer ' + jwt } };
const jput = (o) => ({ method: 'PUT', headers: { Authorization: 'Bearer ' + jwt, 'Content-Type': 'application/json' }, body: JSON.stringify(o) });
const pl = (await jfetch('/api/playlists', jpost(jwt, { name: 'FolderTarget' }))).body;
// (1) AUTO-CREATE: no upload_folder_id -> a folder "Agency — <name>" is created and bound
const tokRes = await jfetch('/api/tokens', jpost(jwt, { name: 'Acme', scope: 'agency', target_playlist_ids: [pl.id] }));
assert.equal(tokRes.status, 201, 'agency token created');
const boundId = tokRes.body.upload_folder_id;
assert.ok(boundId, 'a folder was auto-created and its id returned');
const bound = (await jfetch('/api/folders', jwtAuth)).body.find(f => f.id === boundId);
assert.ok(bound && bound.name === 'Agency — Acme', 'auto-created folder is named after the token');
const atok = tokRes.body.token;
const up = async (folderId) => {
const fd = new FormData();
fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png');
if (folderId) fd.append('folder_id', folderId);
return fetch(BASE + '/api/agency/content', { method: 'POST', headers: { Authorization: 'Bearer ' + atok }, body: fd });
};
// default upload (no folder_id) -> lands in the bound folder
const c1 = await (await up()).json();
assert.equal(c1.folder_id, boundId, 'default upload lands in the bound folder');
// (2) subtree confinement: a subfolder is targetable; a sibling is not
const sub = (await jfetch('/api/folders', jpost(jwt, { name: 'Q1', parent_id: boundId }))).body;
const sibling = (await jfetch('/api/folders', jpost(jwt, { name: 'Internal' }))).body;
const listIds = (await jfetch('/api/agency/folders', { headers: { Authorization: 'Bearer ' + atok } })).body.map(f => f.id).sort();
assert.deepEqual(listIds, [boundId, sub.id].sort(), 'GET /agency/folders returns ONLY the bound subtree, never the sibling');
const c2 = await (await up(sub.id)).json();
assert.equal(c2.folder_id, sub.id, 'upload targeting an in-subtree folder lands there');
const blocked = await up(sibling.id);
assert.equal(blocked.status, 403, 'upload to a sibling folder outside the bound subtree -> 403');
// (3) PICK an existing folder at creation (no auto-create); unknown pick -> 400
const picked = (await jfetch('/api/folders', jpost(jwt, { name: 'Chosen' }))).body;
const tok2 = await jfetch('/api/tokens', jpost(jwt, { name: 'Picky', scope: 'agency', target_playlist_ids: [pl.id], upload_folder_id: picked.id }));
assert.equal(tok2.body.upload_folder_id, picked.id, 'admin-picked folder is bound as-is');
const badPick = await jfetch('/api/tokens', jpost(jwt, { name: 'BadPick', scope: 'agency', target_playlist_ids: [pl.id], upload_folder_id: 'nonexistent' }));
assert.equal(badPick.status, 400, 'binding an unknown/cross-workspace folder at issuance -> 400');
// (4) REBIND to root -> uploads land at root, subtree goes empty
const rebind = await jfetch(`/api/tokens/${tokRes.body.id}/upload-folder`, jput({ upload_folder_id: null }));
assert.equal(rebind.status, 200, 'rebind ok');
assert.equal(rebind.body.upload_folder_id, null, 'rebind cleared the binding (root)');
const c3 = await (await up()).json();
assert.equal(c3.folder_id, null, 'after unbinding, uploads land at library root');
assert.deepEqual((await jfetch('/api/agency/folders', { headers: { Authorization: 'Bearer ' + atok } })).body, [], 'no bound folder -> empty subtree (portal shows no picker)');
});