feat(api): zone-grant confinement for agency tokens - FK-anchored (#73)

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>
This commit is contained in:
ScreenTinker 2026-06-14 14:57:27 -05:00
parent c55ca60b56
commit 289d54f4fa
5 changed files with 147 additions and 5 deletions

View file

@ -200,6 +200,9 @@ const migrations = [
// #73: agency-upload notification queue (batched digest). // #73: agency-upload notification queue (batched digest).
"CREATE TABLE IF NOT EXISTS agency_notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT NOT NULL, token_id TEXT NOT NULL, playlist_id TEXT NOT NULL, action TEXT NOT NULL, content_id TEXT, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), sent_at INTEGER)", "CREATE TABLE IF NOT EXISTS agency_notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT NOT NULL, token_id TEXT NOT NULL, playlist_id TEXT NOT NULL, action TEXT NOT NULL, content_id TEXT, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), sent_at INTEGER)",
"CREATE INDEX IF NOT EXISTS idx_agency_notifications_unsent ON agency_notifications(sent_at)", "CREATE INDEX IF NOT EXISTS idx_agency_notifications_unsent ON agency_notifications(sent_at)",
// #73: zone refinement of a playlist grant - FK-anchored to api_token_targets (orphan-
// impossible + cascades on playlist-grant revoke). Additive; does NOT touch api_token_targets.
"CREATE TABLE IF NOT EXISTS api_token_target_zones (token_id TEXT NOT NULL, playlist_id TEXT NOT NULL, zone_id TEXT NOT NULL REFERENCES layout_zones(id) ON DELETE CASCADE, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), PRIMARY KEY (token_id, playlist_id, zone_id), FOREIGN KEY (token_id, playlist_id) REFERENCES api_token_targets(token_id, playlist_id) ON DELETE CASCADE)",
]; ];
// Apply each ALTER idempotently. A "duplicate column name" / "already exists" // Apply each ALTER idempotently. A "duplicate column name" / "already exists"
// error means the column is already present (expected on a migrated DB) - benign. // error means the column is already present (expected on a migrated DB) - benign.

View file

@ -551,6 +551,22 @@ CREATE TABLE IF NOT EXISTS api_token_targets (
); );
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
-- #73: OPTIONAL zone refinement of a playlist grant. A row here narrows the agency to a
-- specific zone WITHIN an already-granted playlist. STRUCTURALLY anchored: the composite FK
-- to api_token_targets(token_id, playlist_id) means a zone grant CANNOT exist without its
-- playlist grant (orphan-impossible) and CASCADES away when the playlist grant is revoked -
-- that's what makes "narrow" structural, not conventional. zone_id FK -> layout_zones so a
-- deleted zone/layout drops the grant too. No rows for a (token,playlist) = whole-playlist
-- (full-screen), as before. (Requires PRAGMA foreign_keys=ON, set in db/database.js.)
CREATE TABLE IF NOT EXISTS api_token_target_zones (
token_id TEXT NOT NULL,
playlist_id TEXT NOT NULL,
zone_id TEXT NOT NULL REFERENCES layout_zones(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
PRIMARY KEY (token_id, playlist_id, zone_id),
FOREIGN KEY (token_id, playlist_id) REFERENCES api_token_targets(token_id, playlist_id) ON DELETE CASCADE
);
-- #73: agency-upload notification queue. The agency endpoint enqueues one row per item added -- #73: agency-upload notification queue. The agency endpoint enqueues one row per item added
-- (only when email is configured); a 15-min flush job groups per token+playlist+action and -- (only when email is configured); a 15-min flush job groups per token+playlist+action and
-- sends one digest per group, stamping sent_at ONLY after a successful send (failed -> retry). -- sends one digest per group, stamping sent_at ONLY after a successful send (failed -> retry).

View file

@ -17,4 +17,26 @@ function listDesignatedPlaylists(db, tokenId, workspaceId) {
`).all(tokenId, workspaceId); `).all(tokenId, workspaceId);
} }
module.exports = { listDesignatedPlaylists }; // #73: resolve which zone an agency item-add lands in, enforcing the zone grants. The grant
// is the boundary; a body-supplied zone can pick WITHIN it but never escape it.
// - No zone grants for (token, playlist) -> whole-playlist/full-screen (zone_id NULL); a
// body zone_id is ignored (placement isn't agency-driven when nothing's granted).
// - Zone grants exist -> the item MUST land in a GRANTED zone:
// requested zone that IS granted -> use it (agency picks among its grants);
// requested zone NOT granted -> { ok:false, reason:'forbidden' } (403);
// no request, exactly one grant -> auto-place into it;
// no request, multiple grants -> { ok:false, reason:'ambiguous' } (must pick).
function resolveGrantedZone(db, tokenId, playlistId, requestedZoneId) {
const grants = db.prepare('SELECT zone_id FROM api_token_target_zones WHERE token_id = ? AND playlist_id = ?')
.all(tokenId, playlistId).map(r => r.zone_id);
if (!grants.length) return { ok: true, zoneId: null };
if (requestedZoneId) {
return grants.includes(requestedZoneId)
? { ok: true, zoneId: requestedZoneId }
: { ok: false, reason: 'forbidden' };
}
if (grants.length === 1) return { ok: true, zoneId: grants[0] };
return { ok: false, reason: 'ambiguous' };
}
module.exports = { listDesignatedPlaylists, resolveGrantedZone };

View file

@ -13,7 +13,7 @@ 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 { listDesignatedPlaylists, resolveGrantedZone } = require('../lib/agency-targets');
const { listLayoutGeometry } = require('../lib/agency-layouts'); const { listLayoutGeometry } = require('../lib/agency-layouts');
const { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish 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 { isConfigured } = require('../services/email'); // #73: gate digest enqueue on SMTP being set
@ -77,6 +77,16 @@ router.post('/playlists/:playlistId/items', (req, res) => {
return res.status(403).json({ error: 'Content is not in this workspace' }); 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; let { duration_sec, days, start, end, start_date, end_date } = req.body;
if (duration_sec != null && (typeof duration_sec !== 'number' || duration_sec < 1)) { if (duration_sec != null && (typeof duration_sec !== 'number' || duration_sec < 1)) {
return res.status(400).json({ error: 'duration_sec must be a positive integer' }); return res.status(400).json({ error: 'duration_sec must be a positive integer' });
@ -94,8 +104,8 @@ router.post('/playlists/:playlistId/items', (req, res) => {
if (!(TIME_RE.test(en) || en === '24:00')) return res.status(400).json({ error: 'end must be HH:MM or 24:00' }); 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 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 (?, ?, ?, ?)') 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, order, duration_sec).lastInsertRowid; .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)') 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); .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 // #73: draft vs live is decided by the TOKEN's auto_publish (admin-set, read from
@ -116,7 +126,7 @@ router.post('/playlists/:playlistId/items', (req, res) => {
.run(req.workspaceId, req.apiToken.id, req.params.playlistId, published ? 'published' : 'draft', content_id); .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, duration_sec, start_date: sd, end_date: ed, published }); 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; module.exports = router;

View file

@ -0,0 +1,91 @@
'use strict';
// #73 zone-grant security model. Proves the structural narrow guarantee BEFORE any UI rides
// on it: zone grants confine placement, are FK-anchored to the playlist grant (orphan-
// impossible), and cascade away with it. The whole thing depends on PRAGMA foreign_keys=ON,
// so this test asserts that too (a cascade that silently no-ops because FKs are off is the trap).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
const { resolveGrantedZone } = require('../lib/agency-targets');
function freshDb() {
const db = new Database(':memory:');
db.pragma('foreign_keys = ON'); // mirrors db/database.js:13
db.exec(`
CREATE TABLE api_tokens (id TEXT PRIMARY KEY);
CREATE TABLE playlists (id TEXT PRIMARY KEY, workspace_id TEXT);
CREATE TABLE layouts (id TEXT PRIMARY KEY);
CREATE TABLE layout_zones (id TEXT PRIMARY KEY, layout_id TEXT REFERENCES layouts(id) ON DELETE CASCADE);
CREATE TABLE api_token_targets (
token_id TEXT NOT NULL REFERENCES api_tokens(id) ON DELETE CASCADE,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
PRIMARY KEY (token_id, playlist_id));
CREATE TABLE api_token_target_zones (
token_id TEXT NOT NULL, playlist_id TEXT NOT NULL,
zone_id TEXT NOT NULL REFERENCES layout_zones(id) ON DELETE CASCADE,
created_at INTEGER,
PRIMARY KEY (token_id, playlist_id, zone_id),
FOREIGN KEY (token_id, playlist_id) REFERENCES api_token_targets(token_id, playlist_id) ON DELETE CASCADE);
INSERT INTO api_tokens VALUES ('tok1');
INSERT INTO playlists VALUES ('plA','wsA'), ('plB','wsA');
INSERT INTO layouts VALUES ('L1'), ('L2');
INSERT INTO layout_zones VALUES ('zA1','L1'), ('zA2','L1'), ('zB1','L2');
INSERT INTO api_token_targets VALUES ('tok1','plA'), ('tok1','plB');
INSERT INTO api_token_target_zones VALUES ('tok1','plA','zA1', 0); -- plA narrowed to zA1; plB has none
`);
return db;
}
test('#73 foreign_keys is ON (the cascade/FK guarantees are real, not silent no-ops)', () => {
assert.equal(freshDb().pragma('foreign_keys', { simple: true }), 1);
});
test('#73 zone confinement: granted YES, non-granted/cross-playlist/ambiguous all blocked', () => {
const db = freshDb();
// granted zone within a designated playlist -> YES
assert.deepEqual(resolveGrantedZone(db, 'tok1', 'plA', 'zA1'), { ok: true, zoneId: 'zA1' });
// NON-granted zone within the SAME designated playlist -> blocked (the refinement bites)
assert.equal(resolveGrantedZone(db, 'tok1', 'plA', 'zA2').ok, false);
// a zone from a DIFFERENT playlist's layout -> blocked (no cross-playlist)
assert.equal(resolveGrantedZone(db, 'tok1', 'plA', 'zB1').ok, false);
// no requested zone, exactly one grant -> auto-place into it
assert.deepEqual(resolveGrantedZone(db, 'tok1', 'plA', null), { ok: true, zoneId: 'zA1' });
// playlist with NO zone grants -> whole-playlist (full-screen); a body zone is IGNORED
assert.deepEqual(resolveGrantedZone(db, 'tok1', 'plB', null), { ok: true, zoneId: null });
assert.deepEqual(resolveGrantedZone(db, 'tok1', 'plB', 'zB1'), { ok: true, zoneId: null });
// multiple grants, no pick -> must specify
db.prepare("INSERT INTO api_token_target_zones VALUES ('tok1','plA','zA2',0)").run();
assert.equal(resolveGrantedZone(db, 'tok1', 'plA', null).reason, 'ambiguous');
assert.deepEqual(resolveGrantedZone(db, 'tok1', 'plA', 'zA2'), { ok: true, zoneId: 'zA2' }); // picks among grants
});
test('#73 orphan-grant is IMPOSSIBLE: a zone grant cannot exist without its playlist grant', () => {
const db = freshDb();
// (tok1, plC) is NOT in api_token_targets -> the composite FK must reject the zone grant
assert.throws(
() => db.prepare("INSERT INTO api_token_target_zones VALUES ('tok1','plC','zA1',0)").run(),
/FOREIGN KEY/i,
'inserting a zone grant without its playlist grant must be rejected by the FK');
});
test('#73 cascade: revoking the playlist grant removes its zone grants (structural, not manual)', () => {
const db = freshDb();
db.prepare("DELETE FROM api_token_targets WHERE token_id='tok1' AND playlist_id='plA'").run();
assert.equal(db.prepare("SELECT COUNT(*) c FROM api_token_target_zones WHERE playlist_id='plA'").get().c, 0,
'zone grants cascade out when the parent playlist grant is deleted');
});
test('#73 cascade chain: deleting a playlist removes BOTH the playlist grant and the zone grants', () => {
const db = freshDb();
db.prepare("DELETE FROM playlists WHERE id='plA'").run();
assert.equal(db.prepare("SELECT COUNT(*) c FROM api_token_targets WHERE playlist_id='plA'").get().c, 0, 'playlist grant gone');
assert.equal(db.prepare("SELECT COUNT(*) c FROM api_token_target_zones WHERE playlist_id='plA'").get().c, 0, 'zone grants gone (no orphans)');
});
test('#73 cascade: deleting a zone (or its layout) drops the grant referencing it', () => {
const db = freshDb();
db.prepare("DELETE FROM layouts WHERE id='L1'").run(); // -> layout_zones zA1/zA2 cascade -> zone grants cascade
assert.equal(db.prepare("SELECT COUNT(*) c FROM api_token_target_zones WHERE zone_id='zA1'").get().c, 0);
});