feat(mana/web): tier-gate workbench picker, openApps, and per-route navigation

Guests and under-tier users could see and use every module in the
workbench because tier-filtering only existed in @mana/shared-branding's
MANA_APPS list — never in the workbench app-registry that the picker
and the page-level routes actually consume. Three leaks closed:

──── 1. Workbench AppPagePicker ────

The picker was calling getAllApps() and only filtering by "already
open in this scene". Result: a guest opening "Add page" saw all 32
modules including founder-only ones like dreams, finance, memoro.

Fix: new getAccessibleApps(userTier) helper in app-registry/registry.ts
joins the workbench in-memory map with MANA_APPS by id, looks up
each app's requiredTier, and filters via hasAppAccess. Apps that
exist in the workbench registry but NOT in MANA_APPS (`automations`,
`playground`, the `inventar` ↔ `inventory` id mismatch) default to
visible — hiding them would silently break internal tools for
founders/devs.

AppPagePicker now takes a `userTier` prop and calls
getAccessibleApps(userTier) instead of getAllApps(). (app)/+page.svelte
threads authStore.user?.tier into it.

──── 2. openApps soft-filter ────

The default Home scene seeds [todo, calendar, notes] — `notes` is
founder-tier, so a brand-new guest device would still try to render
the notes view in their workbench tab strip even though they can't
actually use it. Same risk for any cross-device synced scene that
contains gated apps (e.g. founder logs in on a public-tier secondary
account).

Fix: (app)/+page.svelte derives `openApps` through a soft filter
(isAppAccessible) instead of using workbenchScenesStore.openApps
directly. The store keeps the full list — we don't destructively
delete on tier downgrades — so the tabs reappear when the user
upgrades or signs in. Internal-only apps (no MANA_APPS entry)
stay visible by the same default-visible rule.

──── 3. Per-route tier gate in (app)/+layout.svelte ────

The wrapping <AuthGate> in (app)/+layout.svelte:
  - only runs onMount, so it doesn't react to client-side navigation
  - skips the tier check entirely when !authStore.isAuthenticated
  - has no per-route requiredTier — it's set once on the outer wrapper

So a guest typing /dreams or /cycles in the URL bar slipped past
silently and rendered the gated module. Same for a public-tier user
clicking through to /finance.

Fix: reactive `routeBlocked` derivation in the (app) layout:
  - Extract first path segment from $page.url.pathname
  - Look it up in MANA_APPS by id
  - If found and user (or 'guest') doesn't satisfy requiredTier,
    render an inline tier-denied panel instead of {@render children()}

The panel mirrors AuthGate's tier-denied design (same locked icon +
tier comparison + "Zur Übersicht" / "Anmelden" buttons) but works
reactively for any subsequent navigation. Routes that don't map to
a MANA_APPS id (settings, profile, admin, help, observatory, …)
fall through with routeAppId=null and are never blocked.

──── New helpers in app-registry ────

  getAccessibleApps(userTier?) — filtered AppDescriptor[]
  isAppAccessible(appId, userTier?) — boolean for single-app lookup

Both treat `userTier === undefined | null` as 'guest' (the lowest
tier in @mana/shared-branding). Both default-visible for apps not
in MANA_APPS so the workbench-internal tools keep working.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 12:08:49 +02:00
parent 237516749a
commit 94d7dd4831
5 changed files with 180 additions and 6 deletions

View file

@ -9,6 +9,8 @@ export {
canDrop,
executeDrop,
getAllApps,
getAccessibleApps,
isAppAccessible,
} from './registry';
// Register all apps eagerly — descriptors are lightweight with lazy imports

View file

@ -4,6 +4,7 @@
import type { DragType } from '@mana/shared-ui/dnd';
import { linkMutations, buildCachedData } from '@mana/shared-links';
import { MANA_APPS, hasAppAccess, type AccessTier } from '@mana/shared-branding';
import type { AppDescriptor, DropResult } from './types';
const apps = new Map<string, AppDescriptor>();
@ -81,3 +82,53 @@ export async function executeDrop(
export function getAllApps(): AppDescriptor[] {
return Array.from(apps.values());
}
/**
* Looks up the access tier for a workbench-registry app via the canonical
* @mana/shared-branding MANA_APPS list. The two registries are intentionally
* separate (workbench needs createItem/dragType/views; MANA_APPS holds tier
* + branding metadata), so the join happens by `id`.
*
* Returns null when there is no matching MANA_APPS entry. Internal-only
* tools that exist in the workbench but not in MANA_APPS (`automations`,
* `playground`, the `inventar` `inventory` id mismatch) fall into this
* case `getAccessibleApps` then treats them as visible by default
* rather than hiding them for everyone, since the alternative is a
* silent regression for founders/devs.
*/
function getAppRequiredTier(appId: string): AccessTier | null {
const branding = MANA_APPS.find((a) => a.id === appId);
return branding?.requiredTier ?? null;
}
/**
* Returns workbench apps the given user tier may access. Used by the
* AppPagePicker to hide gated modules from the "add page" picker, and
* by the workbench layout to soft-filter `openApps` so seeded / migrated
* scenes don't render gated content for downgraded users or guests.
*
* Tier semantics (from @mana/shared-branding):
* guest(0) < public(1) < beta(2) < alpha(3) < founder(4)
*
* Pass `undefined` (no signed-in user) treated as `'guest'`. The
* default behaviour for apps with NO MANA_APPS entry is "visible" (see
* `getAppRequiredTier` rationale above).
*/
export function getAccessibleApps(userTier?: string | null): AppDescriptor[] {
const tier = userTier ?? 'guest';
return Array.from(apps.values()).filter((app) => {
const required = getAppRequiredTier(app.id);
if (!required) return true;
return hasAppAccess(tier, required);
});
}
/** Single-app version of `getAccessibleApps`. Returns true when the
* given user tier may use this app. Used by the (app) layout's
* per-route tier check so direct URL navigation to a gated module
* is blocked for users without access. */
export function isAppAccessible(appId: string, userTier?: string | null): boolean {
const required = getAppRequiredTier(appId);
if (!required) return true;
return hasAppAccess(userTier ?? 'guest', required);
}

View file

@ -4,7 +4,7 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import PickerOverlay from '$lib/components/PickerOverlay.svelte';
import { getAllApps } from '$lib/app-registry';
import { getAccessibleApps } from '$lib/app-registry';
function appName(id: string, fallback: string): string {
const key = `apps.${id}`;
@ -16,11 +16,19 @@
onSelect: (appId: string) => void;
onClose: () => void;
activeAppIds?: string[];
/** User access tier from authStore.user?.tier — `undefined` falls
* through to 'guest' inside getAccessibleApps. */
userTier?: string | null;
}
let { onSelect, onClose, activeAppIds = [] }: Props = $props();
let { onSelect, onClose, activeAppIds = [], userTier = null }: Props = $props();
let availableApps = $derived(getAllApps().filter((app) => !activeAppIds.includes(app.id)));
// Filter twice: tier-gate first (so guests + public users don't see
// founder/alpha/beta apps at all), then drop apps that are already
// open in the current scene.
let availableApps = $derived(
getAccessibleApps(userTier).filter((app) => !activeAppIds.includes(app.id))
);
</script>
<PickerOverlay

View file

@ -30,6 +30,8 @@
import { getAdapterLoader } from '$lib/quick-input/registry';
import { createFallbackAdapter } from '$lib/quick-input/fallback-adapter';
import { AuthGate, GuestWelcomeModal } from '@mana/shared-auth-ui';
import { MANA_APPS, hasAppAccess, ACCESS_TIER_LABELS } from '@mana/shared-branding';
import type { AccessTier } from '@mana/shared-branding';
import { createGuestMode, type GuestMode } from '$lib/stores/guest-mode.svelte';
import { guestPrompt, setGuestPromptNavigator } from '$lib/stores/guest-prompt.svelte';
import { NotificationBar } from '@mana/shared-ui';
@ -74,6 +76,39 @@
// ── App switcher ────────────────────────────────────────
let appItems = $derived(getPillAppItems('mana', undefined, undefined, authStore.user?.tier));
// ── Per-route tier gate ─────────────────────────────────
// AuthGate (the wrapping component) only checks tiers onMount and only
// for authenticated users — so a guest typing /dreams into the URL bar
// or a public-tier user navigating into a founder module would slip
// past silently. This reactive check looks up the first path segment
// in MANA_APPS, and if that app has a requiredTier the current user
// (or guest) doesn't meet, we render a denial panel instead of the
// routed view.
//
// Routes that don't map to a MANA_APPS id (settings, profile, admin,
// help, …) fall through with `routeAppId === null` and are never
// blocked here. Workbench `/` (empty first segment) likewise passes
// through — soft-filtering of openApps happens in (app)/+page.svelte.
let routeAppId = $derived.by(() => {
const seg = $page.url.pathname.split('/')[1] ?? '';
if (!seg) return null;
return MANA_APPS.find((a) => a.id === seg) ?? null;
});
let routeBlocked = $derived.by(() => {
if (!routeAppId) return false;
const tier = authStore.user?.tier ?? 'guest';
return !hasAppAccess(tier, routeAppId.requiredTier);
});
let routeTierLabels = $derived.by(() => {
const labels = ACCESS_TIER_LABELS[($locale || 'de') === 'de' ? 'de' : 'en'];
const userTier = (authStore.user?.tier ?? 'guest') as AccessTier;
const required = routeAppId?.requiredTier ?? ('public' as AccessTier);
return {
user: labels[userTier] ?? userTier,
required: labels[required] ?? required,
};
});
// ── UI State ────────────────────────────────────────────
let isCollapsed = $state(false);
let showShortcuts = $state(false);
@ -610,7 +645,74 @@
<!-- Main content -->
<main style="padding-bottom: {bottomChromeHeight + 32}px">
<div class="mx-auto max-w-7xl px-3 py-4 sm:px-6 sm:py-8 lg:px-8">
{@render children()}
{#if routeBlocked && routeAppId}
<!-- Per-route tier gate. The wrapping AuthGate only fires
onMount + only for authenticated users, so this is the
only place that catches direct URL navigation into a
gated module by a guest or under-tier user. -->
<div class="flex min-h-[60vh] items-center justify-center p-6">
<div
class="w-full max-w-96 rounded-2xl border px-8 py-10 text-center shadow-sm"
style:border-color="hsl(var(--border, 0 0% 90%))"
style:background-color="hsl(var(--card, 0 0% 100%))"
>
<h1 class="mb-4 text-xl font-bold" style:color="hsl(var(--foreground, 0 0% 9%))">
{routeAppId.name}
</h1>
<div class="mb-4 text-5xl">🔒</div>
<p
class="mb-6 text-[0.9375rem] leading-relaxed"
style:color="hsl(var(--muted-foreground, 0 0% 45%))"
>
{($locale || 'de') === 'de'
? 'Diese App ist aktuell in der geschlossenen '
: 'This app is currently in closed '}<strong>{routeTierLabels.required}</strong
>{($locale || 'de') === 'de' ? '-Phase.' : ' phase.'}
</p>
<div
class="mb-6 flex flex-col gap-2 rounded-xl p-4"
style:background-color="hsl(var(--muted, 0 0% 96%))"
>
<div class="flex items-center justify-between text-sm">
<span style:color="hsl(var(--muted-foreground, 0 0% 45%))"
>{($locale || 'de') === 'de' ? 'Dein Zugang:' : 'Your access:'}</span
>
<span class="font-semibold" style:color="hsl(var(--foreground, 0 0% 9%))"
>{routeTierLabels.user}</span
>
</div>
<div class="flex items-center justify-between text-sm">
<span style:color="hsl(var(--muted-foreground, 0 0% 45%))"
>{($locale || 'de') === 'de' ? 'Benötigt:' : 'Required:'}</span
>
<span class="font-semibold text-violet-500">{routeTierLabels.required}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<button
class="w-full cursor-pointer rounded-lg border-none px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90"
style:background-color="hsl(var(--primary, 239 84% 67%))"
style:color="hsl(var(--primary-foreground, 0 0% 100%))"
onclick={() => goto('/')}
>
{($locale || 'de') === 'de' ? 'Zur Übersicht' : 'Back to overview'}
</button>
{#if !authStore.isAuthenticated}
<button
class="w-full cursor-pointer rounded-lg border px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90"
style:border-color="hsl(var(--border, 0 0% 90%))"
style:color="hsl(var(--foreground, 0 0% 9%))"
onclick={() => goto('/login')}
>
{($locale || 'de') === 'de' ? 'Anmelden' : 'Sign in'}
</button>
{/if}
</div>
</div>
</div>
{:else}
{@render children()}
{/if}
</div>
</main>

View file

@ -5,9 +5,10 @@
import SceneRenameDialog from '$lib/components/workbench/scenes/SceneRenameDialog.svelte';
import ConfirmDialog from '$lib/components/workbench/scenes/ConfirmDialog.svelte';
import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel';
import { getApp, getAppByDragType } from '$lib/app-registry';
import { getApp, getAppByDragType, isAppAccessible } from '$lib/app-registry';
import { onMount, onDestroy } from 'svelte';
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { DragPreview } from '@mana/shared-ui/dnd';
import type { DragType } from '@mana/shared-ui/dnd';
import { ContextMenu } from '@mana/shared-ui';
@ -49,7 +50,16 @@
let scenes = $derived(workbenchScenesStore.scenes);
let activeSceneId = $derived(workbenchScenesStore.activeSceneId);
let openApps = $derived(workbenchScenesStore.openApps);
// Soft-filter the scene's open apps so gated modules don't render
// for users who aren't allowed to use them. The store still holds
// the full list — that way a founder-tier scene migrated to a
// guest device just hides the gated tabs locally instead of
// destructively deleting them from sync. When the user upgrades
// (or signs in) the apps reappear automatically.
let openApps = $derived(
workbenchScenesStore.openApps.filter((a) => isAppAccessible(a.appId, authStore.user?.tier))
);
// ── Map openApps → CarouselPage[] ───────────────────────
let carouselPages = $derived<CarouselPage[]>(
@ -229,6 +239,7 @@
onSelect={handleAddApp}
onClose={() => (showPicker = false)}
activeAppIds={openApps.map((a) => a.appId)}
userTier={authStore.user?.tier}
/>
{/snippet}
</PageCarousel>