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