screentinker/server/routes/agency.js
ScreenTinker 40102b2b41 feat(api): agency portal endpoints + router.param target seam (#73)
The agency capability behind the proven off-ladder/agencyGate primitive:
- agencyGate is now SCOPE-only at the mount; the per-target check is router.param
  ('playlistId') in routes/agency.js - it fires WITH the param before the handler, so no
  :playlistId route can skip it (drift-proof). A mount-level target check was silently
  bypassed (Express populates req.params only at route match); the integration bite-suite
  caught it - this is the fix.
- routes/agency.js: POST /content (shared ingest) + POST /playlists/:id/items (date-bounded
  #74/#75 item; lands as draft so the admin's re-publish is the approval gate).
- tokens.js: issue scope='agency' tokens bound to a non-empty in-workspace playlist
  allowlist (atomic); PUT /:id/targets re-designates (JWT-only -> can't self-widen).
- server.js: AGENCY_ROUTERS mounted bearerAuth + resolveTenancy + agencyGate.

Full bite-suite (test/agency.test.js) GREEN and re-proven to bite on the SHIPPING path:
neutralizing the router.param check makes non-designated->403 go red. Four assertions at
three seams: target (router.param), off-ladder (tokenScopeGate), can't-widen (tokens
JWT-only), issuance cross-workspace (create validation). 139 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:48:42 -05:00

90 lines
5.1 KiB
JavaScript

'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 TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// #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' });
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);
// 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);
res.status(201).json({ id: itemId, playlist_id: req.params.playlistId, content_id, duration_sec, start_date: sd, end_date: ed });
});
module.exports = router;