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>
This commit is contained in:
ScreenTinker 2026-06-13 22:48:42 -05:00
parent a59b53cc25
commit 40102b2b41
6 changed files with 244 additions and 46 deletions

View file

@ -112,27 +112,18 @@ function requireScope(need) {
}; };
} }
// #73: THE single seam for capability-restricted ('agency') tokens. Mounted on the // #73: mount seam for capability-restricted ('agency') tokens. SCOPE/off-ladder check ONLY:
// AGENCY_ROUTER (config/api-surface.js) in place of tokenScopeGate. Two checks, no more: // only an agency token reaches the agency router (a read/write/full token or a JWT is
// (1) only an agency token passes (a JWT or read/write/full token is rejected); // rejected). The PER-TARGET check CANNOT live here - Express doesn't populate req.params at
// (2) if the request targets a playlist, that playlist must be in THIS token's // app.use-level middleware (params land at route match, inside the router), so a mount-level
// allowlist AND in the token's bound workspace - one query enforces both the // target check is silently bypassed (the integration bite-suite caught exactly this). The
// target restriction and cross-workspace isolation. // target check is router.param('playlistId') in routes/agency.js - it fires WITH the param
// Every agency capability route passes through here, so the whole primitive is proven // before the handler and can't be skipped by any :playlistId route. Two single-registration,
// at one place. Removing the api_token_targets condition makes the bite-test go red. // drift-proof seams: scope (here) + target (router.param).
function agencyGate(req, res, next) { function agencyGate(req, res, next) {
if (!req.viaToken || req.tokenScope !== 'agency') { if (!req.viaToken || req.tokenScope !== 'agency') {
return res.status(403).json({ error: 'agency token required' }); return res.status(403).json({ error: 'agency token required' });
} }
const playlistId = req.params.playlistId || (req.body && req.body.playlist_id);
if (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(); next();
} }

89
server/routes/agency.js Normal file
View file

@ -0,0 +1,89 @@
'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;

View file

@ -8,7 +8,9 @@ const { db } = require('../db/database');
const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken'); const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken');
const { accessContext } = require('../lib/tenancy'); const { accessContext } = require('../lib/tenancy');
const SCOPES = ['read', 'write', 'full']; // #73: 'agency' is OFF the read/write/full ladder (not in apiToken.js SCOPE_RANK), so a
// tokenScopeGate-mounted router rejects it; it reaches only the AGENCY_ROUTER via agencyGate.
const SCOPES = ['read', 'write', 'full', 'agency'];
// List the caller's tokens in the active workspace. Never returns the secret/hash. // List the caller's tokens in the active workspace. Never returns the secret/hash.
router.get('/', (req, res) => { router.get('/', (req, res) => {
@ -27,21 +29,38 @@ router.post('/', (req, res) => {
const scope = req.body.scope || 'read'; const scope = req.body.scope || 'read';
if (!name) return res.status(400).json({ error: 'name is required' }); if (!name) return res.status(400).json({ error: 'name is required' });
if (name.length > 100) return res.status(400).json({ error: 'name too long' }); if (name.length > 100) return res.status(400).json({ error: 'name too long' });
if (!SCOPES.includes(scope)) return res.status(400).json({ error: "scope must be 'read', 'write' or 'full'" }); if (!SCOPES.includes(scope)) return res.status(400).json({ error: "scope must be 'read', 'write', 'full' or 'agency'" });
// The token runs with platform powers stripped (role forced to 'user'), so it must // The token runs with platform powers stripped (role forced to 'user'), so it must
// bind to a workspace the owner reaches via membership/org - not platform act-as - // bind to a workspace the owner reaches via membership/org - not platform act-as -
// else apiTokenAuth+resolveTenancy would land it in no workspace at use time. // else apiTokenAuth+resolveTenancy would land it in no workspace at use time.
if (!accessContext(req.user.id, 'user', req.workspace)) { if (!accessContext(req.user.id, 'user', req.workspace)) {
return res.status(400).json({ error: 'You must be a member of this workspace to create a token here' }); return res.status(400).json({ error: 'You must be a member of this workspace to create a token here' });
} }
// #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 = [];
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' });
const inWs = db.prepare('SELECT id FROM playlists WHERE id = ? AND workspace_id = ?');
for (const pid of targetIds) {
if (!inWs.get(pid, req.workspaceId)) return res.status(400).json({ error: `playlist ${pid} is not in this workspace` });
}
}
const secret = generateToken(); const secret = generateToken();
const id = crypto.randomUUID(); const id = crypto.randomUUID();
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, 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);
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. // `token` is returned only here, never again.
res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId }); res.status(201).json({ id, token: secret, prefix: displayPrefix(secret), name, scope, workspace_id: req.workspaceId, target_playlist_ids: targetIds });
}); });
// 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).
@ -54,4 +73,24 @@ router.delete('/:id', (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
// #73: re-designate an agency token's playlist allowlist (atomic replace). JWT-only (this
// whole router is JWT-only), so an agency token can never widen its OWN targets.
router.put('/:id/targets', (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 targets' });
const ids = Array.isArray(req.body.target_playlist_ids) ? req.body.target_playlist_ids : [];
if (!ids.length) return res.status(400).json({ error: 'target_playlist_ids must be a non-empty array' });
const inWs = db.prepare('SELECT id FROM playlists WHERE id = ? AND workspace_id = ?');
for (const pid of ids) {
if (!inWs.get(pid, tok.workspace_id)) return res.status(400).json({ error: `playlist ${pid} is not in this token's workspace` });
}
const ins = db.prepare('INSERT OR IGNORE INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)');
db.transaction(() => {
db.prepare('DELETE FROM api_token_targets WHERE token_id = ?').run(tok.id);
for (const pid of ids) ins.run(tok.id, pid);
})();
res.json({ id: tok.id, target_playlist_ids: ids });
});
module.exports = router; module.exports = router;

View file

@ -446,7 +446,7 @@ app.get('/api/content/:id/thumbnail', (req, res) => {
const { requireAuth } = require('./middleware/auth'); const { requireAuth } = require('./middleware/auth');
const { resolveTenancy } = require('./lib/tenancy'); const { resolveTenancy } = require('./lib/tenancy');
// Public API token front door (Phase 1). Attached ONLY to the public routers below. // Public API token front door (Phase 1). Attached ONLY to the public routers below.
const { bearerAuth, tokenScopeGate } = require('./middleware/apiToken'); const { bearerAuth, tokenScopeGate, agencyGate } = require('./middleware/apiToken');
// activityLogger wraps res.json on every subsequent route to auto-log // activityLogger wraps res.json on every subsequent route to auto-log
// successful POST/PUT/DELETE mutations. Mount it BEFORE the workspace routes // successful POST/PUT/DELETE mutations. Mount it BEFORE the workspace routes
@ -464,7 +464,7 @@ app.use(activityLogger);
// their jwt.verify and is unreachable (secure by exclusion). Tokens act as a workspace // their jwt.verify and is unreachable (secure by exclusion). Tokens act as a workspace
// member with platform powers stripped, so in-handler ELEVATED/PLATFORM checks (e.g. // member with platform powers stripped, so in-handler ELEVATED/PLATFORM checks (e.g.
// GET /api/devices/unassigned) still deny. // GET /api/devices/unassigned) still deny.
const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS } = require('./config/api-surface'); const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS, AGENCY_ROUTERS } = require('./config/api-surface');
// Public device-render endpoints + the memory-heavy preview limiter must be registered // Public device-render endpoints + the memory-heavy preview limiter must be registered
// BEFORE their parent router mount so the _skipAuth bypass / the limiter fire first. // BEFORE their parent router mount so the _skipAuth bypass / the limiter fire first.
@ -485,6 +485,12 @@ for (const r of JWT_ONLY_ROUTERS) {
if (r.tenancy) app.use(r.path, requireAuth, resolveTenancy, require(r.mod)); if (r.tenancy) app.use(r.path, requireAuth, resolveTenancy, require(r.mod));
else app.use(r.path, requireAuth, require(r.mod)); else app.use(r.path, requireAuth, require(r.mod));
} }
for (const r of AGENCY_ROUTERS) {
// #73: capability-restricted token surface. bearerAuth + resolveTenancy + agencyGate
// (NOT tokenScopeGate). 'agency' is off the read/write/full ladder, so these tokens
// reach ONLY here; agencyGate enforces the playlist allowlist + bound workspace.
app.use(r.path, bearerAuth, resolveTenancy, agencyGate, require(r.mod));
}
// Frontend version hash (changes when files are modified, triggers soft reload) // Frontend version hash (changes when files are modified, triggers soft reload)
const crypto = require('crypto'); const crypto = require('crypto');

View file

@ -1,39 +1,32 @@
'use strict'; 'use strict';
// #73 THE SEAM: agencyGate is the single place capability+target restriction is enforced. // #73 mount seam: agencyGate does SCOPE/off-ladder confinement ONLY (only an agency token
// Prove it confines before any endpoint is built behind it. Removing the api_token_targets // reaches the agency router). The per-target check moved to router.param('playlistId') in
// condition in agencyGate makes "non-designated -> 403" go red (the bite). // routes/agency.js, because Express doesn't populate req.params at mount-level middleware -
// so the target restriction is proven on the REAL runtime path by test/agency.test.js
// (the integration bite-suite), not here.
const { test } = require('node:test'); const { test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const mem = new Database(':memory:'); // agencyGate needs no db now, but requiring the module loads db/database - inject a stub.
mem.exec(`
CREATE TABLE api_token_targets (token_id TEXT, playlist_id TEXT, PRIMARY KEY(token_id, playlist_id));
CREATE TABLE playlists (id TEXT PRIMARY KEY, workspace_id TEXT);
INSERT INTO playlists (id, workspace_id) VALUES ('plA','wsA'), ('plB','wsA'), ('plC','wsB');
INSERT INTO api_token_targets (token_id, playlist_id) VALUES ('tok1','plA'), ('tok1','plC');
`); // tok1 is allowlisted for plA (wsA, its bound ws) and plC (wsB, a DIFFERENT ws)
require.cache[require.resolve('../db/database')] = { require.cache[require.resolve('../db/database')] = {
id: require.resolve('../db/database'), loaded: true, exports: { db: mem }, id: require.resolve('../db/database'), loaded: true, exports: { db: new Database(':memory:') },
}; };
const { agencyGate } = require('../middleware/apiToken'); const { agencyGate } = require('../middleware/apiToken');
function gate(over = {}) { function gate(over = {}) {
const req = { viaToken: true, tokenScope: 'agency', apiToken: { id: 'tok1' }, jwtWorkspaceId: 'wsA', params: {}, body: {}, ...over }; const req = { viaToken: true, tokenScope: 'agency', ...over };
let status = 200, nexted = false; let status = 200, nexted = false;
const res = { status(s) { status = s; return this; }, json() { return this; } }; const res = { status(s) { status = s; return this; }, json() { return this; } };
agencyGate(req, res, () => { nexted = true; }); agencyGate(req, res, () => { nexted = true; });
return { status, nexted }; return { status, nexted };
} }
test('#73 agencyGate: only agency tokens, only allowlisted playlists, only the bound workspace', () => { test('#73 agencyGate (mount seam): only agency tokens pass; non-agency + JWT rejected', () => {
assert.equal(gate({ params: { playlistId: 'plA' } }).nexted, true, 'designated playlist in bound ws -> passes'); assert.equal(gate().nexted, true, 'agency token passes the scope seam');
assert.equal(gate({ params: { playlistId: 'plB' } }).status, 403, 'NON-designated playlist -> 403 (target restriction)'); assert.equal(gate({ tokenScope: 'write' }).status, 403, 'read/write/full token -> 403');
assert.equal(gate({ params: { playlistId: 'plC' } }).status, 403, 'designated but CROSS-workspace -> 403'); assert.equal(gate({ tokenScope: 'full' }).status, 403, 'full token -> 403');
assert.equal(gate({ tokenScope: 'write', params: { playlistId: 'plA' } }).status, 403, 'non-agency token -> 403'); assert.equal(gate({ viaToken: false }).status, 403, 'JWT (not a token) -> 403');
assert.equal(gate({ viaToken: false, params: { playlistId: 'plA' } }).status, 403, 'JWT -> 403');
// body.playlist_id is honored too (create-item path), so the seam covers both routes
assert.equal(gate({ body: { playlist_id: 'plB' } }).status, 403, 'non-designated via body -> 403');
}); });

View file

@ -0,0 +1,80 @@
'use strict';
// #73 FULL bite-suite for the agency-token primitive, end-to-end against a booted server:
// the happy path (upload -> date-bounded item on a DESIGNATED playlist) plus the four
// confinement assertions at their three seams (gate / off-ladder / JWT-only / issuance).
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const PORT = 3992;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-agency-' + crypto.randomBytes(4).toString('hex'));
let proc;
before(async () => {
const logFd = fs.openSync(path.join(os.tmpdir(), 'st-agency.log'), 'w');
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: ['ignore', logFd, logFd],
});
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) break; } catch { /* not yet */ }
await new Promise(r => setTimeout(r, 250));
}
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* ignore */ } });
async function jfetch(p, opts = {}) {
const res = await fetch(BASE + p, opts);
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, body };
}
const jpost = (tok, o) => ({ method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(o || {}) });
const reg = (o) => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(o) });
test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)', async () => {
const email = 'ag' + crypto.randomBytes(4).toString('hex') + '@x.local';
const jwt = (await jfetch('/api/auth/register', reg({ email, password: 'Passw0rd123' }))).body.token;
const pl1 = (await jfetch('/api/playlists', jpost(jwt, { name: 'Designated' }))).body;
const pl2 = (await jfetch('/api/playlists', jpost(jwt, { name: 'Off-limits' }))).body;
// issue an agency token bound to pl1 ONLY
const tokRes = await jfetch('/api/tokens', jpost(jwt, { name: 'Agency', scope: 'agency', target_playlist_ids: [pl1.id] }));
assert.equal(tokRes.status, 201, 'agency token created');
assert.deepEqual(tokRes.body.target_playlist_ids, [pl1.id]);
const atok = tokRes.body.token;
// HAPPY PATH: upload via the agency token (shared ingest -> first-class content)
const fd = new FormData();
fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png');
const up = await fetch(BASE + '/api/agency/content', { method: 'POST', headers: { Authorization: 'Bearer ' + atok }, body: fd });
assert.equal(up.status, 201, 'agency upload -> 201 (first-class content)');
const content = await up.json();
// date-bounded item on the DESIGNATED playlist
const item = await jfetch(`/api/agency/playlists/${pl1.id}/items`, jpost(atok, { content_id: content.id, start_date: '2026-07-01', end_date: '2026-07-31' }));
assert.equal(item.status, 201, 'item on designated playlist -> 201');
// BITE 1 (gate): NON-designated playlist -> 403
const blocked = await jfetch(`/api/agency/playlists/${pl2.id}/items`, jpost(atok, { content_id: content.id }));
assert.equal(blocked.status, 403, 'non-designated playlist -> 403');
// BITE 2 (off-ladder): agency token on a normal public router -> 403
const dev = await jfetch('/api/devices', { headers: { Authorization: 'Bearer ' + atok } });
assert.equal(dev.status, 403, 'agency token on /api/devices -> 403 (off-ladder, tokenScopeGate)');
// BITE 3 (JWT-only): can't reach /api/tokens to widen its OWN targets -> 401
const widen = await jfetch(`/api/tokens/${tokRes.body.id}/targets`, jpost(atok, { target_playlist_ids: [pl1.id, pl2.id] }));
assert.equal(widen.status, 401, 'agency token cannot reach /api/tokens (JWT-only) -> 401');
// BITE 4 (issuance): an agency token can't be BOUND to an out-of-workspace/unknown playlist -> 400
const badTok = await jfetch('/api/tokens', jpost(jwt, { name: 'Bad', scope: 'agency', target_playlist_ids: ['nonexistent'] }));
assert.equal(badTok.status, 400, 'cannot bind an out-of-workspace target at issuance');
});