mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
Placement-as-grant, replacing the inferred auto-place idea. api_token_target_zones is an ADDITIVE second table (does NOT touch the proven api_token_targets), structurally anchored: a composite FK to api_token_targets(token_id, playlist_id) makes a zone grant orphan- impossible and cascade away when the playlist grant is revoked - "narrow" is structural, not conventional. zone_id FK -> layout_zones cascades on zone/layout delete. Confinement (lib/agency-targets.resolveGrantedZone, called in the item-add): grants exist -> the item MUST land in a granted zone (a body zone_id picks among grants, never escapes them); none -> whole-playlist/full-screen as before. The item-add stamps the granted zone_id. Bite-tested (6, all proven incl. neutralize->red on the confinement): granted YES; non- granted/cross-playlist/ambiguous blocked; orphan-grant rejected by the FK; cascade on playlist-grant revoke, on playlist delete, on zone/layout delete; and foreign_keys=ON asserted (a cascade that no-ops because FKs are off is the trap). 153 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
133 lines
7.7 KiB
JavaScript
133 lines
7.7 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 { listDesignatedPlaylists, resolveGrantedZone } = 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 (canvas size + zone positions/sizes + which zones are this token's) so the
|
|
// agency can size/place content. DEVICE-FREE (lib/agency-layouts.js): never touches the fleet
|
|
// tables, so no device names/locations/topology can leak. Bite-tested in agency-layouts.test.js.
|
|
router.get('/layouts', (req, res) => {
|
|
res.json(listLayoutGeometry(db, req.apiToken.id, req.jwtWorkspaceId));
|
|
});
|
|
|
|
// #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' });
|
|
}
|
|
|
|
// #73: zone placement IS the grant. If this token has zone grants for the playlist, the
|
|
// item MUST land in a granted zone (a body zone_id picks among grants, never escapes them);
|
|
// if none, whole-playlist/full-screen. Same FK-anchored api_token_targets seam.
|
|
const z = resolveGrantedZone(db, req.apiToken.id, req.params.playlistId, req.body.zone_id);
|
|
if (!z.ok) {
|
|
return z.reason === 'ambiguous'
|
|
? res.status(400).json({ error: 'This playlist has multiple granted zones — specify zone_id.' })
|
|
: res.status(403).json({ error: 'That zone is not granted to this token for this playlist.' });
|
|
}
|
|
|
|
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, zone_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)')
|
|
.run(req.params.playlistId, content_id, z.zoneId, 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, zone_id: z.zoneId, duration_sec, start_date: sd, end_date: ed, published });
|
|
});
|
|
|
|
module.exports = router;
|