OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / facades / group-facade.js

669 lines · javascript ref:
/**
 * GroupFacade — NamespaceRegistry-compatible facade over unified MetadataBase
 *
 * Implements the exact same public API as NamespaceRegistry but delegates
 * all operations to MetadataBase. Zero changes needed in route files.
 *
 * Key renames:
 *   namespace:{name} → group:{name}
 *   writer:{peerId}  → peer:{peerId}
 *   meta:{key}       → group-meta:{key}
 */

import crypto from 'bare-crypto'

export class GroupFacade {
  constructor ({ metadataBase, repoRegistry }) {
    this._meta = metadataBase
    this._repoRegistry = repoRegistry || null
  }

  // ── Properties (delegate to MetadataBase) ────────────────────

  get bootstrapKey () { return this._meta.key }
  get bootstrapKeyHex () { return this._meta.bootstrapKeyHex }
  get discoveryKey () { return this._meta.discoveryKey }
  get localKey () { return this._meta.localKey }
  get writable () { return this._meta.writable }
  get view () { return this._meta.view }

  // Expose _base for wakeup protocol compatibility
  get _base () { return this._meta._base }

  async ready () {}

  // ── Permission level utility (static) ────────────────────────

  static permissionLevel (permission) {
    const levels = { read: 0, write: 1, moderator: 2, owner: 3 }
    return levels[permission] || 0
  }

  // ── Validation ───────────────────────────────────────────────

  validateName (name) {
    if (!name || typeof name !== 'string') {
      return { valid: false, error: 'Name is required' }
    }
    if (name.length < 2 || name.length > 64) {
      return { valid: false, error: 'Name must be 2-64 characters' }
    }
    if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) {
      return { valid: false, error: 'Name must start with a-z or 0-9, and contain only a-z, 0-9, ., _, -' }
    }
    return { valid: true }
  }

  // ── Namespace/Group CRUD ─────────────────────────────────────

  async create (name, type, data = {}) {
    await this._meta.update()
    const existing = await this._meta.getByKey(`group:${name}`)
    if (existing) throw new Error(`Group already exists: ${name}`)

    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'create-namespace',
      name,
      nsType: type,
      displayName: data.displayName || name,
      description: data.description || '',
      userId: type === 'user' ? (data.userId || null) : null,
      members: data.members || [],
      visibility: data.visibility || 'public',
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`group:${name}`)
  }

  async get (name) {
    return this._meta.getByKey(`group:${name}`)
  }

  async update (name, updates) {
    await this._meta.update()
    const existing = await this._meta.getByKey(`group:${name}`)
    if (!existing) throw new Error(`Group not found: ${name}`)

    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'update-namespace',
      name,
      displayName: updates.displayName,
      description: updates.description,
      visibility: updates.visibility,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`group:${name}`)
  }

  async delete (name) {
    await this._meta.update()
    const existing = await this._meta.getByKey(`group:${name}`)
    if (!existing) throw new Error(`Group not found: ${name}`)
    if (existing.repos && existing.repos.length > 0) {
      throw new Error(`Cannot delete group with repos: ${name}`)
    }

    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'delete-namespace',
      name,
      timestamp: Date.now()
    })
  }

  async list (filters = {}) {
    await this._meta.update()
    const results = []
    for await (const entry of this._meta.view.createReadStream({
      gt: 'group:',
      lt: 'group:\xff'
    })) {
      const ns = entry.value
      if (filters.type && ns.type !== filters.type) continue
      if (filters.visibility && ns.visibility !== filters.visibility) continue
      results.push(ns)
    }
    return results
  }

  // ── Repo membership ──────────────────────────────────────────

  async addRepo (namespaceName, repoName) {
    await this._meta.update()
    const ns = await this._meta.getByKey(`group:${namespaceName}`)
    if (!ns) throw new Error(`Group not found: ${namespaceName}`)

    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'add-repo',
      namespaceName,
      repoName,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`group:${namespaceName}`)
  }

  async removeRepo (namespaceName, repoName) {
    await this._meta.update()
    const ns = await this._meta.getByKey(`group:${namespaceName}`)
    if (!ns) throw new Error(`Group not found: ${namespaceName}`)

    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'remove-repo',
      namespaceName,
      repoName,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`group:${namespaceName}`)
  }

  async moveRepo (repoName, fromNamespace, toNamespace) {
    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'move-repo',
      repoName,
      fromNamespace,
      toNamespace,
      timestamp: Date.now()
    })
  }

  // ── Group members ────────────────────────────────────────────

  async addMember (namespaceName, userId, role = 'member') {
    await this._meta.update()
    const ns = await this._meta.getByKey(`group:${namespaceName}`)
    if (!ns) throw new Error(`Group not found: ${namespaceName}`)
    if (ns.type !== 'group') throw new Error(`Cannot add members to user namespace: ${namespaceName}`)

    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'add-member',
      namespaceName,
      memberId: userId,
      role,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`group:${namespaceName}`)
  }

  async removeMember (namespaceName, userId) {
    await this._meta.update()
    const ns = await this._meta.getByKey(`group:${namespaceName}`)
    if (!ns) throw new Error(`Group not found: ${namespaceName}`)
    if (ns.type !== 'group') throw new Error(`Cannot remove members from user namespace: ${namespaceName}`)

    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'remove-member',
      namespaceName,
      memberId: userId,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`group:${namespaceName}`)
  }

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

  // ── Default namespace + migration ────────────────────────────

  async ensureDefaultNamespace (name = 'local') {
    await this._meta.update()
    const existing = await this._meta.getByKey(`group:${name}`)
    if (existing) return existing
    return this.create(name, 'user', { displayName: name })
  }

  async migrateExistingRepos (repos) {
    let created = 0; let assigned = 0; let skipped = 0
    const defaultNs = await this.ensureDefaultNamespace()
    const allNs = await this.list()
    const assignedRepos = new Set()
    for (const ns of allNs) {
      for (const r of (ns.repos || [])) assignedRepos.add(r)
    }

    for (const repo of repos) {
      if (assignedRepos.has(repo.name)) { skipped++; continue }
      const category = repo.category || defaultNs.name
      let target = await this._meta.getByKey(`group:${category}`)
      if (!target) {
        await this.create(category, 'group', { displayName: category })
        target = await this._meta.getByKey(`group:${category}`)
        created++
      }
      await this.addRepo(category, repo.name)
      assigned++
    }
    return { created, assigned, skipped }
  }

  async reconsolidateGroups (repos) {
    // Simplified: moves orphan repos to default and deletes empty groups
    let moved = 0; let deleted = 0
    const defaultNs = await this.ensureDefaultNamespace()
    const allNs = await this.list()
    const repoNames = new Set(repos.map(r => r.name))

    for (const ns of allNs) {
      if (ns.name === defaultNs.name) continue
      const orphans = (ns.repos || []).filter(r => !repoNames.has(r))
      for (const r of orphans) {
        await this.moveRepo(r, ns.name, defaultNs.name)
        moved++
      }
    }

    // Delete empty groups (except default)
    const refreshed = await this.list()
    for (const ns of refreshed) {
      if (ns.name === defaultNs.name) continue
      if (ns.repos.length === 0 && ns.type === 'group') {
        try {
          await this.delete(ns.name)
          deleted++
        } catch {}
      }
    }

    return { moved, deleted }
  }

  // ── Grants ───────────────────────────────────────────────────

  async createGrant (opts) {
    const grantId = opts.grantId || crypto.randomBytes(8).toString('hex')
    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'create-grant',
      grantId,
      subjectType: opts.subjectType,
      subjectId: opts.subjectId,
      grantType: opts.grantType,
      groupName: opts.groupName || null,
      repoName: opts.repoName || null,
      permission: opts.permission,
      grantedBy: opts.grantedBy || null,
      inviteId: opts.inviteId || null,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`grant:${grantId}`)
  }

  async revokeGrant (grantId, revokedBy) {
    await this._meta.update()
    const grant = await this._meta.getByKey(`grant:${grantId}`)
    if (!grant) throw new Error(`Grant not found: ${grantId}`)
    if (grant.revokedAt) throw new Error(`Grant already revoked: ${grantId}`)

    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'revoke-grant',
      grantId,
      revokedBy: revokedBy || null,
      timestamp: Date.now()
    })
  }

  async getGrant (grantId) {
    return this._meta.getByKey(`grant:${grantId}`)
  }

  async listGrants (filters = {}) {
    await this._meta.update()

    // Use index prefixes for efficient lookups when possible
    let prefix = 'grant:'
    if (filters.subjectId && filters.subjectType === 'user') {
      return this._listGrantsByIndex(`user-grants:${filters.subjectId}:`, filters)
    }
    if (filters.subjectId && filters.subjectType === 'server') {
      return this._listGrantsByIndex(`server-grants:${filters.subjectId}:`, filters)
    }
    if (filters.repoName && filters.grantType === 'repo') {
      return this._listGrantsByIndex(`repo-grants:${filters.repoName}:`, filters)
    }
    if (filters.groupName && filters.grantType === 'group') {
      return this._listGrantsByIndex(`group-grants:${filters.groupName}:`, filters)
    }

    // Full scan fallback
    const results = []
    for await (const entry of this._meta.view.createReadStream({
      gt: prefix,
      lt: prefix + '\xff'
    })) {
      const grant = entry.value
      if (filters.activeOnly && grant.revokedAt) continue
      results.push(grant)
    }
    return results
  }

  async _listGrantsByIndex (indexPrefix, filters) {
    const results = []
    for await (const entry of this._meta.view.createReadStream({
      gt: indexPrefix,
      lt: indexPrefix + '\xff'
    })) {
      const grantId = entry.value.grantId
      const grant = await this._meta.getByKey(`grant:${grantId}`)
      if (!grant) continue
      if (filters.activeOnly && grant.revokedAt) continue
      results.push(grant)
    }
    return results
  }

  // ── Invites ──────────────────────────────────────────────────

  async createAccessInvite (opts) {
    const inviteId = opts.inviteId || crypto.randomBytes(8).toString('hex')
    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'create-invite',
      inviteId,
      inviteType: opts.inviteType,
      targetGroup: opts.targetGroup || null,
      targetRepo: opts.targetRepo || null,
      permission: opts.permission,
      maxUses: opts.maxUses || 1,
      createdBy: opts.createdBy || null,
      expiresAt: opts.expiresAt || null,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`invite:${inviteId}`)
  }

  async consumeInvite (inviteId, opts) {
    await this._meta.update()
    const invite = await this._meta.getByKey(`invite:${inviteId}`)
    if (!invite) throw new Error(`Invite not found: ${inviteId}`)
    if (invite.revoked) throw new Error(`Invite revoked: ${inviteId}`)
    if (invite.maxUses > 0 && invite.usedCount >= invite.maxUses) throw new Error(`Invite exhausted: ${inviteId}`)
    if (invite.expiresAt && Date.now() > invite.expiresAt) throw new Error(`Invite expired: ${inviteId}`)

    const grantId = crypto.randomBytes(8).toString('hex')
    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'consume-invite',
      inviteId,
      grantId,
      subjectType: opts.subjectType,
      subjectId: opts.subjectId,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`grant:${grantId}`)
  }

  async revokeAccessInvite (inviteId) {
    await this._meta.update()
    const invite = await this._meta.getByKey(`invite:${inviteId}`)
    if (!invite) throw new Error(`Invite not found: ${inviteId}`)

    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'revoke-invite',
      inviteId,
      timestamp: Date.now()
    })
  }

  async getInvite (inviteId) {
    return this._meta.getByKey(`invite:${inviteId}`)
  }

  async listInvites (filters = {}) {
    await this._meta.update()
    const results = []
    for await (const entry of this._meta.view.createReadStream({
      gt: 'invite:',
      lt: 'invite:\xff'
    })) {
      const invite = entry.value
      if (filters.activeOnly && invite.revoked) continue
      results.push(invite)
    }
    return results
  }

  // ── Access Requests ──────────────────────────────────────────

  async createAccessRequest (opts) {
    // Check for duplicate pending request
    const existing = await this.listAccessRequests({
      status: 'pending',
      subjectId: opts.subjectId,
      requestType: opts.requestType,
      groupName: opts.groupName,
      repoName: opts.repoName
    })
    if (existing.length > 0) throw new Error('Duplicate pending request')

    const requestId = opts.requestId || crypto.randomBytes(8).toString('hex')
    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'create-access-request',
      requestId,
      subjectType: opts.subjectType,
      subjectId: opts.subjectId,
      requestType: opts.requestType,
      groupName: opts.groupName || null,
      repoName: opts.repoName || null,
      permission: opts.permission,
      reason: opts.reason || null,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`request:${requestId}`)
  }

  async approveAccessRequest (requestId, approvedBy) {
    await this._meta.update()
    const request = await this._meta.getByKey(`request:${requestId}`)
    if (!request) throw new Error(`Request not found: ${requestId}`)
    if (request.status !== 'pending') throw new Error(`Request not pending: ${requestId}`)

    const grantId = crypto.randomBytes(8).toString('hex')
    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'approve-access-request',
      requestId,
      grantId,
      approvedBy: approvedBy || null,
      timestamp: Date.now()
    })

    await this._meta.update()
    return this._meta.getByKey(`grant:${grantId}`)
  }

  async denyAccessRequest (requestId, deniedBy, denyReason) {
    await this._meta.update()
    const request = await this._meta.getByKey(`request:${requestId}`)
    if (!request) throw new Error(`Request not found: ${requestId}`)
    if (request.status !== 'pending') throw new Error(`Request not pending: ${requestId}`)

    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'deny-access-request',
      requestId,
      deniedBy: deniedBy || null,
      denyReason: denyReason || null,
      timestamp: Date.now()
    })
  }

  async getAccessRequest (requestId) {
    return this._meta.getByKey(`request:${requestId}`)
  }

  async listAccessRequests (filters = {}) {
    await this._meta.update()
    const results = []
    for await (const entry of this._meta.view.createReadStream({
      gt: 'request:',
      lt: 'request:\xff'
    })) {
      const req = entry.value
      if (filters.status && req.status !== filters.status) continue
      if (filters.subjectId && req.subjectId !== filters.subjectId) continue
      if (filters.requestType && req.requestType !== filters.requestType) continue
      if (filters.groupName && req.groupName !== filters.groupName) continue
      if (filters.repoName && req.repoName !== filters.repoName) continue
      results.push(req)
    }
    return results
  }

  // ── Access check ─────────────────────────────────────────────

  async checkAccess (opts) {
    const { userId, serverKey, repoName, requiredPermission } = opts
    const requiredLevel = GroupFacade.permissionLevel(requiredPermission)
    const grants = await this.listGrants({ activeOnly: true })

    for (const grant of grants) {
      // Check subject match
      const subjectMatch =
        (grant.subjectType === 'user' && grant.subjectId === userId) ||
        (grant.subjectType === 'server' && grant.subjectId === serverKey)
      if (!subjectMatch) continue

      // Check target match
      if (grant.grantType === 'repo' && grant.repoName === repoName) {
        if (GroupFacade.permissionLevel(grant.permission) >= requiredLevel) return true
      }
      if (grant.grantType === 'group' && grant.groupName) {
        // Check if repo is in this group
        const group = await this._meta.getByKey(`group:${grant.groupName}`)
        if (group && group.repos && group.repos.includes(repoName)) {
          if (GroupFacade.permissionLevel(grant.permission) >= requiredLevel) return true
        }
      }
    }
    return false
  }

  // ── Writer management (federation peers) ─────────────────────

  async addWriter (peerId, serverKey, role = 'writer') {
    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'add-writer',
      peerId,
      serverKey,
      role,
      addedBy: null,
      timestamp: Date.now()
    })
  }

  async removeWriter (peerId) {
    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'remove-writer',
      peerId,
      timestamp: Date.now()
    })
  }

  async getWriter (peerId) {
    return this._meta.getByKey(`peer:${peerId}`)
  }

  async listWriters (opts = {}) {
    await this._meta.update()
    const results = []
    for await (const entry of this._meta.view.createReadStream({
      gt: 'peer:',
      lt: 'peer:\xff'
    })) {
      const writer = entry.value
      if (!opts.includeRemoved && writer.removedAt) continue
      const peerId = entry.key.slice('peer:'.length)
      results.push({ peerId, ...writer })
    }
    return results
  }

  // ── Meta ─────────────────────────────────────────────────────

  async setMeta (key, value) {
    await this._meta.append({
      v: 2,
      ns: 'group',
      type: 'set-meta',
      key,
      value,
      timestamp: Date.now()
    })
  }

  async getMeta (key) {
    const val = await this._meta.getByKey(`group-meta:${key}`)
    return val ? val.value : null
  }

  // ── Lifecycle ────────────────────────────────────────────────

  async replicate (stream) {
    return stream
  }

  async sync (options = {}) {
    await this._meta.update()
  }

  async close () {
    // MetadataBase owns the Autobase lifecycle
  }
}