flow-scope.mjs
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