OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc commits / 4114387

0.1.8: opt-in gate for anonymous dispatch (pear-git org #12)
Every registered handler was 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, so a daemon behind a public reverse proxy exposed its ENTIRE RPC surface to the internet — and every new svc.handle() was silently world-callable. Verified against prod: an anonymous curl to https://7rnx.net/git/rpc/list-orgs returns data. Adds `anonymousPolicy: 'deny'` plus per-handler opt-in via `svc.handle(name, fn, { anonymous: true })`. Opt-in at REGISTRATION rather than a separate allowlist table: with ~100 handlers a table drifts out of step with the handler set, a flag cannot, and the drift direction is safe — an unclassified handler is DENIED, surfacing as a visible 403 instead of a silent hole. Default stays 'allow', so pear-git-v3, hypernote, hyper-social-web and pgh-sidecar are untouched until they opt in. This package is shared; flipping the default here would have broken all four at once. `isAnonymous` is injectable because identity semantics belong to the consumer, not to hyper-svc. It matters: this stack produces FOUR ctx shapes and three are non-null — http session { publicKey } a real user http anon null not swarm-rpc { type:'swarm', remotePublicKey, trust:'network' } not uds { trust:'local' } local operator so a gate written as `if (!ctx.identity) deny` waves every swarm peer through and misreads UDS. `trust` is the discriminator, not presence. Pinned by a test that asserts the swarm ctx identity is non-null and still denied. Details that are load-bearing: - ENOMETHOD is checked BEFORE the gate, so a typo stays a 404 rather than becoming a 403 and making the API undebuggable. It reveals only which method names exist, which listHandlers already exposes. - A throwing isAnonymous predicate fails CLOSED. - Denials carry code EDENIED / status 403, and the HTTP transport now maps that to 403 instead of lumping it into 400 — an authorization outcome, not a bad request. - An `onDeny` hook reports refusals so a consumer can record them next to its own audit trail, which is what makes intended-vs-actual authority a diff rather than an audit. 8 tests / 26 asserts, exercising dispatch directly so this file stays off the sibling-package import chain that currently breaks test/basic.test.js (hyper-rooms → blind-pairing not installed; pre-existing, both runtimes). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4114387ab8a23ac12b6746006635ade511a55265
by plankatron <fleeky@gmail.com> · 2026-07-30T17:37:31.000Z

Changed files (4) +214 -2

lib/service.js +82 -1
……@@ -43,6 +43,28 @@ class NoSuchMethodError extends Error {
4343 }
4444}
4545
46class AnonymousDeniedError extends Error {
47 constructor (method) {
48 super(`anonymous access denied: ${method}`)
49 this.code = 'EDENIED'
50 this.status = 403
51 }
52}
53
54/**
55 * Default "is this caller anonymous?" predicate.
56 *
57 * Deliberately conservative — anything that is not clearly an authenticated
58 * caller counts as anonymous. Consumers whose ctx carries richer identity
59 * (pear-git-v3 has four distinct shapes) should pass their own via
60 * opts.isAnonymous rather than widen this one.
61 */
62function defaultIsAnonymous (ctx) {
63 if (!ctx) return true
64 if (ctx.transport === 'uds') return false // local operator holds the socket
65 return !ctx.identity
66}
67
4668export class Service extends ReadyResource {
4769 /**
4870 * @param {object} opts
……@@ -61,6 +83,31 @@ export class Service extends ReadyResource {
6183 this._handlers = new Map()
6284 this._mounts = new Map()
6385
86 // Anonymous-dispatch policy (pear-git org #12).
87 //
88 // Every registered handler used to be callable by anyone, over HTTP and
89 // over swarm-rpc, with no allowlist and no auth gate — so each new
90 // svc.handle() was silently world-callable, and a daemon behind a public
91 // reverse proxy exposed its entire RPC surface to the internet.
92 //
93 // Default stays 'allow' so the existing consumers (pear-git-v3, hypernote,
94 // hyper-social-web, pgh-sidecar) are untouched until they opt in. Under
95 // 'deny', a handler must declare `{ anonymous: true }` at registration to
96 // be reachable by an unauthenticated caller.
97 //
98 // Opt-in at REGISTRATION rather than via a separate allowlist table: with
99 // ~100 handlers a table drifts out of step with the handler set, whereas a
100 // flag cannot. And the drift direction is safe — a handler nobody marked
101 // is DENIED, which surfaces as a visible 403 rather than a silent hole.
102 this._anonymousPolicy = opts.anonymousPolicy === 'deny' ? 'deny' : 'allow'
103 this._isAnonymous = typeof opts.isAnonymous === 'function'
104 ? opts.isAnonymous
105 : defaultIsAnonymous
106 // Called with ({ method, ctx }) whenever a dispatch is refused, so the
107 // consumer can record denials alongside its own audit trail.
108 this._onDeny = typeof opts.onDeny === 'function' ? opts.onDeny : null
109 this._anonymousAllowed = new Set()
110
64111 this._udsOpt = opts.uds || null
65112 this._httpOpt = opts.http || null
66113 this._swarmOpt = opts.swarm || null
……@@ -161,13 +208,23 @@ export class Service extends ReadyResource {
161208 * @param {string} name - method name (e.g. "list-orgs")
162209 * @param {(ctx: object, params: any) => Promise<any>|any} fn
163210 */
164 handle (name, fn) {
211 handle (name, fn, opts = {}) {
165212 if (typeof name !== 'string' || !name) throw new Error('handle requires a name')
166213 if (typeof fn !== 'function') throw new Error('handle requires a function')
167214 if (this._handlers.has(name)) throw new Error(`handler "${name}" already registered`)
168215 this._handlers.set(name, fn)
216 // `anonymous: true` opts this method IN to unauthenticated dispatch when
217 // the service runs anonymousPolicy: 'deny'. Absent = denied, which is the
218 // safe default for a handler nobody has classified yet.
219 if (opts && opts.anonymous === true) this._anonymousAllowed.add(name)
169220 }
170221
222 /** Is this method callable by an unauthenticated caller? */
223 allowsAnonymous (name) { return this._anonymousAllowed.has(name) }
224
225 /** Methods explicitly opted in to anonymous dispatch, sorted. */
226 listAnonymous () { return Array.from(this._anonymousAllowed).sort() }
227
171228 /** Whether a handler is registered. */
172229 hasHandler (name) { return this._handlers.has(name) }
173230
……@@ -204,6 +261,30 @@ export class Service extends ReadyResource {
204261 async dispatch (method, params, ctx) {
205262 const handler = this._handlers.get(method)
206263 if (!handler) throw new NoSuchMethodError(method)
264
265 // Anonymous gate (#12). Runs for EVERY transport, because the surface was
266 // open on all of them — HTTP via a public reverse proxy, and swarm-rpc to
267 // any peer that knows the topic.
268 //
269 // Ordered after the ENOMETHOD check on purpose: an unknown method should
270 // still say so, otherwise the gate turns every typo into a 403 and makes
271 // the API undebuggable. It leaks only which method names exist, which the
272 // handler list already exposes.
273 if (this._anonymousPolicy === 'deny' && !this._anonymousAllowed.has(method)) {
274 let anon = true
275 try {
276 anon = this._isAnonymous(ctx)
277 } catch {
278 anon = true // a predicate that throws must fail CLOSED
279 }
280 if (anon) {
281 if (this._onDeny) {
282 try { this._onDeny({ method, ctx, reason: 'anonymous' }) } catch {}
283 }
284 throw new AnonymousDeniedError(method)
285 }
286 }
287
207288 return handler(ctx || {}, params)
208289 }
209290
lib/transports/http.js +6 -0
……@@ -154,6 +154,12 @@ export class HttpServer {
154154 if (err && err.code === 'ENOMETHOD') {
155155 return jsonError(res, 404, err.message)
156156 }
157 // An anonymous-gate denial is an authorization outcome, not a bad
158 // request — surface it as 403 so callers (and the audit trail) can
159 // tell "you may not" from "you sent nonsense".
160 if (err && err.code === 'EDENIED') {
161 return jsonError(res, err.status || 403, err.message)
162 }
157163 return jsonError(res, 400, err.message || String(err))
158164 }
159165 }
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-svc",
3 "version": "0.1.7",
3 "version": "0.1.8",
44 "description": "Reusable per-app daemon library \u2014 UDS + HTTP for local RPC, Hyperswarm for peer mirroring",
55 "type": "module",
66 "main": "index.js",
test/anonymous-gate.test.js +125 -0
……@@ -0,0 +1,125 @@
1// Anonymous-dispatch gate (pear-git org #12).
2//
3// Every registered handler used to be callable by anyone, over HTTP and over
4// swarm-rpc, with no allowlist and no auth gate. `POST /rpc/<method>` builds
5// `ctx = { transport:'http', identity: null }` and hands the method straight
6// to dispatch, and a daemon behind a public reverse proxy therefore exposed
7// its whole RPC surface to the internet. Each new svc.handle() was silently
8// world-callable.
9//
10// The gate is opt-IN at registration: under anonymousPolicy 'deny', a handler
11// must declare `{ anonymous: true }` to be reachable unauthenticated. A
12// handler nobody classified is DENIED — drift produces a visible 403, never a
13// silent hole.
14//
15// Dispatch is exercised directly; no transport is needed, and that keeps this
16// file off the sibling-package import chain the other suites depend on.
17
18import test from 'brittle'
19import { Service } from '../index.js'
20
21function svcWith (opts = {}) {
22 return new Service({ name: 'gate-test', ...opts })
23}
24
25// The four ctx shapes this stack actually produces. Only the first is a real
26// authenticated user; three of four are non-null, which is why a check shaped
27// as `if (!ctx.identity) deny` admits swarm peers and misreads UDS.
28const HTTP_SESSION = { transport: 'http', identity: { publicKey: 'abc123' } }
29const HTTP_ANON = { transport: 'http', identity: null }
30const SWARM_RPC = { transport: 'swarm-rpc', identity: { type: 'swarm', remotePublicKey: 'ff', trust: 'network' } }
31const UDS = { transport: 'uds', identity: { trust: 'local' } }
32
33test('default policy is unchanged — existing consumers keep working', async (t) => {
34 const svc = svcWith() // no anonymousPolicy
35 svc.handle('anything', async () => 'ok')
36
37 t.is(await svc.dispatch('anything', {}, HTTP_ANON), 'ok', 'anonymous HTTP still allowed by default')
38 t.is(await svc.dispatch('anything', {}, SWARM_RPC), 'ok', 'swarm-rpc still allowed by default')
39 t.is(svc.listAnonymous().length, 0, 'nothing marked when nothing opted in')
40})
41
42test('deny policy blocks an unmarked handler for anonymous callers', async (t) => {
43 const svc = svcWith({ anonymousPolicy: 'deny' })
44 svc.handle('secret', async () => 'leaked')
45
46 await t.exception(svc.dispatch('secret', {}, HTTP_ANON), /anonymous access denied/, 'anonymous HTTP denied')
47 t.is(await svc.dispatch('secret', {}, UDS), 'leaked', 'UDS still allowed — local operator holds the socket')
48 t.is(await svc.dispatch('secret', {}, HTTP_SESSION), 'leaked', 'an authenticated session is not anonymous')
49})
50
51test('swarm-rpc is treated as anonymous despite a NON-NULL identity', async (t) => {
52 // The trap: swarm-rpc sets identity to { type:'swarm', trust:'network' } —
53 // truthy, but not a user. A gate written as `if (!ctx.identity) deny` would
54 // wave every network peer through.
55 const svc = svcWith({
56 anonymousPolicy: 'deny',
57 isAnonymous: (ctx) => {
58 if (!ctx) return true
59 if (ctx.transport === 'uds') return false
60 if (!ctx.identity) return true
61 return ctx.identity.trust === 'network'
62 }
63 })
64 svc.handle('secret', async () => 'leaked')
65
66 t.ok(SWARM_RPC.identity, 'precondition: the swarm ctx identity is NOT null')
67 await t.exception(svc.dispatch('secret', {}, SWARM_RPC), /anonymous access denied/, 'swarm peer denied')
68})
69
70test('an opted-in handler stays reachable anonymously', async (t) => {
71 const svc = svcWith({ anonymousPolicy: 'deny' })
72 svc.handle('public-read', async () => 'fine', { anonymous: true })
73 svc.handle('gated', async () => 'nope')
74
75 t.is(await svc.dispatch('public-read', {}, HTTP_ANON), 'fine', 'marked handler serves anonymous HTTP')
76 t.is(await svc.dispatch('public-read', {}, SWARM_RPC), 'fine', 'and anonymous swarm')
77 await t.exception(svc.dispatch('gated', {}, HTTP_ANON), /anonymous access denied/, 'its neighbour is still denied')
78
79 t.ok(svc.allowsAnonymous('public-read'), 'introspection reports the opt-in')
80 t.absent(svc.allowsAnonymous('gated'), 'and reports the denial')
81 t.alike(svc.listAnonymous(), ['public-read'], 'listAnonymous enumerates the surface')
82})
83
84test('an unknown method still reports ENOMETHOD, not a denial', async (t) => {
85 // Otherwise every typo becomes a 403 and the API is undebuggable. The
86 // handler NAMES are already discoverable via listHandlers.
87 const svc = svcWith({ anonymousPolicy: 'deny' })
88 let err = null
89 try { await svc.dispatch('no-such-thing', {}, HTTP_ANON) } catch (e) { err = e }
90 t.ok(err, 'it threw')
91 t.is(err && err.code, 'ENOMETHOD', 'ENOMETHOD, not EDENIED')
92 t.ok(/unknown method/.test(err.message), 'and says so')
93})
94
95test('denials are reported to onDeny with the method and ctx', async (t) => {
96 const seen = []
97 const svc = svcWith({ anonymousPolicy: 'deny', onDeny: (d) => seen.push(d) })
98 svc.handle('gated', async () => 'nope')
99
100 await t.exception(svc.dispatch('gated', {}, HTTP_ANON), /anonymous access denied/)
101 t.is(seen.length, 1, 'one denial recorded')
102 t.is(seen[0].method, 'gated', 'method captured')
103 t.is(seen[0].reason, 'anonymous', 'reason captured')
104 t.is(seen[0].ctx.transport, 'http', 'ctx passed through for the audit trail')
105})
106
107test('the denial carries EDENIED/403 so transports can map it', async (t) => {
108 const svc = svcWith({ anonymousPolicy: 'deny' })
109 svc.handle('gated', async () => 'nope')
110 let err = null
111 try { await svc.dispatch('gated', {}, HTTP_ANON) } catch (e) { err = e }
112 t.ok(err, 'it threw')
113 t.is(err && err.code, 'EDENIED', 'code is EDENIED')
114 t.is(err && err.status, 403, 'status is 403')
115})
116
117test('an isAnonymous predicate that THROWS fails closed', async (t) => {
118 const svc = svcWith({
119 anonymousPolicy: 'deny',
120 isAnonymous: () => { throw new Error('predicate blew up') }
121 })
122 svc.handle('gated', async () => 'leaked')
123 await t.exception(svc.dispatch('gated', {}, HTTP_SESSION), /anonymous access denied/,
124 'a broken predicate denies rather than admits')
125})