// Bearer invites for mounting a Room's autobase (issue #35 scope feature).
//
// Lives in hyper-rooms because the concept is Room-level and SHARED:
// hyper-orgs uses it for `org invite`, hyper-issues uses it for
// `repo invite`. Both wrap Room; an invite is just "a bearer token that
// carries the capability to MOUNT one Room's autobase."
//
// An invite carries the resource autobase's bootstrap key + advisory
// role bits + expiry/use limits. Holding it lets a peer replicate ONLY
// that one autobase — the read-isolation the post-mortem required
// (separate autobase per org/repo, not one shared autobase).
//
// Scope of THIS module (deliberately minimal, extensible):
// - encode/decode the bearer string
// - newSeed() for a 32-byte secret
//
// Deferred (the fuller Keet `generateRoomCapabilities` pattern — see
// keet-deobfuscated lib/invitations): deriving the autobase bootstrap
// key + swarm topic + ENCRYPTION key deterministically from the seed so
// the seed is the only secret and non-members can't decrypt even if they
// learn the bootstrap key. Today the bootstrap key travels in the invite
// directly and isolation is by separate-autobase (not encryption). The
// `seed` field is carried now so adding the encryption layer later is a
// non-breaking change.
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
// Versioned prefix so the decoder can reject foreign / future formats.
const PREFIX = 'roominv1:'
export const ROLES = ['reader', 'contributor', 'maintainer', 'admin']
export function newSeed () {
return crypto.randomBytes(32)
}
/**
* @param {object} opts
* @param {Buffer|string} opts.bootstrapKey resource autobase root key
* @param {string} [opts.kind='room'] 'org' | 'repo' | 'room'
* @param {string} [opts.name] human name (advisory; the
* canonical name lives in the
* autobase record)
* @param {string[]} [opts.roles=['reader']] advisory role bits, enforced
* at write-enrollment time
* @param {number|null} [opts.expiresAt=null]
* @param {number} [opts.maxUses=1]
* @param {Buffer|string} [opts.seed] 32-byte secret (future:
* encryption + deterministic root)
* @returns {string} bearer invite
*/
export function encodeInvite (opts = {}) {
if (!opts.bootstrapKey) throw new Error('encodeInvite requires bootstrapKey')
const key = hex(opts.bootstrapKey)
if (key.length !== 64) throw new Error('bootstrapKey must be 32 bytes (64 hex chars)')
for (const r of (opts.roles || [])) {
if (!ROLES.includes(r)) throw new Error('unknown role: ' + r)
}
const payload = {
v: 1,
kind: opts.kind || 'room',
name: opts.name || null,
key,
roles: opts.roles || ['reader'],
// `source` (optional): where to reach the owner for write-enrollment
// — e.g. the owning daemon's serverKey. Makes the invite
// self-contained so a joiner knows whom to ask to be enrolled.
source: opts.source || null,
seed: opts.seed ? hex(opts.seed) : null,
expiresAt: opts.expiresAt || null,
maxUses: opts.maxUses == null ? 1 : opts.maxUses,
nonce: b4a.toString(crypto.randomBytes(8), 'hex')
}
return PREFIX + b4a.toString(b4a.from(JSON.stringify(payload)), 'base64')
}
/**
* @param {string} invite
* @returns {{kind:string,name:string|null,bootstrapKey:string,roles:string[],seed:string|null,expiresAt:number|null,maxUses:number}}
*/
export function decodeInvite (invite) {
if (typeof invite !== 'string' || !invite.startsWith(PREFIX)) {
throw new Error('not a valid room invite')
}
let payload
try {
payload = JSON.parse(b4a.toString(b4a.from(invite.slice(PREFIX.length), 'base64')))
} catch {
throw new Error('malformed invite (decode failed)')
}
if (payload.v !== 1 || !payload.key) throw new Error('malformed invite (bad payload)')
return {
kind: payload.kind || 'room',
name: payload.name || null,
bootstrapKey: payload.key,
roles: payload.roles || ['reader'],
source: payload.source || null,
seed: payload.seed || null,
expiresAt: payload.expiresAt || null,
maxUses: payload.maxUses == null ? 1 : payload.maxUses
}
}
/** True if the invite carries an expiry that has passed. */
export function isExpired (decoded, now = Date.now()) {
return !!(decoded.expiresAt && now > decoded.expiresAt)
}
function hex (v) {
return typeof v === 'string' ? v : b4a.toString(v, 'hex')
}