OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms commits / 6b98b98

Add bearer-invite primitive (issue #35 scope feature)
lib/invitations.js: encode/decode a bearer invite carrying a Room's autobase bootstrap key + advisory role bits + expiry/use limits. Shared by hyper-orgs (org invite) and hyper-issues (repo invite) — both wrap Room, so the invite primitive lives in the shared base module. Exported from index. 10/10 unit tests pass. Seed-derived encryption / deterministic roots (fuller Keet pattern) deferred; seed field carried now so adding it later is non-breaking. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
6b98b98b8a070d8689416f033dcc640c166ece13
by plankatron <fleeky@gmail.com> · 2026-05-24T23:15:52.000Z

Changed files (3) +193 -0

index.js +3 -0
……@@ -4,7 +4,10 @@
44 * Exports:
55 * Room — the multiwriter room class
66 * jsonSchema — convenience builder for JSON-encoded schemas
7 * invites — bearer-invite encode/decode (shared by hyper-orgs
8 * `org invite` + hyper-issues `repo invite`)
79 */
810
911export { Room } from './lib/room.js'
1012export { jsonSchema } from './lib/json-schema.js'
13export { encodeInvite, decodeInvite, newSeed, isExpired, ROLES } from './lib/invitations.js'
lib/invitations.js +107 -0
……@@ -0,0 +1,107 @@
1// Bearer invites for mounting a Room's autobase (issue #35 scope feature).
2//
3// Lives in hyper-rooms because the concept is Room-level and SHARED:
4// hyper-orgs uses it for `org invite`, hyper-issues uses it for
5// `repo invite`. Both wrap Room; an invite is just "a bearer token that
6// carries the capability to MOUNT one Room's autobase."
7//
8// An invite carries the resource autobase's bootstrap key + advisory
9// role bits + expiry/use limits. Holding it lets a peer replicate ONLY
10// that one autobase — the read-isolation the post-mortem required
11// (separate autobase per org/repo, not one shared autobase).
12//
13// Scope of THIS module (deliberately minimal, extensible):
14// - encode/decode the bearer string
15// - newSeed() for a 32-byte secret
16//
17// Deferred (the fuller Keet `generateRoomCapabilities` pattern — see
18// keet-deobfuscated lib/invitations): deriving the autobase bootstrap
19// key + swarm topic + ENCRYPTION key deterministically from the seed so
20// the seed is the only secret and non-members can't decrypt even if they
21// learn the bootstrap key. Today the bootstrap key travels in the invite
22// directly and isolation is by separate-autobase (not encryption). The
23// `seed` field is carried now so adding the encryption layer later is a
24// non-breaking change.
25
26import b4a from 'b4a'
27import crypto from 'hypercore-crypto'
28
29// Versioned prefix so the decoder can reject foreign / future formats.
30const PREFIX = 'roominv1:'
31
32export const ROLES = ['reader', 'contributor', 'maintainer', 'admin']
33
34export function newSeed () {
35 return crypto.randomBytes(32)
36}
37
38/**
39 * @param {object} opts
40 * @param {Buffer|string} opts.bootstrapKey resource autobase root key
41 * @param {string} [opts.kind='room'] 'org' | 'repo' | 'room'
42 * @param {string} [opts.name] human name (advisory; the
43 * canonical name lives in the
44 * autobase record)
45 * @param {string[]} [opts.roles=['reader']] advisory role bits, enforced
46 * at write-enrollment time
47 * @param {number|null} [opts.expiresAt=null]
48 * @param {number} [opts.maxUses=1]
49 * @param {Buffer|string} [opts.seed] 32-byte secret (future:
50 * encryption + deterministic root)
51 * @returns {string} bearer invite
52 */
53export function encodeInvite (opts = {}) {
54 if (!opts.bootstrapKey) throw new Error('encodeInvite requires bootstrapKey')
55 const key = hex(opts.bootstrapKey)
56 if (key.length !== 64) throw new Error('bootstrapKey must be 32 bytes (64 hex chars)')
57 for (const r of (opts.roles || [])) {
58 if (!ROLES.includes(r)) throw new Error('unknown role: ' + r)
59 }
60 const payload = {
61 v: 1,
62 kind: opts.kind || 'room',
63 name: opts.name || null,
64 key,
65 roles: opts.roles || ['reader'],
66 seed: opts.seed ? hex(opts.seed) : null,
67 expiresAt: opts.expiresAt || null,
68 maxUses: opts.maxUses == null ? 1 : opts.maxUses,
69 nonce: b4a.toString(crypto.randomBytes(8), 'hex')
70 }
71 return PREFIX + b4a.toString(b4a.from(JSON.stringify(payload)), 'base64')
72}
73
74/**
75 * @param {string} invite
76 * @returns {{kind:string,name:string|null,bootstrapKey:string,roles:string[],seed:string|null,expiresAt:number|null,maxUses:number}}
77 */
78export function decodeInvite (invite) {
79 if (typeof invite !== 'string' || !invite.startsWith(PREFIX)) {
80 throw new Error('not a valid room invite')
81 }
82 let payload
83 try {
84 payload = JSON.parse(b4a.toString(b4a.from(invite.slice(PREFIX.length), 'base64')))
85 } catch {
86 throw new Error('malformed invite (decode failed)')
87 }
88 if (payload.v !== 1 || !payload.key) throw new Error('malformed invite (bad payload)')
89 return {
90 kind: payload.kind || 'room',
91 name: payload.name || null,
92 bootstrapKey: payload.key,
93 roles: payload.roles || ['reader'],
94 seed: payload.seed || null,
95 expiresAt: payload.expiresAt || null,
96 maxUses: payload.maxUses == null ? 1 : payload.maxUses
97 }
98}
99
100/** True if the invite carries an expiry that has passed. */
101export function isExpired (decoded, now = Date.now()) {
102 return !!(decoded.expiresAt && now > decoded.expiresAt)
103}
104
105function hex (v) {
106 return typeof v === 'string' ? v : b4a.toString(v, 'hex')
107}
test/invitations.test.js +83 -0
……@@ -0,0 +1,83 @@
1// Unit tests for the shared bearer-invite primitive (issue #35).
2// hyper-rooms/lib/invitations.js — used by hyper-orgs `org invite` and
3// hyper-issues `repo invite`.
4
5import test from 'brittle'
6import b4a from 'b4a'
7import { encodeInvite, decodeInvite, newSeed, isExpired, ROLES } from '../index.js'
8
9const KEY = 'a'.repeat(64) // 32-byte hex bootstrap key
10
11test('round-trip preserves all fields', (t) => {
12 const seed = newSeed()
13 const invite = encodeInvite({
14 bootstrapKey: KEY,
15 kind: 'org',
16 name: 'alpha',
17 roles: ['contributor', 'maintainer'],
18 seed,
19 expiresAt: 1799999999999,
20 maxUses: 5
21 })
22 const d = decodeInvite(invite)
23 t.is(d.bootstrapKey, KEY)
24 t.is(d.kind, 'org')
25 t.is(d.name, 'alpha')
26 t.alike(d.roles, ['contributor', 'maintainer'])
27 t.is(d.seed, b4a.toString(seed, 'hex'))
28 t.is(d.expiresAt, 1799999999999)
29 t.is(d.maxUses, 5)
30})
31
32test('accepts a Buffer bootstrapKey', (t) => {
33 const buf = b4a.from(KEY, 'hex')
34 const d = decodeInvite(encodeInvite({ bootstrapKey: buf, kind: 'repo' }))
35 t.is(d.bootstrapKey, KEY)
36 t.is(d.kind, 'repo')
37})
38
39test('defaults: kind=room, roles=[reader], maxUses=1, no seed/expiry', (t) => {
40 const d = decodeInvite(encodeInvite({ bootstrapKey: KEY }))
41 t.is(d.kind, 'room')
42 t.alike(d.roles, ['reader'])
43 t.is(d.maxUses, 1)
44 t.is(d.seed, null)
45 t.is(d.expiresAt, null)
46})
47
48test('each invite is unique (nonce)', (t) => {
49 const a = encodeInvite({ bootstrapKey: KEY })
50 const b = encodeInvite({ bootstrapKey: KEY })
51 t.not(a, b, 'two invites for the same key differ')
52})
53
54test('encode rejects missing bootstrapKey', (t) => {
55 t.exception(() => encodeInvite({ kind: 'org' }), /requires bootstrapKey/)
56})
57
58test('encode rejects wrong-length bootstrapKey', (t) => {
59 t.exception(() => encodeInvite({ bootstrapKey: 'deadbeef' }), /32 bytes/)
60})
61
62test('encode rejects unknown role', (t) => {
63 t.exception(() => encodeInvite({ bootstrapKey: KEY, roles: ['superuser'] }), /unknown role/)
64})
65
66test('decode rejects non-invite / foreign / malformed strings', (t) => {
67 t.exception(() => decodeInvite('not-an-invite'), /valid room invite/)
68 t.exception(() => decodeInvite(''), /valid room invite/)
69 t.exception(() => decodeInvite('roominv1:!!!not-base64-json'), /malformed invite/)
70})
71
72test('isExpired honours expiresAt', (t) => {
73 const past = decodeInvite(encodeInvite({ bootstrapKey: KEY, expiresAt: 1000 }))
74 const future = decodeInvite(encodeInvite({ bootstrapKey: KEY, expiresAt: Date.now() + 1e6 }))
75 const none = decodeInvite(encodeInvite({ bootstrapKey: KEY }))
76 t.ok(isExpired(past), 'past expiry is expired')
77 t.absent(isExpired(future), 'future expiry is not expired')
78 t.absent(isExpired(none), 'no expiry never expires')
79})
80
81test('ROLES has the expected ladder', (t) => {
82 t.alike(ROLES, ['reader', 'contributor', 'maintainer', 'admin'])
83})