Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/auth/role-mutex.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
15 changes: 14 additions & 1 deletion src/auth/role-mutex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { log } from '../log.js';

type LoginFn = () => Promise<RoleSession>;

/**
* 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.
Expand Down Expand Up @@ -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);
Expand Down