| … | … | @@ -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 | |
| 26 | import b4a from 'b4a' |
| 27 | import crypto from 'hypercore-crypto' |
| 28 | |
| 29 | // Versioned prefix so the decoder can reject foreign / future formats. |
| 30 | const PREFIX = 'roominv1:' |
| 31 | |
| 32 | export const ROLES = ['reader', 'contributor', 'maintainer', 'admin'] |
| 33 | |
| 34 | export 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 | */ |
| 53 | export 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 | */ |
| 78 | export 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. */ |
| 101 | export function isExpired (decoded, now = Date.now()) { |
| 102 | return !!(decoded.expiresAt && now > decoded.expiresAt) |
| 103 | } |
| 104 | |
| 105 | function hex (v) { |
| 106 | return typeof v === 'string' ? v : b4a.toString(v, 'hex') |
| 107 | } |