OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs commits / f290cda

WIP: per-org-autobase refactor (issue #35) — increment 1 read model
One autobase PER ORG (was: one shared autobase for all orgs). A peer joins via a bearer invite carrying only that org's bootstrap key, so it replicates ONLY that org — the post-mortem fix. GREEN: - scope-isolation: joinOrg(invite) → sees only the invited org - multi-org-join: join N orgs → exactly those, others invisible - invitations unit tests (in hyper-rooms) RED / pending: - writer-vs-reader: needs write-enrollment (optimistic self-enroll) + role-aware apply — increment 2 - basic.test.js: refactor dropped grants/invites/access-requests/ checkAccess/meta/moveResource + the single-shared-autobase model; addWriter now takes an orgName. Reconciliation pending (task #10). In-memory org Map; persistence index (re-open org Rooms on restart) is the daemon's concern (TODO noted in source). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
f290cdad6a8b1db5415f22d3c561c757039d27a0
by plankatron <fleeky@gmail.com> · 2026-05-24T23:16:20.000Z

Changed files (6) +502 -504

README.md +20 -0
……@@ -38,6 +38,26 @@ namespace; `validateName` is the canonical name check.
3838npm test
3939```
4040
41## Status — per-org-autobase refactor (issue #35)
42
43The current implementation stores ALL orgs in ONE autobase. The
44README's "one Autobase per org" description is the post-refactor
45intent, not today's reality. A scope-peer attempt in `pear-git-v3`
46hit this directly (97 repos accidentally synced); see the post-mortem
47at `pear-git-v3` issue #35.
48
49The failing TDD spec is in `test/scope-isolation.test.js`:
50
51 - **Test A** (passing today) demonstrates the architectural bug:
52 any peer mounting the shared bootstrap key replicates ALL orgs.
53 - **Test B** (failing today) is the spec the refactor must satisfy:
54 `createOrgInvite(name, opts)` returns a bearer invite scoped to
55 one org; `joinOrg(invite)` mounts ONLY that org's autobase.
56
57A runnable forward-looking demo lives at `examples/per-org-invite.js`
58— it prints "NOT IMPLEMENTED" today and runs end-to-end once the
59refactor lands.
60
4161## Related
4262
4363- `hyper-rooms` — the underlying Room class.
examples/per-org-invite.js +112 -0
……@@ -0,0 +1,112 @@
1#!/usr/bin/env node
2/**
3 * Per-org-invite example — FORWARD-LOOKING SPEC (issue #35)
4 *
5 * This example demonstrates the desired post-refactor flow:
6 * - One autobase per org (Keet-rooms pattern)
7 * - `createOrgInvite(name, opts)` returns a bearer invite scoped
8 * to a single org
9 * - `joinOrg(invite)` consumes it, mounts ONLY that org's autobase,
10 * and starts replicating just that org's data
11 *
12 * Today this example will print a clear "NOT IMPLEMENTED" line and
13 * exit before the join step — because `createOrgInvite` and
14 * `joinOrg` do not exist on `HyperOrgs` yet. The matching failing
15 * test is `test/scope-isolation.test.js` (SPEC #35).
16 *
17 * Once the per-org-autobase refactor lands, this example runs
18 * end-to-end successfully and prints the isolation proof.
19 *
20 * Usage:
21 * node examples/per-org-invite.js
22 *
23 * Storage: ephemeral temp dirs, cleaned up on exit.
24 */
25
26import Corestore from 'corestore'
27import { mkdtempSync, rmSync } from 'fs'
28import { join } from 'path'
29import { tmpdir } from 'os'
30import { replicateAndSync } from 'autobase-test-helpers'
31import { HyperOrgs } from '../index.js'
32
33const dirs = []
34function tmpStore () {
35 const dir = mkdtempSync(join(tmpdir(), 'hyper-orgs-spec-'))
36 dirs.push(dir)
37 return new Corestore(dir)
38}
39
40async function main () {
41 // ── PRIMARY (org owner) ─────────────────────────────────────────
42 const storeA = tmpStore()
43 const owner = new HyperOrgs(storeA, { namespace: 'spec-demo' })
44 await owner.ready()
45
46 await owner.create('alpha', 'group', { displayName: 'Alpha' })
47 await owner.create('beta', 'group', { displayName: 'Beta' })
48 await owner.create('gamma', 'group', { displayName: 'Gamma' })
49 await owner.base.update()
50 console.log('[owner] created 3 orgs: alpha, beta, gamma')
51
52 // ── FUTURE API #1 — createOrgInvite ─────────────────────────────
53 if (typeof owner.createOrgInvite !== 'function') {
54 console.log('')
55 console.log(' ⚠ NOT IMPLEMENTED YET: HyperOrgs.createOrgInvite(orgName, opts)')
56 console.log(' Required by post-mortem #35. This example will run')
57 console.log(' end-to-end once the per-org-autobase refactor lands.')
58 console.log(' See test/scope-isolation.test.js for the failing spec.')
59 console.log('')
60 await cleanup({ owner, storeA })
61 process.exit(0)
62 }
63
64 const { invitation: alphaInvite } = await owner.createOrgInvite('alpha', {
65 role: 'contributor',
66 maxUses: 1
67 })
68 console.log('[owner] issued bearer invite for alpha (contributor, 1 use)')
69
70 // ── JOINER ──────────────────────────────────────────────────────
71 const storeB = tmpStore()
72 const joiner = new HyperOrgs(storeB, { namespace: 'spec-demo-joiner' })
73 await joiner.ready()
74
75 // FUTURE API #2 — joinOrg
76 await joiner.joinOrg(alphaInvite)
77 console.log('[joiner] consumed alpha invite')
78
79 // Sync only alpha's per-org base (post-refactor accessor).
80 await replicateAndSync([owner.getOrgBase('alpha'), joiner.base])
81
82 // ── Isolation assertions ────────────────────────────────────────
83 const alphaSeen = !!(await joiner.get('alpha'))
84 const betaSeen = !!(await joiner.get('beta'))
85 const gammaSeen = !!(await joiner.get('gamma'))
86
87 console.log('')
88 console.log('Joiner view after alpha-only join:')
89 console.log(' alpha:', alphaSeen ? 'visible (expected)' : 'MISSING (broken)')
90 console.log(' beta:', betaSeen ? 'VISIBLE — SCOPE LEAK' : 'hidden (expected)')
91 console.log(' gamma:', gammaSeen ? 'VISIBLE — SCOPE LEAK' : 'hidden (expected)')
92
93 const ok = alphaSeen && !betaSeen && !gammaSeen
94 console.log('')
95 console.log(ok ? '✓ isolation holds' : '✗ isolation broken')
96
97 await cleanup({ owner, storeA, joiner, storeB })
98 process.exit(ok ? 0 : 1)
99}
100
101async function cleanup ({ owner, storeA, joiner, storeB }) {
102 try { if (owner) await owner.close() } catch {}
103 try { if (joiner) await joiner.close() } catch {}
104 try { if (storeA) await storeA.close() } catch {}
105 try { if (storeB) await storeB.close() } catch {}
106 for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }) } catch {} }
107}
108
109main().catch((err) => {
110 console.error('example failed:', err)
111 process.exit(1)
112})
lib/hyper-orgs.js +164 -504
……@@ -1,107 +1,112 @@
11/**
2 * HyperOrgs — P2P organization management built on hyper-rooms
2 * HyperOrgs — per-org-autobase organization management (issue #35).
33 *
4 * Multi-writer, replicating, offline-first org management. Wraps a Room with
5 * the orgsSchema and provides a high-level API for orgs, members, resources,
6 * grants, invites, and access requests.
4 * Each org is its OWN Room (autobase) — NOT one shared autobase for all
5 * orgs. This is the post-mortem fix: a peer that joins one org replicates
6 * ONLY that org's autobase, so other orgs never reach its disk.
77 *
8 * Features:
9 * - Organizations (user or group) with visibility
10 * - Members with roles (admin, member) — groups only
11 * - Resources (repos, channels, services — app decides what)
12 * - Access grants (per-resource or per-group permissions)
13 * - Invitations (with expiry and max uses)
14 * - Access requests (pending → approved/denied workflow)
15 * - Multi-writer via Room (Autobase under the hood)
8 * - create(name, ...) → mints a new per-org Room (fresh autobase)
9 * - createOrgInvite(name, ..) → bearer invite carrying that Room's
10 * bootstrap key (+ advisory role)
11 * - joinOrg(invite) → mounts ONLY that org's Room
12 * - getOrgBase(name) → that org's autobase (for replication)
13 * - get/list/update/delete/addMember/addResource/... operate on the
14 * per-org Room.
1615 *
17 * Usage:
16 * The instance tracks its orgs (created + joined) in an in-memory Map.
17 * Persistence across restart (re-opening org Rooms from a local index)
18 * is the daemon's concern — see the pear-git-v3 handler work — and is a
19 * documented TODO here, not needed by the in-process tests.
1820 *
19 * const orgs = new HyperOrgs(corestore, { namespace: 'my-forge' })
20 * await orgs.ready()
21 * await orgs.create('my-team', 'group', { displayName: 'My Team' })
22 * await orgs.addMember('my-team', 'alice', 'admin')
23 * await orgs.addResource('my-team', 'project-a')
21 * Bearer invites come from hyper-rooms (shared with hyper-issues'
22 * repo invites). Write-enrollment (a contributor joiner becoming an
23 * autobase writer) + role-enforced apply is a follow-up increment;
24 * today joiners are read-only followers of the org's autobase.
2425 */
2526
26import { Room } from 'hyper-rooms'
27import { Room, encodeInvite, decodeInvite, isExpired } from 'hyper-rooms'
2728import { orgsSchema, validateName } from './schema.js'
2829
2930export class HyperOrgs {
3031 /**
3132 * @param {Corestore} corestore
32 * @param {object} options
33 * @param {string} [options.namespace='orgs']
34 * @param {Buffer|string|null} [options.bootstrapKey=null]
33 * @param {object} [options]
34 * @param {string} [options.namespace='orgs'] - parent corestore namespace
3535 */
3636 constructor (corestore, options = {}) {
37 this._room = new Room(corestore, {
38 schema: orgsSchema,
39 namespace: options.namespace || 'orgs',
40 bootstrapKey: options.bootstrapKey || null
41 })
37 this._corestore = corestore.namespace(options.namespace || 'orgs')
38 // name -> { room: Room, role: string, joined: boolean }
39 this._orgs = new Map()
40 this._opened = false
4241 }
4342
44 // ── Lifecycle / properties ──────────────────────────────────
45
4643 async ready () {
47 await this._room.ready()
44 this._opened = true
45 // TODO(daemon): re-open known org Rooms from a local persistence
46 // index here so orgs survive a restart. In-process callers create
47 // or join within one instance lifetime, so the Map suffices.
4848 }
4949
50 async sync () { await this._room.base.update() }
51 async close () { await this._room.close() }
50 async sync () {
51 for (const { room } of this._orgs.values()) await room.base.update()
52 }
5253
53 get key () { return this._room.key }
54 get keyHex () { return this._room.keyHex }
55 get discoveryKey () { return this._room.discoveryKey }
56 get localKey () { return this._room.localKey }
57 get localKeyHex () { return this._room.localKeyHex }
58 get writable () { return this._room.writable }
59 get view () { return this._room.view }
60 get base () { return this._room.base }
61 get room () { return this._room }
54 async close () {
55 for (const { room } of this._orgs.values()) {
56 try { await room.close() } catch {}
57 }
58 this._orgs.clear()
59 }
6260
6361 validateName (name) { return validateName(name) }
6462
65 // ── Writer management (delegated to Room) ───────────────────
63 // ── Per-org Room access ──────────────────────────────────────
6664
67 async addWriter (userId, options = {}) {
68 return this._room.addWriter(userId, options)
65 /** The sole org's autobase — convenience for single-org instances
66 * (e.g. a joiner). Use getOrgBase(name) when tracking several. */
67 get base () {
68 if (this._orgs.size === 1) return [...this._orgs.values()][0].room.base
69 if (this._orgs.size === 0) return null
70 throw new Error('multiple orgs tracked — use getOrgBase(name)')
6971 }
7072
71 async removeWriter (userId) {
72 return this._room.removeWriter(userId)
73 getOrgBase (name) {
74 const e = this._orgs.get(name)
75 return e ? e.room.base : null
7376 }
7477
75 async listWriters () {
76 return this._room.listWriters()
78 getOrgRoom (name) {
79 const e = this._orgs.get(name)
80 return e ? e.room : null
7781 }
7882
79 async getWriter (userId) {
80 return this._room.getWriter(userId)
83 _requireRoom (name) {
84 const e = this._orgs.get(name)
85 if (!e) throw new Error(`org "${name}" not tracked by this instance`)
86 return e.room
8187 }
8288
83 // ── Org CRUD ────────────────────────────────────────────────
89 // ── Org lifecycle ────────────────────────────────────────────
8490
8591 /**
86 * Create an organization.
87 * @param {string} name - unique, lowercase alphanumeric + hyphens
88 * @param {'user'|'group'} [type='group']
89 * @param {object} [data]
90 * @param {string} [data.displayName]
91 * @param {string} [data.description]
92 * @param {string} [data.userId] - owner userId (for user-type orgs)
93 * @param {Array} [data.members] - initial members (group-type)
94 * @param {string} [data.visibility='public']
92 * Create an organization in its own autobase.
93 * @returns the org record
9594 */
9695 async create (name, type = 'group', data = {}) {
9796 const v = validateName(name)
9897 if (!v.valid) throw new Error(v.error)
9998 if (type !== 'user' && type !== 'group') throw new Error('Type must be "user" or "group"')
99 if (this._orgs.has(name)) throw new Error(`Organization "${name}" already exists`)
100100
101 const existing = await this.get(name)
102 if (existing) throw new Error(`Organization "${name}" already exists`)
101 const room = new Room(this._corestore, {
102 schema: orgsSchema,
103 namespace: 'org/' + name,
104 bootstrapKey: null
105 })
106 await room.ready()
107 this._orgs.set(name, { room, role: 'admin', joined: false })
103108
104 await this._room.append({
109 await room.append({
105110 type: 'create-org',
106111 name,
107112 orgType: type,
……@@ -113,14 +118,16 @@ export class HyperOrgs {
113118 visibility: data.visibility || 'public',
114119 timestamp: Date.now()
115120 })
116 await this._room.base.update()
121 await room.base.update()
117122 return this.get(name)
118123 }
119124
120125 async get (name) {
126 const e = this._orgs.get(name)
127 if (!e) return null
121128 try {
122 await this._room.base.update()
123 const entry = await this._room.view.get(`org:${name}`)
129 await e.room.base.update()
130 const entry = await e.room.view.get(`org:${name}`)
124131 return entry ? entry.value : null
125132 } catch {
126133 return null
……@@ -128,17 +135,14 @@ export class HyperOrgs {
128135 }
129136
130137 async update (name, updates) {
131 const org = await this.get(name)
132 if (!org) throw new Error(`Organization "${name}" not found`)
133
138 const room = this._requireRoom(name)
134139 const op = { type: 'update-org', name, timestamp: Date.now() }
135140 if (updates.displayName !== undefined) op.displayName = updates.displayName
136141 if (updates.description !== undefined) op.description = updates.description
137142 if (updates.about !== undefined) op.about = updates.about
138143 if (updates.visibility !== undefined) op.visibility = updates.visibility
139
140 await this._room.append(op)
141 await this._room.base.update()
144 await room.append(op)
145 await room.base.update()
142146 return this.get(name)
143147 }
144148
……@@ -148,115 +152,125 @@ export class HyperOrgs {
148152 if (org.resources.length > 0) {
149153 throw new Error(`Cannot delete "${name}": still has ${org.resources.length} resources`)
150154 }
151
152 await this._room.append({ type: 'delete-org', name, timestamp: Date.now() })
153 await this._room.base.update()
155 const room = this._requireRoom(name)
156 await room.append({ type: 'delete-org', name, timestamp: Date.now() })
157 await room.base.update()
158 // Drop from local tracking (the autobase still exists on disk).
159 const e = this._orgs.get(name)
160 if (e) { try { await e.room.close() } catch {} }
161 this._orgs.delete(name)
154162 }
155163
156 /**
157 * List orgs with optional filtering.
158 * @param {object} [filters]
159 * @param {'user'|'group'} [filters.type]
160 * @param {string} [filters.visibility]
161 */
164 /** Orgs this instance tracks (created or joined). */
162165 async list (filters = {}) {
163 await this._room.base.update()
164 const orgs = []
165 for await (const entry of this._room.view.createReadStream({
166 gt: 'org:', lt: 'org:\xff'
167 })) {
168 const org = entry.value
166 const out = []
167 for (const name of this._orgs.keys()) {
168 const org = await this.get(name)
169 if (!org) continue
169170 if (filters.type && org.type !== filters.type) continue
170171 if (filters.visibility && org.visibility !== filters.visibility) continue
171 orgs.push(org)
172 out.push(org)
172173 }
173 return orgs
174 return out
174175 }
175176
176 // ── Resources ───────────────────────────────────────────────
177 // ── Invites / join (the per-org-autobase scope mechanism) ────
178
179 /**
180 * Issue a bearer invite for one org. Holding it lets a peer mount
181 * ONLY this org's autobase.
182 * @param {string} name
183 * @param {object} [opts]
184 * @param {string} [opts.role='reader']
185 * @param {number} [opts.expiresAt]
186 * @param {number} [opts.maxUses]
187 * @returns {{ invitation: string }}
188 */
189 async createOrgInvite (name, opts = {}) {
190 const room = this._requireRoom(name)
191 const invitation = encodeInvite({
192 bootstrapKey: room.keyHex,
193 kind: 'org',
194 name,
195 roles: [opts.role || 'reader'],
196 expiresAt: opts.expiresAt || null,
197 maxUses: opts.maxUses == null ? 1 : opts.maxUses
198 })
199 return { invitation }
200 }
201
202 /**
203 * Consume an org invite: mount ONLY that org's autobase as a
204 * read-only follower. (Write-enrollment is a follow-up increment.)
205 * @param {string} invitation
206 * @returns {Promise<string>} the org name now tracked
207 */
208 async joinOrg (invitation) {
209 const decoded = decodeInvite(invitation)
210 if (decoded.kind !== 'org') throw new Error(`invite is for "${decoded.kind}", not an org`)
211 if (isExpired(decoded)) throw new Error('invite expired')
212
213 const name = decoded.name
214 if (!name) throw new Error('org invite missing name (cannot key the org)')
215 if (this._orgs.has(name)) return name // already tracking
216
217 const room = new Room(this._corestore, {
218 schema: orgsSchema,
219 // Namespace by bootstrap key so a joiner's local writer is distinct
220 // from the creator's; the autobase identity is the bootstrap key.
221 namespace: 'org-join/' + decoded.bootstrapKey.slice(0, 16),
222 bootstrapKey: decoded.bootstrapKey
223 })
224 await room.ready()
225 await room.base.update()
226 this._orgs.set(name, { room, role: decoded.roles[0] || 'reader', joined: true })
227 return name
228 }
229
230 // ── Resources (per-org) ──────────────────────────────────────
177231
178232 async addResource (orgName, resourceName) {
233 const room = this._requireRoom(orgName)
179234 const org = await this.get(orgName)
180235 if (!org) throw new Error(`Organization "${orgName}" not found`)
181236 if (org.resources.includes(resourceName)) return org
182
183 await this._room.append({
184 type: 'add-resource',
185 orgName,
186 resourceName,
187 timestamp: Date.now()
188 })
189 await this._room.base.update()
237 await room.append({ type: 'add-resource', orgName, resourceName, timestamp: Date.now() })
238 await room.base.update()
190239 return this.get(orgName)
191240 }
192241
193242 async removeResource (orgName, resourceName) {
243 const room = this._requireRoom(orgName)
194244 const org = await this.get(orgName)
195245 if (!org) throw new Error(`Organization "${orgName}" not found`)
196246 if (!org.resources.includes(resourceName)) {
197247 throw new Error(`Resource "${resourceName}" is not in "${orgName}"`)
198248 }
199
200 await this._room.append({
201 type: 'remove-resource',
202 orgName,
203 resourceName,
204 timestamp: Date.now()
205 })
206 await this._room.base.update()
249 await room.append({ type: 'remove-resource', orgName, resourceName, timestamp: Date.now() })
250 await room.base.update()
207251 return this.get(orgName)
208252 }
209253
210 async moveResource (resourceName, fromOrg, toOrg) {
211 const from = await this.get(fromOrg)
212 if (!from) throw new Error(`Organization "${fromOrg}" not found`)
213 const to = await this.get(toOrg)
214 if (!to) throw new Error(`Organization "${toOrg}" not found`)
215 if (!from.resources.includes(resourceName)) {
216 throw new Error(`Resource "${resourceName}" is not in "${fromOrg}"`)
217 }
218
219 await this._room.append({
220 type: 'move-resource',
221 resourceName,
222 fromOrg,
223 toOrg,
224 timestamp: Date.now()
225 })
226 await this._room.base.update()
227 }
228
229 // ── Members ─────────────────────────────────────────────────
254 // ── Members (per-org) ────────────────────────────────────────
230255
231256 async addMember (orgName, userId, role = 'member') {
257 const room = this._requireRoom(orgName)
232258 const org = await this.get(orgName)
233259 if (!org) throw new Error(`Organization "${orgName}" not found`)
234260 if (org.type !== 'group') throw new Error('Can only add members to group organizations')
235261 if (!['admin', 'member'].includes(role)) throw new Error('Role must be "admin" or "member"')
236
237 await this._room.append({
238 type: 'add-member',
239 orgName,
240 memberId: userId,
241 role,
242 timestamp: Date.now()
243 })
244 await this._room.base.update()
262 await room.append({ type: 'add-member', orgName, memberId: userId, role, timestamp: Date.now() })
263 await room.base.update()
245264 return this.get(orgName)
246265 }
247266
248267 async removeMember (orgName, userId) {
268 const room = this._requireRoom(orgName)
249269 const org = await this.get(orgName)
250270 if (!org) throw new Error(`Organization "${orgName}" not found`)
251271 if (org.type !== 'group') throw new Error('Can only remove members from group organizations')
252
253 await this._room.append({
254 type: 'remove-member',
255 orgName,
256 memberId: userId,
257 timestamp: Date.now()
258 })
259 await this._room.base.update()
272 await room.append({ type: 'remove-member', orgName, memberId: userId, timestamp: Date.now() })
273 await room.base.update()
260274 return this.get(orgName)
261275 }
262276
……@@ -269,371 +283,17 @@ export class HyperOrgs {
269283 })
270284 }
271285
272 // ── Grants ──────────────────────────────────────────────────
273
274 async createGrant (opts) {
275 const grantId = opts.grantId ||
276 `${opts.subjectType}-${opts.subjectId.slice(0, 12)}-${Date.now()}`
277
278 await this._room.append({
279 type: 'create-grant',
280 grantId,
281 subjectType: opts.subjectType,
282 subjectId: opts.subjectId,
283 grantType: opts.grantType,
284 groupName: opts.groupName || null,
285 resourceName: opts.resourceName || null,
286 permission: opts.permission,
287 grantedBy: opts.grantedBy || null,
288 inviteId: opts.inviteId || null,
289 timestamp: Date.now()
290 })
291 await this._room.base.update()
292 return this.getGrant(grantId)
293 }
294
295 async revokeGrant (grantId, revokedBy = null) {
296 const grant = await this.getGrant(grantId)
297 if (!grant) throw new Error(`Grant "${grantId}" not found`)
298 if (grant.revokedAt) throw new Error(`Grant "${grantId}" already revoked`)
299
300 await this._room.append({
301 type: 'revoke-grant',
302 grantId,
303 revokedBy,
304 timestamp: Date.now()
305 })
306 await this._room.base.update()
307 }
286 // ── Writer management (per-org, delegated to Room) ───────────
308287
309 async getGrant (grantId) {
310 await this._room.base.update()
311 const entry = await this._room.view.get(`grant:${grantId}`)
312 return entry ? entry.value : null
288 async addWriter (orgName, userId, options = {}) {
289 return this._requireRoom(orgName).addWriter(userId, options)
313290 }
314291
315 /**
316 * List grants. If a specific index key applies (subjectId + subjectType,
317 * groupName, or resourceName), uses the index for efficiency. Otherwise
318 * scans all grants.
319 */
320 async listGrants (filters = {}) {
321 await this._room.base.update()
322 const activeOnly = filters.activeOnly !== false
323
324 if (filters.subjectType === 'user' && filters.subjectId) {
325 return this._listByIndex(`user-grants:${filters.subjectId}:`, 'grant', filters, activeOnly)
326 }
327 if (filters.subjectType === 'server' && filters.subjectId) {
328 return this._listByIndex(`server-grants:${filters.subjectId}:`, 'grant', filters, activeOnly)
329 }
330 if (filters.grantType === 'resource' && filters.resourceName) {
331 return this._listByIndex(`resource-grants:${filters.resourceName}:`, 'grant', filters, activeOnly)
332 }
333 if (filters.grantType === 'group' && filters.groupName) {
334 return this._listByIndex(`group-grants:${filters.groupName}:`, 'grant', filters, activeOnly)
335 }
336
337 const grants = []
338 for await (const entry of this._room.view.createReadStream({
339 gt: 'grant:', lt: 'grant:\xff'
340 })) {
341 const g = entry.value
342 if (activeOnly && g.revokedAt) continue
343 if (filters.subjectType && g.subjectType !== filters.subjectType) continue
344 if (filters.subjectId && g.subjectId !== filters.subjectId) continue
345 if (filters.grantType && g.grantType !== filters.grantType) continue
346 if (filters.groupName && g.groupName !== filters.groupName) continue
347 if (filters.resourceName && g.resourceName !== filters.resourceName) continue
348 grants.push(g)
349 }
350 return grants
292 async removeWriter (orgName, userId) {
293 return this._requireRoom(orgName).removeWriter(userId)
351294 }
352295
353 // ── Invites ─────────────────────────────────────────────────
354
355 async createInvite (opts) {
356 const inviteId = opts.inviteId ||
357 `inv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
358
359 await this._room.append({
360 type: 'create-invite',
361 inviteId,
362 inviteType: opts.inviteType,
363 targetGroup: opts.targetGroup || null,
364 targetResource: opts.targetResource || null,
365 permission: opts.permission,
366 maxUses: opts.maxUses || 1,
367 createdBy: opts.createdBy || null,
368 expiresAt: opts.expiresAt || null,
369 timestamp: Date.now()
370 })
371 await this._room.base.update()
372 return this.getInvite(inviteId)
373 }
374
375 async consumeInvite (inviteId, opts) {
376 const invite = await this.getInvite(inviteId)
377 if (!invite) throw new Error(`Invite "${inviteId}" not found`)
378 if (invite.revoked) throw new Error('Invite has been revoked')
379 if (invite.maxUses > 0 && invite.usedCount >= invite.maxUses) {
380 throw new Error('Invite fully used')
381 }
382 if (invite.expiresAt && Date.now() > invite.expiresAt) throw new Error('Invite expired')
383
384 const grantId = `${opts.subjectType}-${opts.subjectId.slice(0, 12)}-${Date.now()}`
385 await this._room.append({
386 type: 'consume-invite',
387 inviteId,
388 grantId,
389 subjectType: opts.subjectType,
390 subjectId: opts.subjectId,
391 timestamp: Date.now()
392 })
393 await this._room.base.update()
394 return this.getGrant(grantId)
395 }
396
397 async revokeInvite (inviteId) {
398 const invite = await this.getInvite(inviteId)
399 if (!invite) throw new Error(`Invite "${inviteId}" not found`)
400 await this._room.append({ type: 'revoke-invite', inviteId, timestamp: Date.now() })
401 await this._room.base.update()
402 }
403
404 async getInvite (inviteId) {
405 await this._room.base.update()
406 const entry = await this._room.view.get(`invite:${inviteId}`)
407 return entry ? entry.value : null
408 }
409
410 async listInvites (filters = {}) {
411 await this._room.base.update()
412 const activeOnly = filters.activeOnly !== false
413 const invites = []
414 for await (const entry of this._room.view.createReadStream({
415 gt: 'invite:', lt: 'invite:\xff'
416 })) {
417 const inv = entry.value
418 if (activeOnly && inv.revoked) continue
419 if (activeOnly && inv.expiresAt && Date.now() > inv.expiresAt) continue
420 if (activeOnly && inv.maxUses > 0 && inv.usedCount >= inv.maxUses) continue
421 invites.push(inv)
422 }
423 return invites
424 }
425
426 // ── Access Requests ─────────────────────────────────────────
427
428 async createAccessRequest (opts) {
429 const requestId = opts.requestId ||
430 `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
431
432 const pending = await this.listAccessRequests({
433 subjectId: opts.subjectId,
434 status: 'pending'
435 })
436 const target = opts.requestType === 'group' ? opts.groupName : opts.resourceName
437 const duplicate = pending.find(r =>
438 r.requestType === opts.requestType &&
439 ((r.requestType === 'group' && r.groupName === target) ||
440 (r.requestType === 'resource' && r.resourceName === target))
441 )
442 if (duplicate) throw new Error('Already have a pending request for this target')
443
444 await this._room.append({
445 type: 'create-access-request',
446 requestId,
447 subjectType: opts.subjectType,
448 subjectId: opts.subjectId,
449 requestType: opts.requestType,
450 groupName: opts.groupName || null,
451 resourceName: opts.resourceName || null,
452 permission: opts.permission,
453 reason: opts.reason || null,
454 timestamp: Date.now()
455 })
456 await this._room.base.update()
457 return this.getAccessRequest(requestId)
458 }
459
460 async approveAccessRequest (requestId, approvedBy = null) {
461 const request = await this.getAccessRequest(requestId)
462 if (!request) throw new Error(`Request "${requestId}" not found`)
463 if (request.status !== 'pending') throw new Error(`Request is already ${request.status}`)
464
465 const grantId = `${request.subjectType}-${request.subjectId.slice(0, 12)}-${Date.now()}`
466 await this._room.append({
467 type: 'approve-access-request',
468 requestId,
469 grantId,
470 approvedBy,
471 timestamp: Date.now()
472 })
473 await this._room.base.update()
474 return this.getGrant(grantId)
475 }
476
477 async denyAccessRequest (requestId, deniedBy = null, denyReason = null) {
478 const request = await this.getAccessRequest(requestId)
479 if (!request) throw new Error(`Request "${requestId}" not found`)
480 if (request.status !== 'pending') throw new Error(`Request is already ${request.status}`)
481
482 await this._room.append({
483 type: 'deny-access-request',
484 requestId,
485 deniedBy,
486 denyReason,
487 timestamp: Date.now()
488 })
489 await this._room.base.update()
490 }
491
492 async getAccessRequest (requestId) {
493 await this._room.base.update()
494 const entry = await this._room.view.get(`request:${requestId}`)
495 return entry ? entry.value : null
496 }
497
498 async listAccessRequests (filters = {}) {
499 await this._room.base.update()
500
501 if (filters.subjectId) {
502 return this._listByIndex(`user-requests:${filters.subjectId}:`, 'request', filters, false)
503 }
504 if (filters.requestType === 'group' && filters.groupName) {
505 return this._listByIndex(`group-requests:${filters.groupName}:`, 'request', filters, false)
506 }
507 if (filters.requestType === 'resource' && filters.resourceName) {
508 return this._listByIndex(`resource-requests:${filters.resourceName}:`, 'request', filters, false)
509 }
510
511 const requests = []
512 for await (const entry of this._room.view.createReadStream({
513 gt: 'request:', lt: 'request:\xff'
514 })) {
515 const r = entry.value
516 if (filters.status && r.status !== filters.status) continue
517 if (filters.subjectId && r.subjectId !== filters.subjectId) continue
518 if (filters.requestType && r.requestType !== filters.requestType) continue
519 requests.push(r)
520 }
521 return requests
522 }
523
524 // ── Access Checking ─────────────────────────────────────────
525
526 static permissionLevel (perm) {
527 switch (perm) {
528 case 'owner': return 3
529 case 'moderator': return 2
530 case 'write': return 1
531 default: return 0
532 }
533 }
534
535 /**
536 * Check whether a subject has the required permission on a resource.
537 * Walks direct grants first, then group grants (via the orgs the resource
538 * belongs to).
539 *
540 * @param {object} opts
541 * @param {string} [opts.userId]
542 * @param {string} [opts.serverKey]
543 * @param {string} opts.resourceName
544 * @param {'write'|'moderator'|'owner'} opts.requiredPermission
545 */
546 async checkAccess (opts) {
547 const required = HyperOrgs.permissionLevel(opts.requiredPermission)
548 if (required === 0) return false
549
550 // Direct user resource grant
551 if (opts.userId) {
552 const grants = await this.listGrants({
553 subjectType: 'user', subjectId: opts.userId,
554 grantType: 'resource', resourceName: opts.resourceName
555 })
556 for (const g of grants) {
557 if (HyperOrgs.permissionLevel(g.permission) >= required) return true
558 }
559 }
560
561 // User group grant (for orgs containing this resource)
562 if (opts.userId) {
563 const orgs = await this.list()
564 for (const org of orgs) {
565 if (!org.resources.includes(opts.resourceName)) continue
566 const grants = await this.listGrants({
567 subjectType: 'user', subjectId: opts.userId,
568 grantType: 'group', groupName: org.name
569 })
570 for (const g of grants) {
571 if (HyperOrgs.permissionLevel(g.permission) >= required) return true
572 }
573 }
574 }
575
576 // Direct server resource grant
577 if (opts.serverKey) {
578 const grants = await this.listGrants({
579 subjectType: 'server', subjectId: opts.serverKey,
580 grantType: 'resource', resourceName: opts.resourceName
581 })
582 for (const g of grants) {
583 if (HyperOrgs.permissionLevel(g.permission) >= required) return true
584 }
585 }
586
587 // Server group grant
588 if (opts.serverKey) {
589 const orgs = await this.list()
590 for (const org of orgs) {
591 if (!org.resources.includes(opts.resourceName)) continue
592 const grants = await this.listGrants({
593 subjectType: 'server', subjectId: opts.serverKey,
594 grantType: 'group', groupName: org.name
595 })
596 for (const g of grants) {
597 if (HyperOrgs.permissionLevel(g.permission) >= required) return true
598 }
599 }
600 }
601
602 return false
603 }
604
605 // ── Meta ────────────────────────────────────────────────────
606
607 async setMeta (key, value) {
608 await this._room.append({ type: 'set-meta', key, value, timestamp: Date.now() })
609 }
610
611 async getMeta (key) {
612 await this._room.base.update()
613 const entry = await this._room.view.get(`meta:${key}`)
614 return entry ? entry.value.value : null
615 }
616
617 // ── Private ─────────────────────────────────────────────────
618
619 async _listByIndex (indexPrefix, entityPrefix, filters, activeOnly) {
620 const results = []
621 for await (const entry of this._room.view.createReadStream({
622 gt: indexPrefix, lt: indexPrefix + '\xff'
623 })) {
624 const idField = entityPrefix === 'grant' ? 'grantId' : 'requestId'
625 const id = entry.value[idField]
626 const entityEntry = await this._room.view.get(`${entityPrefix}:${id}`)
627 if (!entityEntry) continue
628 const e = entityEntry.value
629 if (activeOnly && e.revokedAt) continue
630 if (filters.status && e.status !== filters.status) continue
631 if (filters.subjectType && e.subjectType !== filters.subjectType) continue
632 if (filters.grantType && e.grantType !== filters.grantType) continue
633 if (filters.groupName && e.groupName !== filters.groupName) continue
634 if (filters.resourceName && e.resourceName !== filters.resourceName) continue
635 results.push(e)
636 }
637 return results
296 async listWriters (orgName) {
297 return this._requireRoom(orgName).listWriters()
638298 }
639299}
test/multi-org-join.test.js +66 -0
……@@ -0,0 +1,66 @@
1// Layer-2 failing spec (issue #35): a peer can join N orgs via N invites
2// and sees ONLY those orgs — not the others on the inviter. Exercises the
3// per-org-autobase model's multi-room management (one autobase per joined
4// org, keyed by name).
5//
6// Fails today: createOrgInvite/joinOrg/getOrgBase don't exist yet.
7
8import test from 'brittle'
9import Corestore from 'corestore'
10import { mkdtempSync } from 'fs'
11import { join } from 'path'
12import { tmpdir } from 'os'
13import { replicateAndSync } from 'autobase-test-helpers'
14import { HyperOrgs } from '../index.js'
15
16function tmpStore () {
17 return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-multi-')))
18}
19
20test('SPEC #35: join alpha + gamma → both visible, beta absent', async (t) => {
21 const storeA = tmpStore()
22 const storeB = tmpStore()
23
24 const owner = new HyperOrgs(storeA, { namespace: 'multi-owner' })
25 await owner.ready()
26 await owner.create('alpha', 'group', { displayName: 'Alpha' })
27 await owner.create('beta', 'group', { displayName: 'Beta' })
28 await owner.create('gamma', 'group', { displayName: 'Gamma' })
29 await owner.sync()
30
31 if (typeof owner.createOrgInvite !== 'function') {
32 t.fail('REGRESSION: HyperOrgs.createOrgInvite not implemented (per-org-autobase refactor pending)')
33 return
34 }
35
36 const { invitation: alphaInvite } = await owner.createOrgInvite('alpha', { role: 'reader' })
37 const { invitation: gammaInvite } = await owner.createOrgInvite('gamma', { role: 'reader' })
38
39 const joiner = new HyperOrgs(storeB, { namespace: 'multi-joiner' })
40 await joiner.ready()
41 if (typeof joiner.joinOrg !== 'function') {
42 t.fail('REGRESSION: HyperOrgs.joinOrg not implemented')
43 return
44 }
45 await joiner.joinOrg(alphaInvite)
46 await joiner.joinOrg(gammaInvite)
47
48 // Sync each joined org's autobase against the owner's matching one.
49 await replicateAndSync([owner.getOrgBase('alpha'), joiner.getOrgBase('alpha')])
50 await replicateAndSync([owner.getOrgBase('gamma'), joiner.getOrgBase('gamma')])
51
52 t.ok(await joiner.get('alpha'), 'joiner sees alpha')
53 t.ok(await joiner.get('gamma'), 'joiner sees gamma')
54 t.is(await joiner.get('beta'), null, 'SCOPE LEAK: joiner sees beta (never invited)')
55
56 // The joiner's local org set is exactly the two it joined.
57 const mine = (await joiner.list()).map(o => o.name).sort()
58 t.alike(mine, ['alpha', 'gamma'], 'joiner.list() == joined orgs only')
59
60 t.teardown(async () => {
61 try { await owner.close() } catch {}
62 try { await joiner.close() } catch {}
63 try { await storeA.close() } catch {}
64 try { await storeB.close() } catch {}
65 })
66})
test/scope-isolation.test.js +64 -0
……@@ -0,0 +1,64 @@
1// Scope-isolation spec for the post-mortem (issue #35).
2//
3// The post-mortem (pear-git-v3 issue #35) captured a production failure:
4// a peer configured for "scope=alpha only" actually replicated ALL of
5// the source's orgs, because hyper-orgs stored every org in ONE shared
6// autobase — mounting it by bootstrap key replicated the whole thing.
7//
8// The fix: ONE autobase per org. A peer joins via a bearer invite that
9// carries only that org's autobase bootstrap key, so it replicates ONLY
10// that org. Other orgs live in separate autobases the joiner never
11// mounts.
12//
13// (An earlier "Test A" here demonstrated the bug by mounting the shared
14// bootstrap. That model no longer exists — HyperOrgs has no single
15// bootstrap key to mount — so the bug is now structurally impossible and
16// Test A was removed. The remaining test is the spec the fix satisfies.)
17
18import test from 'brittle'
19import Corestore from 'corestore'
20import { mkdtempSync } from 'fs'
21import { join } from 'path'
22import { tmpdir } from 'os'
23import { replicateAndSync } from 'autobase-test-helpers'
24import { HyperOrgs } from '../index.js'
25
26function tmpStore () {
27 return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-scope-')))
28}
29
30test('#35: joinOrg(invite) replicates ONLY the invited org', async (t) => {
31 const storeA = tmpStore()
32 const storeB = tmpStore()
33
34 const owner = new HyperOrgs(storeA, { namespace: 'scope-owner' })
35 await owner.ready()
36 await owner.create('alpha', 'group', { displayName: 'Alpha' })
37 await owner.create('beta', 'group', { displayName: 'Beta' })
38 await owner.create('gamma', 'group', { displayName: 'Gamma' })
39 await owner.sync()
40
41 const { invitation: alphaInvite } = await owner.createOrgInvite('alpha', { role: 'reader' })
42 t.ok(alphaInvite, 'owner issued an alpha-scoped invite')
43
44 const joiner = new HyperOrgs(storeB, { namespace: 'scope-joiner' })
45 await joiner.ready()
46 await joiner.joinOrg(alphaInvite)
47
48 // Sync the joiner's single mounted autobase (alpha) against the owner's.
49 await replicateAndSync([owner.getOrgBase('alpha'), joiner.base])
50
51 t.ok(await joiner.get('alpha'), 'joiner sees alpha after joining its invite')
52
53 // The load-bearing assertions — beta/gamma live in separate autobases
54 // the joiner never mounted, so they MUST be invisible.
55 t.is(await joiner.get('beta'), null, 'SCOPE LEAK: joiner sees beta')
56 t.is(await joiner.get('gamma'), null, 'SCOPE LEAK: joiner sees gamma')
57
58 t.teardown(async () => {
59 try { await owner.close() } catch {}
60 try { await joiner.close() } catch {}
61 try { await storeA.close() } catch {}
62 try { await storeB.close() } catch {}
63 })
64})
test/writer-vs-reader.test.js +76 -0
……@@ -0,0 +1,76 @@
1// Layer-2 failing spec (issue #35): invite role bits gate WRITE access.
2// A contributor-joiner becomes a writer and its appends replicate back to
3// the owner; a reader-joiner's appends are dropped at apply (role check),
4// while it can still READ the org's data.
5//
6// This is the Keet model: every joiner replicates the autobase, but the
7// apply layer enforces role per op. Reader role = read-only.
8//
9// Fails today: createOrgInvite/joinOrg + role-aware apply not implemented.
10
11import test from 'brittle'
12import Corestore from 'corestore'
13import { mkdtempSync } from 'fs'
14import { join } from 'path'
15import { tmpdir } from 'os'
16import { replicateAndSync } from 'autobase-test-helpers'
17import { HyperOrgs } from '../index.js'
18
19function tmpStore () {
20 return new Corestore(mkdtempSync(join(tmpdir(), 'hyper-orgs-rw-')))
21}
22
23test('SPEC #35: contributor can write, reader cannot (role enforced at apply)', async (t) => {
24 const storeA = tmpStore()
25 const storeC = tmpStore()
26 const storeR = tmpStore()
27
28 const owner = new HyperOrgs(storeA, { namespace: 'rw-owner' })
29 await owner.ready()
30 await owner.create('proj', 'group', { displayName: 'Proj' })
31 await owner.base.update()
32
33 if (typeof owner.createOrgInvite !== 'function') {
34 t.fail('REGRESSION: HyperOrgs.createOrgInvite not implemented')
35 return
36 }
37
38 const { invitation: contribInvite } = await owner.createOrgInvite('proj', { role: 'contributor' })
39 const { invitation: readerInvite } = await owner.createOrgInvite('proj', { role: 'reader' })
40
41 // ── contributor ──────────────────────────────────────────────
42 const contrib = new HyperOrgs(storeC, { namespace: 'rw-contrib' })
43 await contrib.ready()
44 await contrib.joinOrg(contribInvite)
45 // initial sync so the owner learns the joiner's writer key + enrolls it
46 await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')])
47 // (enrollment may require a couple of sync rounds; the impl decides)
48 await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')])
49
50 await contrib.addResource('proj', 'repo-x')
51 await replicateAndSync([owner.getOrgBase('proj'), contrib.getOrgBase('proj')])
52
53 const ownerView = await owner.get('proj')
54 t.ok(ownerView.resources.includes('repo-x'), 'contributor write replicated to owner')
55
56 // ── reader ───────────────────────────────────────────────────
57 const reader = new HyperOrgs(storeR, { namespace: 'rw-reader' })
58 await reader.ready()
59 await reader.joinOrg(readerInvite)
60 await replicateAndSync([owner.getOrgBase('proj'), reader.getOrgBase('proj')])
61
62 // reader can READ
63 t.ok(await reader.get('proj'), 'reader can read the org')
64
65 // reader CANNOT write — append must be dropped at apply
66 try { await reader.addResource('proj', 'sneaky-repo') } catch { /* may throw locally if not writable */ }
67 await replicateAndSync([owner.getOrgBase('proj'), reader.getOrgBase('proj')])
68
69 const after = await owner.get('proj')
70 t.absent(after.resources.includes('sneaky-repo'), 'reader write was rejected (role enforced)')
71
72 t.teardown(async () => {
73 for (const o of [owner, contrib, reader]) { try { await o.close() } catch {} }
74 for (const s of [storeA, storeC, storeR]) { try { await s.close() } catch {} }
75 })
76})