From 40102b2b41f26f951dbdd607c487f6349d155015 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sat, 13 Jun 2026 22:48:42 -0500 Subject: [PATCH] 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) --- server/middleware/apiToken.js | 25 +++------ server/routes/agency.js | 89 +++++++++++++++++++++++++++++++++ server/routes/tokens.js | 53 +++++++++++++++++--- server/server.js | 10 +++- server/test/agency-gate.test.js | 33 +++++------- server/test/agency.test.js | 80 +++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 46 deletions(-) create mode 100644 server/routes/agency.js create mode 100644 server/test/agency.test.js diff --git a/server/middleware/apiToken.js b/server/middleware/apiToken.js index a9ca1dc..74dbed8 100644 --- a/server/middleware/apiToken.js +++ b/server/middleware/apiToken.js @@ -112,27 +112,18 @@ function requireScope(need) { }; } -// #73: THE single seam for capability-restricted ('agency') tokens. Mounted on the -// AGENCY_ROUTER (config/api-surface.js) in place of tokenScopeGate. Two checks, no more: -// (1) only an agency token passes (a JWT or read/write/full token is rejected); -// (2) if the request targets a playlist, that playlist must be in THIS token's -// allowlist AND in the token's bound workspace - one query enforces both the -// target restriction and cross-workspace isolation. -// Every agency capability route passes through here, so the whole primitive is proven -// at one place. Removing the api_token_targets condition makes the bite-test go red. +// #73: mount seam for capability-restricted ('agency') tokens. SCOPE/off-ladder check ONLY: +// only an agency token reaches the agency router (a read/write/full token or a JWT is +// rejected). The PER-TARGET check CANNOT live here - Express doesn't populate req.params at +// app.use-level middleware (params land at route match, inside the router), so a mount-level +// target check is silently bypassed (the integration bite-suite caught exactly this). The +// target check is router.param('playlistId') in routes/agency.js - it fires WITH the param +// before the handler and can't be skipped by any :playlistId route. Two single-registration, +// drift-proof seams: scope (here) + target (router.param). function agencyGate(req, res, next) { if (!req.viaToken || req.tokenScope !== 'agency') { 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(); } diff --git a/server/routes/agency.js b/server/routes/agency.js new file mode 100644 index 0000000..8f04bc1 --- /dev/null +++ b/server/routes/agency.js @@ -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; diff --git a/server/routes/tokens.js b/server/routes/tokens.js index ea0a26d..20436c3 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -8,7 +8,9 @@ const { db } = require('../db/database'); const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken'); 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. router.get('/', (req, res) => { @@ -27,21 +29,38 @@ router.post('/', (req, res) => { const scope = req.body.scope || 'read'; 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 (!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 // 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. 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' }); } + // #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 id = crypto.randomUUID(); - db.prepare(` - INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) - `).run(id, hashToken(secret), displayPrefix(secret), name, req.user.id, req.workspaceId, scope); + db.transaction(() => { + db.prepare(` + INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now')) + `).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. - 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). @@ -54,4 +73,24 @@ router.delete('/:id', (req, res) => { 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; diff --git a/server/server.js b/server/server.js index 8ac7932..a270676 100644 --- a/server/server.js +++ b/server/server.js @@ -446,7 +446,7 @@ app.get('/api/content/:id/thumbnail', (req, res) => { const { requireAuth } = require('./middleware/auth'); const { resolveTenancy } = require('./lib/tenancy'); // 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 // 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 // member with platform powers stripped, so in-handler ELEVATED/PLATFORM checks (e.g. // 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 // 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)); 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) const crypto = require('crypto'); diff --git a/server/test/agency-gate.test.js b/server/test/agency-gate.test.js index 9a2e7dc..76fc6d2 100644 --- a/server/test/agency-gate.test.js +++ b/server/test/agency-gate.test.js @@ -1,39 +1,32 @@ 'use strict'; -// #73 THE SEAM: agencyGate is the single place capability+target restriction is enforced. -// Prove it confines before any endpoint is built behind it. Removing the api_token_targets -// condition in agencyGate makes "non-designated -> 403" go red (the bite). +// #73 mount seam: agencyGate does SCOPE/off-ladder confinement ONLY (only an agency token +// reaches the agency router). The per-target check moved to router.param('playlistId') in +// 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 assert = require('node:assert/strict'); const Database = require('better-sqlite3'); -const mem = new Database(':memory:'); -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) +// agencyGate needs no db now, but requiring the module loads db/database - inject a stub. 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'); 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; const res = { status(s) { status = s; return this; }, json() { return this; } }; agencyGate(req, res, () => { nexted = true; }); return { status, nexted }; } -test('#73 agencyGate: only agency tokens, only allowlisted playlists, only the bound workspace', () => { - assert.equal(gate({ params: { playlistId: 'plA' } }).nexted, true, 'designated playlist in bound ws -> passes'); - assert.equal(gate({ params: { playlistId: 'plB' } }).status, 403, 'NON-designated playlist -> 403 (target restriction)'); - assert.equal(gate({ params: { playlistId: 'plC' } }).status, 403, 'designated but CROSS-workspace -> 403'); - assert.equal(gate({ tokenScope: 'write', params: { playlistId: 'plA' } }).status, 403, 'non-agency 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'); +test('#73 agencyGate (mount seam): only agency tokens pass; non-agency + JWT rejected', () => { + assert.equal(gate().nexted, true, 'agency token passes the scope seam'); + assert.equal(gate({ tokenScope: 'write' }).status, 403, 'read/write/full token -> 403'); + assert.equal(gate({ tokenScope: 'full' }).status, 403, 'full token -> 403'); + assert.equal(gate({ viaToken: false }).status, 403, 'JWT (not a token) -> 403'); }); diff --git a/server/test/agency.test.js b/server/test/agency.test.js new file mode 100644 index 0000000..b2fb8c2 --- /dev/null +++ b/server/test/agency.test.js @@ -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'); +});