| … | … | @@ -1,107 +1,112 @@ |
| 1 | 1 | /** |
| 2 | | * HyperOrgs — P2P organization management built on hyper-rooms |
| 2 | * HyperOrgs — per-org-autobase organization management (issue #35). |
| 3 | 3 | * |
| 4 | | * Multi-writer, replicating, offline-first org management. Wraps a Room with |
| 5 | | * the orgsSchema and provides a high-level API for orgs, members, resources, |
| 6 | | * grants, invites, and access requests. |
| 4 | * Each org is its OWN Room (autobase) — NOT one shared autobase for all |
| 5 | * orgs. This is the post-mortem fix: a peer that joins one org replicates |
| 6 | * ONLY that org's autobase, so other orgs never reach its disk. |
| 7 | 7 | * |
| 8 | | * Features: |
| 9 | | * - Organizations (user or group) with visibility |
| 10 | | * - Members with roles (admin, member) — groups only |
| 11 | | * - Resources (repos, channels, services — app decides what) |
| 12 | | * - Access grants (per-resource or per-group permissions) |
| 13 | | * - Invitations (with expiry and max uses) |
| 14 | | * - Access requests (pending → approved/denied workflow) |
| 15 | | * - Multi-writer via Room (Autobase under the hood) |
| 8 | * - create(name, ...) → mints a new per-org Room (fresh autobase) |
| 9 | * - createOrgInvite(name, ..) → bearer invite carrying that Room's |
| 10 | * bootstrap key (+ advisory role) |
| 11 | * - joinOrg(invite) → mounts ONLY that org's Room |
| 12 | * - getOrgBase(name) → that org's autobase (for replication) |
| 13 | * - get/list/update/delete/addMember/addResource/... operate on the |
| 14 | * per-org Room. |
| 16 | 15 | * |
| 17 | | * Usage: |
| 16 | * The instance tracks its orgs (created + joined) in an in-memory Map. |
| 17 | * Persistence across restart (re-opening org Rooms from a local index) |
| 18 | * is the daemon's concern — see the pear-git-v3 handler work — and is a |
| 19 | * documented TODO here, not needed by the in-process tests. |
| 18 | 20 | * |
| 19 | | * const orgs = new HyperOrgs(corestore, { namespace: 'my-forge' }) |
| 20 | | * await orgs.ready() |
| 21 | | * await orgs.create('my-team', 'group', { displayName: 'My Team' }) |
| 22 | | * await orgs.addMember('my-team', 'alice', 'admin') |
| 23 | | * await orgs.addResource('my-team', 'project-a') |
| 21 | * Bearer invites come from hyper-rooms (shared with hyper-issues' |
| 22 | * repo invites). Write-enrollment (a contributor joiner becoming an |
| 23 | * autobase writer) + role-enforced apply is a follow-up increment; |
| 24 | * today joiners are read-only followers of the org's autobase. |
| 24 | 25 | */ |
| 25 | 26 | |
| 26 | | import { Room } from 'hyper-rooms' |
| 27 | import { Room, encodeInvite, decodeInvite, isExpired } from 'hyper-rooms' |
| 27 | 28 | import { orgsSchema, validateName } from './schema.js' |
| 28 | 29 | |
| 29 | 30 | export class HyperOrgs { |
| 30 | 31 | /** |
| 31 | 32 | * @param {Corestore} corestore |
| 32 | | * @param {object} options |
| 33 | | * @param {string} [options.namespace='orgs'] |
| 34 | | * @param {Buffer|string|null} [options.bootstrapKey=null] |
| 33 | * @param {object} [options] |
| 34 | * @param {string} [options.namespace='orgs'] - parent corestore namespace |
| 35 | 35 | */ |
| 36 | 36 | constructor (corestore, options = {}) { |
| 37 | | this._room = new Room(corestore, { |
| 38 | | schema: orgsSchema, |
| 39 | | namespace: options.namespace || 'orgs', |
| 40 | | bootstrapKey: options.bootstrapKey || null |
| 41 | | }) |
| 37 | this._corestore = corestore.namespace(options.namespace || 'orgs') |
| 38 | // name -> { room: Room, role: string, joined: boolean } |
| 39 | this._orgs = new Map() |
| 40 | this._opened = false |
| 42 | 41 | } |
| 43 | 42 | |
| 44 | | // ── Lifecycle / properties ────────────────────────────────── |
| 45 | | |
| 46 | 43 | async ready () { |
| 47 | | await this._room.ready() |
| 44 | this._opened = true |
| 45 | // TODO(daemon): re-open known org Rooms from a local persistence |
| 46 | // index here so orgs survive a restart. In-process callers create |
| 47 | // or join within one instance lifetime, so the Map suffices. |
| 48 | 48 | } |
| 49 | 49 | |
| 50 | | async sync () { await this._room.base.update() } |
| 51 | | async close () { await this._room.close() } |
| 50 | async sync () { |
| 51 | for (const { room } of this._orgs.values()) await room.base.update() |
| 52 | } |
| 52 | 53 | |
| 53 | | get key () { return this._room.key } |
| 54 | | get keyHex () { return this._room.keyHex } |
| 55 | | get discoveryKey () { return this._room.discoveryKey } |
| 56 | | get localKey () { return this._room.localKey } |
| 57 | | get localKeyHex () { return this._room.localKeyHex } |
| 58 | | get writable () { return this._room.writable } |
| 59 | | get view () { return this._room.view } |
| 60 | | get base () { return this._room.base } |
| 61 | | get room () { return this._room } |
| 54 | async close () { |
| 55 | for (const { room } of this._orgs.values()) { |
| 56 | try { await room.close() } catch {} |
| 57 | } |
| 58 | this._orgs.clear() |
| 59 | } |
| 62 | 60 | |
| 63 | 61 | validateName (name) { return validateName(name) } |
| 64 | 62 | |
| 65 | | // ── Writer management (delegated to Room) ─────────────────── |
| 63 | // ── Per-org Room access ────────────────────────────────────── |
| 66 | 64 | |
| 67 | | async addWriter (userId, options = {}) { |
| 68 | | return this._room.addWriter(userId, options) |
| 65 | /** The sole org's autobase — convenience for single-org instances |
| 66 | * (e.g. a joiner). Use getOrgBase(name) when tracking several. */ |
| 67 | get base () { |
| 68 | if (this._orgs.size === 1) return [...this._orgs.values()][0].room.base |
| 69 | if (this._orgs.size === 0) return null |
| 70 | throw new Error('multiple orgs tracked — use getOrgBase(name)') |
| 69 | 71 | } |
| 70 | 72 | |
| 71 | | async removeWriter (userId) { |
| 72 | | return this._room.removeWriter(userId) |
| 73 | getOrgBase (name) { |
| 74 | const e = this._orgs.get(name) |
| 75 | return e ? e.room.base : null |
| 73 | 76 | } |
| 74 | 77 | |
| 75 | | async listWriters () { |
| 76 | | return this._room.listWriters() |
| 78 | getOrgRoom (name) { |
| 79 | const e = this._orgs.get(name) |
| 80 | return e ? e.room : null |
| 77 | 81 | } |
| 78 | 82 | |
| 79 | | async getWriter (userId) { |
| 80 | | return this._room.getWriter(userId) |
| 83 | _requireRoom (name) { |
| 84 | const e = this._orgs.get(name) |
| 85 | if (!e) throw new Error(`org "${name}" not tracked by this instance`) |
| 86 | return e.room |
| 81 | 87 | } |
| 82 | 88 | |
| 83 | | // ── Org CRUD ──────────────────────────────────────────────── |
| 89 | // ── Org lifecycle ──────────────────────────────────────────── |
| 84 | 90 | |
| 85 | 91 | /** |
| 86 | | * Create an organization. |
| 87 | | * @param {string} name - unique, lowercase alphanumeric + hyphens |
| 88 | | * @param {'user'|'group'} [type='group'] |
| 89 | | * @param {object} [data] |
| 90 | | * @param {string} [data.displayName] |
| 91 | | * @param {string} [data.description] |
| 92 | | * @param {string} [data.userId] - owner userId (for user-type orgs) |
| 93 | | * @param {Array} [data.members] - initial members (group-type) |
| 94 | | * @param {string} [data.visibility='public'] |
| 92 | * Create an organization in its own autobase. |
| 93 | * @returns the org record |
| 95 | 94 | */ |
| 96 | 95 | async create (name, type = 'group', data = {}) { |
| 97 | 96 | const v = validateName(name) |
| 98 | 97 | if (!v.valid) throw new Error(v.error) |
| 99 | 98 | if (type !== 'user' && type !== 'group') throw new Error('Type must be "user" or "group"') |
| 99 | if (this._orgs.has(name)) throw new Error(`Organization "${name}" already exists`) |
| 100 | 100 | |
| 101 | | const existing = await this.get(name) |
| 102 | | if (existing) throw new Error(`Organization "${name}" already exists`) |
| 101 | const room = new Room(this._corestore, { |
| 102 | schema: orgsSchema, |
| 103 | namespace: 'org/' + name, |
| 104 | bootstrapKey: null |
| 105 | }) |
| 106 | await room.ready() |
| 107 | this._orgs.set(name, { room, role: 'admin', joined: false }) |
| 103 | 108 | |
| 104 | | await this._room.append({ |
| 109 | await room.append({ |
| 105 | 110 | type: 'create-org', |
| 106 | 111 | name, |
| 107 | 112 | orgType: type, |
| … | … | @@ -113,14 +118,16 @@ export class HyperOrgs { |
| 113 | 118 | visibility: data.visibility || 'public', |
| 114 | 119 | timestamp: Date.now() |
| 115 | 120 | }) |
| 116 | | await this._room.base.update() |
| 121 | await room.base.update() |
| 117 | 122 | return this.get(name) |
| 118 | 123 | } |
| 119 | 124 | |
| 120 | 125 | async get (name) { |
| 126 | const e = this._orgs.get(name) |
| 127 | if (!e) return null |
| 121 | 128 | try { |
| 122 | | await this._room.base.update() |
| 123 | | const entry = await this._room.view.get(`org:${name}`) |
| 129 | await e.room.base.update() |
| 130 | const entry = await e.room.view.get(`org:${name}`) |
| 124 | 131 | return entry ? entry.value : null |
| 125 | 132 | } catch { |
| 126 | 133 | return null |
| … | … | @@ -128,17 +135,14 @@ export class HyperOrgs { |
| 128 | 135 | } |
| 129 | 136 | |
| 130 | 137 | async update (name, updates) { |
| 131 | | const org = await this.get(name) |
| 132 | | if (!org) throw new Error(`Organization "${name}" not found`) |
| 133 | | |
| 138 | const room = this._requireRoom(name) |
| 134 | 139 | const op = { type: 'update-org', name, timestamp: Date.now() } |
| 135 | 140 | if (updates.displayName !== undefined) op.displayName = updates.displayName |
| 136 | 141 | if (updates.description !== undefined) op.description = updates.description |
| 137 | 142 | if (updates.about !== undefined) op.about = updates.about |
| 138 | 143 | if (updates.visibility !== undefined) op.visibility = updates.visibility |
| 139 | | |
| 140 | | await this._room.append(op) |
| 141 | | await this._room.base.update() |
| 144 | await room.append(op) |
| 145 | await room.base.update() |
| 142 | 146 | return this.get(name) |
| 143 | 147 | } |
| 144 | 148 | |
| … | … | @@ -148,115 +152,125 @@ export class HyperOrgs { |
| 148 | 152 | if (org.resources.length > 0) { |
| 149 | 153 | throw new Error(`Cannot delete "${name}": still has ${org.resources.length} resources`) |
| 150 | 154 | } |
| 151 | | |
| 152 | | await this._room.append({ type: 'delete-org', name, timestamp: Date.now() }) |
| 153 | | await this._room.base.update() |
| 155 | const room = this._requireRoom(name) |
| 156 | await room.append({ type: 'delete-org', name, timestamp: Date.now() }) |
| 157 | await room.base.update() |
| 158 | // Drop from local tracking (the autobase still exists on disk). |
| 159 | const e = this._orgs.get(name) |
| 160 | if (e) { try { await e.room.close() } catch {} } |
| 161 | this._orgs.delete(name) |
| 154 | 162 | } |
| 155 | 163 | |
| 156 | | /** |
| 157 | | * List orgs with optional filtering. |
| 158 | | * @param {object} [filters] |
| 159 | | * @param {'user'|'group'} [filters.type] |
| 160 | | * @param {string} [filters.visibility] |
| 161 | | */ |
| 164 | /** Orgs this instance tracks (created or joined). */ |
| 162 | 165 | async list (filters = {}) { |
| 163 | | await this._room.base.update() |
| 164 | | const orgs = [] |
| 165 | | for await (const entry of this._room.view.createReadStream({ |
| 166 | | gt: 'org:', lt: 'org:\xff' |
| 167 | | })) { |
| 168 | | const org = entry.value |
| 166 | const out = [] |
| 167 | for (const name of this._orgs.keys()) { |
| 168 | const org = await this.get(name) |
| 169 | if (!org) continue |
| 169 | 170 | if (filters.type && org.type !== filters.type) continue |
| 170 | 171 | if (filters.visibility && org.visibility !== filters.visibility) continue |
| 171 | | orgs.push(org) |
| 172 | out.push(org) |
| 172 | 173 | } |
| 173 | | return orgs |
| 174 | return out |
| 174 | 175 | } |
| 175 | 176 | |
| 176 | | // ── Resources ─────────────────────────────────────────────── |
| 177 | // ── Invites / join (the per-org-autobase scope mechanism) ──── |
| 178 | |
| 179 | /** |
| 180 | * Issue a bearer invite for one org. Holding it lets a peer mount |
| 181 | * ONLY this org's autobase. |
| 182 | * @param {string} name |
| 183 | * @param {object} [opts] |
| 184 | * @param {string} [opts.role='reader'] |
| 185 | * @param {number} [opts.expiresAt] |
| 186 | * @param {number} [opts.maxUses] |
| 187 | * @returns {{ invitation: string }} |
| 188 | */ |
| 189 | async createOrgInvite (name, opts = {}) { |
| 190 | const room = this._requireRoom(name) |
| 191 | const invitation = encodeInvite({ |
| 192 | bootstrapKey: room.keyHex, |
| 193 | kind: 'org', |
| 194 | name, |
| 195 | roles: [opts.role || 'reader'], |
| 196 | expiresAt: opts.expiresAt || null, |
| 197 | maxUses: opts.maxUses == null ? 1 : opts.maxUses |
| 198 | }) |
| 199 | return { invitation } |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * Consume an org invite: mount ONLY that org's autobase as a |
| 204 | * read-only follower. (Write-enrollment is a follow-up increment.) |
| 205 | * @param {string} invitation |
| 206 | * @returns {Promise<string>} the org name now tracked |
| 207 | */ |
| 208 | async joinOrg (invitation) { |
| 209 | const decoded = decodeInvite(invitation) |
| 210 | if (decoded.kind !== 'org') throw new Error(`invite is for "${decoded.kind}", not an org`) |
| 211 | if (isExpired(decoded)) throw new Error('invite expired') |
| 212 | |
| 213 | const name = decoded.name |
| 214 | if (!name) throw new Error('org invite missing name (cannot key the org)') |
| 215 | if (this._orgs.has(name)) return name // already tracking |
| 216 | |
| 217 | const room = new Room(this._corestore, { |
| 218 | schema: orgsSchema, |
| 219 | // Namespace by bootstrap key so a joiner's local writer is distinct |
| 220 | // from the creator's; the autobase identity is the bootstrap key. |
| 221 | namespace: 'org-join/' + decoded.bootstrapKey.slice(0, 16), |
| 222 | bootstrapKey: decoded.bootstrapKey |
| 223 | }) |
| 224 | await room.ready() |
| 225 | await room.base.update() |
| 226 | this._orgs.set(name, { room, role: decoded.roles[0] || 'reader', joined: true }) |
| 227 | return name |
| 228 | } |
| 229 | |
| 230 | // ── Resources (per-org) ────────────────────────────────────── |
| 177 | 231 | |
| 178 | 232 | async addResource (orgName, resourceName) { |
| 233 | const room = this._requireRoom(orgName) |
| 179 | 234 | const org = await this.get(orgName) |
| 180 | 235 | if (!org) throw new Error(`Organization "${orgName}" not found`) |
| 181 | 236 | if (org.resources.includes(resourceName)) return org |
| 182 | | |
| 183 | | await this._room.append({ |
| 184 | | type: 'add-resource', |
| 185 | | orgName, |
| 186 | | resourceName, |
| 187 | | timestamp: Date.now() |
| 188 | | }) |
| 189 | | await this._room.base.update() |
| 237 | await room.append({ type: 'add-resource', orgName, resourceName, timestamp: Date.now() }) |
| 238 | await room.base.update() |
| 190 | 239 | return this.get(orgName) |
| 191 | 240 | } |
| 192 | 241 | |
| 193 | 242 | async removeResource (orgName, resourceName) { |
| 243 | const room = this._requireRoom(orgName) |
| 194 | 244 | const org = await this.get(orgName) |
| 195 | 245 | if (!org) throw new Error(`Organization "${orgName}" not found`) |
| 196 | 246 | if (!org.resources.includes(resourceName)) { |
| 197 | 247 | throw new Error(`Resource "${resourceName}" is not in "${orgName}"`) |
| 198 | 248 | } |
| 199 | | |
| 200 | | await this._room.append({ |
| 201 | | type: 'remove-resource', |
| 202 | | orgName, |
| 203 | | resourceName, |
| 204 | | timestamp: Date.now() |
| 205 | | }) |
| 206 | | await this._room.base.update() |
| 249 | await room.append({ type: 'remove-resource', orgName, resourceName, timestamp: Date.now() }) |
| 250 | await room.base.update() |
| 207 | 251 | return this.get(orgName) |
| 208 | 252 | } |
| 209 | 253 | |
| 210 | | async moveResource (resourceName, fromOrg, toOrg) { |
| 211 | | const from = await this.get(fromOrg) |
| 212 | | if (!from) throw new Error(`Organization "${fromOrg}" not found`) |
| 213 | | const to = await this.get(toOrg) |
| 214 | | if (!to) throw new Error(`Organization "${toOrg}" not found`) |
| 215 | | if (!from.resources.includes(resourceName)) { |
| 216 | | throw new Error(`Resource "${resourceName}" is not in "${fromOrg}"`) |
| 217 | | } |
| 218 | | |
| 219 | | await this._room.append({ |
| 220 | | type: 'move-resource', |
| 221 | | resourceName, |
| 222 | | fromOrg, |
| 223 | | toOrg, |
| 224 | | timestamp: Date.now() |
| 225 | | }) |
| 226 | | await this._room.base.update() |
| 227 | | } |
| 228 | | |
| 229 | | // ── Members ───────────────────────────────────────────────── |
| 254 | // ── Members (per-org) ──────────────────────────────────────── |
| 230 | 255 | |
| 231 | 256 | async addMember (orgName, userId, role = 'member') { |
| 257 | const room = this._requireRoom(orgName) |
| 232 | 258 | const org = await this.get(orgName) |
| 233 | 259 | if (!org) throw new Error(`Organization "${orgName}" not found`) |
| 234 | 260 | if (org.type !== 'group') throw new Error('Can only add members to group organizations') |
| 235 | 261 | if (!['admin', 'member'].includes(role)) throw new Error('Role must be "admin" or "member"') |
| 236 | | |
| 237 | | await this._room.append({ |
| 238 | | type: 'add-member', |
| 239 | | orgName, |
| 240 | | memberId: userId, |
| 241 | | role, |
| 242 | | timestamp: Date.now() |
| 243 | | }) |
| 244 | | await this._room.base.update() |
| 262 | await room.append({ type: 'add-member', orgName, memberId: userId, role, timestamp: Date.now() }) |
| 263 | await room.base.update() |
| 245 | 264 | return this.get(orgName) |
| 246 | 265 | } |
| 247 | 266 | |
| 248 | 267 | async removeMember (orgName, userId) { |
| 268 | const room = this._requireRoom(orgName) |
| 249 | 269 | const org = await this.get(orgName) |
| 250 | 270 | if (!org) throw new Error(`Organization "${orgName}" not found`) |
| 251 | 271 | if (org.type !== 'group') throw new Error('Can only remove members from group organizations') |
| 252 | | |
| 253 | | await this._room.append({ |
| 254 | | type: 'remove-member', |
| 255 | | orgName, |
| 256 | | memberId: userId, |
| 257 | | timestamp: Date.now() |
| 258 | | }) |
| 259 | | await this._room.base.update() |
| 272 | await room.append({ type: 'remove-member', orgName, memberId: userId, timestamp: Date.now() }) |
| 273 | await room.base.update() |
| 260 | 274 | return this.get(orgName) |
| 261 | 275 | } |
| 262 | 276 | |
| … | … | @@ -269,371 +283,17 @@ export class HyperOrgs { |
| 269 | 283 | }) |
| 270 | 284 | } |
| 271 | 285 | |
| 272 | | // ── Grants ────────────────────────────────────────────────── |
| 273 | | |
| 274 | | async createGrant (opts) { |
| 275 | | const grantId = opts.grantId || |
| 276 | | `${opts.subjectType}-${opts.subjectId.slice(0, 12)}-${Date.now()}` |
| 277 | | |
| 278 | | await this._room.append({ |
| 279 | | type: 'create-grant', |
| 280 | | grantId, |
| 281 | | subjectType: opts.subjectType, |
| 282 | | subjectId: opts.subjectId, |
| 283 | | grantType: opts.grantType, |
| 284 | | groupName: opts.groupName || null, |
| 285 | | resourceName: opts.resourceName || null, |
| 286 | | permission: opts.permission, |
| 287 | | grantedBy: opts.grantedBy || null, |
| 288 | | inviteId: opts.inviteId || null, |
| 289 | | timestamp: Date.now() |
| 290 | | }) |
| 291 | | await this._room.base.update() |
| 292 | | return this.getGrant(grantId) |
| 293 | | } |
| 294 | | |
| 295 | | async revokeGrant (grantId, revokedBy = null) { |
| 296 | | const grant = await this.getGrant(grantId) |
| 297 | | if (!grant) throw new Error(`Grant "${grantId}" not found`) |
| 298 | | if (grant.revokedAt) throw new Error(`Grant "${grantId}" already revoked`) |
| 299 | | |
| 300 | | await this._room.append({ |
| 301 | | type: 'revoke-grant', |
| 302 | | grantId, |
| 303 | | revokedBy, |
| 304 | | timestamp: Date.now() |
| 305 | | }) |
| 306 | | await this._room.base.update() |
| 307 | | } |
| 286 | // ── Writer management (per-org, delegated to Room) ─────────── |
| 308 | 287 | |
| 309 | | async getGrant (grantId) { |
| 310 | | await this._room.base.update() |
| 311 | | const entry = await this._room.view.get(`grant:${grantId}`) |
| 312 | | return entry ? entry.value : null |
| 288 | async addWriter (orgName, userId, options = {}) { |
| 289 | return this._requireRoom(orgName).addWriter(userId, options) |
| 313 | 290 | } |
| 314 | 291 | |
| 315 | | /** |
| 316 | | * List grants. If a specific index key applies (subjectId + subjectType, |
| 317 | | * groupName, or resourceName), uses the index for efficiency. Otherwise |
| 318 | | * scans all grants. |
| 319 | | */ |
| 320 | | async listGrants (filters = {}) { |
| 321 | | await this._room.base.update() |
| 322 | | const activeOnly = filters.activeOnly !== false |
| 323 | | |
| 324 | | if (filters.subjectType === 'user' && filters.subjectId) { |
| 325 | | return this._listByIndex(`user-grants:${filters.subjectId}:`, 'grant', filters, activeOnly) |
| 326 | | } |
| 327 | | if (filters.subjectType === 'server' && filters.subjectId) { |
| 328 | | return this._listByIndex(`server-grants:${filters.subjectId}:`, 'grant', filters, activeOnly) |
| 329 | | } |
| 330 | | if (filters.grantType === 'resource' && filters.resourceName) { |
| 331 | | return this._listByIndex(`resource-grants:${filters.resourceName}:`, 'grant', filters, activeOnly) |
| 332 | | } |
| 333 | | if (filters.grantType === 'group' && filters.groupName) { |
| 334 | | return this._listByIndex(`group-grants:${filters.groupName}:`, 'grant', filters, activeOnly) |
| 335 | | } |
| 336 | | |
| 337 | | const grants = [] |
| 338 | | for await (const entry of this._room.view.createReadStream({ |
| 339 | | gt: 'grant:', lt: 'grant:\xff' |
| 340 | | })) { |
| 341 | | const g = entry.value |
| 342 | | if (activeOnly && g.revokedAt) continue |
| 343 | | if (filters.subjectType && g.subjectType !== filters.subjectType) continue |
| 344 | | if (filters.subjectId && g.subjectId !== filters.subjectId) continue |
| 345 | | if (filters.grantType && g.grantType !== filters.grantType) continue |
| 346 | | if (filters.groupName && g.groupName !== filters.groupName) continue |
| 347 | | if (filters.resourceName && g.resourceName !== filters.resourceName) continue |
| 348 | | grants.push(g) |
| 349 | | } |
| 350 | | return grants |
| 292 | async removeWriter (orgName, userId) { |
| 293 | return this._requireRoom(orgName).removeWriter(userId) |
| 351 | 294 | } |
| 352 | 295 | |
| 353 | | // ── Invites ───────────────────────────────────────────────── |
| 354 | | |
| 355 | | async createInvite (opts) { |
| 356 | | const inviteId = opts.inviteId || |
| 357 | | `inv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` |
| 358 | | |
| 359 | | await this._room.append({ |
| 360 | | type: 'create-invite', |
| 361 | | inviteId, |
| 362 | | inviteType: opts.inviteType, |
| 363 | | targetGroup: opts.targetGroup || null, |
| 364 | | targetResource: opts.targetResource || null, |
| 365 | | permission: opts.permission, |
| 366 | | maxUses: opts.maxUses || 1, |
| 367 | | createdBy: opts.createdBy || null, |
| 368 | | expiresAt: opts.expiresAt || null, |
| 369 | | timestamp: Date.now() |
| 370 | | }) |
| 371 | | await this._room.base.update() |
| 372 | | return this.getInvite(inviteId) |
| 373 | | } |
| 374 | | |
| 375 | | async consumeInvite (inviteId, opts) { |
| 376 | | const invite = await this.getInvite(inviteId) |
| 377 | | if (!invite) throw new Error(`Invite "${inviteId}" not found`) |
| 378 | | if (invite.revoked) throw new Error('Invite has been revoked') |
| 379 | | if (invite.maxUses > 0 && invite.usedCount >= invite.maxUses) { |
| 380 | | throw new Error('Invite fully used') |
| 381 | | } |
| 382 | | if (invite.expiresAt && Date.now() > invite.expiresAt) throw new Error('Invite expired') |
| 383 | | |
| 384 | | const grantId = `${opts.subjectType}-${opts.subjectId.slice(0, 12)}-${Date.now()}` |
| 385 | | await this._room.append({ |
| 386 | | type: 'consume-invite', |
| 387 | | inviteId, |
| 388 | | grantId, |
| 389 | | subjectType: opts.subjectType, |
| 390 | | subjectId: opts.subjectId, |
| 391 | | timestamp: Date.now() |
| 392 | | }) |
| 393 | | await this._room.base.update() |
| 394 | | return this.getGrant(grantId) |
| 395 | | } |
| 396 | | |
| 397 | | async revokeInvite (inviteId) { |
| 398 | | const invite = await this.getInvite(inviteId) |
| 399 | | if (!invite) throw new Error(`Invite "${inviteId}" not found`) |
| 400 | | await this._room.append({ type: 'revoke-invite', inviteId, timestamp: Date.now() }) |
| 401 | | await this._room.base.update() |
| 402 | | } |
| 403 | | |
| 404 | | async getInvite (inviteId) { |
| 405 | | await this._room.base.update() |
| 406 | | const entry = await this._room.view.get(`invite:${inviteId}`) |
| 407 | | return entry ? entry.value : null |
| 408 | | } |
| 409 | | |
| 410 | | async listInvites (filters = {}) { |
| 411 | | await this._room.base.update() |
| 412 | | const activeOnly = filters.activeOnly !== false |
| 413 | | const invites = [] |
| 414 | | for await (const entry of this._room.view.createReadStream({ |
| 415 | | gt: 'invite:', lt: 'invite:\xff' |
| 416 | | })) { |
| 417 | | const inv = entry.value |
| 418 | | if (activeOnly && inv.revoked) continue |
| 419 | | if (activeOnly && inv.expiresAt && Date.now() > inv.expiresAt) continue |
| 420 | | if (activeOnly && inv.maxUses > 0 && inv.usedCount >= inv.maxUses) continue |
| 421 | | invites.push(inv) |
| 422 | | } |
| 423 | | return invites |
| 424 | | } |
| 425 | | |
| 426 | | // ── Access Requests ───────────────────────────────────────── |
| 427 | | |
| 428 | | async createAccessRequest (opts) { |
| 429 | | const requestId = opts.requestId || |
| 430 | | `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` |
| 431 | | |
| 432 | | const pending = await this.listAccessRequests({ |
| 433 | | subjectId: opts.subjectId, |
| 434 | | status: 'pending' |
| 435 | | }) |
| 436 | | const target = opts.requestType === 'group' ? opts.groupName : opts.resourceName |
| 437 | | const duplicate = pending.find(r => |
| 438 | | r.requestType === opts.requestType && |
| 439 | | ((r.requestType === 'group' && r.groupName === target) || |
| 440 | | (r.requestType === 'resource' && r.resourceName === target)) |
| 441 | | ) |
| 442 | | if (duplicate) throw new Error('Already have a pending request for this target') |
| 443 | | |
| 444 | | await this._room.append({ |
| 445 | | type: 'create-access-request', |
| 446 | | requestId, |
| 447 | | subjectType: opts.subjectType, |
| 448 | | subjectId: opts.subjectId, |
| 449 | | requestType: opts.requestType, |
| 450 | | groupName: opts.groupName || null, |
| 451 | | resourceName: opts.resourceName || null, |
| 452 | | permission: opts.permission, |
| 453 | | reason: opts.reason || null, |
| 454 | | timestamp: Date.now() |
| 455 | | }) |
| 456 | | await this._room.base.update() |
| 457 | | return this.getAccessRequest(requestId) |
| 458 | | } |
| 459 | | |
| 460 | | async approveAccessRequest (requestId, approvedBy = null) { |
| 461 | | const request = await this.getAccessRequest(requestId) |
| 462 | | if (!request) throw new Error(`Request "${requestId}" not found`) |
| 463 | | if (request.status !== 'pending') throw new Error(`Request is already ${request.status}`) |
| 464 | | |
| 465 | | const grantId = `${request.subjectType}-${request.subjectId.slice(0, 12)}-${Date.now()}` |
| 466 | | await this._room.append({ |
| 467 | | type: 'approve-access-request', |
| 468 | | requestId, |
| 469 | | grantId, |
| 470 | | approvedBy, |
| 471 | | timestamp: Date.now() |
| 472 | | }) |
| 473 | | await this._room.base.update() |
| 474 | | return this.getGrant(grantId) |
| 475 | | } |
| 476 | | |
| 477 | | async denyAccessRequest (requestId, deniedBy = null, denyReason = null) { |
| 478 | | const request = await this.getAccessRequest(requestId) |
| 479 | | if (!request) throw new Error(`Request "${requestId}" not found`) |
| 480 | | if (request.status !== 'pending') throw new Error(`Request is already ${request.status}`) |
| 481 | | |
| 482 | | await this._room.append({ |
| 483 | | type: 'deny-access-request', |
| 484 | | requestId, |
| 485 | | deniedBy, |
| 486 | | denyReason, |
| 487 | | timestamp: Date.now() |
| 488 | | }) |
| 489 | | await this._room.base.update() |
| 490 | | } |
| 491 | | |
| 492 | | async getAccessRequest (requestId) { |
| 493 | | await this._room.base.update() |
| 494 | | const entry = await this._room.view.get(`request:${requestId}`) |
| 495 | | return entry ? entry.value : null |
| 496 | | } |
| 497 | | |
| 498 | | async listAccessRequests (filters = {}) { |
| 499 | | await this._room.base.update() |
| 500 | | |
| 501 | | if (filters.subjectId) { |
| 502 | | return this._listByIndex(`user-requests:${filters.subjectId}:`, 'request', filters, false) |
| 503 | | } |
| 504 | | if (filters.requestType === 'group' && filters.groupName) { |
| 505 | | return this._listByIndex(`group-requests:${filters.groupName}:`, 'request', filters, false) |
| 506 | | } |
| 507 | | if (filters.requestType === 'resource' && filters.resourceName) { |
| 508 | | return this._listByIndex(`resource-requests:${filters.resourceName}:`, 'request', filters, false) |
| 509 | | } |
| 510 | | |
| 511 | | const requests = [] |
| 512 | | for await (const entry of this._room.view.createReadStream({ |
| 513 | | gt: 'request:', lt: 'request:\xff' |
| 514 | | })) { |
| 515 | | const r = entry.value |
| 516 | | if (filters.status && r.status !== filters.status) continue |
| 517 | | if (filters.subjectId && r.subjectId !== filters.subjectId) continue |
| 518 | | if (filters.requestType && r.requestType !== filters.requestType) continue |
| 519 | | requests.push(r) |
| 520 | | } |
| 521 | | return requests |
| 522 | | } |
| 523 | | |
| 524 | | // ── Access Checking ───────────────────────────────────────── |
| 525 | | |
| 526 | | static permissionLevel (perm) { |
| 527 | | switch (perm) { |
| 528 | | case 'owner': return 3 |
| 529 | | case 'moderator': return 2 |
| 530 | | case 'write': return 1 |
| 531 | | default: return 0 |
| 532 | | } |
| 533 | | } |
| 534 | | |
| 535 | | /** |
| 536 | | * Check whether a subject has the required permission on a resource. |
| 537 | | * Walks direct grants first, then group grants (via the orgs the resource |
| 538 | | * belongs to). |
| 539 | | * |
| 540 | | * @param {object} opts |
| 541 | | * @param {string} [opts.userId] |
| 542 | | * @param {string} [opts.serverKey] |
| 543 | | * @param {string} opts.resourceName |
| 544 | | * @param {'write'|'moderator'|'owner'} opts.requiredPermission |
| 545 | | */ |
| 546 | | async checkAccess (opts) { |
| 547 | | const required = HyperOrgs.permissionLevel(opts.requiredPermission) |
| 548 | | if (required === 0) return false |
| 549 | | |
| 550 | | // Direct user resource grant |
| 551 | | if (opts.userId) { |
| 552 | | const grants = await this.listGrants({ |
| 553 | | subjectType: 'user', subjectId: opts.userId, |
| 554 | | grantType: 'resource', resourceName: opts.resourceName |
| 555 | | }) |
| 556 | | for (const g of grants) { |
| 557 | | if (HyperOrgs.permissionLevel(g.permission) >= required) return true |
| 558 | | } |
| 559 | | } |
| 560 | | |
| 561 | | // User group grant (for orgs containing this resource) |
| 562 | | if (opts.userId) { |
| 563 | | const orgs = await this.list() |
| 564 | | for (const org of orgs) { |
| 565 | | if (!org.resources.includes(opts.resourceName)) continue |
| 566 | | const grants = await this.listGrants({ |
| 567 | | subjectType: 'user', subjectId: opts.userId, |
| 568 | | grantType: 'group', groupName: org.name |
| 569 | | }) |
| 570 | | for (const g of grants) { |
| 571 | | if (HyperOrgs.permissionLevel(g.permission) >= required) return true |
| 572 | | } |
| 573 | | } |
| 574 | | } |
| 575 | | |
| 576 | | // Direct server resource grant |
| 577 | | if (opts.serverKey) { |
| 578 | | const grants = await this.listGrants({ |
| 579 | | subjectType: 'server', subjectId: opts.serverKey, |
| 580 | | grantType: 'resource', resourceName: opts.resourceName |
| 581 | | }) |
| 582 | | for (const g of grants) { |
| 583 | | if (HyperOrgs.permissionLevel(g.permission) >= required) return true |
| 584 | | } |
| 585 | | } |
| 586 | | |
| 587 | | // Server group grant |
| 588 | | if (opts.serverKey) { |
| 589 | | const orgs = await this.list() |
| 590 | | for (const org of orgs) { |
| 591 | | if (!org.resources.includes(opts.resourceName)) continue |
| 592 | | const grants = await this.listGrants({ |
| 593 | | subjectType: 'server', subjectId: opts.serverKey, |
| 594 | | grantType: 'group', groupName: org.name |
| 595 | | }) |
| 596 | | for (const g of grants) { |
| 597 | | if (HyperOrgs.permissionLevel(g.permission) >= required) return true |
| 598 | | } |
| 599 | | } |
| 600 | | } |
| 601 | | |
| 602 | | return false |
| 603 | | } |
| 604 | | |
| 605 | | // ── Meta ──────────────────────────────────────────────────── |
| 606 | | |
| 607 | | async setMeta (key, value) { |
| 608 | | await this._room.append({ type: 'set-meta', key, value, timestamp: Date.now() }) |
| 609 | | } |
| 610 | | |
| 611 | | async getMeta (key) { |
| 612 | | await this._room.base.update() |
| 613 | | const entry = await this._room.view.get(`meta:${key}`) |
| 614 | | return entry ? entry.value.value : null |
| 615 | | } |
| 616 | | |
| 617 | | // ── Private ───────────────────────────────────────────────── |
| 618 | | |
| 619 | | async _listByIndex (indexPrefix, entityPrefix, filters, activeOnly) { |
| 620 | | const results = [] |
| 621 | | for await (const entry of this._room.view.createReadStream({ |
| 622 | | gt: indexPrefix, lt: indexPrefix + '\xff' |
| 623 | | })) { |
| 624 | | const idField = entityPrefix === 'grant' ? 'grantId' : 'requestId' |
| 625 | | const id = entry.value[idField] |
| 626 | | const entityEntry = await this._room.view.get(`${entityPrefix}:${id}`) |
| 627 | | if (!entityEntry) continue |
| 628 | | const e = entityEntry.value |
| 629 | | if (activeOnly && e.revokedAt) continue |
| 630 | | if (filters.status && e.status !== filters.status) continue |
| 631 | | if (filters.subjectType && e.subjectType !== filters.subjectType) continue |
| 632 | | if (filters.grantType && e.grantType !== filters.grantType) continue |
| 633 | | if (filters.groupName && e.groupName !== filters.groupName) continue |
| 634 | | if (filters.resourceName && e.resourceName !== filters.resourceName) continue |
| 635 | | results.push(e) |
| 636 | | } |
| 637 | | return results |
| 296 | async listWriters (orgName) { |
| 297 | return this._requireRoom(orgName).listWriters() |
| 638 | 298 | } |
| 639 | 299 | } |