mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
feat(api): GET /api/agency/playlists - a token's designated targets (#73)
The portal needs to show an agency which playlists it may post to. New read surface on the security primitive, built with write-path rigor: the confinement query lives in lib/agency-targets.js (own token + bound workspace only) and is bite-tested four ways - own targets yes; another token's, outside the allowlist, and cross-workspace all NO; neutralizing the t.token_id filter makes it go red. Real-path wiring + the portal's graceful 401 trigger asserted in the integration suite. No :playlistId, so router.param doesn't apply - the query is the seam. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
40102b2b41
commit
6d152a5ccf
20
server/lib/agency-targets.js
Normal file
20
server/lib/agency-targets.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #73: the single query behind GET /api/agency/playlists. Returns ONLY this token's
|
||||||
|
// designated playlists, in its bound workspace. The WHERE clause IS the confinement and is
|
||||||
|
// the thing to bite-test:
|
||||||
|
// t.token_id = ? -> this token's targets, never another token's
|
||||||
|
// (JOIN api_token_targets) -> only allowlisted playlists, never one outside the allowlist
|
||||||
|
// p.workspace_id = ? -> only the bound workspace, never cross-workspace
|
||||||
|
// db is passed in (not module-required) so the confinement is unit-testable in isolation.
|
||||||
|
function listDesignatedPlaylists(db, tokenId, workspaceId) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT p.id, p.name, p.status
|
||||||
|
FROM api_token_targets t
|
||||||
|
JOIN playlists p ON p.id = t.playlist_id
|
||||||
|
WHERE t.token_id = ? AND p.workspace_id = ?
|
||||||
|
ORDER BY p.name
|
||||||
|
`).all(tokenId, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listDesignatedPlaylists };
|
||||||
|
|
@ -13,10 +13,18 @@ const { db } = require('../db/database');
|
||||||
const upload = require('../middleware/upload');
|
const upload = require('../middleware/upload');
|
||||||
const { checkStorageLimit } = require('../middleware/subscription');
|
const { checkStorageLimit } = require('../middleware/subscription');
|
||||||
const { ingestUploadedFile } = require('../lib/content-ingest');
|
const { ingestUploadedFile } = require('../lib/content-ingest');
|
||||||
|
const { listDesignatedPlaylists } = require('../lib/agency-targets');
|
||||||
|
|
||||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
// #73 THE target seam. router.param fires for EVERY route with :playlistId, WITH the param,
|
// #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
|
// 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
|
// (the api-surface.js can't-drift property, at the param level: you cannot add a :playlistId
|
||||||
|
|
|
||||||
35
server/test/agency-list.test.js
Normal file
35
server/test/agency-list.test.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #73: GET /api/agency/playlists is a new READ surface on the security primitive, so prove
|
||||||
|
// it confines with write-path rigor. The query (lib/agency-targets.js) must return ONLY this
|
||||||
|
// token's designated, in-workspace playlists. Four ways it could leak, all asserted here;
|
||||||
|
// neutralizing the t.token_id filter makes it go red (the bite).
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const { listDesignatedPlaylists } = require('../lib/agency-targets');
|
||||||
|
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.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, name TEXT, status TEXT, workspace_id TEXT);
|
||||||
|
INSERT INTO playlists (id, name, status, workspace_id) VALUES
|
||||||
|
('p1','One', 'published','wsA'),
|
||||||
|
('p2','Two', 'published','wsA'),
|
||||||
|
('p3','Three','published','wsA'),
|
||||||
|
('pX','Cross','published','wsB');
|
||||||
|
INSERT INTO api_token_targets (token_id, playlist_id) VALUES
|
||||||
|
('tokA','p1'), -- own + in-workspace -> MUST appear
|
||||||
|
('tokA','pX'), -- own but CROSS-workspace -> must NOT appear
|
||||||
|
('tokB','p2'); -- ANOTHER token's -> must NOT appear for tokA
|
||||||
|
-- p3 is in wsA but designated to no one -> OUTSIDE the allowlist -> must NOT appear
|
||||||
|
`);
|
||||||
|
|
||||||
|
test('#73 GET targets: returns ONLY this token\'s designated, in-workspace playlists', () => {
|
||||||
|
const a = listDesignatedPlaylists(db, 'tokA', 'wsA').map(r => r.id);
|
||||||
|
assert.deepEqual(a, ['p1'],
|
||||||
|
'tokA sees ONLY p1 - not p2 (another token), not p3 (outside allowlist), not pX (cross-workspace)');
|
||||||
|
const b = listDesignatedPlaylists(db, 'tokB', 'wsA').map(r => r.id);
|
||||||
|
assert.deepEqual(b, ['p2'], 'tokB sees ONLY p2');
|
||||||
|
});
|
||||||
|
|
@ -51,6 +51,11 @@ test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)'
|
||||||
assert.deepEqual(tokRes.body.target_playlist_ids, [pl1.id]);
|
assert.deepEqual(tokRes.body.target_playlist_ids, [pl1.id]);
|
||||||
const atok = tokRes.body.token;
|
const atok = tokRes.body.token;
|
||||||
|
|
||||||
|
// GET targets (real path: agencyGate -> handler -> query): returns ONLY the designated pl1
|
||||||
|
const mine = await jfetch('/api/agency/playlists', { headers: { Authorization: 'Bearer ' + atok } });
|
||||||
|
assert.equal(mine.status, 200, 'agency can list its targets');
|
||||||
|
assert.deepEqual(mine.body.map(p => p.id), [pl1.id], 'GET /agency/playlists returns ONLY the designated playlist (not pl2)');
|
||||||
|
|
||||||
// HAPPY PATH: upload via the agency token (shared ingest -> first-class content)
|
// HAPPY PATH: upload via the agency token (shared ingest -> first-class content)
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png');
|
fd.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 't.png');
|
||||||
|
|
@ -77,4 +82,9 @@ test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)'
|
||||||
// BITE 4 (issuance): an agency token can't be BOUND to an out-of-workspace/unknown playlist -> 400
|
// 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'] }));
|
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');
|
assert.equal(badTok.status, 400, 'cannot bind an out-of-workspace target at issuance');
|
||||||
|
|
||||||
|
// Portal graceful-failure trigger: an invalid/revoked key -> 401, which the portal catches
|
||||||
|
// to show "paste it again" (never a wall of 403s).
|
||||||
|
const bogus = await jfetch('/api/agency/playlists', { headers: { Authorization: 'Bearer st_bogus_invalid_key' } });
|
||||||
|
assert.equal(bogus.status, 401, 'invalid agency key -> 401 (portal resets to the entry screen)');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue