OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs / lib / schema.js

548 lines · javascript ref:
/**
 * orgsSchema — hyper-rooms schema for P2P organization management
 *
 * Domain ops (writer ops are handled by Room):
 *   create-org, update-org, delete-org
 *   add-resource, remove-resource, move-resource
 *   add-member, remove-member
 *   create-grant, revoke-grant
 *   create-invite, consume-invite, revoke-invite
 *   create-access-request, approve-access-request, deny-access-request
 *   set-meta
 *
 * View schema (Hyperbee keys):
 *   org:{name}                           → { name, displayName, description, about, type, userId, members, resources, visibility, createdAt, updatedAt }
 *   grant:{grantId}                      → { grantId, subjectType, subjectId, grantType, groupName, resourceName, permission, ... }
 *   invite:{inviteId}                    → { inviteId, inviteType, targetGroup, targetResource, permission, maxUses, ... }
 *   request:{requestId}                  → { requestId, subjectType, subjectId, requestType, groupName, resourceName, permission, status, ... }
 *   user-grants:{subjectId}:{grantId}    → { grantId }
 *   server-grants:{subjectId}:{grantId}  → { grantId }
 *   group-grants:{groupName}:{grantId}   → { grantId }
 *   resource-grants:{resourceName}:{grantId} → { grantId }
 *   user-requests:{subjectId}:{requestId}       → { requestId }
 *   server-requests:{subjectId}:{requestId}     → { requestId }
 *   group-requests:{groupName}:{requestId}      → { requestId }
 *   resource-requests:{resourceName}:{requestId} → { requestId }
 *   meta:{key}                           → { value }
 */

import { jsonSchema } from 'hyper-rooms'

// Reserved names that cannot be used as org names
export const RESERVED_NAMES = new Set([
  'admin', 'api', 'git', 'settings', 'login', 'logout',
  'register', 'auth', 'static', 'assets', 'public',
  'local', 'system', 'root', 'null', 'undefined'
])

export function validateName (name) {
  if (!name || typeof name !== 'string') return { valid: false, error: 'Name is required' }
  if (name.length < 2) return { valid: false, error: 'Name must be at least 2 characters' }
  if (name.length > 64) return { valid: false, error: 'Name must be at most 64 characters' }
  if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(name)) {
    return { valid: false, error: 'Name must be lowercase alphanumeric with hyphens, not starting/ending with hyphen' }
  }
  if (RESERVED_NAMES.has(name)) return { valid: false, error: `"${name}" is a reserved name` }
  return { valid: true }
}

export const orgsSchema = jsonSchema({
  validate (op) {
    switch (op.type) {
      case 'create-org':
        if (!op.name) throw new Error('create-org requires name')
        if (!op.orgType || !['user', 'group'].includes(op.orgType)) {
          throw new Error('create-org requires orgType (user|group)')
        }
        break
      case 'update-org':
        if (!op.name) throw new Error('update-org requires name')
        break
      case 'delete-org':
        if (!op.name) throw new Error('delete-org requires name')
        break

      case 'add-resource':
        if (!op.orgName) throw new Error('add-resource requires orgName')
        if (!op.resourceName) throw new Error('add-resource requires resourceName')
        break
      case 'remove-resource':
        if (!op.orgName) throw new Error('remove-resource requires orgName')
        if (!op.resourceName) throw new Error('remove-resource requires resourceName')
        break
      case 'move-resource':
        if (!op.resourceName) throw new Error('move-resource requires resourceName')
        if (!op.fromOrg) throw new Error('move-resource requires fromOrg')
        if (!op.toOrg) throw new Error('move-resource requires toOrg')
        break

      case 'add-member':
        if (!op.orgName) throw new Error('add-member requires orgName')
        if (!op.memberId) throw new Error('add-member requires memberId')
        if (!op.role || !['admin', 'member'].includes(op.role)) {
          throw new Error('add-member requires role (admin|member)')
        }
        break
      case 'remove-member':
        if (!op.orgName) throw new Error('remove-member requires orgName')
        if (!op.memberId) throw new Error('remove-member requires memberId')
        break

      case 'create-grant':
        if (!op.grantId) throw new Error('create-grant requires grantId')
        if (!op.subjectType || !['user', 'server'].includes(op.subjectType)) {
          throw new Error('create-grant requires subjectType (user|server)')
        }
        if (!op.subjectId) throw new Error('create-grant requires subjectId')
        if (!op.grantType || !['group', 'resource'].includes(op.grantType)) {
          throw new Error('create-grant requires grantType (group|resource)')
        }
        if (op.grantType === 'group' && !op.groupName) {
          throw new Error('create-grant with grantType=group requires groupName')
        }
        if (op.grantType === 'resource' && !op.resourceName) {
          throw new Error('create-grant with grantType=resource requires resourceName')
        }
        if (!op.permission || !['read', 'write', 'moderator', 'owner'].includes(op.permission)) {
          throw new Error('create-grant requires permission (read|write|moderator|owner)')
        }
        break
      case 'revoke-grant':
        if (!op.grantId) throw new Error('revoke-grant requires grantId')
        break

      case 'create-invite':
        if (!op.inviteId) throw new Error('create-invite requires inviteId')
        if (!op.inviteType || !['group', 'resource'].includes(op.inviteType)) {
          throw new Error('create-invite requires inviteType (group|resource)')
        }
        if (op.inviteType === 'group' && !op.targetGroup) {
          throw new Error('create-invite with inviteType=group requires targetGroup')
        }
        if (op.inviteType === 'resource' && !op.targetResource) {
          throw new Error('create-invite with inviteType=resource requires targetResource')
        }
        if (!op.permission || !['read', 'write', 'moderator', 'owner'].includes(op.permission)) {
          throw new Error('create-invite requires permission (read|write|moderator|owner)')
        }
        break
      case 'consume-invite':
        if (!op.inviteId) throw new Error('consume-invite requires inviteId')
        if (!op.grantId) throw new Error('consume-invite requires grantId')
        if (!op.subjectType || !['user', 'server'].includes(op.subjectType)) {
          throw new Error('consume-invite requires subjectType (user|server)')
        }
        if (!op.subjectId) throw new Error('consume-invite requires subjectId')
        break
      case 'revoke-invite':
        if (!op.inviteId) throw new Error('revoke-invite requires inviteId')
        break

      case 'create-access-request':
        if (!op.requestId) throw new Error('create-access-request requires requestId')
        if (!op.subjectType || !['user', 'server'].includes(op.subjectType)) {
          throw new Error('create-access-request requires subjectType (user|server)')
        }
        if (!op.subjectId) throw new Error('create-access-request requires subjectId')
        if (!op.requestType || !['group', 'resource'].includes(op.requestType)) {
          throw new Error('create-access-request requires requestType (group|resource)')
        }
        if (op.requestType === 'group' && !op.groupName) {
          throw new Error('create-access-request with requestType=group requires groupName')
        }
        if (op.requestType === 'resource' && !op.resourceName) {
          throw new Error('create-access-request with requestType=resource requires resourceName')
        }
        if (!op.permission || !['read', 'write', 'moderator', 'owner'].includes(op.permission)) {
          throw new Error('create-access-request requires permission (read|write|moderator|owner)')
        }
        break
      case 'approve-access-request':
        if (!op.requestId) throw new Error('approve-access-request requires requestId')
        if (!op.grantId) throw new Error('approve-access-request requires grantId')
        break
      case 'deny-access-request':
        if (!op.requestId) throw new Error('deny-access-request requires requestId')
        break

      case 'set-meta':
        if (!op.key) throw new Error('set-meta requires key')
        break
    }
  },

  async apply (batch, op, host) {
    switch (op.type) {
      // ── Orgs ─────────────────────────────────────────────────
      case 'create-org': {
        const existing = await batch.get(`org:${op.name}`)
        if (existing) break

        await batch.put(`org:${op.name}`, {
          name: op.name,
          displayName: op.displayName || op.name,
          description: op.description || '',
          // `about` is free-form markdown shown on the org's overview
          // page (the README-equivalent for orgs). Optional; legacy
          // records load with `about` undefined and the renderer treats
          // that the same as empty.
          about: op.about || '',
          type: op.orgType,
          userId: op.orgType === 'user' ? (op.userId || null) : null,
          members: op.members || [],
          resources: [],
          // Bootstrap key per grouped repo, so a peer that joins this org
          // can mount exactly these repos (scoped) without a separate
          // per-repo invite. Keyed by repo name; filled by add-resource
          // when the repo's tracker exists. Legacy records lack it →
          // treated as {} on read.
          resourceKeys: {},
          visibility: op.visibility || 'public',
          createdAt: op.timestamp,
          updatedAt: op.timestamp
        })
        break
      }

      case 'update-org': {
        const entry = await batch.get(`org:${op.name}`)
        if (!entry) break
        const org = entry.value
        if (op.displayName !== undefined) org.displayName = op.displayName
        if (op.description !== undefined) org.description = op.description
        if (op.about !== undefined) org.about = op.about
        if (op.visibility !== undefined) org.visibility = op.visibility
        org.updatedAt = op.timestamp
        await batch.put(`org:${op.name}`, org)
        break
      }

      case 'delete-org': {
        const entry = await batch.get(`org:${op.name}`)
        if (!entry) break
        if (entry.value.resources.length > 0) break
        await batch.del(`org:${op.name}`)
        break
      }

      // ── Resources ────────────────────────────────────────────
      case 'add-resource': {
        const orgEntry = await batch.get(`org:${op.orgName}`)
        if (!orgEntry) break

        // Remove the resource from any other org that currently holds it
        for await (const entry of batch.createReadStream({
          gt: 'org:', lt: 'org:\xff'
        })) {
          const other = entry.value
          if (other.name !== op.orgName && other.resources.includes(op.resourceName)) {
            other.resources = other.resources.filter(r => r !== op.resourceName)
            if (other.resourceKeys) delete other.resourceKeys[op.resourceName]
            other.updatedAt = op.timestamp
            await batch.put(`org:${other.name}`, other)
          }
        }

        const refreshed = await batch.get(`org:${op.orgName}`)
        const org = refreshed.value
        if (!org.resourceKeys) org.resourceKeys = {}
        let changed = false
        if (!org.resources.includes(op.resourceName)) {
          org.resources.push(op.resourceName)
          changed = true
        }
        // Record the repo's tracker bootstrap key when supplied (the repo
        // must exist for the producer to resolve it). Re-adding with a key
        // backfills a name-only entry added before the repo was created.
        if (op.bootstrapKey && org.resourceKeys[op.resourceName] !== op.bootstrapKey) {
          org.resourceKeys[op.resourceName] = op.bootstrapKey
          changed = true
        }
        if (changed) {
          org.updatedAt = op.timestamp
          await batch.put(`org:${op.orgName}`, org)
        }
        break
      }

      case 'remove-resource': {
        const entry = await batch.get(`org:${op.orgName}`)
        if (!entry) break
        const org = entry.value
        org.resources = org.resources.filter(r => r !== op.resourceName)
        if (org.resourceKeys) delete org.resourceKeys[op.resourceName]
        org.updatedAt = op.timestamp
        await batch.put(`org:${op.orgName}`, org)
        break
      }

      case 'move-resource': {
        const fromEntry = await batch.get(`org:${op.fromOrg}`)
        const toEntry = await batch.get(`org:${op.toOrg}`)
        if (!fromEntry || !toEntry) break

        const from = fromEntry.value
        const to = toEntry.value

        from.resources = from.resources.filter(r => r !== op.resourceName)
        const movedKey = from.resourceKeys && from.resourceKeys[op.resourceName]
        if (from.resourceKeys) delete from.resourceKeys[op.resourceName]
        from.updatedAt = op.timestamp
        await batch.put(`org:${op.fromOrg}`, from)

        if (!to.resources.includes(op.resourceName)) to.resources.push(op.resourceName)
        if (movedKey) {
          if (!to.resourceKeys) to.resourceKeys = {}
          to.resourceKeys[op.resourceName] = movedKey
        }
        to.updatedAt = op.timestamp
        await batch.put(`org:${op.toOrg}`, to)
        break
      }

      // ── Members ──────────────────────────────────────────────
      case 'add-member': {
        const entry = await batch.get(`org:${op.orgName}`)
        if (!entry) break
        const org = entry.value
        const idx = org.members.findIndex(m => m.userId === op.memberId)
        if (idx >= 0) {
          org.members[idx].role = op.role
        } else {
          org.members.push({ userId: op.memberId, role: op.role, addedAt: op.timestamp })
        }
        org.updatedAt = op.timestamp
        await batch.put(`org:${op.orgName}`, org)
        break
      }

      case 'remove-member': {
        const entry = await batch.get(`org:${op.orgName}`)
        if (!entry) break
        const org = entry.value
        org.members = org.members.filter(m => m.userId !== op.memberId)
        org.updatedAt = op.timestamp
        await batch.put(`org:${op.orgName}`, org)
        break
      }

      // ── Grants ───────────────────────────────────────────────
      case 'create-grant': {
        const grantKey = `grant:${op.grantId}`
        const existing = await batch.get(grantKey)
        if (existing) break

        await batch.put(grantKey, {
          grantId: op.grantId,
          subjectType: op.subjectType,
          subjectId: op.subjectId,
          grantType: op.grantType,
          groupName: op.groupName || null,
          resourceName: op.resourceName || null,
          permission: op.permission,
          grantedBy: op.grantedBy || null,
          grantedAt: op.timestamp,
          revokedAt: null,
          revokedBy: null,
          inviteId: op.inviteId || null
        })

        if (op.subjectType === 'user') {
          await batch.put(`user-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`server-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
        }
        if (op.grantType === 'resource') {
          await batch.put(`resource-grants:${op.resourceName}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`group-grants:${op.groupName}:${op.grantId}`, { grantId: op.grantId })
        }
        break
      }

      case 'revoke-grant': {
        const entry = await batch.get(`grant:${op.grantId}`)
        if (!entry) break
        const grant = entry.value
        if (grant.revokedAt) break
        grant.revokedAt = op.timestamp
        grant.revokedBy = op.revokedBy || null
        await batch.put(`grant:${op.grantId}`, grant)
        break
      }

      // ── Invites ──────────────────────────────────────────────
      case 'create-invite': {
        const inviteKey = `invite:${op.inviteId}`
        const existing = await batch.get(inviteKey)
        if (existing) break

        await batch.put(inviteKey, {
          inviteId: op.inviteId,
          inviteType: op.inviteType,
          targetGroup: op.targetGroup || null,
          targetResource: op.targetResource || null,
          permission: op.permission,
          maxUses: op.maxUses || 1,
          usedCount: 0,
          createdBy: op.createdBy || null,
          createdAt: op.timestamp,
          expiresAt: op.expiresAt || null,
          revoked: false
        })
        break
      }

      case 'consume-invite': {
        const inviteKey = `invite:${op.inviteId}`
        const inviteEntry = await batch.get(inviteKey)
        if (!inviteEntry) break

        const invite = inviteEntry.value
        if (invite.revoked) break
        if (invite.maxUses > 0 && invite.usedCount >= invite.maxUses) break
        if (invite.expiresAt && op.timestamp > invite.expiresAt) break

        invite.usedCount++
        await batch.put(inviteKey, invite)

        const grantKey = `grant:${op.grantId}`
        const existingGrant = await batch.get(grantKey)
        if (existingGrant) break

        await batch.put(grantKey, {
          grantId: op.grantId,
          subjectType: op.subjectType,
          subjectId: op.subjectId,
          grantType: invite.inviteType,
          groupName: invite.targetGroup || null,
          resourceName: invite.targetResource || null,
          permission: invite.permission,
          grantedBy: invite.createdBy || null,
          grantedAt: op.timestamp,
          revokedAt: null,
          revokedBy: null,
          inviteId: op.inviteId
        })

        if (op.subjectType === 'user') {
          await batch.put(`user-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`server-grants:${op.subjectId}:${op.grantId}`, { grantId: op.grantId })
        }
        if (invite.inviteType === 'resource') {
          await batch.put(`resource-grants:${invite.targetResource}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`group-grants:${invite.targetGroup}:${op.grantId}`, { grantId: op.grantId })
        }
        break
      }

      case 'revoke-invite': {
        const entry = await batch.get(`invite:${op.inviteId}`)
        if (!entry) break
        entry.value.revoked = true
        await batch.put(`invite:${op.inviteId}`, entry.value)
        break
      }

      // ── Access requests ──────────────────────────────────────
      case 'create-access-request': {
        const reqKey = `request:${op.requestId}`
        const existing = await batch.get(reqKey)
        if (existing) break

        await batch.put(reqKey, {
          requestId: op.requestId,
          subjectType: op.subjectType,
          subjectId: op.subjectId,
          requestType: op.requestType,
          groupName: op.groupName || null,
          resourceName: op.resourceName || null,
          permission: op.permission,
          reason: op.reason || null,
          status: 'pending',
          createdAt: op.timestamp,
          respondedAt: null,
          respondedBy: null,
          denyReason: null
        })

        const target = op.requestType === 'group' ? op.groupName : op.resourceName
        await batch.put(`${op.requestType}-requests:${target}:${op.requestId}`, { requestId: op.requestId })
        if (op.subjectType === 'user') {
          await batch.put(`user-requests:${op.subjectId}:${op.requestId}`, { requestId: op.requestId })
        } else {
          await batch.put(`server-requests:${op.subjectId}:${op.requestId}`, { requestId: op.requestId })
        }
        break
      }

      case 'approve-access-request': {
        const reqKey = `request:${op.requestId}`
        const reqEntry = await batch.get(reqKey)
        if (!reqEntry) break
        const request = reqEntry.value
        if (request.status !== 'pending') break

        request.status = 'approved'
        request.respondedAt = op.timestamp
        request.respondedBy = op.approvedBy || null
        await batch.put(reqKey, request)

        const grantKey = `grant:${op.grantId}`
        const existingGrant = await batch.get(grantKey)
        if (existingGrant) break

        await batch.put(grantKey, {
          grantId: op.grantId,
          subjectType: request.subjectType,
          subjectId: request.subjectId,
          grantType: request.requestType,
          groupName: request.groupName || null,
          resourceName: request.resourceName || null,
          permission: request.permission,
          grantedBy: op.approvedBy || null,
          grantedAt: op.timestamp,
          revokedAt: null,
          revokedBy: null,
          requestId: op.requestId
        })

        if (request.subjectType === 'user') {
          await batch.put(`user-grants:${request.subjectId}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`server-grants:${request.subjectId}:${op.grantId}`, { grantId: op.grantId })
        }
        if (request.requestType === 'resource') {
          await batch.put(`resource-grants:${request.resourceName}:${op.grantId}`, { grantId: op.grantId })
        } else {
          await batch.put(`group-grants:${request.groupName}:${op.grantId}`, { grantId: op.grantId })
        }
        break
      }

      case 'deny-access-request': {
        const reqKey = `request:${op.requestId}`
        const reqEntry = await batch.get(reqKey)
        if (!reqEntry) break
        const request = reqEntry.value
        if (request.status !== 'pending') break

        request.status = 'denied'
        request.respondedAt = op.timestamp
        request.respondedBy = op.deniedBy || null
        request.denyReason = op.denyReason || null
        await batch.put(reqKey, request)
        break
      }

      // ── Meta ─────────────────────────────────────────────────
      case 'set-meta': {
        await batch.put(`meta:${op.key}`, { value: op.value })
        break
      }
    }
  }
})