mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
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:
parent
c55ca60b56
commit
289d54f4fa
|
|
@ -200,6 +200,9 @@ const migrations = [
|
|||
// #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 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"
|
||||
// error means the column is already present (expected on a migrated DB) - benign.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
-- #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
|
||||
-- (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).
|
||||
|
|
|
|||
|
|
@ -17,4 +17,26 @@ function listDesignatedPlaylists(db, 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 };
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const { db } = require('../db/database');
|
|||
const upload = require('../middleware/upload');
|
||||
const { checkStorageLimit } = require('../middleware/subscription');
|
||||
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 { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish
|
||||
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' });
|
||||
}
|
||||
|
||||
// #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;
|
||||
if (duration_sec != null && (typeof duration_sec !== 'number' || duration_sec < 1)) {
|
||||
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' });
|
||||
|
||||
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;
|
||||
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, 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)')
|
||||
.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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
91
server/test/agency-zone-grants.test.js
Normal file
91
server/test/agency-zone-grants.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue