OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms / lib / invitations.js

113 lines · javascript ref:
// 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')
}