Minesweeper is now plugins/minesweeper (gitea.officer.dev/plugins/minesweeper). Removes /api/minesweeper, the Dashboard screen, the officerdev panel, the permission registry entry, the App.tsx route and the hand-written websocket provider in server.tsx. Registry entries: 22 -> 21, execution 4 -> 3. The relay changed transport, and that is the part worth reading. A sidecar announces exactly one port. That single announcement is the whole provenance argument in target.ts — the platform watched the port arrive at registration, which is the only thing separating it from a number a manifest asserted. But a raw-TCP relay requires that port to BE a raw TCP listener, so the sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed command vocabulary over the registration socket. That is the mechanism the fleet has spent months retiring, and Minesweeper would have been the first thing to go back to it. Relaying websocket-to-websocket dissolves the conflict. The sidecar serves HTTP and /ws on the one port it announced, exactly as the pty sidecar already does; createSidecarProxy handles the HTTP half; and the conversion from frames to whatever the plugin actually speaks happens inside the plugin, which is the only process that knows. The relay is still ignorant of the payload — the ignorance sits one layer up. Also: the caller's JWT is no longer forwarded upstream. A sidecar has no use for it and forwarding one puts a bearer token into whatever that process logs. X-Officer-User carries the only part it needs. (The terminal relay still forwards its token to the pty sidecar; not touched here.) Verified: bunx tsgo clean, bun test 1403 pass / 0 fail, bun run docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
379 lines
20 KiB
TypeScript
379 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'];
|
|
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.
|
|
//
|
|
// `sub-agents` joined on 2026-08-23, and it is confined IN FACT on the day it was added rather than ahead of
|
|
// itself. `POST /sub-agents/spawn` resolves the identity through `chatIdentity` from the caller's token and
|
|
// passes `member: { osUser, home }` to the sidecar — the same mechanism `chat` runs under, reached by the
|
|
// same resolver. It inherits the containment rather than claiming it: there is no path in that endpoint that
|
|
// chooses a directory, so there is nothing for a caller to point somewhere else.
|
|
test('confined is a short, deliberate list', () => {
|
|
expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual([
|
|
'terminal',
|
|
'chat',
|
|
'files',
|
|
'sub-agents',
|
|
]);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|