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