| … | … | @@ -350,12 +350,19 @@ export class HyperOrgs { |
| 350 | 350 | |
| 351 | 351 | // ── Resources (per-org) ────────────────────────────────────── |
| 352 | 352 | |
| 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 = {}) { | |
| 354 | 357 | const room = this._requireRoom(orgName) |
| 355 | 358 | const org = await this.get(orgName) |
| 356 | 359 | 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) | |
| 359 | 366 | await room.base.update() |
| 360 | 367 | return this.get(orgName) |
| 361 | 368 | } |
| … | … | @@ -191,6 +191,12 @@ export const orgsSchema = jsonSchema({ |
| 191 | 191 | userId: op.orgType === 'user' ? (op.userId || null) : null, |
| 192 | 192 | members: op.members || [], |
| 193 | 193 | 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: {}, | |
| 194 | 200 | visibility: op.visibility || 'public', |
| 195 | 201 | createdAt: op.timestamp, |
| 196 | 202 | updatedAt: op.timestamp |
| … | … | @@ -231,6 +237,7 @@ export const orgsSchema = jsonSchema({ |
| 231 | 237 | const other = entry.value |
| 232 | 238 | if (other.name !== op.orgName && other.resources.includes(op.resourceName)) { |
| 233 | 239 | other.resources = other.resources.filter(r => r !== op.resourceName) |
| 240 | if (other.resourceKeys) delete other.resourceKeys[op.resourceName] | |
| 234 | 241 | other.updatedAt = op.timestamp |
| 235 | 242 | await batch.put(`org:${other.name}`, other) |
| 236 | 243 | } |
| … | … | @@ -238,8 +245,20 @@ export const orgsSchema = jsonSchema({ |
| 238 | 245 | |
| 239 | 246 | const refreshed = await batch.get(`org:${op.orgName}`) |
| 240 | 247 | const org = refreshed.value |
| 248 | if (!org.resourceKeys) org.resourceKeys = {} | |
| 249 | let changed = false | |
| 241 | 250 | if (!org.resources.includes(op.resourceName)) { |
| 242 | 251 | 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) { | |
| 243 | 262 | org.updatedAt = op.timestamp |
| 244 | 263 | await batch.put(`org:${op.orgName}`, org) |
| 245 | 264 | } |
| … | … | @@ -251,6 +270,7 @@ export const orgsSchema = jsonSchema({ |
| 251 | 270 | if (!entry) break |
| 252 | 271 | const org = entry.value |
| 253 | 272 | org.resources = org.resources.filter(r => r !== op.resourceName) |
| 273 | if (org.resourceKeys) delete org.resourceKeys[op.resourceName] | |
| 254 | 274 | org.updatedAt = op.timestamp |
| 255 | 275 | await batch.put(`org:${op.orgName}`, org) |
| 256 | 276 | break |
| … | … | @@ -265,10 +285,16 @@ export const orgsSchema = jsonSchema({ |
| 265 | 285 | const to = toEntry.value |
| 266 | 286 | |
| 267 | 287 | 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] | |
| 268 | 290 | from.updatedAt = op.timestamp |
| 269 | 291 | await batch.put(`org:${op.fromOrg}`, from) |
| 270 | 292 | |
| 271 | 293 | 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 | } | |
| 272 | 298 | to.updatedAt = op.timestamp |
| 273 | 299 | await batch.put(`org:${op.toOrg}`, to) |
| 274 | 300 | break |
| … | … | @@ -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 | ||
| 6 | import test from 'brittle' | |
| 7 | import Corestore from 'corestore' | |
| 8 | import { mkdtempSync } from 'fs' | |
| 9 | import { join } from 'path' | |
| 10 | import { tmpdir } from 'os' | |
| 11 | import { HyperOrgs } from '../index.js' | |
| 12 | ||
| 13 | function tmpStore () { | |
| 14 | return new Corestore(mkdtempSync(join(tmpdir(), 'hyperorgs-rk-'))) | |
| 15 | } | |
| 16 | ||
| 17 | async 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 | ||
| 28 | const KEY_A = 'a'.repeat(64) | |
| 29 | const KEY_B = 'b'.repeat(64) | |
| 30 | ||
| 31 | test('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 | ||
| 37 | test('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 | ||
| 45 | test('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 | ||
| 53 | test('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 | ||
| 62 | test('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 | ||
| 71 | test('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 | }) |