routes refuse at the route, and a new home is empty

Three things, from a member sitting on /music with no music capability on a server with
no music sidecar: an empty library, and 403s in the console.

PERMISSIONS AT THE ROUTE. `canVisit` filtered the dock and nothing else, so the tile was
hidden and the route was wide open — typing the path, following an old link or restoring
a tab rendered the screen anyway. RouteGate now wraps every screen in one place, inside
the error boundary.

It does not redirect. Sending someone to `/` erases what they asked for and reads as a
bug: they clicked Music and landed on Home. It says why instead, and the URL stays put so
a reload after installing the thing just works.

And it says which of the two reasons applies, because they need different screens and send
the reader to different places. `not-installed` is a fact about the SERVER — the owner gets
a link to the app store. `not-granted` is a fact about the ACCOUNT, and only the owner can
change it. Presenting either as the other sends you looking in the wrong place.

ROUTES FOLLOW THE SIDECAR. Free, once the above exists: `deniedRoutes` already covers
"held but its sidecar is not installed", so an uninstalled feature has no tile AND no
screen. The dock, the Permissions list and the routes now agree because they read one
answer.

NO MORE SEEDING. Downloads/Documents/Music/Videos/Pictures are gone from both places that
made them — the member's provisioning and, older and worse, `/ls`, which created folders in
somebody's home as a side effect of LOOKING at it. A listing that invents its own contents
is a listing you cannot trust, and the platform has no standing to choose a person's folder
layout. A new home is empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:42:39 +00:00
co-authored by Claude Opus 5
parent 4b058a6703
commit b4f88ec161
6 changed files with 106 additions and 34 deletions
@@ -99,10 +99,36 @@ export function useCapabilities() {
[data],
);
/**
* Why a route is refused, or null if it is not.
*
* Two answers, because they need two different screens. `not-installed` is a fact about the SERVER and the
* owner can fix it from the app store; `not-granted` is a fact about the ACCOUNT and only the owner can
* change it. Presenting either as the other sends the reader looking in the wrong place.
*
* Same fail-open posture as `can`: no data means no denial.
*/
const denialReason = useCallback(
(path: string): 'not-installed' | 'not-granted' | null => {
if (!data) return null;
if (!data.deniedRoutes.some((route) => path === route || path.startsWith(`${route}/`))) return null;
// 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));
if (data.isOwner || heldAndUnavailable) return 'not-installed';
return 'not-granted';
},
[data],
);
return {
isOwner: data?.isOwner ?? false,
capabilities: held,
routes: data?.routes ?? [],
unavailable: data?.unavailable ?? [],
denialReason,
// Empty rather than undefined when the request has not landed or failed: the dock then renders its
// baseline, which is the honest "we do not know yet" — not an empty app.
plugins: data?.plugins ?? [],