| … | … | @@ -1,65 +1,191 @@ |
| 1 | 1 | # hyper-orgs |
| 2 | 2 | |
| 3 | P2P organization management — a `hyper-rooms` schema for orgs, | |
| 4 | members, resources (repos/datasets/whatever), grants, invites, and | |
| 5 | access requests. | |
| 3 | P2P organization management built on [`hyper-rooms`](../hyper-rooms). It | |
| 4 | provides orgs (user or group namespaces), members, resources | |
| 5 | (repos/datasets/anything name-addressable), permission grants, and | |
| 6 | P2P join/invite — all replicated peer-to-peer with no server. | |
| 6 | 7 | |
| 7 | One Autobase per org (a Room). Members are autobase writers | |
| 8 | (role-aware: owner = indexer; writer = plain). Schema validates ops | |
| 9 | and applies them to a Hyperbee view keyed by `org:<id>`, | |
| 10 | `member:<orgId>:<userId>`, `grant:<orgId>:<resourceId>:<userId>`, etc. | |
| 8 | It is the access-control and namespace layer for **`pear-git-v3`**: the | |
| 9 | daemon mounts one `HyperOrgs` instance per identity and uses it to decide | |
| 10 | who may push to which repo. | |
| 11 | 11 | |
| 12 | Used by `pear-git-v3` (the daemon mounts a `HyperOrgs` instance per | |
| 13 | identity). | |
| 12 | ## Architecture — one autobase per org | |
| 13 | ||
| 14 | Each org is its **own** [`hyper-rooms`](../hyper-rooms) `Room` (a dedicated | |
| 15 | Autobase + Hyperbee view), **not** one shared autobase for every org. This | |
| 16 | is the post-mortem fix for issue #35: a peer that joins one org replicates | |
| 17 | **only** that org's autobase, so unrelated orgs never reach its disk. (An | |
| 18 | earlier single-shared-autobase design leaked 97 repos to a peer that joined | |
| 19 | one org — that is what this layout prevents.) | |
| 20 | ||
| 21 | A `HyperOrgs` instance tracks the orgs it has created or joined in an | |
| 22 | in-memory `Map`, plus a **local append-only index core** (`orgs-index`, | |
| 23 | *not* replicated) recording `{ name, bootstrapKey, role, created }` per org | |
| 24 | so `ready()` can re-open every tracked org `Room` after a restart. | |
| 25 | ||
| 26 | Org data (members, resources, grants) lives in that org's own view, keyed | |
| 27 | `org:<name>`, `grant:<grantId>`, etc. — see [docs/SCHEMA.md](docs/SCHEMA.md). | |
| 28 | ||
| 29 | ### Two replication modes | |
| 30 | ||
| 31 | | Mode | How | Scope | API | | |
| 32 | |------|-----|-------|-----| | |
| 33 | | **Peer** | Join specific orgs via invite | Only invited orgs replicate (isolated) | `createOrgInvite` / `joinOrg` | | |
| 34 | | **Mirror** | Mount *every* org from a primary's key list | Full replica of all orgs | `listOrgKeys` / `mountOrg` | | |
| 35 | ||
| 36 | Peer mode is what enforces scope isolation; mirror mode is for a sister | |
| 37 | daemon that intentionally replicates the whole forge. | |
| 38 | ||
| 39 | ## Install | |
| 40 | ||
| 41 | ```bash | |
| 42 | npm install # depends on ../hyper-rooms (file: link) | |
| 43 | ``` | |
| 44 | ||
| 45 | `hyper-orgs` is ESM (`"type": "module"`) and runs on Node or Bare. | |
| 14 | 46 | |
| 15 | 47 | ## Usage |
| 16 | 48 | |
| 17 | 49 | ```js |
| 50 | import Corestore from 'corestore' | |
| 18 | 51 | import { HyperOrgs } from 'hyper-orgs' |
| 19 | 52 | |
| 20 | const orgs = new HyperOrgs(corestore, { | |
| 21 | bootstrapKey: existingOrgsKey || null | |
| 22 | }) | |
| 53 | const store = new Corestore('./store') | |
| 54 | const orgs = new HyperOrgs(store, { namespace: 'orgs' }) | |
| 23 | 55 | await orgs.ready() |
| 24 | 56 | |
| 25 | await orgs.createOrg({ id: 'acme', name: 'Acme Inc.', ownerId: alice }) | |
| 26 | await orgs.addMember('acme', { userId: bob, role: 'writer' }) | |
| 27 | await orgs.grant('acme', { resourceId: 'repo:foo', userId: bob, perm: 'push' }) | |
| 57 | // Create a group org and a personal (user) namespace | |
| 58 | await orgs.create('acme', 'group', { displayName: 'Acme Inc.' }) | |
| 59 | await orgs.create('alice', 'user', { userId: aliceKeyHex }) | |
| 60 | ||
| 61 | // Members (group orgs only) — role is 'admin' | 'member' | |
| 62 | await orgs.addMember('acme', bobKeyHex, 'member') | |
| 63 | ||
| 64 | // Resources — a resource lives in exactly one org | |
| 65 | await orgs.addResource('acme', 'repo:widgets') | |
| 66 | ||
| 67 | // Grants — permission is 'write' | 'moderator' | 'owner' | |
| 68 | await orgs.createGrant({ | |
| 69 | subjectType: 'user', | |
| 70 | subjectId: bobKeyHex, | |
| 71 | grantType: 'group', // 'group' (whole org) or 'resource' (one repo) | |
| 72 | groupName: 'acme', | |
| 73 | permission: 'write' | |
| 74 | }) | |
| 75 | ||
| 76 | // Access check (resolves resource → owning org → grants) | |
| 77 | const ok = await orgs.checkAccess({ | |
| 78 | userId: bobKeyHex, | |
| 79 | resourceName: 'repo:widgets', | |
| 80 | requiredPermission: 'write' | |
| 81 | }) // → true | |
| 82 | ``` | |
| 83 | ||
| 84 | > The constructor takes `{ namespace, extraSchemas }` — there is **no** | |
| 85 | > `bootstrapKey` option. Per-org bootstrap keys are minted per `Room` | |
| 86 | > internally; you obtain them via `listOrgKeys()` / `createOrgInvite()`. | |
| 87 | ||
| 88 | ### P2P join (peer mode, blind-pairing) | |
| 89 | ||
| 90 | Joining requires an injected shared `Hyperswarm` (invites travel over the | |
| 91 | swarm, Keet-style — there is no HTTP path). The owner issues an invite for | |
| 92 | one org; a joiner pairs and, for `contributor`+, is added as an autobase | |
| 93 | **writer during pairing**; `reader` stays a read-only mirror. | |
| 94 | ||
| 95 | ```js | |
| 96 | // Owner side | |
| 97 | const { invitation, descriptor, close } = | |
| 98 | await orgs.createOrgInvite('acme', { role: 'contributor', swarm }) | |
| 99 | ||
| 100 | // Joiner side (separate machine / store, same swarm bootstrap) | |
| 101 | const { name, role } = await joiner.joinOrg(invitation, { swarm }) | |
| 102 | // → mounts ONLY 'acme'; other orgs never replicate to the joiner | |
| 28 | 103 | ``` |
| 29 | 104 | |
| 105 | Outstanding invites stay live until `close()`. `resumeOrgPairing(name, | |
| 106 | descriptor, swarm)` re-registers a persisted invite after a restart. | |
| 107 | ||
| 108 | The canonical, runnable end-to-end flow (contributor becomes writer, reader | |
| 109 | stays read-only, scope isolation, multi-org join) is | |
| 110 | `test/org-pairing.test.js`. | |
| 111 | ||
| 112 | ### Mirror mode (full replica) | |
| 113 | ||
| 114 | ```js | |
| 115 | // Primary exposes its org keys (over RPC in the daemon) | |
| 116 | const keys = primary.listOrgKeys() // [{ name, bootstrapKey, role, created }] | |
| 117 | ||
| 118 | // Mirror mounts every org by key — no invite ceremony | |
| 119 | for (const k of keys) await mirror.mountOrg(k.name, k.bootstrapKey, 'reader') | |
| 120 | ``` | |
| 121 | ||
| 122 | See `test/mirror-mode.test.js`. | |
| 123 | ||
| 124 | ## Three role/permission vocabularies (do not conflate) | |
| 125 | ||
| 126 | | Vocabulary | Values | Where | Meaning | | |
| 127 | |------------|--------|-------|---------| | |
| 128 | | **Member role** | `admin`, `member` | `org.members[].role` | Group membership | | |
| 129 | | **Grant permission** | `write` < `moderator` < `owner` | `grant:*` records | Access level (levels 1/2/3) checked by `checkAccess` | | |
| 130 | | **Pairing role** | `reader`, `contributor` | `createOrgInvite`/`joinOrg` | hyper-rooms blind-pairing: `contributor`+ → autobase writer, `reader` → read-only | | |
| 131 | ||
| 132 | These are independent. A pairing `role` controls whether the joiner becomes | |
| 133 | an autobase *writer*; it does **not** create a member record or a grant. | |
| 134 | ||
| 135 | ## API | |
| 136 | ||
| 137 | Full reference: **[docs/API.md](docs/API.md)**. Quick map: | |
| 138 | ||
| 139 | - **Lifecycle:** `ready()`, `close()`, `sync()`, `replicate(conn)` | |
| 140 | - **Orgs:** `create`, `get`, `update`, `delete`, `list` | |
| 141 | - **Members:** `addMember`, `removeMember`, `getUserOrgs` | |
| 142 | - **Resources:** `addResource`, `removeResource`, `moveResource` | |
| 143 | - **Grants / access:** `createGrant`, `revokeGrant`, `getGrant`, `listGrants`, `checkAccess` | |
| 144 | - **Invites / join:** `createOrgInvite`, `joinOrg`, `resumeOrgPairing`, `mountOrg`, `listOrgKeys` | |
| 145 | - **Per-org accessors:** `getOrgBase(name)`, `getOrgRoom(name)`, `base` (single-org convenience — throws if >1 tracked) | |
| 146 | - **Writers (mirror HA):** `addWriter`, `removeWriter`, `listWriters` | |
| 147 | ||
| 30 | 148 | ## Schema |
| 31 | 149 | |
| 32 | See `lib/schema.js` for the full op list. Reserved names guard the | |
| 33 | namespace; `validateName` is the canonical name check. | |
| 150 | The org `Room` view and op set are documented in | |
| 151 | **[docs/SCHEMA.md](docs/SCHEMA.md)**. `lib/schema.js` is the source of | |
| 152 | truth; `RESERVED_NAMES` and `validateName` guard the org-name namespace. | |
| 153 | ||
| 154 | > Note: the schema also defines bearer-invite and access-request ops | |
| 155 | > (`create-invite`/`consume-invite`, `create-access-request` / | |
| 156 | > `approve-access-request` / `deny-access-request`) and their view keys. | |
| 157 | > These are **not yet surfaced as `HyperOrgs` methods** — current join/grant | |
| 158 | > flows use blind-pairing invites instead. They are latent capability in the | |
| 159 | > view, available to a consumer that appends the ops directly. | |
| 34 | 160 | |
| 35 | 161 | ## Tests |
| 36 | 162 | |
| 37 | 163 | ```bash |
| 38 | npm test | |
| 164 | npm test # brittle test/*.test.js | |
| 39 | 165 | ``` |
| 40 | 166 | |
| 41 | ## Status — per-org-autobase refactor (issue #35) | |
| 42 | ||
| 43 | The current implementation stores ALL orgs in ONE autobase. The | |
| 44 | README's "one Autobase per org" description is the post-refactor | |
| 45 | intent, not today's reality. A scope-peer attempt in `pear-git-v3` | |
| 46 | hit this directly (97 repos accidentally synced); see the post-mortem | |
| 47 | at `pear-git-v3` issue #35. | |
| 167 | Coverage: org/member/resource/grant CRUD (`basic.test.js`), P2P | |
| 168 | pairing + scope isolation (`org-pairing.test.js`), mirror mode | |
| 169 | (`mirror-mode.test.js`), restart persistence (`persistence.test.js`), | |
| 170 | resource bootstrap keys (`resource-keys.test.js`), and issue-composition | |
| 171 | (`org-issues-linked-core.test.js`). | |
| 48 | 172 | |
| 49 | The failing TDD spec is in `test/scope-isolation.test.js`: | |
| 173 | ## Related | |
| 50 | 174 | |
| 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. | |
| 175 | - [`hyper-rooms`](../hyper-rooms) — the underlying `Room` (autobase + view + pairing) this builds on. | |
| 176 | - [`hyper-issues`](../hyper-issues) — sibling room schema; composed onto an org `Room` via `extraSchemas` so an org *is* its own issue tracker. | |
| 177 | - **`pear-git-v3`** — primary consumer (one `HyperOrgs` per identity for repo access control). | |
| 56 | 178 | |
| 57 | A runnable forward-looking demo lives at `examples/per-org-invite.js` | |
| 58 | — it prints "NOT IMPLEMENTED" today and runs end-to-end once the | |
| 59 | refactor lands. | |
| 179 | ## Status | |
| 60 | 180 | |
| 61 | ## Related | |
| 181 | The per-org-autobase refactor (issue #35) has **landed** — this is the | |
| 182 | current architecture, exercised by the P2P pairing and mirror-mode tests. | |
| 183 | Known follow-ups: | |
| 62 | 184 | |
| 63 | - `hyper-rooms` — the underlying Room class. | |
| 64 | - `pear-git-v3` — primary consumer. | |
| 65 | - `hyper-issues` — sibling room schema, same shape. | |
| 185 | - Mirror-mode HA **write** enrollment (making a sister daemon a writer on | |
| 186 | every org) reuses `addWriter` and is not yet wired end-to-end. | |
| 187 | - The schema-level invite/access-request ops are defined but not exposed as | |
| 188 | class methods (see Schema note above). | |
| 189 | - `examples/per-org-invite.js` predates the landed API and is stale (it | |
| 190 | guards on `createOrgInvite` being absent and calls `joinOrg` without a | |
| 191 | swarm); use `test/org-pairing.test.js` as the working reference. |
| … | … | @@ -0,0 +1,224 @@ |
| 1 | # hyper-orgs API reference | |
| 2 | ||
| 3 | All methods are on the `HyperOrgs` class (`import { HyperOrgs } from 'hyper-orgs'`). | |
| 4 | Source of truth: `lib/hyper-orgs.js`. Async unless noted. | |
| 5 | ||
| 6 | ## Construction & lifecycle | |
| 7 | ||
| 8 | ### `new HyperOrgs(corestore, options?)` | |
| 9 | ||
| 10 | - `corestore` — a `Corestore`. Internally namespaced to `options.namespace`. | |
| 11 | - `options.namespace` — parent corestore namespace. Default `'orgs'`. | |
| 12 | - `options.extraSchemas` — array of additional `hyper-rooms` schemas composed | |
| 13 | onto **every** org `Room`. The daemon injects `hyper-issues`' schema here so | |
| 14 | an org `Room` doubles as its own issue tracker (org issues then replicate | |
| 15 | *with* the org autobase). `hyper-orgs` never imports `hyper-issues` itself. | |
| 16 | ||
| 17 | ### `await ready()` | |
| 18 | ||
| 19 | Opens the local `orgs-index` core and **replays it** to re-open every | |
| 20 | previously tracked org `Room` (created or joined). Idempotent. Must be | |
| 21 | awaited before any other call. | |
| 22 | ||
| 23 | ### `await close()` | |
| 24 | ||
| 25 | Closes all live invite pairings, then every tracked org `Room`, and clears | |
| 26 | internal tracking. | |
| 27 | ||
| 28 | ### `await sync()` | |
| 29 | ||
| 30 | `base.update()` on every tracked org `Room` (pull latest from replicas). | |
| 31 | ||
| 32 | ### `replicate(conn)` | |
| 33 | ||
| 34 | Replicate every tracked org's autobase over a raw stream `conn`. Used by the | |
| 35 | daemon's swarm transport per connection — a multi-org instance has no single | |
| 36 | `base`, so this mirrors what `base.replicate` would do for each org. | |
| 37 | (`corestore.replicate` usually already covers these cores; this is | |
| 38 | belt-and-suspenders.) Not async. | |
| 39 | ||
| 40 | ### `validateName(name)` → `{ valid, error? }` | |
| 41 | ||
| 42 | Delegates to the schema validator. Not async. See SCHEMA.md. | |
| 43 | ||
| 44 | ## Per-org accessors | |
| 45 | ||
| 46 | ### `get base` | |
| 47 | ||
| 48 | Convenience getter for **single-org** instances (e.g. a joiner): | |
| 49 | ||
| 50 | - 1 org tracked → that org's autobase | |
| 51 | - 0 orgs → `null` | |
| 52 | - \>1 org → **throws** (`use getOrgBase(name)`) | |
| 53 | ||
| 54 | ### `getOrgBase(name)` → autobase | null | |
| 55 | ||
| 56 | That org's autobase, for replication. Not async. | |
| 57 | ||
| 58 | ### `getOrgRoom(name)` → Room | null | |
| 59 | ||
| 60 | That org's `hyper-rooms` `Room` (exposes `.writable`, `.base`, `.view`, | |
| 61 | `.keyHex`, `.localKeyHex`). Not async. | |
| 62 | ||
| 63 | ## Orgs | |
| 64 | ||
| 65 | ### `await create(name, type?, data?)` → org record | |
| 66 | ||
| 67 | Mint a new org in its **own** fresh autobase. | |
| 68 | ||
| 69 | - `name` — validated (`validateName`); lowercase alnum + hyphens, 2–64 chars, | |
| 70 | not a reserved name. Throws on invalid or already-tracked name. | |
| 71 | - `type` — `'group'` (default) or `'user'`. Other values throw. | |
| 72 | - `data` — `{ displayName?, description?, about?, userId?, members?, visibility? }`. | |
| 73 | `userId` applies to `user` orgs; `members` to `group` orgs; `visibility` | |
| 74 | defaults to `'public'`. `about` is free-form markdown (the org's | |
| 75 | README-equivalent overview). | |
| 76 | ||
| 77 | The creating instance becomes the org's first autobase writer (role `admin`, | |
| 78 | `created: true`) and the org is recorded in the local index. | |
| 79 | ||
| 80 | ### `await get(name)` → org record | null | |
| 81 | ||
| 82 | Reads `org:<name>` from that org's view after an `update()`. Returns `null` | |
| 83 | if not tracked or not found. | |
| 84 | ||
| 85 | ### `await update(name, updates)` → org record | |
| 86 | ||
| 87 | Appends `update-org`. Mutable fields: `displayName`, `description`, `about`, | |
| 88 | `visibility`. | |
| 89 | ||
| 90 | ### `await delete(name)` | |
| 91 | ||
| 92 | Appends `delete-org`, then drops the org from local tracking and the index. | |
| 93 | **Throws** if the org still has resources (remove them first). The autobase | |
| 94 | remains on disk; only local tracking is dropped. | |
| 95 | ||
| 96 | ### `await list(filters?)` → org record[] | |
| 97 | ||
| 98 | Orgs this instance tracks. `filters.type` (`user`/`group`) and | |
| 99 | `filters.visibility` narrow the result. | |
| 100 | ||
| 101 | ## Members (group orgs only) | |
| 102 | ||
| 103 | ### `await addMember(orgName, userId, role?)` → org record | |
| 104 | ||
| 105 | `role` is `'member'` (default) or `'admin'`. Re-adding an existing member | |
| 106 | updates their role (upsert). Throws on a `user` org or invalid role. | |
| 107 | ||
| 108 | ### `await removeMember(orgName, userId)` → org record | |
| 109 | ||
| 110 | ### `await getUserOrgs(userId)` → org record[] | |
| 111 | ||
| 112 | Every tracked org where `userId` is the `user`-org owner or a `group` member. | |
| 113 | ||
| 114 | ## Resources | |
| 115 | ||
| 116 | A **resource** is a name owned by exactly one org. `add-resource` steals the | |
| 117 | resource from any other org that currently holds it (single-owner invariant). | |
| 118 | ||
| 119 | ### `await addResource(orgName, resourceName, opts?)` → org record | |
| 120 | ||
| 121 | `opts.bootstrapKey` records the resource's (repo tracker) bootstrap key in the | |
| 122 | org's `resourceKeys` map, so a peer that joins the org can mount exactly that | |
| 123 | repo (scoped). Re-adding with a key backfills a name-only entry. A no-op | |
| 124 | re-add (same name, no/identical key) returns early. | |
| 125 | ||
| 126 | ### `await removeResource(orgName, resourceName)` → org record | |
| 127 | ||
| 128 | ### `await moveResource(resourceName, fromOrg, toOrg)` | |
| 129 | ||
| 130 | Convenience: `removeResource(fromOrg)` then `addResource(toOrg)`. Both orgs | |
| 131 | must be tracked. | |
| 132 | ||
| 133 | ## Grants & access | |
| 134 | ||
| 135 | Permission levels: `owner` (3) > `moderator` (2) > `write` (1). A grant lives | |
| 136 | in the autobase of the org it pertains to (a group grant in that org's `Room`; | |
| 137 | a resource grant in the `Room` of the org that owns the resource). | |
| 138 | ||
| 139 | ### `static permissionLevel(perm)` → number | |
| 140 | ||
| 141 | `owner`→3, `moderator`→2, `write`→1, anything else →0. | |
| 142 | ||
| 143 | ### `await createGrant(opts)` → grant record | |
| 144 | ||
| 145 | - `opts.subjectType` — `'user'` | `'server'` | |
| 146 | - `opts.subjectId` — subject key | |
| 147 | - `opts.grantType` — `'group'` (needs `groupName`) | `'resource'` (needs `resourceName`) | |
| 148 | - `opts.permission` — `'write'` | `'moderator'` | `'owner'` | |
| 149 | - `opts.grantId?` — defaults to `<subjectType>-<subjectId[:12]>-<ts>` | |
| 150 | - `opts.grantedBy?`, `opts.inviteId?` | |
| 151 | ||
| 152 | ### `await revokeGrant(grantId, revokedBy?)` | |
| 153 | ||
| 154 | Marks the grant revoked (soft) in whichever tracked org's `Room` holds it. | |
| 155 | Throws if not found. (Revoked grants are retained, not deleted.) | |
| 156 | ||
| 157 | ### `await getGrant(grantId)` → grant record | null | |
| 158 | ||
| 159 | ### `await listGrants(filters?)` → grant record[] | |
| 160 | ||
| 161 | Filters: `subjectType`, `subjectId`, `grantType`, `groupName`, | |
| 162 | `resourceName`, and `activeOnly` (default `true` — set `false` to include | |
| 163 | revoked). The query narrows to one `Room` when `groupName` or a resolvable | |
| 164 | `resourceName` is given, else scans all tracked orgs. | |
| 165 | ||
| 166 | ### `await checkAccess(opts)` → boolean | |
| 167 | ||
| 168 | Whether a subject has at least `opts.requiredPermission` on | |
| 169 | `opts.resourceName`. Resolves the resource to its owning org, then checks | |
| 170 | **direct resource grants** and **group grants** on that org, for whichever of | |
| 171 | `opts.userId` (→ user subject) and `opts.serverKey` (→ server subject) are | |
| 172 | supplied. Returns `false` if the resource isn't owned by any tracked org or | |
| 173 | `requiredPermission` resolves to level 0. | |
| 174 | ||
| 175 | ## Invites & join (peer mode — blind-pairing over a swarm) | |
| 176 | ||
| 177 | All require an injected shared `Hyperswarm` (P2P transport; no HTTP). | |
| 178 | ||
| 179 | ### `await createOrgInvite(name, opts)` → `{ invitation, descriptor, close }` | |
| 180 | ||
| 181 | Issue a blind-pairing invite for one org (hyper-rooms `createRoomPairing`). | |
| 182 | ||
| 183 | - `opts.swarm` — **required** shared Hyperswarm. | |
| 184 | - `opts.role` — pairing role, default `'reader'`. `contributor`+ joiners are | |
| 185 | added as autobase writers during pairing; `reader` joiners stay read-only. | |
| 186 | ||
| 187 | The pairing member stays live (held internally) until `close()`. The org | |
| 188 | `name` travels in the invite `meta` so the joiner can key its record. | |
| 189 | `descriptor` lets the daemon persist and later `resumeOrgPairing`. | |
| 190 | ||
| 191 | ### `await resumeOrgPairing(name, descriptor, swarm)` → pairing | |
| 192 | ||
| 193 | Re-register a persisted invite pairing after a restart (the org `Room` must | |
| 194 | already be mounted) so an already-shared invite keeps accepting joiners. | |
| 195 | ||
| 196 | ### `await joinOrg(invitation, opts)` → `{ name, role }` | |
| 197 | ||
| 198 | Consume an org invite: mount **only** that org's autobase. `opts.swarm` | |
| 199 | required. Decodes the invite, rejects non-org invites, and for `contributor`+ | |
| 200 | becomes a writer during pairing. Idempotent if already joined. | |
| 201 | ||
| 202 | ## Mirror mode (full replica — no invite) | |
| 203 | ||
| 204 | ### `await mountOrg(name, bootstrapKey, role?)` → `{ name, writerKey, role }` | |
| 205 | ||
| 206 | Mount an org's autobase directly by its bootstrap key. `role` default | |
| 207 | `'reader'`. Idempotent. The mirror counterpart to `joinOrg`. | |
| 208 | ||
| 209 | ### `listOrgKeys()` → `[{ name, bootstrapKey, role, created }]` | |
| 210 | ||
| 211 | Every org this instance tracks, with its bootstrap key. A primary exposes | |
| 212 | this so a mirror can `mountOrg` each for a full replica. Peers never call this | |
| 213 | (they only receive specific invites) — which is what prevents cross-org | |
| 214 | leakage. Not async. | |
| 215 | ||
| 216 | ## Writer management (delegated to the org's Room) | |
| 217 | ||
| 218 | Used for mirror-mode HA writer coordination. (Contributor write-enrollment | |
| 219 | during a normal join happens automatically inside `joinOrg`; the old | |
| 220 | HTTP `enrollWriter`/`enrollFromInvite` path is retired.) | |
| 221 | ||
| 222 | ### `await addWriter(orgName, userId, options?)` | |
| 223 | ### `await removeWriter(orgName, userId)` | |
| 224 | ### `await listWriters(orgName)` |
| … | … | @@ -0,0 +1,87 @@ |
| 1 | # hyper-orgs schema reference | |
| 2 | ||
| 3 | `lib/schema.js` is the source of truth. The schema is a `hyper-rooms` | |
| 4 | `jsonSchema` with a `validate(op)` (rejects malformed ops) and an | |
| 5 | `apply(batch, op, host)` (writes the Hyperbee view). It is composed onto | |
| 6 | every org `Room`; writer-management ops (add/remove writer) are handled by | |
| 7 | `hyper-rooms` itself, not here. | |
| 8 | ||
| 9 | ## Name validation | |
| 10 | ||
| 11 | `validateName(name)` / `RESERVED_NAMES`: | |
| 12 | ||
| 13 | - Required string, 2–64 chars. | |
| 14 | - Pattern `^[a-z0-9][a-z0-9-]*[a-z0-9]$` — lowercase alphanumeric and | |
| 15 | hyphens, not starting or ending with a hyphen. | |
| 16 | - Reserved (rejected): `admin, api, git, settings, login, logout, register, | |
| 17 | auth, static, assets, public, local, system, root, null, undefined`. | |
| 18 | ||
| 19 | ## Operations | |
| 20 | ||
| 21 | Appended to an org `Room` (most via a `HyperOrgs` method; see notes). | |
| 22 | ||
| 23 | | Op | Required fields | Surfaced by | | |
| 24 | |----|-----------------|-------------| | |
| 25 | | `create-org` | `name`, `orgType` (`user`\|`group`) | `create()` | | |
| 26 | | `update-org` | `name` | `update()` | | |
| 27 | | `delete-org` | `name` | `delete()` | | |
| 28 | | `add-resource` | `orgName`, `resourceName` | `addResource()` | | |
| 29 | | `remove-resource` | `orgName`, `resourceName` | `removeResource()` | | |
| 30 | | `move-resource` | `resourceName`, `fromOrg`, `toOrg` | (`moveResource()` uses remove+add) | | |
| 31 | | `add-member` | `orgName`, `memberId`, `role` (`admin`\|`member`) | `addMember()` | | |
| 32 | | `remove-member` | `orgName`, `memberId` | `removeMember()` | | |
| 33 | | `create-grant` | `grantId`, `subjectType` (`user`\|`server`), `subjectId`, `grantType` (`group`\|`resource`), `permission` (`write`\|`moderator`\|`owner`) | `createGrant()` | | |
| 34 | | `revoke-grant` | `grantId` | `revokeGrant()` | | |
| 35 | | `create-invite` | `inviteId`, `inviteType` (`group`\|`resource`), `permission` | — *(latent)* | | |
| 36 | | `consume-invite` | `inviteId`, `grantId`, `subjectType`, `subjectId` | — *(latent)* | | |
| 37 | | `revoke-invite` | `inviteId` | — *(latent)* | | |
| 38 | | `create-access-request` | `requestId`, `subjectType`, `subjectId`, `requestType` (`group`\|`resource`), `permission` | — *(latent)* | | |
| 39 | | `approve-access-request` | `requestId`, `grantId` | — *(latent)* | | |
| 40 | | `deny-access-request` | `requestId` | — *(latent)* | | |
| 41 | | `set-meta` | `key` | — *(latent)* | | |
| 42 | ||
| 43 | For `grantType`/`inviteType`/`requestType` = `group`, a `groupName` is | |
| 44 | required; for `resource`, a `resourceName` (or `targetGroup`/`targetResource` | |
| 45 | for invites). See `validate()` for the exact per-type checks. | |
| 46 | ||
| 47 | **Latent ops** are defined and applied by the schema but have no `HyperOrgs` | |
| 48 | method yet. A consumer can append them directly to an org `Room`. They | |
| 49 | implement a full bearer-invite + access-request workflow (request → approve → | |
| 50 | auto-grant) that the class does not currently expose; the class uses | |
| 51 | blind-pairing invites instead. | |
| 52 | ||
| 53 | ## View keys (Hyperbee) | |
| 54 | ||
| 55 | | Key | Value | | |
| 56 | |-----|-------| | |
| 57 | | `org:{name}` | `{ name, displayName, description, about, type, userId, members[], resources[], resourceKeys{}, visibility, createdAt, updatedAt }` | | |
| 58 | | `grant:{grantId}` | `{ grantId, subjectType, subjectId, grantType, groupName, resourceName, permission, grantedBy, grantedAt, revokedAt, revokedBy, inviteId }` | | |
| 59 | | `invite:{inviteId}` | `{ inviteId, inviteType, targetGroup, targetResource, permission, maxUses, usedCount, createdBy, createdAt, expiresAt, revoked }` | | |
| 60 | | `request:{requestId}` | `{ requestId, subjectType, subjectId, requestType, groupName, resourceName, permission, reason, status, createdAt, respondedAt, respondedBy, denyReason }` | | |
| 61 | | `user-grants:{subjectId}:{grantId}` | `{ grantId }` (secondary index) | | |
| 62 | | `server-grants:{subjectId}:{grantId}` | `{ grantId }` | | |
| 63 | | `group-grants:{groupName}:{grantId}` | `{ grantId }` | | |
| 64 | | `resource-grants:{resourceName}:{grantId}` | `{ grantId }` | | |
| 65 | | `user-requests:{subjectId}:{requestId}` | `{ requestId }` | | |
| 66 | | `server-requests:{subjectId}:{requestId}` | `{ requestId }` | | |
| 67 | | `group-requests:{groupName}:{requestId}` | `{ requestId }` | | |
| 68 | | `resource-requests:{resourceName}:{requestId}` | `{ requestId }` | | |
| 69 | | `meta:{key}` | `{ value }` | | |
| 70 | ||
| 71 | ### Notes on org records | |
| 72 | ||
| 73 | - `resources` is a flat array of resource names; a resource belongs to | |
| 74 | exactly one org (`add-resource` removes it from any other org). | |
| 75 | - `resourceKeys` maps `resourceName → bootstrapKey` (a grouped repo's tracker | |
| 76 | key) for scoped mounting by peers. Legacy records without it read as `{}`. | |
| 77 | - `members` (group orgs) is `[{ userId, role, addedAt }]`; `add-member` | |
| 78 | upserts by `userId`. | |
| 79 | - `about` is free-form markdown (org overview / README-equivalent). Legacy | |
| 80 | records load with `about` undefined, treated as empty. | |
| 81 | ||
| 82 | ### Idempotency / conflict behavior | |
| 83 | ||
| 84 | `apply` is defensive for autobase convergence: `create-org`/`create-grant`/ | |
| 85 | `create-invite` no-op if the key already exists; `delete-org` refuses while | |
| 86 | resources remain; `revoke-grant`/`consume-invite`/access-request transitions | |
| 87 | no-op if already in the target state or limits are exceeded. |