From e3ef20db385c338b679dfb497339da2cb749e2c4 Mon Sep 17 00:00:00 2001 From: Brad Cunningham Date: Wed, 3 Jun 2026 11:38:24 -0400 Subject: [PATCH] fix(auth): treat "anonymous" as a built-in credential-less role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoleMutex.refresh() threw "Unknown role: anonymous" whenever a caller exercised a role not declared in surfacemcp.config.json roles[]. Since `surfacemcp init` writes roles:[] for a fresh project, any anonymous surface_call was rejected — breaking the entire unauthenticated surface for consumers like BugHunter (whose default no-login role is "anonymous"). doLogin() already handles a credential-less role (returns an unauthenticated session); the bug was purely the roles[] lookup throwing first. Synthesize the built-in "anonymous" role when it is not declared, so the public surface can always be exercised. A genuinely unknown, non-anonymous role still throws. Verified end-to-end: a BugHunter calibrate run against a bench Next.js app went from 12 "Unknown role: anonymous" infra failures to 0. Co-Authored-By: Claude Opus 4.8 --- src/auth/role-mutex.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/auth/role-mutex.ts | 15 ++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/auth/role-mutex.test.ts diff --git a/src/auth/role-mutex.test.ts b/src/auth/role-mutex.test.ts new file mode 100644 index 0000000..7a0a838 --- /dev/null +++ b/src/auth/role-mutex.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { RoleMutex } from './role-mutex.js'; +import type { AuthConfig } from '../types.js'; + +const NONE: AuthConfig = { kind: 'none' }; +const BASE = 'http://127.0.0.1:4104'; + +describe('RoleMutex — built-in anonymous role', () => { + it('returns an unauthenticated session for "anonymous" even when roles[] is empty', async () => { + // BugHunter exercises the public surface as role "anonymous". SurfaceMCP must + // not reject it with "Unknown role" just because the config declares no roles. + const mutex = new RoleMutex(BASE, NONE, []); + const session = await mutex.refresh('anonymous'); + expect(session.cookies).toEqual([]); + expect(session.token).toBeUndefined(); + }); + + it('caches the anonymous session via ensureSession', async () => { + const mutex = new RoleMutex(BASE, NONE, []); + const first = await mutex.ensureSession('anonymous'); + const second = await mutex.ensureSession('anonymous'); + expect(second).toBe(first); + }); + + it('still throws for a genuinely unknown (non-anonymous) role', async () => { + const mutex = new RoleMutex(BASE, NONE, []); + await expect(mutex.refresh('owner')).rejects.toThrow('Unknown role: owner'); + }); + + it('honors an explicitly declared "anonymous" role identically', async () => { + const mutex = new RoleMutex(BASE, NONE, [{ name: 'anonymous' }]); + const session = await mutex.refresh('anonymous'); + expect(session.cookies).toEqual([]); + expect(session.token).toBeUndefined(); + }); +}); diff --git a/src/auth/role-mutex.ts b/src/auth/role-mutex.ts index 666ac37..576e7b2 100644 --- a/src/auth/role-mutex.ts +++ b/src/auth/role-mutex.ts @@ -7,6 +7,13 @@ import { log } from '../log.js'; type LoginFn = () => Promise; +/** + * The canonical anonymous role. A built-in: it need not be declared in + * surfacemcp.config.json roles[]. Requests made as this role go unauthenticated, + * so the public surface can always be exercised (BugHunter's no-login default). + */ +const ANONYMOUS_ROLE_NAME = 'anonymous'; + /** * Per-role mutex: ensures only one login is in-flight at a time per role. * Concurrent callers that arrive during a refresh queue and reuse the result. @@ -39,7 +46,13 @@ export class RoleMutex { const existing = this.inflight.get(roleName); if (existing) return existing; - const role = this.roles.find((r) => r.name === roleName); + let role = this.roles.find((r) => r.name === roleName); + // 'anonymous' is a built-in credential-less role — synthesize it when the + // config declares no matching role, so doLogin() returns an unauthenticated + // session instead of throwing "Unknown role: anonymous". + if (!role && roleName === ANONYMOUS_ROLE_NAME) { + role = { name: ANONYMOUS_ROLE_NAME }; + } if (!role) throw new Error(`Unknown role: ${roleName}`); const promise = this.doLogin(role);