// Anonymous-dispatch gate (pear-git org #12).
//
// Every registered handler used to be callable by anyone, over HTTP and over
// swarm-rpc, with no allowlist and no auth gate. `POST /rpc/<method>` builds
// `ctx = { transport:'http', identity: null }` and hands the method straight
// to dispatch, and a daemon behind a public reverse proxy therefore exposed
// its whole RPC surface to the internet. Each new svc.handle() was silently
// world-callable.
//
// The gate is opt-IN at registration: under anonymousPolicy 'deny', a handler
// must declare `{ anonymous: true }` to be reachable unauthenticated. A
// handler nobody classified is DENIED — drift produces a visible 403, never a
// silent hole.
//
// Dispatch is exercised directly; no transport is needed, and that keeps this
// file off the sibling-package import chain the other suites depend on.
import test from 'brittle'
import { Service } from '../index.js'
function svcWith (opts = {}) {
return new Service({ name: 'gate-test', ...opts })
}
// The four ctx shapes this stack actually produces. Only the first is a real
// authenticated user; three of four are non-null, which is why a check shaped
// as `if (!ctx.identity) deny` admits swarm peers and misreads UDS.
const HTTP_SESSION = { transport: 'http', identity: { publicKey: 'abc123' } }
const HTTP_ANON = { transport: 'http', identity: null }
const SWARM_RPC = { transport: 'swarm-rpc', identity: { type: 'swarm', remotePublicKey: 'ff', trust: 'network' } }
const UDS = { transport: 'uds', identity: { trust: 'local' } }
test('default policy is unchanged — existing consumers keep working', async (t) => {
const svc = svcWith() // no anonymousPolicy
svc.handle('anything', async () => 'ok')
t.is(await svc.dispatch('anything', {}, HTTP_ANON), 'ok', 'anonymous HTTP still allowed by default')
t.is(await svc.dispatch('anything', {}, SWARM_RPC), 'ok', 'swarm-rpc still allowed by default')
t.is(svc.listAnonymous().length, 0, 'nothing marked when nothing opted in')
})
test('deny policy blocks an unmarked handler for anonymous callers', async (t) => {
const svc = svcWith({ anonymousPolicy: 'deny' })
svc.handle('secret', async () => 'leaked')
await t.exception(svc.dispatch('secret', {}, HTTP_ANON), /anonymous access denied/, 'anonymous HTTP denied')
t.is(await svc.dispatch('secret', {}, UDS), 'leaked', 'UDS still allowed — local operator holds the socket')
t.is(await svc.dispatch('secret', {}, HTTP_SESSION), 'leaked', 'an authenticated session is not anonymous')
})
test('swarm-rpc is treated as anonymous despite a NON-NULL identity', async (t) => {
// The trap: swarm-rpc sets identity to { type:'swarm', trust:'network' } —
// truthy, but not a user. A gate written as `if (!ctx.identity) deny` would
// wave every network peer through.
const svc = svcWith({
anonymousPolicy: 'deny',
isAnonymous: (ctx) => {
if (!ctx) return true
if (ctx.transport === 'uds') return false
if (!ctx.identity) return true
return ctx.identity.trust === 'network'
}
})
svc.handle('secret', async () => 'leaked')
t.ok(SWARM_RPC.identity, 'precondition: the swarm ctx identity is NOT null')
await t.exception(svc.dispatch('secret', {}, SWARM_RPC), /anonymous access denied/, 'swarm peer denied')
})
test('an opted-in handler stays reachable anonymously', async (t) => {
const svc = svcWith({ anonymousPolicy: 'deny' })
svc.handle('public-read', async () => 'fine', { anonymous: true })
svc.handle('gated', async () => 'nope')
t.is(await svc.dispatch('public-read', {}, HTTP_ANON), 'fine', 'marked handler serves anonymous HTTP')
t.is(await svc.dispatch('public-read', {}, SWARM_RPC), 'fine', 'and anonymous swarm')
await t.exception(svc.dispatch('gated', {}, HTTP_ANON), /anonymous access denied/, 'its neighbour is still denied')
t.ok(svc.allowsAnonymous('public-read'), 'introspection reports the opt-in')
t.absent(svc.allowsAnonymous('gated'), 'and reports the denial')
t.alike(svc.listAnonymous(), ['public-read'], 'listAnonymous enumerates the surface')
})
test('an unknown method still reports ENOMETHOD, not a denial', async (t) => {
// Otherwise every typo becomes a 403 and the API is undebuggable. The
// handler NAMES are already discoverable via listHandlers.
const svc = svcWith({ anonymousPolicy: 'deny' })
let err = null
try { await svc.dispatch('no-such-thing', {}, HTTP_ANON) } catch (e) { err = e }
t.ok(err, 'it threw')
t.is(err && err.code, 'ENOMETHOD', 'ENOMETHOD, not EDENIED')
t.ok(/unknown method/.test(err.message), 'and says so')
})
test('denials are reported to onDeny with the method and ctx', async (t) => {
const seen = []
const svc = svcWith({ anonymousPolicy: 'deny', onDeny: (d) => seen.push(d) })
svc.handle('gated', async () => 'nope')
await t.exception(svc.dispatch('gated', {}, HTTP_ANON), /anonymous access denied/)
t.is(seen.length, 1, 'one denial recorded')
t.is(seen[0].method, 'gated', 'method captured')
t.is(seen[0].reason, 'anonymous', 'reason captured')
t.is(seen[0].ctx.transport, 'http', 'ctx passed through for the audit trail')
})
test('the denial carries EDENIED/403 so transports can map it', async (t) => {
const svc = svcWith({ anonymousPolicy: 'deny' })
svc.handle('gated', async () => 'nope')
let err = null
try { await svc.dispatch('gated', {}, HTTP_ANON) } catch (e) { err = e }
t.ok(err, 'it threw')
t.is(err && err.code, 'EDENIED', 'code is EDENIED')
t.is(err && err.status, 403, 'status is 403')
})
test('an isAnonymous predicate that THROWS fails closed', async (t) => {
const svc = svcWith({
anonymousPolicy: 'deny',
isAnonymous: () => { throw new Error('predicate blew up') }
})
svc.handle('gated', async () => 'leaked')
await t.exception(svc.dispatch('gated', {}, HTTP_SESSION), /anonymous access denied/,
'a broken predicate denies rather than admits')
})