mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
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:
parent
a59b53cc25
commit
40102b2b41
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
89
server/routes/agency.js
Normal file
89
server/routes/agency.js
Normal 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;
|
||||
|
|
@ -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.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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
80
server/test/agency.test.js
Normal file
80
server/test/agency.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue