flow-scope.mjs
167 lines 5.7 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 17 hours ago
1 /**
2 * Flow workspace scope resolution (Phase 7A-10b).
3 *
4 * Maps verified identity + role + optional data_dir grants to the server-resolved
5 * `visibleScopes` set fed into the Flow store. Deny-by-default: absent resolution
6 * yields `{ personal }` only.
7 *
8 * @see docs/FLOW-STORE-CONTRACT-7A-10.md §4
9 */
10
11 import fs from 'fs';
12 import path from 'path';
13
14 /** @typedef {'personal'|'project'|'org'} FlowScope */
15
16 export const FLOW_SCOPES = /** @type {const} */ (['personal', 'project', 'org']);
17
18 const FLOW_SCOPE_FILE = 'hub_flow_workspace_scope.json';
19
20 /** @type {Record<FlowScope, number>} */
21 export const SCOPE_RANK = { personal: 0, project: 1, org: 2 };
22
23 /**
24 * @param {string} dataDir
25 * @returns {Record<string, Record<string, { scopes?: string[], ambiguous?: boolean }>>}
26 */
27 export function readFlowWorkspaceScope(dataDir) {
28 if (!dataDir) return {};
29 const filePath = path.join(dataDir, FLOW_SCOPE_FILE);
30 try {
31 if (!fs.existsSync(filePath)) return {};
32 const raw = fs.readFileSync(filePath, 'utf8');
33 const data = JSON.parse(raw);
34 if (!data || typeof data !== 'object') return {};
35 return data;
36 } catch {
37 return {};
38 }
39 }
40
41 /**
42 * Role-derived baseline visible scopes (deny-by-default).
43 *
44 * @param {string|undefined|null} role
45 * @returns {Set<FlowScope>}
46 */
47 export function visibleScopesForRole(role) {
48 const r = typeof role === 'string' ? role.trim().toLowerCase() : '';
49 if (r === 'admin') return new Set(['personal', 'project', 'org']);
50 if (r === 'editor') return new Set(['personal', 'project']);
51 return new Set(['personal']);
52 }
53
54 /**
55 * Resolve the caller's authorized Flow workspace scopes.
56 *
57 * @param {{ dataDir?: string, userId?: string, vaultId?: string, role?: string, cliScopes?: FlowScope[] }} ctx
58 * @returns {{ visibleScopes: Set<FlowScope>, ambiguous: boolean }}
59 */
60 export function resolveFlowVisibleScopes(ctx) {
61 if (Array.isArray(ctx.cliScopes) && ctx.cliScopes.length > 0) {
62 const scopes = ctx.cliScopes.filter((s) => FLOW_SCOPES.includes(s));
63 if (scopes.length === 0) {
64 return { visibleScopes: new Set(['personal']), ambiguous: false };
65 }
66 return { visibleScopes: new Set(scopes), ambiguous: false };
67 }
68
69 const userId = typeof ctx.userId === 'string' ? ctx.userId.trim() : '';
70 const vaultId = typeof ctx.vaultId === 'string' ? ctx.vaultId.trim() : 'default';
71 const map = readFlowWorkspaceScope(ctx.dataDir ?? '');
72 const entry = userId && map[userId]?.[vaultId];
73
74 if (entry?.ambiguous === true) {
75 return { visibleScopes: new Set(['personal']), ambiguous: true };
76 }
77
78 let visible = visibleScopesForRole(ctx.role);
79 if (entry && Array.isArray(entry.scopes) && entry.scopes.length > 0) {
80 const granted = entry.scopes.filter((s) => FLOW_SCOPES.includes(s));
81 if (granted.length > 0) {
82 visible = new Set(granted);
83 }
84 }
85
86 return { visibleScopes: visible, ambiguous: false };
87 }
88
89 /**
90 * Highest authorized scope (for effective_scope when not narrowed).
91 *
92 * @param {Set<FlowScope>} visibleScopes
93 * @returns {FlowScope}
94 */
95 export function highestFlowScope(visibleScopes) {
96 let best = /** @type {FlowScope} */ ('personal');
97 for (const scope of visibleScopes) {
98 if (SCOPE_RANK[scope] > SCOPE_RANK[best]) best = scope;
99 }
100 return best;
101 }
102
103 /**
104 * Resolve **write** authority for a target Flow scope (Phase 7A-L1b).
105 *
106 * Write authority is a second, stricter gate than read visibility
107 * (FLOW-AUTHORING-WRITEBACK-CONTRACT-7A-L1 §2): the actor must hold the target
108 * scope in their server-resolved authorized set. Deny-by-default — the client
109 * never supplies its own write tier. `personal` is granted to any authenticated
110 * writer whose authorized set includes `personal`; `project` requires the
111 * editor-tier grant; `org` requires the admin-tier grant. The authorized set is
112 * exactly the role/grant-derived `visibleScopes`, so `visibleScopes.has(target)`
113 * encodes the role gate without trusting any client-asserted tier.
114 *
115 * @param {Set<FlowScope>} visibleScopes - server-resolved authorized scopes.
116 * @param {FlowScope} targetScope - the scope the draft/bundle wants to write.
117 * @returns {{ ok: true } | { ok: false, status: number, error: string, code: string }}
118 */
119 export function resolveFlowWriteAuthority(visibleScopes, targetScope) {
120 if (!FLOW_SCOPES.includes(targetScope)) {
121 return { ok: false, status: 400, error: 'Invalid scope', code: 'FLOW_DRAFT_INVALID' };
122 }
123 if (!(visibleScopes instanceof Set) || !visibleScopes.has(targetScope)) {
124 return {
125 ok: false,
126 status: 403,
127 error: 'Flow write scope not authorized',
128 code: 'FLOW_SCOPE_DENIED',
129 };
130 }
131 return { ok: true };
132 }
133
134 /**
135 * Validate an optional scope query param against authorized scopes.
136 *
137 * @param {Set<FlowScope>} visibleScopes
138 * @param {string|undefined|null} requestedScope
139 * @returns {{ ok: true, effectiveScope: FlowScope, filterScopes: Set<FlowScope> } | { ok: false, status: number, error: string, code: string }}
140 */
141 export function resolveFlowScopeQuery(visibleScopes, requestedScope) {
142 const trimmed = typeof requestedScope === 'string' ? requestedScope.trim() : '';
143 if (trimmed) {
144 if (!FLOW_SCOPES.includes(/** @type {FlowScope} */ (trimmed))) {
145 return { ok: false, status: 400, error: 'Invalid scope', code: 'BAD_REQUEST' };
146 }
147 if (!visibleScopes.has(/** @type {FlowScope} */ (trimmed))) {
148 return {
149 ok: false,
150 status: 403,
151 error: 'Flow scope not authorized',
152 code: 'FLOW_SCOPE_DENIED',
153 };
154 }
155 return {
156 ok: true,
157 effectiveScope: /** @type {FlowScope} */ (trimmed),
158 filterScopes: new Set([/** @type {FlowScope} */ (trimmed)]),
159 };
160 }
161
162 return {
163 ok: true,
164 effectiveScope: highestFlowScope(visibleScopes),
165 filterScopes: visibleScopes,
166 };
167 }
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 17 hours ago