/**
* 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
}
}