two failures another session found in a shared checkout, both mine. send() took a plain Uint8Array, whose default buffer type is ArrayBufferLike and may be a SharedArrayBuffer — WebSocket.send takes only BufferSource. narrowed to Uint8Array<ArrayBuffer>, which every caller already builds and which the terminal relay already does. registry.test.ts keeps REAL_WS as a hand-written mirror of server.tsx's handlers map, and its own comment says that drift is what it catches. it caught mine: the route and the registry entry landed, the mirror did not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
368 lines
20 KiB
TypeScript
368 lines
20 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import type { Permission } from './registry';
|
|
import {
|
|
PERMISSIONS,
|
|
PERMISSION_BY_KEY,
|
|
GRANTABLE_PERMISSIONS,
|
|
permissionForApiPath,
|
|
permissionForWsProvider,
|
|
isRequestAllowedAtLevel,
|
|
isSelfServiceRoute,
|
|
isOwnerOnlyRoute,
|
|
} from './registry';
|
|
import { assertPermissionTotality, isExemptApiPath } from './totality';
|
|
|
|
// The database-backed half (authorize.ts) is exercised against the live schema; this file covers the pure
|
|
// half, which is where the rules actually live. Everything here runs without a database.
|
|
|
|
// Mirrors the `handlers` map in server.tsx by hand, which is itself the drift this file keeps catching.
|
|
// `cliamp` and `cliamp-audio` left on 2026-08-15 with `plugins/music/cliamp/` — see below. `vault` left
|
|
// on 2026-08-18 with `plugins/vault/`, taking its `/vault` api prefix and its ws provider with it: a
|
|
// plugin's routes are mounted behind the account gate like any other, so neither needs an exemption now.
|
|
// `desktop` is NOT here: server.tsx's handlers map has it commented out with the rest of the plugin,
|
|
// switched off 2026-08-13. It was listed here until 2026-08-18 and made this file's own totality tests
|
|
// fail for five days — the mirror claiming a provider the server does not serve, which is precisely the
|
|
// drift the comment above warns about, caught by the check and then ignored because it was "the baseline".
|
|
const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar', 'minesweeper'];
|
|
const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))];
|
|
const surface = () => ({
|
|
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/sidecar'],
|
|
wsProviders: REAL_WS,
|
|
});
|
|
|
|
describe('totality', () => {
|
|
test('the registry covers its own declared surface', () => {
|
|
expect(() => assertPermissionTotality(surface())).not.toThrow();
|
|
});
|
|
|
|
// The four ways this is allowed to fail. Each one is a real bug it exists to catch, and a check that
|
|
// only ever passes is worth nothing — so assert that it refuses, not merely that it runs.
|
|
test('refuses a mounted router no permission claims', () => {
|
|
const s = surface();
|
|
s.apiPrefixes.push('/newthing');
|
|
expect(() => assertPermissionTotality(s)).toThrow(/\/api\/newthing is mounted but no permission claims it/);
|
|
});
|
|
|
|
test('refuses a served socket no permission claims — the 2026-08-06 hole', () => {
|
|
const s = surface();
|
|
s.wsProviders = [...REAL_WS, 'newsocket'];
|
|
expect(() => assertPermissionTotality(s)).toThrow(/websocket provider 'newsocket' is served/);
|
|
});
|
|
|
|
// Used `/gitea`, which claims no API prefix any more (unmounted 2026-08-13), so removing it from the
|
|
// served list produced no error and the test could not detect what it was written to detect. `/tasks`
|
|
// is core, mounted, and claimed — so withdrawing it is a real "a permission points at nothing" case.
|
|
test('refuses a claim on a router that no longer exists', () => {
|
|
const s = surface();
|
|
s.apiPrefixes = s.apiPrefixes.filter((p) => p !== '/tasks');
|
|
expect(() => assertPermissionTotality(s)).toThrow(/claims \/api\/tasks, which nothing mounts/);
|
|
});
|
|
|
|
test('refuses a claim on a socket that is not served', () => {
|
|
const s = surface();
|
|
s.wsProviders = REAL_WS.filter((p) => p !== 'terminal');
|
|
expect(() => assertPermissionTotality(s)).toThrow(/claims websocket 'terminal', which is not served/);
|
|
});
|
|
|
|
test('no two permissions claim the same prefix', () => {
|
|
const seen = new Map<string, string>();
|
|
for (const permission of PERMISSIONS) {
|
|
for (const prefix of permission.api) {
|
|
expect(seen.has(prefix)).toBe(false);
|
|
seen.set(prefix, permission.key);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('signin is exempt and an ordinary route is not', () => {
|
|
expect(isExemptApiPath('/api/auth/signin')).toBe(true);
|
|
expect(isExemptApiPath('/api/chat')).toBe(false);
|
|
expect(isExemptApiPath('/api/dashboards/4')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('path → permission', () => {
|
|
// These used to read `/api/gitea`, which stopped resolving on 2026-08-13 when gitea's router was
|
|
// unmounted pending its own extraction — the permission still exists and still gates the FRONTEND route,
|
|
// but `permissionForApiPath` answers about mounted API prefixes and there is no longer one. Re-pointed
|
|
// at `/tasks`, which is core and mounted, so the test exercises the resolver rather than the state of
|
|
// whichever feature happens to be mid-extraction.
|
|
test('resolves a prefix and its descendants', () => {
|
|
expect(permissionForApiPath('/api/tasks')?.key).toBe('tasks');
|
|
expect(permissionForApiPath('/api/tasks/pipeline/ws')?.key).toBe('tasks');
|
|
});
|
|
|
|
test('does not match a prefix that is merely a string prefix', () => {
|
|
// '/api/username' must not resolve to the 'account' permission, which claims '/user'. A naive
|
|
// startsWith would. (This was '/api/musicbrainz' against 'music' until music became a plugin.)
|
|
expect(permissionForApiPath('/api/username')).toBeNull();
|
|
});
|
|
|
|
// Was `/dav` vs `/caldav`, both of which are unmounted now — and both claimed by the same permission,
|
|
// so it never demonstrated the property anyway. `/user` (account) against `/users` (user-admin) is the
|
|
// live overlap AND a real privilege boundary: resolving `/api/users` to `account` would hand the
|
|
// ordinary-account permission the user-administration API.
|
|
test('longest prefix wins, so /user and /users do not fight', () => {
|
|
expect(permissionForApiPath('/api/user')?.key).toBe('account');
|
|
expect(permissionForApiPath('/api/user/settings')?.key).toBe('account');
|
|
expect(permissionForApiPath('/api/users')?.key).toBe('user-admin');
|
|
expect(permissionForApiPath('/api/users/3/permissions')?.key).toBe('user-admin');
|
|
});
|
|
|
|
test('an unclaimed path resolves to nothing rather than to something permissive', () => {
|
|
expect(permissionForApiPath('/api/not-a-thing')).toBeNull();
|
|
});
|
|
|
|
test('sockets resolve to their permission', () => {
|
|
expect(permissionForWsProvider('terminal')?.key).toBe('terminal');
|
|
expect(permissionForWsProvider('chat')?.key).toBe('chat');
|
|
expect(permissionForWsProvider('nope')).toBeNull();
|
|
});
|
|
|
|
// This pinned a real hole for one day: `/api/cliamp/ws` and `/api/cliamp/audio/ws` were SERVED in
|
|
// server.tsx's route table while no permission claimed them and their `handlers` entries were commented
|
|
// out — so connecting crashed on a non-null assertion, and the boot check could not see any of it
|
|
// because it reads `Object.keys(handlers)` rather than the route table.
|
|
//
|
|
// Closed on 2026-08-15 by cliamp leaving for `plugins/music/cliamp/`: the routes, the relay and the
|
|
// provider names all went with it, and the two lists agree again. Kept as an assertion rather than
|
|
// deleted, because "the route table serves nothing the handlers map lacks" is the invariant the
|
|
// original incident was about, and this is the cheapest place to notice it breaking again.
|
|
test('no cliamp socket is served or claimed — the route table and handlers agree', () => {
|
|
expect(permissionForWsProvider('cliamp')).toBeNull();
|
|
expect(permissionForWsProvider('cliamp-audio')).toBeNull();
|
|
expect(REAL_WS).not.toContain('cliamp');
|
|
});
|
|
});
|
|
|
|
describe('levels', () => {
|
|
// A FIXTURE, not a registry entry.
|
|
//
|
|
// These tests ran against the real `music` permission until 2026-08-15, when music left with
|
|
// `plugins/music/`. Re-anchoring them on whichever entry happens to have a `personal` list today only
|
|
// moves the problem to the next extraction — and it had already half-broken before that, because the
|
|
// moment music's `api` was commented out (2026-08-13) every path below stopped matching and three of
|
|
// these four tests passed for the wrong reason: everything is refused when nothing is claimed.
|
|
//
|
|
// `isRequestAllowedAtLevel` is a pure function of a Permission. Handing it one states what is actually
|
|
// under test — the RULE — rather than borrowing a feature that can leave.
|
|
const fixture: Permission = {
|
|
key: 'fixture',
|
|
label: 'Fixture',
|
|
description: 'Not in the registry — a shape to exercise the level rules against',
|
|
kind: 'app',
|
|
api: ['/fixture'],
|
|
personal: ['/favorites', '/now-playing'],
|
|
};
|
|
|
|
test('write permits anything within the permission', () => {
|
|
expect(isRequestAllowedAtLevel(fixture, 'write', 'DELETE', '/api/fixture/track/9')).toBe(true);
|
|
});
|
|
|
|
test('read permits safe methods', () => {
|
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'GET', '/api/fixture/albums')).toBe(true);
|
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'HEAD', '/api/fixture/albums')).toBe(true);
|
|
});
|
|
|
|
test('read permits mutations only under personal sub-paths', () => {
|
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true);
|
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'PUT', '/api/fixture/now-playing')).toBe(true);
|
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/scan')).toBe(false);
|
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'DELETE', '/api/fixture/track/9')).toBe(false);
|
|
});
|
|
|
|
test('a personal entry does not leak across a name boundary', () => {
|
|
// '/favorites-export' must not be covered by the '/favorites' personal entry.
|
|
expect(isRequestAllowedAtLevel(fixture, 'read', 'POST', '/api/fixture/favorites-export')).toBe(false);
|
|
});
|
|
|
|
test('a plugin gets the same rule through readOnlyWrites', () => {
|
|
// The two lists are concatenated, so a plugin declaring per-caller paths on the one field its
|
|
// manifest has behaves identically to a core permission declaring `personal`. This is what music
|
|
// relies on now that it ships as one.
|
|
const plugin: Permission = { ...fixture, personal: undefined, readOnlyWrites: ['/favorites', '/now-playing'] };
|
|
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true);
|
|
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/scan')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('self-service routes', () => {
|
|
const userAdmin = PERMISSION_BY_KEY.get('user-admin')!;
|
|
|
|
test('PUT /api/users is self-profile update and stays reachable', () => {
|
|
expect(isSelfServiceRoute(userAdmin, 'PUT', '/api/users')).toBe(true);
|
|
expect(isSelfServiceRoute(userAdmin, 'put', '/api/users')).toBe(true);
|
|
});
|
|
|
|
test('it does not open the rest of the router', () => {
|
|
expect(isSelfServiceRoute(userAdmin, 'GET', '/api/users')).toBe(false);
|
|
expect(isSelfServiceRoute(userAdmin, 'DELETE', '/api/users/5')).toBe(false);
|
|
expect(isSelfServiceRoute(userAdmin, 'PATCH', '/api/users/5/role')).toBe(false);
|
|
// The exact-match rule: a descendant of a self-service path is not self-service.
|
|
expect(isSelfServiceRoute(userAdmin, 'PUT', '/api/users/5')).toBe(false);
|
|
});
|
|
|
|
test('permissions without the field are unaffected', () => {
|
|
// Was `wallet` until 2026-08-19, when it became `plugins/wallet/`. Any permission with no
|
|
// selfService field will do — this pins the absence, not the key.
|
|
expect(isSelfServiceRoute(PERMISSION_BY_KEY.get('terminal')!, 'PUT', '/api/terminal')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('kinds', () => {
|
|
test('execution permissions are never grantable', () => {
|
|
const grantable = new Set(GRANTABLE_PERMISSIONS.map((c) => c.key));
|
|
// `files` left this list on 2026-08-11, then `terminal` and `chat` the same day — see the tests below
|
|
// and docs/per-user-linux-accounts.md. `desktop` left on 2026-08-21, differently: it became a plugin,
|
|
// and a plugin permission has no kind at all. What made it execution — one physical screen, the
|
|
// owner's — is now `ownerOnly: ['/']` in that plugin's manifest, which is the same refusal reached by
|
|
// a different route. Everything still here runs as the OWNER in the owner's home.
|
|
for (const key of ['tasks', 'browser', 'items']) {
|
|
expect(PERMISSION_BY_KEY.get(key)?.kind).toBe('execution');
|
|
expect(grantable.has(key)).toBe(false);
|
|
}
|
|
});
|
|
|
|
// A confined permission is grantable, but the grant is inert without a Linux account — enforced in
|
|
// authorize.ts, which is where the rule can cover routes, sockets and the dock at once.
|
|
test('confined permissions are grantable', () => {
|
|
const grantable = new Set(GRANTABLE_PERMISSIONS.map((c) => c.key));
|
|
for (const key of ['files', 'terminal', 'chat']) {
|
|
expect(PERMISSION_BY_KEY.get(key)?.kind).toBe('confined');
|
|
expect(grantable.has(key)).toBe(true);
|
|
}
|
|
});
|
|
|
|
// The claim `confined` makes is that every path it reaches resolves its directory from the CALLER. That
|
|
// cannot be asserted from the registry, so this pins the inverse: nothing becomes confined without a
|
|
// deliberate edit here, and the list stays short enough to audit by eye.
|
|
//
|
|
// `chat` was on this list ahead of its implementation for a day, with `api/chat/chat.ts` and the chat socket
|
|
// both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed
|
|
// on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential,
|
|
// transcripts and session ownership. `chat` is now confined in fact and not only in the registry.
|
|
test('confined is a short, deliberate list', () => {
|
|
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']);
|
|
});
|
|
|
|
test('admin permissions are never grantable', () => {
|
|
const grantable = new Set(GRANTABLE_PERMISSIONS.map((c) => c.key));
|
|
// `headscale` was here until 2026-08-15 when it left with offscale, and `wallet` until 2026-08-19.
|
|
// A plugin's permissions are registered at install from its manifest, so they are not in this
|
|
// compile-time list by design — and a plugin's are ALWAYS `app`, which is exactly why the wallet
|
|
// becoming one had to be a deliberate decision rather than a side effect.
|
|
for (const key of ['user-admin', 'server-admin']) {
|
|
expect(PERMISSION_BY_KEY.get(key)?.kind).toBe('admin');
|
|
expect(grantable.has(key)).toBe(false);
|
|
}
|
|
});
|
|
|
|
// `gitea` was this test's subject until 2026-08-19, when it became `plugins/gitea/`. A plugin's
|
|
// permissions are registered at INSTALL from its manifest and are always `app`, so they are never in
|
|
// this compile-time list — which is why the assertion has to name a core one to mean anything.
|
|
test('an app permission is grantable — the case this was built for', () => {
|
|
expect(GRANTABLE_PERMISSIONS.some((c) => c.key === 'chat')).toBe(true);
|
|
});
|
|
|
|
/*
|
|
Was `every permission declares at least one api prefix`, and that stopped being true on 2026-08-13
|
|
rather than becoming wrong: eleven permissions now declare none, because their routers were unmounted
|
|
pending plugin extraction while the permission stayed to keep the frontend route gated and existing
|
|
role grants meaningful. The test went red and was carried as "baseline" for five days.
|
|
|
|
What it was actually protecting is still worth protecting — a permission that gates NOTHING can be
|
|
granted and revoked to no effect, which is worse than not existing because it reads as a control. So
|
|
the invariant becomes "gates something a client can reach": an API prefix, or a frontend route.
|
|
*/
|
|
test('every permission gates something a client can reach', () => {
|
|
// `notify` is the one that genuinely gates nothing today: no API (unmounted with its plugin) and no
|
|
// screen of its own by design — it produces notifications FOR other features. Named here rather than
|
|
// silently tolerated, so it is a known state and not a hole the test stopped being able to see.
|
|
const DORMANT = new Set(['notify']);
|
|
for (const permission of PERMISSIONS) {
|
|
if (DORMANT.has(permission.key)) continue;
|
|
const gates = permission.api.length + (permission.routes?.length ?? 0);
|
|
expect(gates).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
test('notify: a read-level member can register and deregister their own device', () => {
|
|
// Regression. `personal` was ['/devices'], but the sidecar serves '/_officer/devices' — and `personal`
|
|
// is matched against the path AFTER the permission's own prefix is stripped, so it matched nothing.
|
|
// A member could GET their devices and neither add nor remove one, which is the opposite of the intent.
|
|
const notify = permissionForApiPath('/api/notify/_officer/devices');
|
|
expect(notify?.key).toBe('notify');
|
|
|
|
expect(isRequestAllowedAtLevel(notify!, 'read', 'POST', '/api/notify/_officer/devices')).toBe(true);
|
|
expect(isRequestAllowedAtLevel(notify!, 'read', 'DELETE', '/api/notify/_officer/devices/tok')).toBe(true);
|
|
expect(isRequestAllowedAtLevel(notify!, 'read', 'GET', '/api/notify/_officer/devices')).toBe(true);
|
|
|
|
// Still scoped: read level does not become write level for the rest of the surface.
|
|
expect(isRequestAllowedAtLevel(notify!, 'read', 'POST', '/api/notify/_officer/notify')).toBe(false);
|
|
});
|
|
|
|
// ── ownerOnly: the administrative corner of a shareable surface ──
|
|
//
|
|
// The case: a plugin worth granting to a Member that also holds the credential everything else runs on.
|
|
// Jellyfin's `/_config` stores a server's admin token and drives account provisioning; the rest of the
|
|
// plugin is exactly what a Member should have. Before this the choice was all of it or none.
|
|
//
|
|
// The owner never reaches any of this — `isApiRequestAllowed` returns for them before the permission is
|
|
// even resolved — so every case here is about somebody who legitimately holds the permission.
|
|
|
|
const withOwnerOnly: Permission = {
|
|
key: 'jellyfin',
|
|
label: 'Jellyfin',
|
|
description: 'films and shows',
|
|
kind: 'app',
|
|
api: ['/jellyfin'],
|
|
ownerOnly: ['/_config'],
|
|
personal: ['/_officer'],
|
|
selfService: ['POST /_config/ping'],
|
|
};
|
|
|
|
describe('isOwnerOnlyRoute', () => {
|
|
test('matches the declared sub-path', () => {
|
|
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_config')).toBe(true);
|
|
});
|
|
|
|
test('matches everything under it — a settings surface is a tree, not a list of leaves', () => {
|
|
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_config/3/users')).toBe(true);
|
|
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_config/3/activate')).toBe(true);
|
|
});
|
|
|
|
test('leaves the rest of the plugin alone', () => {
|
|
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_jf/Items')).toBe(false);
|
|
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin')).toBe(false);
|
|
});
|
|
|
|
test('does not match a path that merely starts with the same letters', () => {
|
|
// `/_configuration` is a different route. Prefix matching that ignored the boundary would silently
|
|
// lock a route nobody declared, which is the mirror of the bug this feature exists to prevent.
|
|
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_configuration')).toBe(false);
|
|
});
|
|
|
|
test('does not reach outside the permission that declared it', () => {
|
|
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/music/_config')).toBe(false);
|
|
});
|
|
|
|
test('a permission declaring none is unaffected', () => {
|
|
expect(isOwnerOnlyRoute({ ...withOwnerOnly, ownerOnly: undefined }, '/api/jellyfin/_config')).toBe(false);
|
|
});
|
|
|
|
// The two rules that could contradict each other, and which way it has to fall. `authorize.ts` checks
|
|
// ownerOnly FIRST, so these assertions are about the matchers agreeing that the overlap exists — the
|
|
// precedence is what makes it safe.
|
|
test('overlapping selfService does not unlock it', () => {
|
|
expect(isSelfServiceRoute(withOwnerOnly, 'POST', '/api/jellyfin/_config/ping')).toBe(true);
|
|
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_config/ping')).toBe(true);
|
|
});
|
|
|
|
test('a write grant does not unlock it either', () => {
|
|
// `isRequestAllowedAtLevel` says yes, because level is not the question ownerOnly answers.
|
|
expect(isRequestAllowedAtLevel(withOwnerOnly, 'write', 'POST', '/api/jellyfin/_config')).toBe(true);
|
|
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_config')).toBe(true);
|
|
});
|
|
});
|