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
@@ -9,6 +9,7 @@ import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from './Dock'; import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from './Dock';
import { useIsTouch } from './useIsTouch'; import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle'; import { usePageTitleSync } from '@/state/usePageTitle';
import { RouteGate } from './RouteGate';
type DashboardLayoutProps = { type DashboardLayoutProps = {
children?: React.ReactNode; children?: React.ReactNode;
@@ -55,7 +56,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
resetKeys={[pathname]} resetKeys={[pathname]}
fallback={({ error, reset }) => <ScreenErrorFallback error={error} reset={reset} />} fallback={({ error, reset }) => <ScreenErrorFallback error={error} reset={reset} />}
> >
{children} {/* Inside the boundary and around every screen, so one place decides whether a route exists for
this account on this server. Filtering the dock was never enough: the tile was hidden and the
route still rendered for anyone who typed it, followed an old link or restored a tab. */}
<RouteGate>{children}</RouteGate>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</section> </section>
@@ -0,0 +1,61 @@
import { useLocation, Link } from 'react-router';
import { PackageOpen, Lock } from 'lucide-react';
import { useCapabilities } from 'hooks/useCapabilities';
// A screen only exists if this account can reach it AND this server has the thing behind it.
//
// Until this existed, `canVisit` filtered the dock and nothing else — so the icon was hidden and the ROUTE
// was wide open. Typing /music, following an old link or restoring a tab rendered the Music screen for an
// account with no music capability on a server with no music sidecar: an empty library, a spinner, and a
// handful of 403s in the console. The refusal has to be at the route, because that is where the reader
// arrives.
//
// Deliberately NOT a redirect. Sending someone to `/` erases what they asked for and reads as a bug — they
// clicked Music and landed on Home. Saying "Music is not installed" answers the question they actually have,
// and the URL stays put so a reload after installing it just works.
//
// This is a courtesy, not the lock. Every route here is refused server-side as well; hiding the screen only
// stops the app promising something it will then refuse.
export function RouteGate({ children }: { children?: React.ReactNode }) {
const { pathname } = useLocation();
const { denialReason, isOwner } = useCapabilities();
const reason = denialReason(pathname);
if (!reason) return <>{children}</>;
const name = pathname.split('/').filter(Boolean)[0] ?? 'This';
const label = name.charAt(0).toUpperCase() + name.slice(1);
return (
<div className="flex h-full items-center justify-center p-6">
<div className="flex max-w-md flex-col items-center gap-3 text-center">
{reason === 'not-installed' ? (
<>
<PackageOpen className="h-8 w-8 text-muted-foreground" />
<div className="text-lg font-medium">{label} is not installed</div>
<p className="text-sm text-muted-foreground">
Nothing on this server provides it yet.
{isOwner ? ' Install it and this page starts working.' : ' Ask the server owner to install it.'}
</p>
{/* Only offered to the owner: the app store is owner-only, so a member following this link would
meet a second refusal. */}
{isOwner && (
<Link to="/app-store" className="rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent">
Open the app store
</Link>
)}
</>
) : (
<>
<Lock className="h-8 w-8 text-muted-foreground" />
<div className="text-lg font-medium">{label} is not available to you</div>
<p className="text-sm text-muted-foreground">
Your role does not include it. The server owner decides this under Settings User management.
</p>
</>
)}
</div>
</div>
);
}
+9 -15
View File
@@ -2,7 +2,7 @@ import { createRouter } from '@@/create-router';
import { resolve, dirname, join, sep, parse as parsePath } from 'node:path'; import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { getOwnerHomeDir, DATA_PATH, HOME_SEED_DIRS } from '@@/data-path'; import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
import { resolveHomeDir } from '@@/user-home'; import { resolveHomeDir } from '@@/user-home';
import * as errors from '@@/custom-errors'; import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts'; import { readTtsConfig } from '@@/api/server-settings/tts';
@@ -19,7 +19,6 @@ async function getUserTtsVoice(userId: number): Promise<string | null> {
return null; return null;
} }
const DEFAULT_HOME_DIRS = HOME_SEED_DIRS;
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video']; const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
async function cleanOldCacheDirs(userDataDir: string) { async function cleanOldCacheDirs(userDataDir: string) {
@@ -29,12 +28,9 @@ async function cleanOldCacheDirs(userDataDir: string) {
} }
} }
async function seedHomeDir(homeDir: string) { // `seedHomeDir` used to be here, creating Downloads/Documents/Music/Videos/Pictures on the first listing of
for (const dir of DEFAULT_HOME_DIRS) { // any home. Removed 2026-08-11: it invented folders in somebody's home directory as a side effect of LOOKING
const target = join(homeDir, dir); // at it, which is not a listing's business and not a layout the platform has any standing to choose.
if (!existsSync(target)) await mkdir(target, { recursive: true });
}
}
export const router = createRouter(); export const router = createRouter();
@@ -172,18 +168,16 @@ router.get('/ls', async (ctx) => {
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, ''); const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
const absPath = resolveUserPath(rootDir, relPath); const absPath = resolveUserPath(rootDir, relPath);
// Auto-create dir if missing (only for user home root). // Create the home root itself if it is missing, and nothing else. A listing that invents its own contents
// is a listing you cannot trust — the folder set it used to seed is gone.
// //
// Non-fatal since per-user Linux accounts: a member's home is 700 and owned by THEM, so the platform // Non-fatal: a member's home is theirs, so this can raise EPERM, and `readdir` below is the real test of
// cannot write into it and every one of these calls raises EPERM. Their folders are seeded at account // whether the directory can be used.
// creation, as them. Letting a convenience take down `/ls` would mean the file browser failing to list a
// directory it can read perfectly well.
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') { if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
try { try {
await seedHomeDir(rootDir);
await mkdir(absPath, { recursive: true }); await mkdir(absPath, { recursive: true });
} catch { } catch {
// Nothing to report: either it exists, or it is not ours to create. `readdir` below is the real test. // Either it exists, or it is not ours to create.
} }
} }
-9
View File
@@ -63,15 +63,6 @@ export const USER_DIRS = [
'sidecar', 'sidecar',
] as const; ] as const;
/**
* The folders a home is seeded with, so a new account's file browser is not an empty rectangle.
*
* Here rather than in the file browser because there are now two seeders: that router (for the owner, whose
* home it can write to) and the Linux-account provisioner, which has to create them AS the member because
* their home is 700 and theirs. Two lists would mean a member's home quietly differing from the owner's.
*/
export const HOME_SEED_DIRS = ['Downloads', 'Documents', 'Music', 'Videos', 'Pictures'] as const;
/** /**
* Create an account's root and its skeleton, closed by default. * Create an account's root and its skeleton, closed by default.
* *
+5 -9
View File
@@ -1,7 +1,7 @@
import { chmod, mkdir, readdir, stat } from 'node:fs/promises'; import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { DATA_PATH, HOME_SEED_DIRS, USER_DIRS, toShellUsername } from './data-path'; import { DATA_PATH, USER_DIRS, toShellUsername } from './data-path';
// Real Linux accounts for members, so the surfaces that execute code can run as them. // Real Linux accounts for members, so the surfaces that execute code can run as them.
// //
@@ -250,14 +250,10 @@ export async function ensureOsUser(params: { email: string; username: string | n
}; };
} }
// Seeded AS the member, because after the chown above their home is 700 and theirs — the platform cannot // A new home is EMPTY, deliberately. This used to create Downloads/Documents/Music/Videos/Pictures — a
// write into it, which is exactly the point. Best-effort: an empty file browser is a cosmetic problem, and // habit inherited from the file browser, which did the same lazily for the owner. Nothing needs them:
// failing the whole account creation over Downloads/ would be absurd. // guessing at somebody's folder layout is a decision the platform has no standing to make, and an empty
const seed = runAs(osUser, ['mkdir', '-p', ...HOME_SEED_DIRS.map((dir) => join(home, dir))]); // home is honest about being new.
if ((await seed.exited) !== 0) {
console.warn(`[os-user] could not seed ${osUser}'s home folders: ${await new Response(seed.stderr).text()}`);
}
return { ok: true, osUser, uid: ids.uid, gid: ids.gid, created }; return { ok: true, osUser, uid: ids.uid, gid: ids.gid, created };
} }
@@ -99,10 +99,36 @@ export function useCapabilities() {
[data], [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 { return {
isOwner: data?.isOwner ?? false, isOwner: data?.isOwner ?? false,
capabilities: held, capabilities: held,
routes: data?.routes ?? [], routes: data?.routes ?? [],
unavailable: data?.unavailable ?? [],
denialReason,
// Empty rather than undefined when the request has not landed or failed: the dock then renders its // 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. // baseline, which is the honest "we do not know yet" — not an empty app.
plugins: data?.plugins ?? [], plugins: data?.plugins ?? [],