OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-orgs commits / e50ddd0

orgs: carry grouped repos' bootstrap keys (resourceKeys) for scoped join
Phase B: so a peer that joins an org can mount exactly that org's repos (scoped — no global repo stream), the org record now carries a resourceKeys map { repoName: trackerBootstrapKey } alongside resources. - create-org initialises resourceKeys: {} (legacy records read as {}). - addResource(org, name, { bootstrapKey }) records the key; re-adding with a key backfills a name-only entry (repo added before it existed). - remove-resource / move-resource keep the key in lockstep with the name. - The cross-org "remove from other org" scan also clears the key — dead in the per-org-autobase model (separate views) but kept to mirror the existing resources handling. Additive design (chosen over richer resource objects after auditing read sites): records are schemaless Hyperbee values so there's no encoding migration either way, and a parallel map leaves the ~8 existing resources:[string] read sites (SSR + the cross-org-uniqueness apply logic) untouched. Tests: resource-keys.test.js 6/6; basic suite 19/19 (no regression). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e50ddd0656b1cb8db5ec6720338be4f65cd3e18e
by plankatron <fleeky@gmail.com> · 2026-06-02T19:04:29.000Z

Changed files (3) +124 -3

lib/hyper-orgs.js +10 -3
……@@ -350,12 +350,19 @@ export class HyperOrgs {
350350
351351 // ── Resources (per-org) ──────────────────────────────────────
352352
353 async addResource (orgName, resourceName) {
353 // opts.bootstrapKey — the repo's tracker bootstrap key, recorded so a
354 // peer that joins this org can mount the repo (scoped). Re-appending an
355 // already-present resource WITH a new key backfills a name-only entry.
356 async addResource (orgName, resourceName, opts = {}) {
354357 const room = this._requireRoom(orgName)
355358 const org = await this.get(orgName)
356359 if (!org) throw new Error(`Organization "${orgName}" not found`)
357 if (org.resources.includes(resourceName)) return org
358 await room.append({ type: 'add-resource', orgName, resourceName, timestamp: Date.now() })
360 const hasName = org.resources.includes(resourceName)
361 const curKey = org.resourceKeys && org.resourceKeys[resourceName]
362 if (hasName && (!opts.bootstrapKey || curKey === opts.bootstrapKey)) return org
363 const op = { type: 'add-resource', orgName, resourceName, timestamp: Date.now() }
364 if (opts.bootstrapKey) op.bootstrapKey = opts.bootstrapKey
365 await room.append(op)
359366 await room.base.update()
360367 return this.get(orgName)
361368 }
lib/schema.js +26 -0
……@@ -191,6 +191,12 @@ export const orgsSchema = jsonSchema({
191191 userId: op.orgType === 'user' ? (op.userId || null) : null,
192192 members: op.members || [],
193193 resources: [],
194 // Bootstrap key per grouped repo, so a peer that joins this org
195 // can mount exactly these repos (scoped) without a separate
196 // per-repo invite. Keyed by repo name; filled by add-resource
197 // when the repo's tracker exists. Legacy records lack it →
198 // treated as {} on read.
199 resourceKeys: {},
194200 visibility: op.visibility || 'public',
195201 createdAt: op.timestamp,
196202 updatedAt: op.timestamp
……@@ -231,6 +237,7 @@ export const orgsSchema = jsonSchema({
231237 const other = entry.value
232238 if (other.name !== op.orgName && other.resources.includes(op.resourceName)) {
233239 other.resources = other.resources.filter(r => r !== op.resourceName)
240 if (other.resourceKeys) delete other.resourceKeys[op.resourceName]
234241 other.updatedAt = op.timestamp
235242 await batch.put(`org:${other.name}`, other)
236243 }
……@@ -238,8 +245,20 @@ export const orgsSchema = jsonSchema({
238245
239246 const refreshed = await batch.get(`org:${op.orgName}`)
240247 const org = refreshed.value
248 if (!org.resourceKeys) org.resourceKeys = {}
249 let changed = false
241250 if (!org.resources.includes(op.resourceName)) {
242251 org.resources.push(op.resourceName)
252 changed = true
253 }
254 // Record the repo's tracker bootstrap key when supplied (the repo
255 // must exist for the producer to resolve it). Re-adding with a key
256 // backfills a name-only entry added before the repo was created.
257 if (op.bootstrapKey && org.resourceKeys[op.resourceName] !== op.bootstrapKey) {
258 org.resourceKeys[op.resourceName] = op.bootstrapKey
259 changed = true
260 }
261 if (changed) {
243262 org.updatedAt = op.timestamp
244263 await batch.put(`org:${op.orgName}`, org)
245264 }
……@@ -251,6 +270,7 @@ export const orgsSchema = jsonSchema({
251270 if (!entry) break
252271 const org = entry.value
253272 org.resources = org.resources.filter(r => r !== op.resourceName)
273 if (org.resourceKeys) delete org.resourceKeys[op.resourceName]
254274 org.updatedAt = op.timestamp
255275 await batch.put(`org:${op.orgName}`, org)
256276 break
……@@ -265,10 +285,16 @@ export const orgsSchema = jsonSchema({
265285 const to = toEntry.value
266286
267287 from.resources = from.resources.filter(r => r !== op.resourceName)
288 const movedKey = from.resourceKeys && from.resourceKeys[op.resourceName]
289 if (from.resourceKeys) delete from.resourceKeys[op.resourceName]
268290 from.updatedAt = op.timestamp
269291 await batch.put(`org:${op.fromOrg}`, from)
270292
271293 if (!to.resources.includes(op.resourceName)) to.resources.push(op.resourceName)
294 if (movedKey) {
295 if (!to.resourceKeys) to.resourceKeys = {}
296 to.resourceKeys[op.resourceName] = movedKey
297 }
272298 to.updatedAt = op.timestamp
273299 await batch.put(`org:${op.toOrg}`, to)
274300 break
test/resource-keys.test.js +88 -0
……@@ -0,0 +1,88 @@
1// Phase B: org records carry each grouped repo's tracker bootstrap key
2// (resourceKeys) so a peer that joins the org can mount exactly those
3// repos (scoped). These tests cover the schema apply + addResource API:
4// store-with-key, name-only, backfill, remove, and cross-org reassignment.
5
6import test from 'brittle'
7import Corestore from 'corestore'
8import { mkdtempSync } from 'fs'
9import { join } from 'path'
10import { tmpdir } from 'os'
11import { HyperOrgs } from '../index.js'
12
13function tmpStore () {
14 return new Corestore(mkdtempSync(join(tmpdir(), 'hyperorgs-rk-')))
15}
16
17async function makeOrgs (t, namespace = 'rk') {
18 const store = tmpStore()
19 const orgs = new HyperOrgs(store, { namespace })
20 await orgs.ready()
21 t.teardown(async () => {
22 try { await orgs.close() } catch {}
23 try { await store.close() } catch {}
24 })
25 return orgs
26}
27
28const KEY_A = 'a'.repeat(64)
29const KEY_B = 'b'.repeat(64)
30
31test('new org record initialises resourceKeys to {}', async (t) => {
32 const orgs = await makeOrgs(t, 'rk-1')
33 const org = await orgs.create('team', 'group')
34 t.alike(org.resourceKeys, {}, 'resourceKeys present and empty')
35})
36
37test('addResource with a bootstrapKey records it under resourceKeys', async (t) => {
38 const orgs = await makeOrgs(t, 'rk-2')
39 await orgs.create('team', 'group')
40 const org = await orgs.addResource('team', 'repo-1', { bootstrapKey: KEY_A })
41 t.alike(org.resources, ['repo-1'])
42 t.is(org.resourceKeys['repo-1'], KEY_A, 'key stored')
43})
44
45test('addResource without a key adds the name only (no key)', async (t) => {
46 const orgs = await makeOrgs(t, 'rk-3')
47 await orgs.create('team', 'group')
48 const org = await orgs.addResource('team', 'repo-1')
49 t.alike(org.resources, ['repo-1'])
50 t.absent(org.resourceKeys['repo-1'], 'no key for a name-only add')
51})
52
53test('re-adding with a key backfills a name-only entry', async (t) => {
54 const orgs = await makeOrgs(t, 'rk-4')
55 await orgs.create('team', 'group')
56 await orgs.addResource('team', 'repo-1') // name only (repo not created yet)
57 const org = await orgs.addResource('team', 'repo-1', { bootstrapKey: KEY_A })
58 t.alike(org.resources, ['repo-1'], 'no duplicate name')
59 t.is(org.resourceKeys['repo-1'], KEY_A, 'key backfilled')
60})
61
62test('removeResource drops both the name and the key', async (t) => {
63 const orgs = await makeOrgs(t, 'rk-5')
64 await orgs.create('team', 'group')
65 await orgs.addResource('team', 'repo-1', { bootstrapKey: KEY_A })
66 const org = await orgs.removeResource('team', 'repo-1')
67 t.alike(org.resources, [])
68 t.absent(org.resourceKeys['repo-1'], 'key removed with the name')
69})
70
71test('each org keeps its own key — no cross-org uniqueness in the per-org model', async (t) => {
72 // Under #37 each org is its OWN autobase, so the cross-org "remove from
73 // other orgs" scan in add-resource only sees the current org's view. A
74 // repo can therefore belong to two orgs independently, each carrying its
75 // own resourceKeys entry. (Cross-org uniqueness was a property of the old
76 // single-orgs-autobase model; it does not hold here, and that's fine for
77 // the grouped-repos feature — each org lists + keys its own repos.)
78 const orgs = await makeOrgs(t, 'rk-6')
79 await orgs.create('team-a', 'group')
80 await orgs.create('team-b', 'group')
81 await orgs.addResource('team-a', 'repo-1', { bootstrapKey: KEY_A })
82 await orgs.addResource('team-b', 'repo-1', { bootstrapKey: KEY_B })
83
84 const a = await orgs.get('team-a')
85 const b = await orgs.get('team-b')
86 t.is(a.resourceKeys['repo-1'], KEY_A, 'team-a keeps its own key')
87 t.is(b.resourceKeys['repo-1'], KEY_B, 'team-b keeps its own key')
88})