diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
index 3b14035f..f7916465 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
@@ -9,6 +9,7 @@ import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from './Dock';
import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle';
+import { RouteGate } from './RouteGate';
type DashboardLayoutProps = {
children?: React.ReactNode;
@@ -55,7 +56,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
resetKeys={[pathname]}
fallback={({ error, 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. */}
+ {children}
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx
new file mode 100644
index 00000000..605b0805
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx
@@ -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 (
+
+
+ {reason === 'not-installed' ? (
+ <>
+
+
{label} is not installed
+
+ Nothing on this server provides it yet.
+ {isOwner ? ' Install it and this page starts working.' : ' Ask the server owner to install it.'}
+
+ {/* Only offered to the owner: the app store is owner-only, so a member following this link would
+ meet a second refusal. */}
+ {isOwner && (
+
+ Open the app store
+
+ )}
+ >
+ ) : (
+ <>
+
+
{label} is not available to you
+
+ Your role does not include it. The server owner decides this under Settings → User management.
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts
index f2b2f6c0..9b7ff49d 100644
--- a/src/servers/api/file-browser/router.ts
+++ b/src/servers/api/file-browser/router.ts
@@ -2,7 +2,7 @@ import { createRouter } from '@@/create-router';
import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
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 * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
@@ -19,7 +19,6 @@ async function getUserTtsVoice(userId: number): Promise {
return null;
}
-const DEFAULT_HOME_DIRS = HOME_SEED_DIRS;
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
async function cleanOldCacheDirs(userDataDir: string) {
@@ -29,12 +28,9 @@ async function cleanOldCacheDirs(userDataDir: string) {
}
}
-async function seedHomeDir(homeDir: string) {
- for (const dir of DEFAULT_HOME_DIRS) {
- const target = join(homeDir, dir);
- if (!existsSync(target)) await mkdir(target, { recursive: true });
- }
-}
+// `seedHomeDir` used to be here, creating Downloads/Documents/Music/Videos/Pictures on the first listing of
+// any home. Removed 2026-08-11: it invented folders in somebody's home directory as a side effect of LOOKING
+// at it, which is not a listing's business and not a layout the platform has any standing to choose.
export const router = createRouter();
@@ -172,18 +168,16 @@ router.get('/ls', async (ctx) => {
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
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
- // cannot write into it and every one of these calls raises EPERM. Their folders are seeded at account
- // creation, as them. Letting a convenience take down `/ls` would mean the file browser failing to list a
- // directory it can read perfectly well.
+ // Non-fatal: a member's home is theirs, so this can raise EPERM, and `readdir` below is the real test of
+ // whether the directory can be used.
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
try {
- await seedHomeDir(rootDir);
await mkdir(absPath, { recursive: true });
} 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.
}
}
diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts
index 27b7461d..e1f6b9b3 100644
--- a/src/servers/data-path.ts
+++ b/src/servers/data-path.ts
@@ -63,15 +63,6 @@ export const USER_DIRS = [
'sidecar',
] 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.
*
diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts
index 26d75a34..17713c11 100644
--- a/src/servers/os-user.ts
+++ b/src/servers/os-user.ts
@@ -1,7 +1,7 @@
import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
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.
//
@@ -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
- // write into it, which is exactly the point. Best-effort: an empty file browser is a cosmetic problem, and
- // failing the whole account creation over Downloads/ would be absurd.
- const seed = runAs(osUser, ['mkdir', '-p', ...HOME_SEED_DIRS.map((dir) => join(home, dir))]);
- if ((await seed.exited) !== 0) {
- console.warn(`[os-user] could not seed ${osUser}'s home folders: ${await new Response(seed.stderr).text()}`);
- }
-
+ // A new home is EMPTY, deliberately. This used to create Downloads/Documents/Music/Videos/Pictures — a
+ // habit inherited from the file browser, which did the same lazily for the owner. Nothing needs them:
+ // guessing at somebody's folder layout is a decision the platform has no standing to make, and an empty
+ // home is honest about being new.
return { ok: true, osUser, uid: ids.uid, gid: ids.gid, created };
}
diff --git a/src/workspaces/hooks/src/useCapabilities.ts b/src/workspaces/hooks/src/useCapabilities.ts
index 40c7a755..bde0c05f 100644
--- a/src/workspaces/hooks/src/useCapabilities.ts
+++ b/src/workspaces/hooks/src/useCapabilities.ts
@@ -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 ?? [],