// Invites — how a node is admitted without a human pasting a writer key.
//
// ⛔ The invite does NOT grant anything. It cannot: a redeem rides the joiner's own weightless
// block, and `system.addWriter` caps a grant at the carrying block's weight, so anything B signs
// about B is worth zero (see namespace.js). What the token does is authorise the INVITER'S node to
// answer a request without asking a human again. The admit is still carried by the admin.
//
// ⚠ Single-use, hash-only in the log, consumed inside apply — so two nodes racing one invite cannot
// both win. The shape `browsers.js` already uses, including its decision to have NO EXPIRY: a
// deadline would need a clock inside apply, and apply is replayed per peer.
import test from 'brittle'
import Corestore from 'corestore'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { Namespace } from '../lib/namespace.js'
import { identity } from './helpers.js'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-nsinv-'))
function key (ch) {
const k = String(ch).repeat(64).slice(0, 64)
if (!/^[0-9a-f]{64}$/.test(k)) throw new Error('test fixture is not a key: ' + k)
return k
}
async function who () {
const id = await identity()
return { root: id.rootHex, device: await id.device() }
}
async function founded (cred) {
const n = new Namespace(new Corestore(tmp()).namespace('names'), null)
await n.ready()
await n.found(cred)
await n.update()
return n
}
const invitedWriters = async (n) => (await n.requests()).filter((r) => r.invited).map((r) => r.writer)
test('★ an invited request is marked, and the invite is spent', async (t) => {
const a = await who()
const b = await who()
const n = await founded(a.device)
t.teardown(() => n.close())
const { token } = await n.invite(a.device)
await n.update()
t.is((await n.invites()).length, 1, 'the log holds the HASH, never the token')
t.absent(JSON.stringify(await n.invites()).includes(token), 'and the token itself is not in it')
await n.requestWriter(key('b'), b.device, token)
await n.update()
t.alike(await invitedWriters(n), [key('b')], 'the ask arrives already vouched for')
t.alike((await n.invites()).filter((i) => !i.used).length, 0, 'and the invite is spent')
})
test('⛔ single-use — a second redeem of the same token is not invited', async (t) => {
const a = await who()
const b = await who()
const c = await who()
const n = await founded(a.device)
t.teardown(() => n.close())
const { token } = await n.invite(a.device)
await n.update()
await n.requestWriter(key('b'), b.device, token)
await n.requestWriter(key('c'), c.device, token)
await n.update()
// Consumed inside apply, so the linearization decides — there is no window in which both win.
t.alike(await invitedWriters(n), [key('b')], 'the second redeemer asks like anyone else')
})
test('⛔ a wrong token vouches for nothing, and the ask still stands', async (t) => {
const a = await who()
const b = await who()
const n = await founded(a.device)
t.teardown(() => n.close())
await n.invite(a.device)
await n.update()
await n.requestWriter(key('b'), b.device, 'ff'.repeat(32))
await n.update()
// The request is NOT refused — anyone may ask, and asking grants nothing. It simply is not vouched
// for, so no admin's node will answer it unattended.
t.alike((await n.requests()).map((r) => r.writer), [key('b')], 'still recorded')
t.alike(await invitedWriters(n), [], 'and nobody is auto-admitting it')
t.is((await n.invites()).filter((i) => !i.used).length, 1, 'the real invite is untouched')
})
test('⛔ only an admin may mint an invite', async (t) => {
const a = await who()
const stranger = await who()
const n = await founded(a.device)
t.teardown(() => n.close())
const { token } = await n.invite(stranger.device)
await n.update()
t.is((await n.invites()).length, 0, 'or an invite is something anyone can print')
await n.requestWriter(key('b'), stranger.device, token)
await n.update()
t.alike(await invitedWriters(n), [], 'and a self-minted token vouches for nothing')
})
test('an admin can withdraw an unspent invite', async (t) => {
const a = await who()
const b = await who()
const n = await founded(a.device)
t.teardown(() => n.close())
const { token, hash } = await n.invite(a.device)
await n.update()
await n.withdrawInvite(hash, a.device)
await n.update()
// ⚠ Revocation rather than expiry. A deadline would need a clock inside apply, and apply is
// replayed on every peer — the same reason `browsers.js` has no expiry either.
await n.requestWriter(key('b'), b.device, token)
await n.update()
t.alike(await invitedWriters(n), [], 'a withdrawn invite cannot be redeemed')
})
test('⛔⛔ an invite still grants NO weight — the admit is the granting act', async (t) => {
const a = await who()
const b = await who()
const n = await founded(a.device)
t.teardown(() => n.close())
const { token } = await n.invite(a.device)
await n.update()
await n.requestWriter(key('b'), b.device, token)
await n.update()
// This is the whole reason the ceremony has two halves. If redeeming granted weight, the grant
// would ride the redeemer's own weightless block and be capped to zero — silently.
const info = await n.base.system.get(Buffer.from(key('b'), 'hex'))
t.is(info, null, 'redeeming makes you vouched for, never admitted')
await n.admitWriter(key('b'), a.device)
await n.update()
const after = await n.base.system.get(Buffer.from(key('b'), 'hex'))
t.is(after.maxWeight, 2, 'the admin carrying the admit is what confers it')
})
test('★ an admin node answers vouched-for asks on its own', async (t) => {
const a = await who()
const b = await who()
const n = await founded(a.device)
t.teardown(() => n.close())
const { token } = await n.invite(a.device)
await n.update()
await n.requestWriter(key('b'), b.device, token)
await n.requestWriter(key('c'), b.device) // uninvited — must be left alone
await n.update()
const answered = await n.answerInvited(a.device)
await n.update()
t.alike(answered, [key('b')], 'exactly the ask that carried a valid token')
t.is((await n.base.system.get(Buffer.from(key('b'), 'hex'))).maxWeight, 2)
t.is(await n.base.system.get(Buffer.from(key('c'), 'hex')), null,
'an uninvited ask still waits for a human — that is what the invite was for')
})