screentinker/server/lib/agency-targets.js
screentinker 2f3dd80881
feat(agency): per-token upload folder — auto-created, subtree-confined (#158) (#171)
Agency-portal uploads previously all landed at the workspace library root, unsorted.
Instead of the issue's whole-workspace folder dropdown (which would leak every folder
name to an external party), bind ONE folder per agency token — admin-controlled and
agency-invisible — and scope the portal picker strictly to that folder's own subtree
(Hybrid-C). Fully backwards-compatible: no bound folder -> root, exactly as before.

Model / multi-workspace: an agency token is bound to ONE workspace at issuance, so the
token key IS that workspace's private link and the bound folder lives in that workspace.
An admin with N workspaces mints one token per workspace (each with its own auto-folder).
No workspace-switcher in the portal — the token is the tenant boundary.

Backend:
- api_tokens.upload_folder_id (additive; ON DELETE SET NULL -> deleting the folder falls
  back to root).
- lib/agency-targets.folderSubtree(): recursive-CTE helper = the SINGLE confinement source
  shared by GET /api/agency/folders AND the POST /api/agency/content target check, so the
  set the agency can SEE and the set it may WRITE to can never drift. Workspace-guarded at
  the anchor row; descendants inherit the workspace (folders.js forbids cross-ws parents).
- routes/agency.js: GET /folders (bound subtree only); POST /content defaults to the bound
  folder and 403s any folder_id outside the subtree.
- routes/tokens.js: create auto-creates "Agency — <name>" (or binds a picked folder,
  validated same-workspace, respecting the 100-folder cap) inside the token tx; new
  PUT /:id/upload-folder to rebind; listing surfaces the bound folder name.
- middleware/apiToken.js + lib/content-ingest.js: upload_folder_id onto req.apiToken; ingest
  writes folder_id.

Frontend:
- Agency portal: folder <select> shown only when a real subfolder choice exists (identifies
  the "Main folder" root client-side without learning the token's folder id).
- Settings: folder pick at token creation, bound-folder display, rebind modal.
- i18n: 7 new apitoken.* keys across all 5 locales.

Tests (429/429):
- test/agency-folder.test.js: 5 folderSubtree confinement bites (subtree in, siblings out,
  workspace guard, null -> root).
- test/agency.test.js (+1 e2e): auto-create, default-to-bound, in-subtree pick lands there,
  sibling -> 403, admin-pick, unknown-pick -> 400, rebind-to-root.

Closes #158.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:23:25 -05:00

52 lines
2.7 KiB
JavaScript

'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);
}
// #73 full-screen guardrail: a playlist is "zoned" if any item targets a layout zone. Agency
// uploads are full-screen and can't safely target a zone, so a zoned playlist can't be shared
// with an agency. Checked at BOTH designation (reject the grant) AND upload (block the add) -
// the upload check is mandatory because auto-publish has no draft step to catch a playlist
// that becomes zoned after designation.
function isZonedPlaylist(db, playlistId) {
return !!db.prepare('SELECT 1 FROM playlist_items WHERE playlist_id = ? AND zone_id IS NOT NULL LIMIT 1').get(playlistId);
}
// #158 (Hybrid-C): the folder subtree an agency token may upload into = its bound
// upload_folder_id PLUS every descendant. This one recursive query IS the confinement,
// used by BOTH GET /api/agency/folders (the portal dropdown) and POST /api/agency/content
// (the upload target check) — so the list the agency sees and the set it may write to can
// never drift apart. The anchor row is workspace-guarded; descendants inherit the workspace
// because folders.js forbids a cross-workspace parent, so the whole subtree stays in-workspace.
// Returns [] for a null/foreign/absent root (legacy or root-bound token -> uploads go to root,
// no dropdown). rootFolderId included in the result (an agency can upload to the folder itself).
function folderSubtree(db, rootFolderId, workspaceId) {
if (!rootFolderId) return [];
return db.prepare(`
WITH RECURSIVE sub(id) AS (
SELECT id FROM content_folders WHERE id = ? AND workspace_id = ?
UNION
SELECT cf.id FROM content_folders cf JOIN sub ON cf.parent_id = sub.id
)
SELECT cf.id, cf.name, cf.parent_id
FROM content_folders cf JOIN sub ON cf.id = sub.id
ORDER BY cf.name COLLATE NOCASE
`).all(rootFolderId, workspaceId);
}
module.exports = { listDesignatedPlaylists, isZonedPlaylist, folderSubtree };