OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs commits / 1783b3d

Reconcile basic.test.js to per-org model + re-implement grants/checkAccess
Re-implemented per-org (load-bearing for daemon git ACL): - moveResource (across two tracked org Rooms) - grants: createGrant/revokeGrant/getGrant/listGrants — a group grant lives in that org's Room; a resource grant in the Room of the org owning the resource - checkAccess: resolves resource→owning org, checks direct + group grants within that one Room Superseded tests removed (behavior intentionally gone in per-org model): - add-resource auto-move (resources are org-local now) - app-layer invites/consumeInvite (replaced by createOrgInvite/joinOrg) - access-requests (request-without-invite flow; deferred) - meta, single-key properties, shared-bootstrap replication (no single shared autobase) — superseded by the scope-isolation tests Full hyper-orgs suite green: 22/22 tests, 57/57 asserts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1783b3d65bec01c8d39010dc4c8e594c8c01158d
by plankatron <fleeky@gmail.com> · 2026-05-24T23:53:44.000Z

Changed files (2) +142 -238

lib/hyper-orgs.js +142 -0
……@@ -290,6 +290,148 @@ export class HyperOrgs {
290290 })
291291 }
292292
293 /** Move a resource between two tracked orgs (both must be tracked). */
294 async moveResource (resourceName, fromOrg, toOrg) {
295 const from = await this.get(fromOrg)
296 if (!from) throw new Error(`Organization "${fromOrg}" not found`)
297 if (!(await this.get(toOrg))) throw new Error(`Organization "${toOrg}" not found`)
298 if (!from.resources.includes(resourceName)) {
299 throw new Error(`Resource "${resourceName}" is not in "${fromOrg}"`)
300 }
301 await this.removeResource(fromOrg, resourceName)
302 await this.addResource(toOrg, resourceName)
303 }
304
305 /** Find the tracked org Room that owns a resource, or null. */
306 async _orgRoomForResource (resourceName) {
307 for (const name of this._orgs.keys()) {
308 const org = await this.get(name)
309 if (org && org.resources.includes(resourceName)) return { orgName: name, room: this._orgs.get(name).room }
310 }
311 return null
312 }
313
314 // ── Grants (per-org) ─────────────────────────────────────────
315 // Grants live in the autobase of the org they pertain to: a group
316 // grant in that org's Room; a resource grant in the Room of the org
317 // that owns the resource. checkAccess resolves resource→org, then
318 // reads grants from that one Room.
319
320 static permissionLevel (perm) {
321 switch (perm) {
322 case 'owner': return 3
323 case 'moderator': return 2
324 case 'write': return 1
325 default: return 0
326 }
327 }
328
329 async _grantRoom (opts) {
330 if (opts.grantType === 'group') return this._requireRoom(opts.groupName)
331 const found = await this._orgRoomForResource(opts.resourceName)
332 if (!found) throw new Error(`no tracked org owns resource "${opts.resourceName}"`)
333 return found.room
334 }
335
336 async createGrant (opts) {
337 const grantId = opts.grantId ||
338 `${opts.subjectType}-${opts.subjectId.slice(0, 12)}-${Date.now()}`
339 const room = await this._grantRoom(opts)
340 await room.append({
341 type: 'create-grant',
342 grantId,
343 subjectType: opts.subjectType,
344 subjectId: opts.subjectId,
345 grantType: opts.grantType,
346 groupName: opts.groupName || null,
347 resourceName: opts.resourceName || null,
348 permission: opts.permission,
349 grantedBy: opts.grantedBy || null,
350 inviteId: opts.inviteId || null,
351 timestamp: Date.now()
352 })
353 await room.base.update()
354 return this.getGrant(grantId)
355 }
356
357 async revokeGrant (grantId, revokedBy = null) {
358 for (const { room } of this._orgs.values()) {
359 const e = await room.view.get(`grant:${grantId}`)
360 if (e) {
361 await room.append({ type: 'revoke-grant', grantId, revokedBy, timestamp: Date.now() })
362 await room.base.update()
363 return
364 }
365 }
366 throw new Error(`Grant "${grantId}" not found`)
367 }
368
369 async getGrant (grantId) {
370 for (const { room } of this._orgs.values()) {
371 await room.base.update()
372 const e = await room.view.get(`grant:${grantId}`)
373 if (e) return e.value
374 }
375 return null
376 }
377
378 async listGrants (filters = {}) {
379 const activeOnly = filters.activeOnly !== false
380 const out = []
381 // Narrow to one Room when the filter names an org (group) or a
382 // resource we can resolve; otherwise scan all tracked Rooms.
383 let rooms
384 if (filters.grantType === 'group' && filters.groupName && this._orgs.has(filters.groupName)) {
385 rooms = [this._orgs.get(filters.groupName).room]
386 } else if (filters.grantType === 'resource' && filters.resourceName) {
387 const found = await this._orgRoomForResource(filters.resourceName)
388 rooms = found ? [found.room] : []
389 } else {
390 rooms = [...this._orgs.values()].map(e => e.room)
391 }
392 for (const room of rooms) {
393 await room.base.update()
394 for await (const entry of room.view.createReadStream({ gt: 'grant:', lt: 'grant:\xff' })) {
395 const g = entry.value
396 if (activeOnly && g.revokedAt) continue
397 if (filters.subjectType && g.subjectType !== filters.subjectType) continue
398 if (filters.subjectId && g.subjectId !== filters.subjectId) continue
399 if (filters.grantType && g.grantType !== filters.grantType) continue
400 if (filters.groupName && g.groupName !== filters.groupName) continue
401 if (filters.resourceName && g.resourceName !== filters.resourceName) continue
402 out.push(g)
403 }
404 }
405 return out
406 }
407
408 /**
409 * Whether a subject has the required permission on a resource. Resolves
410 * resource→owning org, then checks direct resource grants + group
411 * grants for that org, within that org's Room only.
412 */
413 async checkAccess (opts) {
414 const required = HyperOrgs.permissionLevel(opts.requiredPermission)
415 if (required === 0) return false
416 const found = await this._orgRoomForResource(opts.resourceName)
417 if (!found) return false
418 const { orgName } = found
419
420 const subjects = []
421 if (opts.userId) subjects.push({ subjectType: 'user', subjectId: opts.userId })
422 if (opts.serverKey) subjects.push({ subjectType: 'server', subjectId: opts.serverKey })
423
424 for (const s of subjects) {
425 // direct resource grant
426 const direct = await this.listGrants({ ...s, grantType: 'resource', resourceName: opts.resourceName })
427 for (const g of direct) if (HyperOrgs.permissionLevel(g.permission) >= required) return true
428 // group grant on the owning org
429 const group = await this.listGrants({ ...s, grantType: 'group', groupName: orgName })
430 for (const g of group) if (HyperOrgs.permissionLevel(g.permission) >= required) return true
431 }
432 return false
433 }
434
293435 // ── Enrollment (owner-approves model, B) ─────────────────────
294436
295437 // Map an invite role to a Room writer role:
test/basic.test.js +0 -238
……@@ -209,21 +209,6 @@ test('move resource between orgs', async (t) => {
209209 t.is(b.resources[0], 'project')
210210})
211211
212test('adding a resource moves it from any previous owner', async (t) => {
213 const orgs = await makeOrgs(t, 'test-14')
214
215 await orgs.create('team-a', 'group')
216 await orgs.create('team-b', 'group')
217 await orgs.addResource('team-a', 'shared-repo')
218
219 // Add to team-b directly — should remove from team-a (apply-side logic)
220 await orgs.addResource('team-b', 'shared-repo')
221
222 const a = await orgs.get('team-a')
223 const b = await orgs.get('team-b')
224 t.is(a.resources.length, 0)
225 t.is(b.resources.length, 1)
226})
227212
228213// ── Grants ────────────────────────────────────────────────────
229214
……@@ -276,122 +261,6 @@ test('listGrants filters revoked by default', async (t) => {
276261 t.is(all.length, 2)
277262})
278263
279// ── Invites ───────────────────────────────────────────────────
280
281test('invites', async (t) => {
282 const orgs = await makeOrgs(t, 'test-17')
283
284 await orgs.create('team', 'group')
285
286 const invite = await orgs.createInvite({
287 inviteType: 'group',
288 targetGroup: 'team',
289 permission: 'write',
290 maxUses: 2
291 })
292
293 t.ok(invite.inviteId)
294 t.is(invite.usedCount, 0)
295 t.is(invite.maxUses, 2)
296
297 const grant = await orgs.consumeInvite(invite.inviteId, {
298 subjectType: 'user',
299 subjectId: 'bob'
300 })
301
302 t.ok(grant)
303 t.is(grant.permission, 'write')
304 t.is(grant.groupName, 'team')
305
306 const updatedInvite = await orgs.getInvite(invite.inviteId)
307 t.is(updatedInvite.usedCount, 1)
308})
309
310test('revoked invite cannot be consumed', async (t) => {
311 const orgs = await makeOrgs(t, 'test-18')
312
313 await orgs.create('team', 'group')
314 const invite = await orgs.createInvite({
315 inviteType: 'group', targetGroup: 'team', permission: 'write'
316 })
317 await orgs.revokeInvite(invite.inviteId)
318
319 try {
320 await orgs.consumeInvite(invite.inviteId, {
321 subjectType: 'user', subjectId: 'bob'
322 })
323 t.fail('should have thrown')
324 } catch (err) {
325 t.ok(err.message.toLowerCase().includes('revoked'))
326 }
327})
328
329// ── Access Requests ───────────────────────────────────────────
330
331test('access request approve flow', async (t) => {
332 const orgs = await makeOrgs(t, 'test-19')
333
334 await orgs.create('team', 'group')
335
336 const request = await orgs.createAccessRequest({
337 subjectType: 'user',
338 subjectId: 'charlie',
339 requestType: 'group',
340 groupName: 'team',
341 permission: 'write',
342 reason: 'Want to contribute'
343 })
344
345 t.is(request.status, 'pending')
346
347 const grant = await orgs.approveAccessRequest(request.requestId, 'admin')
348 t.ok(grant)
349 t.is(grant.permission, 'write')
350 t.is(grant.groupName, 'team')
351
352 const updatedReq = await orgs.getAccessRequest(request.requestId)
353 t.is(updatedReq.status, 'approved')
354})
355
356test('access request deny flow', async (t) => {
357 const orgs = await makeOrgs(t, 'test-20')
358
359 await orgs.create('team', 'group')
360
361 const request = await orgs.createAccessRequest({
362 subjectType: 'user',
363 subjectId: 'charlie',
364 requestType: 'group',
365 groupName: 'team',
366 permission: 'write'
367 })
368
369 await orgs.denyAccessRequest(request.requestId, 'admin', 'Not yet')
370 const updated = await orgs.getAccessRequest(request.requestId)
371 t.is(updated.status, 'denied')
372 t.is(updated.denyReason, 'Not yet')
373})
374
375test('duplicate pending access request rejected', async (t) => {
376 const orgs = await makeOrgs(t, 'test-21')
377
378 await orgs.create('team', 'group')
379 await orgs.createAccessRequest({
380 subjectType: 'user', subjectId: 'charlie',
381 requestType: 'group', groupName: 'team', permission: 'write'
382 })
383
384 try {
385 await orgs.createAccessRequest({
386 subjectType: 'user', subjectId: 'charlie',
387 requestType: 'group', groupName: 'team', permission: 'write'
388 })
389 t.fail('should have thrown')
390 } catch (err) {
391 t.ok(err.message.toLowerCase().includes('pending'))
392 }
393})
394
395264// ── Access Checking ───────────────────────────────────────────
396265
397266test('checkAccess — denied when no grants', async (t) => {
……@@ -473,110 +342,3 @@ test('checkAccess — server grants', async (t) => {
473342 })
474343 t.is(allowed, true)
475344})
476
477// ── Meta ──────────────────────────────────────────────────────
478
479test('meta key-value storage', async (t) => {
480 const orgs = await makeOrgs(t, 'test-26')
481
482 await orgs.setMeta('homepage', 'https://example.com')
483 await orgs.setMeta('tagline', 'p2p forge')
484
485 t.is(await orgs.getMeta('homepage'), 'https://example.com')
486 t.is(await orgs.getMeta('tagline'), 'p2p forge')
487 t.is(await orgs.getMeta('missing'), null)
488})
489
490// ── Keys ──────────────────────────────────────────────────────
491
492test('key properties', async (t) => {
493 const orgs = await makeOrgs(t, 'test-27')
494
495 t.ok(orgs.key, 'has key')
496 t.is(typeof orgs.keyHex, 'string')
497 t.is(orgs.keyHex.length, 64, 'keyHex is 32 bytes hex')
498 t.ok(orgs.localKey, 'has localKey')
499 t.is(orgs.writable, true, 'first writer is writable')
500})
501
502// ── Multi-writer replication ──────────────────────────────────
503
504test('two orgs instances replicate via autobase', async (t) => {
505 const storeA = tmpStore()
506 const storeB = tmpStore()
507
508 const orgsA = new HyperOrgs(storeA, { namespace: 'shared' })
509 await orgsA.ready()
510
511 // A creates an org and a grant
512 await orgsA.create('team', 'group', { displayName: 'Team A' })
513 await orgsA.addMember('team', 'alice', 'admin')
514 await orgsA.addResource('team', 'repo-1')
515 await orgsA.base.update()
516
517 const orgsB = new HyperOrgs(storeB, {
518 namespace: 'shared',
519 bootstrapKey: orgsA.keyHex
520 })
521 await orgsB.ready()
522
523 await replicateAndSync([orgsA.base, orgsB.base])
524
525 // B should see A's data
526 const org = await orgsB.get('team')
527 t.ok(org, 'B received the org')
528 t.is(org.displayName, 'Team A')
529 t.is(org.members.length, 1)
530 t.is(org.members[0].userId, 'alice')
531 t.alike(org.resources, ['repo-1'])
532
533 t.teardown(async () => {
534 try { await orgsA.close() } catch {}
535 try { await orgsB.close() } catch {}
536 try { await storeA.close() } catch {}
537 try { await storeB.close() } catch {}
538 })
539})
540
541test('two writers can both append after addWriter', async (t) => {
542 const storeA = tmpStore()
543 const storeB = tmpStore()
544
545 const orgsA = new HyperOrgs(storeA, { namespace: 'shared2' })
546 await orgsA.ready()
547
548 const orgsB = new HyperOrgs(storeB, {
549 namespace: 'shared2',
550 bootstrapKey: orgsA.keyHex
551 })
552 await orgsB.ready()
553
554 // Initial sync so B knows the room structure
555 await replicateAndSync([orgsA.base, orgsB.base])
556
557 // A authorizes B as a writer
558 await orgsA.addWriter('bob', { writerKey: orgsB.localKeyHex, role: 'writer' })
559 await replicateAndSync([orgsA.base, orgsB.base])
560
561 t.ok(orgsB.writable, 'B is now writable')
562
563 // Each creates a distinct org (no ID collision — orgs are keyed by name)
564 await orgsA.create('team-alice', 'group')
565 await orgsB.create('team-bob', 'group')
566
567 await replicateAndSync([orgsA.base, orgsB.base])
568
569 const allA = await orgsA.list()
570 const allB = await orgsB.list()
571
572 t.is(allA.length, 2, 'A sees both orgs')
573 t.is(allB.length, 2, 'B sees both orgs')
574 t.alike(allA.map(o => o.name).sort(), ['team-alice', 'team-bob'])
575
576 t.teardown(async () => {
577 try { await orgsA.close() } catch {}
578 try { await orgsB.close() } catch {}
579 try { await storeA.close() } catch {}
580 try { await storeB.close() } catch {}
581 })
582})