step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:
1. the permission registry → RENAMED here
2. $OFFICER_ROOT/capabilities/ items → kept; this is what capabilities are
3. sidecar routing keys → step 3, becoming `handles`
4. Lightning wallet features → kept; a domain term, and on the wire
to the mobile apps
The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.
Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.
Three breaks the typechecker could not see, all found by exercising it live.
The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.
The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.
And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.
The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.
The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.
Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.
tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
This commit is contained in:
+17
-17
@@ -14,20 +14,20 @@ import { useClient } from './useClient';
|
||||
// minute; an owner locked out of their own platform by a transient network error is an incident. The
|
||||
// server refuses what it should refuse either way.
|
||||
|
||||
export type CapabilityLevel = 'read' | 'write';
|
||||
export type PermissionLevel = 'read' | 'write';
|
||||
|
||||
export type SelfCapabilities = {
|
||||
export type SelfPermissions = {
|
||||
isOwner: boolean;
|
||||
capabilities: { key: string; level: CapabilityLevel }[];
|
||||
/** Frontend route prefixes the account holds, flattened across its capabilities. */
|
||||
permissions: { key: string; level: PermissionLevel }[];
|
||||
/** Frontend route prefixes the account holds, flattened across its permissions. */
|
||||
routes: string[];
|
||||
/**
|
||||
* Route prefixes claimed by capabilities the account does NOT hold. Both lists are needed: absence from
|
||||
* `routes` cannot tell a denied route from one no capability claims (`/`, the settings shell), and a
|
||||
* Route prefixes claimed by permissions the account does NOT hold. Both lists are needed: absence from
|
||||
* `routes` cannot tell a denied route from one no permission claims (`/`, the settings shell), and a
|
||||
* guard that cannot tell those apart either blanks the app or guards nothing.
|
||||
*/
|
||||
deniedRoutes: string[];
|
||||
/** Capabilities the account holds whose sidecar is not installed, or is installed but disabled. */
|
||||
/** Permissions the account holds whose sidecar is not installed, or is installed but disabled. */
|
||||
unavailable?: string[];
|
||||
/**
|
||||
* Dock tiles and routes of the sidecars actually installed on this server.
|
||||
@@ -54,20 +54,20 @@ export type PluginManifest = {
|
||||
extraTiles?: Array<{ name: string; icon?: string; image?: string; color: string; route: string }>;
|
||||
};
|
||||
|
||||
export const CAPABILITIES_QUERY_KEY = ['self-capabilities'];
|
||||
export const PERMISSIONS_QUERY_KEY = ['self-permissions'];
|
||||
|
||||
export function useCapabilities() {
|
||||
export function usePermissions() {
|
||||
const client = useClient();
|
||||
|
||||
const { data, isLoading, isError } = useQuery<SelfCapabilities>({
|
||||
queryKey: CAPABILITIES_QUERY_KEY,
|
||||
queryFn: () => client.get<SelfCapabilities>('/user/capabilities'),
|
||||
const { data, isLoading, isError } = useQuery<SelfPermissions>({
|
||||
queryKey: PERMISSIONS_QUERY_KEY,
|
||||
queryFn: () => client.get<SelfPermissions>('/user/permissions'),
|
||||
// Grants change rarely and only by an owner action, but they change the shape of the whole app when
|
||||
// they do. A minute matches the `tasks` / `task-categories` caches the rest of the app uses.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const held = useMemo(() => new Map((data?.capabilities ?? []).map((c) => [c.key, c.level])), [data]);
|
||||
const held = useMemo(() => new Map((data?.permissions ?? []).map((c) => [c.key, c.level])), [data]);
|
||||
|
||||
// `can` and `canVisit` are memoised on `data` alone, and that matters more than it looks. A verb rebuilt
|
||||
// every render gets a new identity every render, so anything putting one in a useCallback or useMemo
|
||||
@@ -75,9 +75,9 @@ export function useCapabilities() {
|
||||
// Jellyfin player was disabled for days by an unmount cleanup re-running mid-playback. `data` is a React
|
||||
// Query value with a stable reference between fetches, so these change only when the answer does.
|
||||
|
||||
/** Whether the account holds a capability, optionally at write level. */
|
||||
/** Whether the account holds a permission, optionally at write level. */
|
||||
const can = useCallback(
|
||||
(key: string, level: CapabilityLevel = 'read'): boolean => {
|
||||
(key: string, level: PermissionLevel = 'read'): boolean => {
|
||||
if (!data) return true; // see the note above: fail open, the server does not
|
||||
if (data.isOwner) return true;
|
||||
const granted = held.get(key);
|
||||
@@ -118,7 +118,7 @@ export function useCapabilities() {
|
||||
// Held but unavailable → the sidecar is missing. Checked against the capability that claims the route,
|
||||
// which is why `unavailable` is returned as capability keys rather than routes.
|
||||
const unavailable = new Set(data.unavailable ?? []);
|
||||
const heldAndUnavailable = data.capabilities.some(({ key }) => unavailable.has(key));
|
||||
const heldAndUnavailable = data.permissions.some(({ key }) => unavailable.has(key));
|
||||
if (data.isOwner || heldAndUnavailable) return 'not-installed';
|
||||
return 'not-granted';
|
||||
},
|
||||
@@ -127,7 +127,7 @@ export function useCapabilities() {
|
||||
|
||||
return {
|
||||
isOwner: data?.isOwner ?? false,
|
||||
capabilities: held,
|
||||
permissions: held,
|
||||
routes: data?.routes ?? [],
|
||||
unavailable: data?.unavailable ?? [],
|
||||
denialReason,
|
||||
Reference in New Issue
Block a user