mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
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>
52 lines
2.7 KiB
JavaScript
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 };
|