OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs / lib / hyper-orgs.js

631 lines · javascript ref:
/**
 * HyperOrgs — per-org-autobase organization management (issue #35).
 *
 * Each org is its OWN Room (autobase) — NOT one shared autobase for all
 * orgs. This is the post-mortem fix: a peer that joins one org replicates
 * ONLY that org's autobase, so other orgs never reach its disk.
 *
 *   - create(name, ...)         → mints a new per-org Room (fresh autobase)
 *   - createOrgInvite(name, ..) → bearer invite carrying that Room's
 *                                 bootstrap key (+ advisory role)
 *   - joinOrg(invite)           → mounts ONLY that org's Room
 *   - getOrgBase(name)          → that org's autobase (for replication)
 *   - get/list/update/delete/addMember/addResource/...  operate on the
 *     per-org Room.
 *
 * The instance tracks its orgs (created + joined) in an in-memory Map.
 * Persistence across restart (re-opening org Rooms from a local index)
 * is the daemon's concern — see the pear-git-v3 handler work — and is a
 * documented TODO here, not needed by the in-process tests.
 *
 * Bearer invites come from hyper-rooms (shared with hyper-issues'
 * repo invites). Write-enrollment (a contributor joiner becoming an
 * autobase writer) + role-enforced apply is a follow-up increment;
 * today joiners are read-only followers of the org's autobase.
 */

import { Room, composeSchemas, createRoomPairing, joinRoomPairing, resumeRoomPairing, decodeRoomPairingInvite } from 'hyper-rooms'
import { orgsSchema, validateName } from './schema.js'

export class HyperOrgs {
  /**
   * @param {Corestore} corestore
   * @param {object} [options]
   * @param {string} [options.namespace='orgs'] - parent corestore namespace
   */
  constructor (corestore, options = {}) {
    this._corestore = corestore.namespace(options.namespace || 'orgs')
    // Extra schemas composed onto every org Room (issues-agnostic — the
    // daemon injects issuesSchema so an org Room IS its own issue tracker;
    // org issues then replicate WITH the org autobase). Stays decoupled:
    // hyper-orgs never imports hyper-issues.
    this._extraSchemas = options.extraSchemas || []
    this._schema = this._extraSchemas.length
      ? composeSchemas([orgsSchema, ...this._extraSchemas])
      : orgsSchema
    // name -> { room: Room, role: string, created: boolean }
    this._orgs = new Map()
    // invite string -> pairing handle (live blind-pairing member). Held so
    // outstanding invites keep accepting joiners and are closed with us.
    this._pairings = new Map()
    // Local append-only index (NOT replicated): which orgs this instance
    // tracks + how to re-open each Room on restart. {op,name,bootstrapKey,
    // role,created}. Last-write-wins per name; op:'untrack' drops.
    this._index = null
    this._opened = false
  }

  async ready () {
    if (this._opened) return
    this._index = this._corestore.namespace('orgs-index').get({ name: 'log', valueEncoding: 'json' })
    await this._index.ready()
    // Replay the index → re-open tracked org Rooms.
    const tracked = new Map()
    for (let i = 0; i < this._index.length; i++) {
      const e = await this._index.get(i)
      if (!e || !e.name) continue
      if (e.op === 'untrack') tracked.delete(e.name)
      else tracked.set(e.name, e)
    }
    for (const e of tracked.values()) {
      await this._openOrgRoom(e.name, e.bootstrapKey, { created: !!e.created, role: e.role || 'reader' })
    }
    this._opened = true
  }

  // Open (or re-open) an org's Room. Created orgs use namespace
  // 'org/<name>' (creator's local writer); joined orgs use
  // 'org-join/<key16>'. The namespace must match the original so the
  // local writer identity is stable across restarts.
  async _openOrgRoom (name, bootstrapKey, { created, role }) {
    const namespace = created ? 'org/' + name : 'org-join/' + bootstrapKey.slice(0, 16)
    const room = new Room(this._corestore, { schema: this._schema, namespace, bootstrapKey })
    await room.ready()
    this._orgs.set(name, { room, role, created })
    return room
  }

  async _track (name, bootstrapKey, role, created) {
    if (this._index) await this._index.append({ op: 'track', name, bootstrapKey, role, created })
  }

  async _untrack (name) {
    if (this._index) await this._index.append({ op: 'untrack', name })
  }

  async sync () {
    for (const { room } of this._orgs.values()) await room.base.update()
  }

  /**
   * Replicate every tracked org's autobase over a raw connection. The
   * daemon's swarm transport calls this per connection — a multi-org
   * instance has no single `base` (the getter throws on >1), so the
   * transport's single-autobase `base.replicate` path can't be used.
   * corestore.replicate usually covers these cores already; this mirrors
   * the transport's belt-and-suspenders base.replicate for each org.
   */
  replicate (conn) {
    for (const { room } of this._orgs.values()) {
      if (room.base && typeof room.base.replicate === 'function') {
        room.base.replicate(conn)
      }
    }
  }

  async close () {
    for (const pairing of this._pairings.values()) {
      try { await pairing.close() } catch {}
    }
    this._pairings.clear()
    for (const { room } of this._orgs.values()) {
      try { await room.close() } catch {}
    }
    this._orgs.clear()
  }

  validateName (name) { return validateName(name) }

  // ── Per-org Room access ──────────────────────────────────────

  /** The sole org's autobase — convenience for single-org instances
   *  (e.g. a joiner). Use getOrgBase(name) when tracking several. */
  get base () {
    if (this._orgs.size === 1) return [...this._orgs.values()][0].room.base
    if (this._orgs.size === 0) return null
    throw new Error('multiple orgs tracked — use getOrgBase(name)')
  }

  getOrgBase (name) {
    const e = this._orgs.get(name)
    return e ? e.room.base : null
  }

  getOrgRoom (name) {
    const e = this._orgs.get(name)
    return e ? e.room : null
  }

  _requireRoom (name) {
    const e = this._orgs.get(name)
    if (!e) throw new Error(`org "${name}" not tracked by this instance`)
    return e.room
  }

  // ── Org lifecycle ────────────────────────────────────────────

  /**
   * Create an organization in its own autobase.
   * @returns the org record
   */
  async create (name, type = 'group', data = {}) {
    const v = validateName(name)
    if (!v.valid) throw new Error(v.error)
    if (type !== 'user' && type !== 'group') throw new Error('Type must be "user" or "group"')
    if (this._orgs.has(name)) throw new Error(`Organization "${name}" already exists`)

    const room = new Room(this._corestore, {
      schema: this._schema,
      namespace: 'org/' + name,
      bootstrapKey: null
    })
    await room.ready()
    this._orgs.set(name, { room, role: 'admin', created: true })

    await room.append({
      type: 'create-org',
      name,
      orgType: type,
      displayName: data.displayName || name,
      description: data.description || '',
      about: data.about || '',
      userId: type === 'user' ? (data.userId || null) : null,
      members: type === 'group' ? (data.members || []) : [],
      visibility: data.visibility || 'public',
      timestamp: Date.now()
    })
    await room.base.update()
    await this._track(name, room.keyHex, 'admin', true)
    return this.get(name)
  }

  async get (name) {
    const e = this._orgs.get(name)
    if (!e) return null
    try {
      await e.room.base.update()
      const entry = await e.room.view.get(`org:${name}`)
      return entry ? entry.value : null
    } catch {
      return null
    }
  }

  async update (name, updates) {
    const room = this._requireRoom(name)
    const op = { type: 'update-org', name, timestamp: Date.now() }
    if (updates.displayName !== undefined) op.displayName = updates.displayName
    if (updates.description !== undefined) op.description = updates.description
    if (updates.about !== undefined) op.about = updates.about
    if (updates.visibility !== undefined) op.visibility = updates.visibility
    await room.append(op)
    await room.base.update()
    return this.get(name)
  }

  // ── Record meta (generic set-meta op) ────────────────────────
  // Thin typed accessors over the schema's `set-meta` op (meta:{key} →
  // {value}). The daemon uses these to publish per-daemon origin coordinates
  // (serverKey + repoEventLog) into the org autobase, so every org peer can
  // boot a reverse RepoPuller toward the others — the org-scoped analogue of
  // the IdentityRegistry mirror-writer announce (#16, __server__/__repoeventlog__).
  // Only a WRITER on the org can setMeta; readers replicate + read it.

  async setMeta (orgName, key, value) {
    const room = this._requireRoom(orgName)
    await room.append({ type: 'set-meta', key, value, timestamp: Date.now() })
    await room.base.update()
    return value
  }

  async getMeta (orgName, key) {
    const room = this._requireRoom(orgName)
    await room.base.update()
    const e = await room.view.get(`meta:${key}`)
    return e ? e.value.value : null
  }

  // List meta entries whose key starts with `prefix` (e.g. 'peer:'), returning
  // [{ key, value }] with the `meta:` view-prefix stripped from key. Suffixed
  // keys are strictly > the bare prefix, so `gt` (matching the grants scan
  // convention) never misses an entry.
  async listMeta (orgName, prefix = '') {
    const room = this._requireRoom(orgName)
    await room.base.update()
    const out = []
    const lo = `meta:${prefix}`
    for await (const entry of room.view.createReadStream({ gt: lo, lt: lo + '\xff' })) {
      out.push({ key: entry.key.slice('meta:'.length), value: entry.value.value })
    }
    return out
  }

  async delete (name) {
    const org = await this.get(name)
    if (!org) throw new Error(`Organization "${name}" not found`)
    if (org.resources.length > 0) {
      throw new Error(`Cannot delete "${name}": still has ${org.resources.length} resources`)
    }
    const room = this._requireRoom(name)
    await room.append({ type: 'delete-org', name, timestamp: Date.now() })
    await room.base.update()
    // Drop from local tracking (the autobase still exists on disk).
    const e = this._orgs.get(name)
    if (e) { try { await e.room.close() } catch {} }
    this._orgs.delete(name)
    await this._untrack(name)
  }

  /** Orgs this instance tracks (created or joined). */
  async list (filters = {}) {
    const out = []
    for (const name of this._orgs.keys()) {
      const org = await this.get(name)
      if (!org) continue
      if (filters.type && org.type !== filters.type) continue
      if (filters.visibility && org.visibility !== filters.visibility) continue
      out.push(org)
    }
    return out
  }

  // ── Invites / join (the per-org-autobase scope mechanism) ────

  /**
   * Issue a blind-pairing invite for one org (the canonical Keet flow). The
   * returned `invitation` is the whole UX: a joiner pairs over the swarm and,
   * for contributor+, is addWritered DURING pairing — no HTTP, no separate
   * enroll. The pairing member stays live (held in this._pairings) until
   * closed; the org's name travels in the invite `meta` so the joiner can key
   * its record. Requires the daemon's shared swarm (P2P transport).
   * @param {string} name
   * @param {object} opts
   * @param {string} [opts.role='reader']
   * @param {object} opts.swarm  shared Hyperswarm (injected)
   * @returns {Promise<{ invitation: string, close: () => Promise<void> }>}
   */
  async createOrgInvite (name, opts = {}) {
    const room = this._requireRoom(name)
    if (!opts.swarm) throw new Error('createOrgInvite requires { swarm } (P2P pairing transport)')
    const pairing = createRoomPairing(room, {
      role: opts.role || 'reader',
      swarm: opts.swarm,
      meta: { kind: 'org', name }
    })
    this._pairings.set(pairing.invite, pairing)
    // descriptor lets the daemon persist + re-register this invite on restart.
    return { invitation: pairing.invite, descriptor: pairing.descriptor, close: pairing.close }
  }

  /**
   * Re-register a persisted org-invite pairing member after a restart so an
   * already-shared invite keeps working. The org Room must be mounted first.
   * @param {string} name
   * @param {object} descriptor  from a prior createOrgInvite()
   * @param {object} swarm       shared Hyperswarm (injected)
   */
  async resumeOrgPairing (name, descriptor, swarm) {
    if (!swarm) throw new Error('resumeOrgPairing requires { swarm }')
    const room = this._requireRoom(name)
    const pairing = resumeRoomPairing(room, descriptor, swarm)
    this._pairings.set(descriptor.invitation || (name + ':' + descriptor.discoveryKey), pairing)
    return pairing
  }

  /**
   * Consume an org invite via blind-pairing: mount ONLY that org's autobase
   * and, for contributor+, become a writer DURING pairing (the owner
   * addWriters us over the swarm). For 'reader' we stay a read-only mirror.
   * The write-grant rides the same P2P channel as discovery — no HTTP.
   * @param {string} invitation
   * @param {object} opts
   * @param {object} opts.swarm  shared Hyperswarm (injected)
   * @returns {Promise<{ name: string, role: string }>}
   */
  async joinOrg (invitation, opts = {}) {
    if (!opts.swarm) throw new Error('joinOrg requires { swarm } (P2P pairing transport)')
    const dec = decodeRoomPairingInvite(invitation)
    if (!dec.meta || dec.meta.kind !== 'org') throw new Error('invite is not an org pairing invite')
    const name = dec.meta.name
    if (!name) throw new Error('org pairing invite missing name (cannot key the org)')
    if (this._orgs.has(name)) {
      const e = this._orgs.get(name)
      return { name, role: e.role }
    }
    const { room, role } = await joinRoomPairing(invitation, {
      corestore: this._corestore,
      swarm: opts.swarm,
      schema: this._schema,
      namespace: 'org-join/' + dec.bootstrapKey.slice(0, 16)
    })
    this._orgs.set(name, { room, role, created: false })
    await this._track(name, room.keyHex, role, false)
    return { name, role }
  }

  /**
   * Mount an org's autobase by its bootstrap key directly (no invite
   * ceremony). PEER mode uses joinOrg (invite → key). MIRROR mode uses
   * this: the primary's listOrgKeys() yields {name, bootstrapKey} for
   * every org, and a mirror mounts each → full replica. Idempotent.
   * @returns {Promise<{ name, writerKey, role }>}
   */
  async mountOrg (name, bootstrapKey, role = 'reader') {
    if (this._orgs.has(name)) {
      return { name, writerKey: this._orgs.get(name).room.localKeyHex, role: this._orgs.get(name).role }
    }
    const room = await this._openOrgRoom(name, bootstrapKey, { created: false, role })
    await room.base.update()
    await this._track(name, bootstrapKey, role, false)
    return { name, writerKey: room.localKeyHex, role }
  }

  /**
   * Every org this instance tracks, with its bootstrap key. The primary
   * exposes this so a mirror can mountOrg() each for a full replica
   * (mirror mode). Peers never call this — they only get specific
   * invites (peer mode), which is what prevents the post-mortem leak.
   * @returns {Array<{ name, bootstrapKey, role, created }>}
   */
  listOrgKeys () {
    const out = []
    for (const [name, e] of this._orgs) {
      out.push({ name, bootstrapKey: e.room.keyHex, role: e.role, created: e.created })
    }
    return out
  }

  // ── Resources (per-org) ──────────────────────────────────────

  // opts.bootstrapKey — the repo's tracker bootstrap key, recorded so a
  // peer that joins this org can mount the repo (scoped). Re-appending an
  // already-present resource WITH a new key backfills a name-only entry.
  async addResource (orgName, resourceName, opts = {}) {
    const room = this._requireRoom(orgName)
    const org = await this.get(orgName)
    if (!org) throw new Error(`Organization "${orgName}" not found`)
    const hasName = org.resources.includes(resourceName)
    const curKey = org.resourceKeys && org.resourceKeys[resourceName]
    if (hasName && (!opts.bootstrapKey || curKey === opts.bootstrapKey)) return org
    const op = { type: 'add-resource', orgName, resourceName, timestamp: Date.now() }
    if (opts.bootstrapKey) op.bootstrapKey = opts.bootstrapKey
    await room.append(op)
    await room.base.update()
    return this.get(orgName)
  }

  async removeResource (orgName, resourceName) {
    const room = this._requireRoom(orgName)
    const org = await this.get(orgName)
    if (!org) throw new Error(`Organization "${orgName}" not found`)
    if (!org.resources.includes(resourceName)) {
      throw new Error(`Resource "${resourceName}" is not in "${orgName}"`)
    }
    await room.append({ type: 'remove-resource', orgName, resourceName, timestamp: Date.now() })
    await room.base.update()
    return this.get(orgName)
  }

  // ── Members (per-org) ────────────────────────────────────────

  async addMember (orgName, userId, role = 'member') {
    const room = this._requireRoom(orgName)
    const org = await this.get(orgName)
    if (!org) throw new Error(`Organization "${orgName}" not found`)
    if (org.type !== 'group') throw new Error('Can only add members to group organizations')
    if (!['admin', 'member'].includes(role)) throw new Error('Role must be "admin" or "member"')
    await room.append({ type: 'add-member', orgName, memberId: userId, role, timestamp: Date.now() })
    await room.base.update()
    return this.get(orgName)
  }

  async removeMember (orgName, userId) {
    const room = this._requireRoom(orgName)
    const org = await this.get(orgName)
    if (!org) throw new Error(`Organization "${orgName}" not found`)
    if (org.type !== 'group') throw new Error('Can only remove members from group organizations')
    await room.append({ type: 'remove-member', orgName, memberId: userId, timestamp: Date.now() })
    await room.base.update()
    return this.get(orgName)
  }

  async getUserOrgs (userId) {
    const all = await this.list()
    return all.filter(org => {
      if (org.type === 'user' && org.userId === userId) return true
      if (org.type === 'group' && org.members.some(m => m.userId === userId)) return true
      return false
    })
  }

  /** Move a resource between two tracked orgs (both must be tracked). */
  async moveResource (resourceName, fromOrg, toOrg) {
    const from = await this.get(fromOrg)
    if (!from) throw new Error(`Organization "${fromOrg}" not found`)
    if (!(await this.get(toOrg))) throw new Error(`Organization "${toOrg}" not found`)
    if (!from.resources.includes(resourceName)) {
      throw new Error(`Resource "${resourceName}" is not in "${fromOrg}"`)
    }
    await this.removeResource(fromOrg, resourceName)
    await this.addResource(toOrg, resourceName)
  }

  /** Find the tracked org Room that owns a resource, or null. */
  async _orgRoomForResource (resourceName) {
    for (const name of this._orgs.keys()) {
      const org = await this.get(name)
      if (org && org.resources.includes(resourceName)) return { orgName: name, room: this._orgs.get(name).room }
    }
    return null
  }

  // ── Grants (per-org) ─────────────────────────────────────────
  // Grants live in the autobase of the org they pertain to: a group
  // grant in that org's Room; a resource grant in the Room of the org
  // that owns the resource. checkAccess resolves resource→org, then
  // reads grants from that one Room.

  /**
   * Ordinal for a permission. Higher satisfies lower.
   *
   * `read` is the lowest real level. Before it existed the ladder started at
   * `write`, so `checkAccess({ requiredPermission: 'read' })` fell through to
   * `default: 0` and the guard below returned false UNCONDITIONALLY — the
   * grant model could express who may WRITE a resource but had no way to say
   * who may merely READ it. That gap is why repo visibility (public/private)
   * is unbuilt: every layer of the stack gates writes and leaves reads open.
   *
   * Unknown/absent permissions still map to 0 and are still rejected, so a
   * typo does not silently grant anything.
   *
   * Numbering shifted (read 1 … owner 4). Safe: these values are only ever
   * compared against each other, never persisted — grants store the permission
   * STRING, and the only callers are the comparisons in checkAccess().
   */
  static permissionLevel (perm) {
    switch (perm) {
      case 'owner': return 4
      case 'moderator': return 3
      case 'write': return 2
      case 'read': return 1
      default: return 0
    }
  }

  async _grantRoom (opts) {
    if (opts.grantType === 'group') return this._requireRoom(opts.groupName)
    const found = await this._orgRoomForResource(opts.resourceName)
    if (!found) throw new Error(`no tracked org owns resource "${opts.resourceName}"`)
    return found.room
  }

  async createGrant (opts) {
    const grantId = opts.grantId ||
      `${opts.subjectType}-${opts.subjectId.slice(0, 12)}-${Date.now()}`
    const room = await this._grantRoom(opts)
    await room.append({
      type: 'create-grant',
      grantId,
      subjectType: opts.subjectType,
      subjectId: opts.subjectId,
      grantType: opts.grantType,
      groupName: opts.groupName || null,
      resourceName: opts.resourceName || null,
      permission: opts.permission,
      grantedBy: opts.grantedBy || null,
      inviteId: opts.inviteId || null,
      timestamp: Date.now()
    })
    await room.base.update()
    return this.getGrant(grantId)
  }

  async revokeGrant (grantId, revokedBy = null) {
    for (const { room } of this._orgs.values()) {
      const e = await room.view.get(`grant:${grantId}`)
      if (e) {
        await room.append({ type: 'revoke-grant', grantId, revokedBy, timestamp: Date.now() })
        await room.base.update()
        return
      }
    }
    throw new Error(`Grant "${grantId}" not found`)
  }

  async getGrant (grantId) {
    for (const { room } of this._orgs.values()) {
      await room.base.update()
      const e = await room.view.get(`grant:${grantId}`)
      if (e) return e.value
    }
    return null
  }

  async listGrants (filters = {}) {
    const activeOnly = filters.activeOnly !== false
    const out = []
    // Narrow to one Room when the filter names an org (group) or a
    // resource we can resolve; otherwise scan all tracked Rooms.
    let rooms
    if (filters.grantType === 'group' && filters.groupName && this._orgs.has(filters.groupName)) {
      rooms = [this._orgs.get(filters.groupName).room]
    } else if (filters.grantType === 'resource' && filters.resourceName) {
      const found = await this._orgRoomForResource(filters.resourceName)
      rooms = found ? [found.room] : []
    } else {
      rooms = [...this._orgs.values()].map(e => e.room)
    }
    for (const room of rooms) {
      await room.base.update()
      for await (const entry of room.view.createReadStream({ gt: 'grant:', lt: 'grant:\xff' })) {
        const g = entry.value
        if (activeOnly && g.revokedAt) continue
        if (filters.subjectType && g.subjectType !== filters.subjectType) continue
        if (filters.subjectId && g.subjectId !== filters.subjectId) continue
        if (filters.grantType && g.grantType !== filters.grantType) continue
        if (filters.groupName && g.groupName !== filters.groupName) continue
        if (filters.resourceName && g.resourceName !== filters.resourceName) continue
        out.push(g)
      }
    }
    return out
  }

  /**
   * Whether a subject has the required permission on a resource. Resolves
   * resource→owning org, then checks direct resource grants + group
   * grants for that org, within that org's Room only.
   */
  async checkAccess (opts) {
    const required = HyperOrgs.permissionLevel(opts.requiredPermission)
    if (required === 0) return false
    const found = await this._orgRoomForResource(opts.resourceName)
    if (!found) return false
    const { orgName } = found

    const subjects = []
    if (opts.userId) subjects.push({ subjectType: 'user', subjectId: opts.userId })
    if (opts.serverKey) subjects.push({ subjectType: 'server', subjectId: opts.serverKey })

    for (const s of subjects) {
      // direct resource grant
      const direct = await this.listGrants({ ...s, grantType: 'resource', resourceName: opts.resourceName })
      for (const g of direct) if (HyperOrgs.permissionLevel(g.permission) >= required) return true
      // group grant on the owning org
      const group = await this.listGrants({ ...s, grantType: 'group', groupName: orgName })
      for (const g of group) if (HyperOrgs.permissionLevel(g.permission) >= required) return true
    }
    return false
  }

  // Write-enrollment now happens DURING blind-pairing (createOrgInvite /
  // joinOrg, via hyper-rooms RoomPairing) — the owner addWriters the joiner
  // over the swarm. The old owner-enroll-over-HTTP path (enrollWriter /
  // enrollFromInvite) is retired; addWriter() below remains for mirror-mode
  // HA writer coordination.

  // ── Writer management (per-org, delegated to Room) ───────────

  async addWriter (orgName, userId, options = {}) {
    return this._requireRoom(orgName).addWriter(userId, options)
  }

  async removeWriter (orgName, userId) {
    return this._requireRoom(orgName).removeWriter(userId)
  }

  async listWriters (orgName) {
    return this._requireRoom(orgName).listWriters()
  }
}