mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
revert: drop zone-binding, keep whole-playlist grants + size-guidance card (#73)
Investigation found zone placement is a DEVICE property (device.layout_id), not a playlist property: a normal playlist has no derivable layout (zone_id is NULL unless set in the device-assignment flow), so a playlist-scoped zone grant can't reach the normal flow. The right model: placement belongs to the device (same playlist can be full-screen on one screen, a zone on another); the agency just gets whole-playlist grants + size-guidance. Removed the zone-grant machinery (security-adjacent dead surface is a liability, not dormant convenience): api_token_target_zones (schema + a DROP migration for the dev DB where the short-lived CREATE ran), resolveGrantedZone, grantableZoneIds, buildZoneGrantRows, the create/PUT zone validation, GET /api/playlists/:id/zones, getPlaylistZones, the settings zone-picker + its i18n, and the zone-grant bite-test. KEPT (model-agnostic, good): the reactive per-playlist size-guidance card - GET /api/agency/playlists/:playlistId/layout (router.param-confined) now reports the zones the playlist actually feeds (where/what-size content lands), or full-screen when it has no layout. Whole-playlist grants = today's working model. 147 suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c5550f5bc9
commit
400a438fff
|
|
@ -137,7 +137,6 @@ export const api = {
|
||||||
|
|
||||||
// Playlists
|
// Playlists
|
||||||
getPlaylists: () => request('/playlists'),
|
getPlaylists: () => request('/playlists'),
|
||||||
getPlaylistZones: (id) => request('/playlists/' + id + '/zones'), // #73: grantable zones for the agency designate UI
|
|
||||||
createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }),
|
createPlaylist: (name, description) => request('/playlists', { method: 'POST', body: JSON.stringify({ name, description }) }),
|
||||||
getPlaylist: (id) => request(`/playlists/${id}`),
|
getPlaylist: (id) => request(`/playlists/${id}`),
|
||||||
updatePlaylist: (id, data) => request(`/playlists/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
updatePlaylist: (id, data) => request(`/playlists/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
|
|
||||||
|
|
@ -366,8 +366,6 @@ export default {
|
||||||
'apitoken.auto_publish_label': 'Automatisch veröffentlichen (meine Freigabe überspringen)',
|
'apitoken.auto_publish_label': 'Automatisch veröffentlichen (meine Freigabe überspringen)',
|
||||||
'apitoken.auto_publish_hint': 'Aus (Standard): Hinzufügungen warten als Entwurf auf deine Veröffentlichung. An: sie gehen sofort live – nur für Agenturen, denen du voll vertraust.',
|
'apitoken.auto_publish_hint': 'Aus (Standard): Hinzufügungen warten als Entwurf auf deine Veröffentlichung. An: sie gehen sofort live – nur für Agenturen, denen du voll vertraust.',
|
||||||
'apitoken.auto_publish_on': 'Auto-Veröffentlichung an',
|
'apitoken.auto_publish_on': 'Auto-Veröffentlichung an',
|
||||||
'apitoken.zone_grant_hint': 'Bestimmte Zonen freigeben, oder leer lassen für die ganze Playlist:',
|
|
||||||
'apitoken.zone_grant_fullscreen': 'Vollbild – keine Zonen freizugeben.',
|
|
||||||
'apitoken.create': 'Token erstellen',
|
'apitoken.create': 'Token erstellen',
|
||||||
'apitoken.none': 'Noch keine Tokens.',
|
'apitoken.none': 'Noch keine Tokens.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -402,8 +402,6 @@ export default {
|
||||||
'apitoken.auto_publish_label': 'Auto-publish (skip my approval)',
|
'apitoken.auto_publish_label': 'Auto-publish (skip my approval)',
|
||||||
'apitoken.auto_publish_hint': 'Off (default): additions wait as drafts for you to publish. On: they go live immediately — only for agencies you fully trust.',
|
'apitoken.auto_publish_hint': 'Off (default): additions wait as drafts for you to publish. On: they go live immediately — only for agencies you fully trust.',
|
||||||
'apitoken.auto_publish_on': 'auto-publish on',
|
'apitoken.auto_publish_on': 'auto-publish on',
|
||||||
'apitoken.zone_grant_hint': 'Grant specific zones, or leave unchecked for the whole playlist:',
|
|
||||||
'apitoken.zone_grant_fullscreen': 'Full-screen — no zones to grant.',
|
|
||||||
'apitoken.create': 'Create token',
|
'apitoken.create': 'Create token',
|
||||||
'apitoken.none': 'No tokens yet.',
|
'apitoken.none': 'No tokens yet.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -365,8 +365,6 @@ export default {
|
||||||
'apitoken.auto_publish_label': 'Publicación automática (omitir mi aprobación)',
|
'apitoken.auto_publish_label': 'Publicación automática (omitir mi aprobación)',
|
||||||
'apitoken.auto_publish_hint': 'Desactivado (predeterminado): las adiciones esperan como borradores para que las publiques. Activado: se publican de inmediato, solo para agencias de plena confianza.',
|
'apitoken.auto_publish_hint': 'Desactivado (predeterminado): las adiciones esperan como borradores para que las publiques. Activado: se publican de inmediato, solo para agencias de plena confianza.',
|
||||||
'apitoken.auto_publish_on': 'publicación automática activada',
|
'apitoken.auto_publish_on': 'publicación automática activada',
|
||||||
'apitoken.zone_grant_hint': 'Concede zonas específicas, o deja sin marcar para toda la lista:',
|
|
||||||
'apitoken.zone_grant_fullscreen': 'Pantalla completa: no hay zonas que conceder.',
|
|
||||||
'apitoken.create': 'Crear token',
|
'apitoken.create': 'Crear token',
|
||||||
'apitoken.none': 'Aún no hay tokens.',
|
'apitoken.none': 'Aún no hay tokens.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -366,8 +366,6 @@ export default {
|
||||||
'apitoken.auto_publish_label': 'Publication automatique (ignorer mon approbation)',
|
'apitoken.auto_publish_label': 'Publication automatique (ignorer mon approbation)',
|
||||||
'apitoken.auto_publish_hint': 'Désactivé (par défaut) : les ajouts attendent en brouillon votre publication. Activé : ils sont diffusés immédiatement, uniquement pour les agences de pleine confiance.',
|
'apitoken.auto_publish_hint': 'Désactivé (par défaut) : les ajouts attendent en brouillon votre publication. Activé : ils sont diffusés immédiatement, uniquement pour les agences de pleine confiance.',
|
||||||
'apitoken.auto_publish_on': 'publication automatique activée',
|
'apitoken.auto_publish_on': 'publication automatique activée',
|
||||||
'apitoken.zone_grant_hint': 'Accordez des zones spécifiques, ou laissez décoché pour toute la liste :',
|
|
||||||
'apitoken.zone_grant_fullscreen': 'Plein écran — aucune zone à accorder.',
|
|
||||||
'apitoken.create': 'Créer un jeton',
|
'apitoken.create': 'Créer un jeton',
|
||||||
'apitoken.none': 'Aucun jeton pour le moment.',
|
'apitoken.none': 'Aucun jeton pour le moment.',
|
||||||
'apitoken.col_token': 'Jeton',
|
'apitoken.col_token': 'Jeton',
|
||||||
|
|
|
||||||
|
|
@ -366,8 +366,6 @@ export default {
|
||||||
'apitoken.auto_publish_label': 'Publicação automática (ignorar minha aprovação)',
|
'apitoken.auto_publish_label': 'Publicação automática (ignorar minha aprovação)',
|
||||||
'apitoken.auto_publish_hint': 'Desativado (padrão): as adições aguardam como rascunho para você publicar. Ativado: vão ao ar imediatamente, apenas para agências de total confiança.',
|
'apitoken.auto_publish_hint': 'Desativado (padrão): as adições aguardam como rascunho para você publicar. Ativado: vão ao ar imediatamente, apenas para agências de total confiança.',
|
||||||
'apitoken.auto_publish_on': 'publicação automática ativada',
|
'apitoken.auto_publish_on': 'publicação automática ativada',
|
||||||
'apitoken.zone_grant_hint': 'Conceda zonas específicas, ou deixe sem marcar para a lista inteira:',
|
|
||||||
'apitoken.zone_grant_fullscreen': 'Tela cheia — sem zonas para conceder.',
|
|
||||||
'apitoken.create': 'Criar token',
|
'apitoken.create': 'Criar token',
|
||||||
'apitoken.none': 'Ainda não há tokens.',
|
'apitoken.none': 'Ainda não há tokens.',
|
||||||
'apitoken.col_token': 'Token',
|
'apitoken.col_token': 'Token',
|
||||||
|
|
|
||||||
|
|
@ -413,31 +413,9 @@ export async function render(container) {
|
||||||
agencyPlaylistsLoaded = true;
|
agencyPlaylistsLoaded = true;
|
||||||
const list = document.getElementById('agencyPlaylistList');
|
const list = document.getElementById('agencyPlaylistList');
|
||||||
const pls = await api.getPlaylists().catch(() => []);
|
const pls = await api.getPlaylists().catch(() => []);
|
||||||
if (!pls.length) {
|
list.innerHTML = pls.length
|
||||||
list.innerHTML = `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`;
|
? pls.map(p => `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>`).join('')
|
||||||
return;
|
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`;
|
||||||
}
|
|
||||||
list.innerHTML = pls.map(p => `
|
|
||||||
<div data-pl="${esc(String(p.id))}">
|
|
||||||
<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>
|
|
||||||
<div class="agency-zones" data-pl="${esc(String(p.id))}" style="margin-left:24px;margin-top:4px;display:none"></div>
|
|
||||||
</div>`).join('');
|
|
||||||
// #73: checking a playlist reveals its grantable zones (lazy-loaded). Leaving them all
|
|
||||||
// unchecked = whole-playlist (full-screen). Zones offered come from the playlist's layout.
|
|
||||||
list.querySelectorAll('.agency-pl').forEach(cb => cb.addEventListener('change', async () => {
|
|
||||||
const box = list.querySelector(`.agency-zones[data-pl="${cb.value}"]`);
|
|
||||||
if (!cb.checked) { box.style.display = 'none'; return; }
|
|
||||||
box.style.display = 'block';
|
|
||||||
if (box.dataset.loaded) return;
|
|
||||||
box.dataset.loaded = '1';
|
|
||||||
const zones = await api.getPlaylistZones(cb.value).catch(() => []);
|
|
||||||
box.innerHTML = zones.length
|
|
||||||
? `<div style="font-size:11px;color:var(--text-muted);margin-bottom:2px">${t('apitoken.zone_grant_hint')}</div>` + zones.map(z => {
|
|
||||||
const wpx = Math.round(z.layout_width * z.width_percent / 100), hpx = Math.round(z.layout_height * z.height_percent / 100);
|
|
||||||
return `<label style="display:flex;gap:6px;align-items:center;font-size:12px"><input type="checkbox" class="agency-zone" data-pl="${esc(String(cb.value))}" value="${esc(String(z.id))}"> ${esc(z.name)} <span style="color:var(--text-muted)">${wpx}×${hpx}</span></label>`;
|
|
||||||
}).join('')
|
|
||||||
: `<div style="font-size:11px;color:var(--text-muted)">${t('apitoken.zone_grant_fullscreen')}</div>`;
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -449,13 +427,6 @@ export async function render(container) {
|
||||||
const ids = [...document.querySelectorAll('#agencyPlaylistList .agency-pl:checked')].map(c => c.value);
|
const ids = [...document.querySelectorAll('#agencyPlaylistList .agency-pl:checked')].map(c => c.value);
|
||||||
if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error');
|
if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error');
|
||||||
payload.target_playlist_ids = ids;
|
payload.target_playlist_ids = ids;
|
||||||
// #73: per-playlist zone grants (a playlist with no checked zones = whole-playlist)
|
|
||||||
const target_zones = {};
|
|
||||||
for (const pid of ids) {
|
|
||||||
const zoneIds = [...document.querySelectorAll(`.agency-zone[data-pl="${pid}"]:checked`)].map(c => c.value);
|
|
||||||
if (zoneIds.length) target_zones[pid] = zoneIds;
|
|
||||||
}
|
|
||||||
if (Object.keys(target_zones).length) payload.target_zones = target_zones;
|
|
||||||
payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked;
|
payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked;
|
||||||
}
|
}
|
||||||
const btn = document.getElementById('createTokenBtn');
|
const btn = document.getElementById('createTokenBtn');
|
||||||
|
|
|
||||||
|
|
@ -200,9 +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-
|
// #73: zone-binding was reverted (placement belongs to the device, not the playlist - see
|
||||||
// impossible + cascades on playlist-grant revoke). Additive; does NOT touch api_token_targets.
|
// the agency-tokens history). Drop the table on DBs where the short-lived migration ran.
|
||||||
"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)",
|
"DROP TABLE IF EXISTS api_token_target_zones",
|
||||||
];
|
];
|
||||||
// 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.
|
||||||
|
|
|
||||||
|
|
@ -551,22 +551,6 @@ 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).
|
||||||
|
|
|
||||||
|
|
@ -17,41 +17,4 @@ function listDesignatedPlaylists(db, tokenId, workspaceId) {
|
||||||
`).all(tokenId, workspaceId);
|
`).all(tokenId, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #73: resolve which zone an agency item-add lands in, enforcing the zone grants. The grant
|
module.exports = { listDesignatedPlaylists };
|
||||||
// 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' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// #73 issuance-side mirror of the runtime confinement: the set of zone_ids an admin may
|
|
||||||
// grant for a playlist = all zones of the layout(s) that playlist feeds (its items'
|
|
||||||
// zones -> their layouts -> all zones of those layouts). Token-less (used at create time,
|
|
||||||
// before the token exists). A zone the playlist's layout doesn't have -> not in this set ->
|
|
||||||
// rejected at issuance, the same boundary resolveGrantedZone enforces at runtime.
|
|
||||||
function grantableZoneIds(db, playlistId) {
|
|
||||||
return new Set(db.prepare(`
|
|
||||||
SELECT DISTINCT lz_all.id
|
|
||||||
FROM playlist_items pi
|
|
||||||
JOIN layout_zones lz ON lz.id = pi.zone_id
|
|
||||||
JOIN layout_zones lz_all ON lz_all.layout_id = lz.layout_id
|
|
||||||
WHERE pi.playlist_id = ?
|
|
||||||
`).all(playlistId).map(r => r.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { listDesignatedPlaylists, resolveGrantedZone, grantableZoneIds };
|
|
||||||
|
|
|
||||||
|
|
@ -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, resolveGrantedZone } = require('../lib/agency-targets');
|
const { listDesignatedPlaylists } = 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
|
||||||
|
|
@ -28,18 +28,13 @@ router.get('/playlists', (req, res) => {
|
||||||
res.json(listDesignatedPlaylists(db, req.apiToken.id, req.jwtWorkspaceId));
|
res.json(listDesignatedPlaylists(db, req.apiToken.id, req.jwtWorkspaceId));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Layout GEOMETRY for ONE designated playlist (the per-playlist card): canvas size + zone
|
// Layout GEOMETRY for ONE designated playlist (the per-playlist size-guidance card): canvas
|
||||||
// positions/sizes, with "your zone" = the GRANTED zones (placement = grant). Has :playlistId,
|
// size + zone positions/sizes, with feeds_zone_ids = the zones this playlist actually feeds
|
||||||
// so router.param confines it to a granted playlist. DEVICE-FREE (lib/agency-layouts.js) - no
|
// (so the agency sees where/what-size their content lands). Returns [] when the playlist has
|
||||||
// device names/locations/topology. Bite-tested in agency-layouts.test.js (the geometry) +
|
// no layout -> the card shows the full-screen message. Placement itself stays the admin's job
|
||||||
// router.param (the confinement).
|
// (device-side). Has :playlistId, so router.param confines it. DEVICE-FREE (lib/agency-layouts.js).
|
||||||
router.get('/playlists/:playlistId/layout', (req, res) => {
|
router.get('/playlists/:playlistId/layout', (req, res) => {
|
||||||
const layouts = listLayoutGeometry(db, req.apiToken.id, req.jwtWorkspaceId, req.params.playlistId);
|
res.json(listLayoutGeometry(db, req.apiToken.id, req.jwtWorkspaceId, req.params.playlistId));
|
||||||
const granted = new Set(db.prepare('SELECT zone_id FROM api_token_target_zones WHERE token_id = ? AND playlist_id = ?')
|
|
||||||
.all(req.apiToken.id, req.params.playlistId).map(r => r.zone_id));
|
|
||||||
// "your zone" = the granted zones, not the item-feed zones (placement is the grant)
|
|
||||||
for (const l of layouts) l.feeds_zone_ids = l.zones.filter(z => granted.has(z.id)).map(z => z.id);
|
|
||||||
res.json(layouts);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// #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,
|
||||||
|
|
@ -84,16 +79,6 @@ 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' });
|
||||||
|
|
@ -111,8 +96,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, zone_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)')
|
const itemId = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)')
|
||||||
.run(req.params.playlistId, content_id, z.zoneId, order, duration_sec).lastInsertRowid;
|
.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)')
|
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
|
||||||
|
|
@ -133,7 +118,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, zone_id: z.zoneId, duration_sec, start_date: sd, end_date: ed, published });
|
res.status(201).json({ id: itemId, playlist_id: req.params.playlistId, content_id, duration_sec, start_date: sd, end_date: ed, published });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -291,21 +291,6 @@ router.delete('/:id', requirePlaylistWrite, (req, res) => {
|
||||||
// --- Playlist Items ---
|
// --- Playlist Items ---
|
||||||
|
|
||||||
// List items
|
// List items
|
||||||
// #73: the zones of the layout(s) this playlist feeds - for the agency-token designate UI to
|
|
||||||
// offer grantable zones. Geometry only (matches the agency layout view's safe surface).
|
|
||||||
router.get('/:id/zones', requirePlaylistRead, (req, res) => {
|
|
||||||
res.json(db.prepare(`
|
|
||||||
SELECT DISTINCT lz_all.id, lz_all.name, lz_all.width_percent, lz_all.height_percent,
|
|
||||||
l.name AS layout_name, l.width AS layout_width, l.height AS layout_height
|
|
||||||
FROM playlist_items pi
|
|
||||||
JOIN layout_zones lz ON lz.id = pi.zone_id
|
|
||||||
JOIN layout_zones lz_all ON lz_all.layout_id = lz.layout_id
|
|
||||||
JOIN layouts l ON l.id = lz_all.layout_id
|
|
||||||
WHERE pi.playlist_id = ?
|
|
||||||
ORDER BY lz_all.sort_order, lz_all.z_index
|
|
||||||
`).all(req.params.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id/items', requirePlaylistRead, (req, res) => {
|
router.get('/:id/items', requirePlaylistRead, (req, res) => {
|
||||||
const items = db.prepare(`
|
const items = db.prepare(`
|
||||||
SELECT pi.*,
|
SELECT pi.*,
|
||||||
|
|
|
||||||
|
|
@ -7,26 +7,6 @@ const crypto = require('crypto');
|
||||||
const { db } = require('../db/database');
|
const { db } = require('../db/database');
|
||||||
const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken');
|
const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken');
|
||||||
const { accessContext } = require('../lib/tenancy');
|
const { accessContext } = require('../lib/tenancy');
|
||||||
const { grantableZoneIds } = require('../lib/agency-targets');
|
|
||||||
|
|
||||||
// #73: validate per-playlist zone grants and return the (playlist_id, zone_id) rows to insert.
|
|
||||||
// target_zones is { playlist_id: [zone_id,...] }. Each playlist must be a granted target, and
|
|
||||||
// each zone must be one the playlist's layout actually feeds (grantableZoneIds) - the issuance-
|
|
||||||
// side mirror of the runtime confinement. Returns { error } on the first violation, else { rows }.
|
|
||||||
function buildZoneGrantRows(target_zones, targetIdSet) {
|
|
||||||
const rows = [];
|
|
||||||
if (!target_zones || typeof target_zones !== 'object') return { rows };
|
|
||||||
for (const [playlistId, zoneIds] of Object.entries(target_zones)) {
|
|
||||||
if (!targetIdSet.has(playlistId)) return { error: `zone grant references playlist ${playlistId} which is not a designated target` };
|
|
||||||
if (!Array.isArray(zoneIds)) return { error: `zones for ${playlistId} must be an array` };
|
|
||||||
const grantable = grantableZoneIds(db, playlistId);
|
|
||||||
for (const zid of zoneIds) {
|
|
||||||
if (!grantable.has(zid)) return { error: `zone ${zid} is not in playlist ${playlistId}'s layout` };
|
|
||||||
rows.push({ playlistId, zoneId: zid });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { rows };
|
|
||||||
}
|
|
||||||
|
|
||||||
// #73: 'agency' is OFF the read/write/full ladder (not in apiToken.js SCOPE_RANK), so a
|
// #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.
|
// tokenScopeGate-mounted router rejects it; it reaches only the AGENCY_ROUTER via agencyGate.
|
||||||
|
|
@ -67,7 +47,6 @@ router.post('/', (req, res) => {
|
||||||
// auto_publish is meaningful ONLY for agency scope and is the admin's explicit opt-OUT of
|
// auto_publish is meaningful ONLY for agency scope and is the admin's explicit opt-OUT of
|
||||||
// approval. Anything but agency-scope + literal true -> 0 (draft, the fail-safe default).
|
// approval. Anything but agency-scope + literal true -> 0 (draft, the fail-safe default).
|
||||||
const autoPublish = (scope === 'agency' && req.body.auto_publish === true) ? 1 : 0;
|
const autoPublish = (scope === 'agency' && req.body.auto_publish === true) ? 1 : 0;
|
||||||
let zoneRows = [];
|
|
||||||
if (scope === 'agency') {
|
if (scope === 'agency') {
|
||||||
targetIds = Array.isArray(req.body.target_playlist_ids) ? req.body.target_playlist_ids : [];
|
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' });
|
if (!targetIds.length) return res.status(400).json({ error: 'an agency token requires target_playlist_ids' });
|
||||||
|
|
@ -75,10 +54,6 @@ router.post('/', (req, res) => {
|
||||||
for (const pid of targetIds) {
|
for (const pid of targetIds) {
|
||||||
if (!inWs.get(pid, req.workspaceId)) return res.status(400).json({ error: `playlist ${pid} is not in this workspace` });
|
if (!inWs.get(pid, req.workspaceId)) return res.status(400).json({ error: `playlist ${pid} is not in this workspace` });
|
||||||
}
|
}
|
||||||
// #73: optional zone grants - validated against each playlist's layout zones up front.
|
|
||||||
const zg = buildZoneGrantRows(req.body.target_zones, new Set(targetIds));
|
|
||||||
if (zg.error) return res.status(400).json({ error: zg.error });
|
|
||||||
zoneRows = zg.rows;
|
|
||||||
}
|
}
|
||||||
const secret = generateToken();
|
const secret = generateToken();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|
@ -90,8 +65,6 @@ router.post('/', (req, res) => {
|
||||||
if (scope === 'agency') {
|
if (scope === 'agency') {
|
||||||
const ins = db.prepare('INSERT INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)');
|
const ins = db.prepare('INSERT INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)');
|
||||||
for (const pid of targetIds) ins.run(id, pid);
|
for (const pid of targetIds) ins.run(id, pid);
|
||||||
const insZ = db.prepare('INSERT INTO api_token_target_zones (token_id, playlist_id, zone_id) VALUES (?, ?, ?)');
|
|
||||||
for (const r of zoneRows) insZ.run(id, r.playlistId, r.zoneId); // FK requires the playlist grant above
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
// `token` is returned only here, never again.
|
// `token` is returned only here, never again.
|
||||||
|
|
@ -120,18 +93,12 @@ router.put('/:id/targets', (req, res) => {
|
||||||
for (const pid of ids) {
|
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` });
|
if (!inWs.get(pid, tok.workspace_id)) return res.status(400).json({ error: `playlist ${pid} is not in this token's workspace` });
|
||||||
}
|
}
|
||||||
// #73: optional zone grants, validated against each playlist's layout zones.
|
|
||||||
const zg = buildZoneGrantRows(req.body.target_zones, new Set(ids));
|
|
||||||
if (zg.error) return res.status(400).json({ error: zg.error });
|
|
||||||
const ins = db.prepare('INSERT OR IGNORE INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)');
|
const ins = db.prepare('INSERT OR IGNORE INTO api_token_targets (token_id, playlist_id) VALUES (?, ?)');
|
||||||
const insZ = db.prepare('INSERT INTO api_token_target_zones (token_id, playlist_id, zone_id) VALUES (?, ?, ?)');
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
// delete the PARENT rows; the FK cascade clears the old zone grants (no manual child delete)
|
|
||||||
db.prepare('DELETE FROM api_token_targets WHERE token_id = ?').run(tok.id);
|
db.prepare('DELETE FROM api_token_targets WHERE token_id = ?').run(tok.id);
|
||||||
for (const pid of ids) ins.run(tok.id, pid);
|
for (const pid of ids) ins.run(tok.id, pid);
|
||||||
for (const r of zg.rows) insZ.run(tok.id, r.playlistId, r.zoneId);
|
|
||||||
})();
|
})();
|
||||||
res.json({ id: tok.id, target_playlist_ids: ids, zone_grants: zg.rows.length });
|
res.json({ id: tok.id, target_playlist_ids: ids });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
'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, grantableZoneIds } = 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');
|
|
||||||
CREATE TABLE playlist_items (id INTEGER PRIMARY KEY, playlist_id TEXT, zone_id TEXT);
|
|
||||||
INSERT INTO playlist_items VALUES (1,'plA','zA1'), (2,'plB','zB1'); -- plA feeds L1, plB feeds 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('#73 ISSUANCE validation: can only grant a zone the playlist\'s layout feeds', () => {
|
|
||||||
const db = freshDb();
|
|
||||||
// plA feeds L1, so its layout's zones (zA1, zA2) are grantable - and nothing else
|
|
||||||
assert.deepEqual([...grantableZoneIds(db, 'plA')].sort(), ['zA1', 'zA2']);
|
|
||||||
// zB1 belongs to L2 (plB's layout) - NOT grantable for plA (no cross-playlist-layout grant)
|
|
||||||
assert.equal(grantableZoneIds(db, 'plA').has('zB1'), false);
|
|
||||||
assert.deepEqual([...grantableZoneIds(db, 'plB')], ['zB1']);
|
|
||||||
});
|
|
||||||
|
|
@ -92,11 +92,6 @@ test('#73 agency token: full bite-suite (happy path + 4 confinement assertions)'
|
||||||
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');
|
||||||
|
|
||||||
// BITE 5 (issuance, zone): can't grant a zone the playlist's layout doesn't feed -> 400
|
|
||||||
// (pl1 has no zone-targeted items, so NO zone is grantable for it)
|
|
||||||
const badZone = await jfetch('/api/tokens', jpost(jwt, { name: 'BadZone', scope: 'agency', target_playlist_ids: [pl1.id], target_zones: { [pl1.id]: ['nope-zone'] } }));
|
|
||||||
assert.equal(badZone.status, 400, 'cannot grant a zone the playlist\'s layout does not feed');
|
|
||||||
|
|
||||||
// Portal graceful-failure trigger: an invalid/revoked key -> 401, which the portal catches
|
// Portal graceful-failure trigger: an invalid/revoked key -> 401, which the portal catches
|
||||||
// to show "paste it again" (never a wall of 403s).
|
// to show "paste it again" (never a wall of 403s).
|
||||||
const bogus = await jfetch('/api/agency/playlists', { headers: { Authorization: 'Bearer st_bogus_invalid_key' } });
|
const bogus = await jfetch('/api/agency/playlists', { headers: { Authorization: 'Bearer st_bogus_invalid_key' } });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue