From b737240ec161e72ec7fefcac8e6912081738b504 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 30 Mar 2026 21:50:06 +0200 Subject: [PATCH] feat(auth): add access tier system for phased app releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a tiered access control system so apps can be released gradually (founder → alpha → beta → public) without extra infrastructure. Users are gated at the AuthGate level based on their tier vs the app's requiredTier. All apps remain deployed and reachable, but only users with sufficient tier can enter. - Add accessTier enum + column to users schema (default: 'public') - Add tier claim to JWT payload in better-auth config - Add requiredTier field to ManaApp interface + all 25 apps - Add hasAppAccess(), getAccessibleManaApps(), ACCESS_TIER_LABELS - Update AuthGate with tier check + access denied screen - Update getPillAppItems + Home page to filter by user tier - Update all 22 app layouts to pass user tier to PillNav - Add admin API: GET/PUT /api/v1/admin/users/:id/tier - Document access tier system in CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 76 +++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../web/src/routes/(protected)/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 4 +- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 4 +- .../web/src/routes/(app)/home/+page.svelte | 20 +-- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 4 +- .../web/src/routes/(protected)/+layout.svelte | 2 +- .../apps/web/src/routes/app/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../apps/web/src/routes/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 4 +- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../web/src/routes/(protected)/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../apps/web/src/routes/(app)/+layout.svelte | 2 +- .../src/components/AuthGate.svelte | 152 +++++++++++++++++- packages/shared-auth/src/core/jwtUtils.ts | 1 + packages/shared-auth/src/types/index.ts | 2 + packages/shared-branding/src/index.ts | 5 + packages/shared-branding/src/mana-apps.ts | 96 ++++++++++- .../mana-auth/src/auth/better-auth.config.ts | 4 + services/mana-auth/src/db/schema/auth.ts | 11 ++ services/mana-auth/src/index.ts | 6 + services/mana-auth/src/routes/admin.ts | 108 +++++++++++++ 33 files changed, 494 insertions(+), 39 deletions(-) create mode 100644 services/mana-auth/src/routes/admin.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9cd9bf3bc..fcc509a05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -418,6 +418,82 @@ When adding a new app that should participate in cross-app SSO, update **all thr Missing any of these will silently break SSO for that app. +### Access Tier System (Phased Release) + +Apps can be gated behind access tiers for phased rollouts (e.g., founder-only alpha, then beta, then public). + +#### Tier Hierarchy + +| Tier | Level | Who | +|------|-------|-----| +| `guest` | 0 | Unauthenticated visitors (local-only) | +| `public` | 1 | Any registered user (default for new signups) | +| `beta` | 2 | Beta testers | +| `alpha` | 3 | Alpha testers / internal | +| `founder` | 4 | Founding members | + +A user can access an app if their tier level >= the app's `requiredTier` level. + +#### How It Works + +1. **`mana-apps.ts`** defines `requiredTier` per app (e.g., `requiredTier: 'founder'`) +2. **Users table** stores `accessTier` column (default: `'public'`) +3. **JWT** includes a `tier` claim, set during token creation in better-auth config +4. **AuthGate** checks the tier client-side and shows an "access restricted" state if insufficient + +#### Key Files + +| File | Purpose | +|------|---------| +| `packages/shared-branding/src/mana-apps.ts` | App registry with `requiredTier` | +| `services/mana-auth/src/db/schema/auth.ts` | `accessTier` column on users | +| `services/mana-auth/src/auth/better-auth.config.ts` | Adds `tier` to JWT claims | +| `packages/shared-auth-ui/src/components/AuthGate.svelte` | Client-side tier gating | +| `services/mana-auth/src/routes/admin.ts` | Admin API for tier management | + +#### Gating an App + +Pass `requiredTier` to AuthGate in the app's layout: + +```svelte + + + +``` + +The tier value comes from the app's entry in `mana-apps.ts`. Apps without `requiredTier` default to `'public'` (accessible to all registered users). + +#### Admin API + +```bash +# Set a user's tier +PUT /api/v1/admin/users/:id/tier +{ "tier": "beta" } + +# Get a user's tier +GET /api/v1/admin/users/:id/tier + +# List users (includes tier) +GET /api/v1/admin/users +``` + +#### Releasing an App + +To widen access, change `requiredTier` in `mana-apps.ts`: + +```typescript +// Founder-only alpha +{ id: 'myapp', requiredTier: 'founder' } + +// Open to beta testers +{ id: 'myapp', requiredTier: 'beta' } + +// Public release +{ id: 'myapp', requiredTier: 'public' } +``` + +No database migration needed -- just update the config and redeploy the app. + ### Search Architecture Projects requiring web search and content extraction use **mana-search** as the central search service: diff --git a/apps/calc/apps/web/src/routes/(app)/+layout.svelte b/apps/calc/apps/web/src/routes/(app)/+layout.svelte index d99169539..26be54c01 100644 --- a/apps/calc/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calc/apps/web/src/routes/(app)/+layout.svelte @@ -53,7 +53,7 @@ } } - const appItems = getPillAppItems('calc'); + let appItems = $derived(getPillAppItems('calc', undefined, undefined, authStore.user?.tier)); let { children } = $props(); diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 80b56adae..1fa15cd80 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -70,7 +70,7 @@ let showGuestWelcome = $state(false); // App switcher items - const appItems = getPillAppItems('calendar'); + let appItems = $derived(getPillAppItems('calendar', undefined, undefined, authStore.user?.tier)); // Split-Panel Store für Split-Screen Feature const splitPanel = setSplitPanelContext('calendar', DEFAULT_APPS); diff --git a/apps/chat/apps/web/src/routes/(protected)/+layout.svelte b/apps/chat/apps/web/src/routes/(protected)/+layout.svelte index 1c2f66c94..eb8118b6d 100644 --- a/apps/chat/apps/web/src/routes/(protected)/+layout.svelte +++ b/apps/chat/apps/web/src/routes/(protected)/+layout.svelte @@ -44,7 +44,7 @@ setContext('tags', allTags); // App switcher items - const appItems = getPillAppItems('chat'); + let appItems = $derived(getPillAppItems('chat', undefined, undefined, authStore.user?.tier)); let { children, data }: { children: any; data: LayoutData } = $props(); diff --git a/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte b/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte index f4adc1941..fdbdbde66 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/+layout.svelte @@ -24,7 +24,9 @@ import { setLocale, supportedLocales } from '$lib/i18n'; import { api } from '$lib/api'; - const appItems = getPillAppItems('citycorners'); + let appItems = $derived( + getPillAppItems('citycorners', undefined, undefined, authStore.user?.tier) + ); const allTags = useAllSharedTags(); setContext('tags', allTags); diff --git a/apps/clock/apps/web/src/routes/(app)/+layout.svelte b/apps/clock/apps/web/src/routes/(app)/+layout.svelte index 6bd014917..59ed789d6 100644 --- a/apps/clock/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/clock/apps/web/src/routes/(app)/+layout.svelte @@ -59,7 +59,7 @@ } // App switcher items - const appItems = getPillAppItems('clock'); + let appItems = $derived(getPillAppItems('clock', undefined, undefined, authStore.user?.tier)); let { children } = $props(); diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index 81a9dfd99..cb5173cc7 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -82,7 +82,7 @@ const modalContactId = $derived(contactDetailMatch?.[1] || null); // App switcher items - const appItems = getPillAppItems('contacts'); + let appItems = $derived(getPillAppItems('contacts', undefined, undefined, authStore.user?.tier)); // Split-Panel Store für Split-Screen Feature const splitPanel = setSplitPanelContext('contacts', DEFAULT_APPS); diff --git a/apps/context/apps/web/src/routes/(app)/+layout.svelte b/apps/context/apps/web/src/routes/(app)/+layout.svelte index a3097d448..c472e8d9d 100644 --- a/apps/context/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/context/apps/web/src/routes/(app)/+layout.svelte @@ -39,7 +39,7 @@ useAllTags as useAllSharedTags, } from '@manacore/shared-stores'; - const appItems = getPillAppItems('context'); + let appItems = $derived(getPillAppItems('context', undefined, undefined, authStore.user?.tier)); const allTags = useAllSharedTags(); setContext('tags', allTags); diff --git a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte index 745a8df75..1f8e006c8 100644 --- a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte @@ -46,8 +46,8 @@ let { children }: { children: Snippet } = $props(); - // App switcher items - const appItems = getPillAppItems('manacore'); + // App switcher items (filtered by user's access tier) + let appItems = $derived(getPillAppItems('manacore', undefined, undefined, authStore.user?.tier)); let loading = $state(true); let isCollapsed = $state(false); diff --git a/apps/manacore/apps/web/src/routes/(app)/home/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/home/+page.svelte index de90a661f..b0692ba25 100644 --- a/apps/manacore/apps/web/src/routes/(app)/home/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/home/+page.svelte @@ -5,6 +5,7 @@ MANA_APPS, APP_URLS, APP_STATUS_LABELS, + getAccessibleManaApps, type ManaApp, type AppIconId, } from '@manacore/shared-branding'; @@ -20,8 +21,9 @@ let currentLocale = $derived(($locale as 'de' | 'en') || 'de'); let statusLabels = $derived(APP_STATUS_LABELS[currentLocale] || APP_STATUS_LABELS['de']); - // Filter active (non-archived) apps - const activeApps = MANA_APPS.filter((app) => !app.archived); + // Filter apps by user's access tier + let userTier = $derived(authStore.user?.tier || 'public'); + let activeApps = $derived(getAccessibleManaApps(userTier)); // Group apps by category interface AppCategory { @@ -38,13 +40,13 @@ const productivityIds: AppIconId[] = ['todo', 'calendar', 'contacts', 'manadeck', 'inventory']; const utilityIds: AppIconId[] = ['clock', 'zitare', 'storage', 'moodlit', 'matrix']; - function getAppsForCategory(ids: AppIconId[]): ManaApp[] { + function getAppsForCategory(ids: AppIconId[], apps: ManaApp[]): ManaApp[] { return ids - .map((id) => activeApps.find((app) => app.id === id)) + .map((id) => apps.find((app) => app.id === id)) .filter((app): app is ManaApp => !!app); } - const categories: AppCategory[] = [ + let categories = $derived([ { id: 'ai', titleDe: 'KI & Kreativ', @@ -52,7 +54,7 @@ descDe: 'Intelligente Assistenten und kreative Werkzeuge', descEn: 'Intelligent assistants and creative tools', icon: '🤖', - apps: getAppsForCategory(aiAppIds), + apps: getAppsForCategory(aiAppIds, activeApps), }, { id: 'productivity', @@ -61,7 +63,7 @@ descDe: 'Organisiere deinen Alltag', descEn: 'Organize your daily life', icon: '📋', - apps: getAppsForCategory(productivityIds), + apps: getAppsForCategory(productivityIds, activeApps), }, { id: 'utility', @@ -70,9 +72,9 @@ descDe: 'Praktische Helferlein', descEn: 'Handy helpers', icon: '🔧', - apps: getAppsForCategory(utilityIds), + apps: getAppsForCategory(utilityIds, activeApps), }, - ]; + ] satisfies AppCategory[]); function getStatusColor(status: ManaApp['status']): string { const colors = { diff --git a/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte b/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte index 0be91fd81..ef1e3a883 100644 --- a/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte @@ -33,7 +33,7 @@ import { manadeckStore } from '$lib/data/local-store'; // App switcher items - const appItems = getPillAppItems('manadeck'); + let appItems = $derived(getPillAppItems('manadeck', undefined, undefined, authStore.user?.tier)); // Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs) const allDecks = useAllDecks(); diff --git a/apps/matrix/apps/web/src/routes/(app)/+layout.svelte b/apps/matrix/apps/web/src/routes/(app)/+layout.svelte index f2c82fa73..df77def56 100644 --- a/apps/matrix/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/matrix/apps/web/src/routes/(app)/+layout.svelte @@ -78,7 +78,7 @@ } // App switcher items - const appItems = getPillAppItems('matrix'); + let appItems = $derived(getPillAppItems('matrix')); interface Props { children: Snippet; diff --git a/apps/moodlit/apps/web/src/routes/(app)/+layout.svelte b/apps/moodlit/apps/web/src/routes/(app)/+layout.svelte index 80e4b6259..c962f5bb0 100644 --- a/apps/moodlit/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/moodlit/apps/web/src/routes/(app)/+layout.svelte @@ -10,7 +10,7 @@ import { moodlitStore } from '$lib/data/local-store'; let { children } = $props(); - const appItems = getPillAppItems(); + let appItems = $derived(getPillAppItems(undefined, undefined, undefined, authStore.user?.tier)); let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : ''); let showGuestWelcome = $state(false); let isCollapsed = $state(false); diff --git a/apps/mukke/apps/web/src/routes/(app)/+layout.svelte b/apps/mukke/apps/web/src/routes/(app)/+layout.svelte index e18ba634f..12590d4b2 100644 --- a/apps/mukke/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mukke/apps/web/src/routes/(app)/+layout.svelte @@ -47,7 +47,9 @@ setContext('tags', allTags); // App switcher items - const appItems = getPillAppItems('mukke' as any); + let appItems = $derived( + getPillAppItems('mukke' as any, undefined, undefined, authStore.user?.tier) + ); // Split-Panel Store const splitPanel = setSplitPanelContext('mukke' as any, DEFAULT_APPS); diff --git a/apps/news/apps/web/src/routes/(protected)/+layout.svelte b/apps/news/apps/web/src/routes/(protected)/+layout.svelte index b7c398d83..13daffd7e 100644 --- a/apps/news/apps/web/src/routes/(protected)/+layout.svelte +++ b/apps/news/apps/web/src/routes/(protected)/+layout.svelte @@ -12,7 +12,7 @@ let { children } = $props(); - const appItems = getPillAppItems(); + let appItems = $derived(getPillAppItems(undefined, undefined, undefined, authStore.user?.tier)); let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : ''); const navItems: PillNavItem[] = [ diff --git a/apps/picture/apps/web/src/routes/app/+layout.svelte b/apps/picture/apps/web/src/routes/app/+layout.svelte index 53b4d705f..2c819606b 100644 --- a/apps/picture/apps/web/src/routes/app/+layout.svelte +++ b/apps/picture/apps/web/src/routes/app/+layout.svelte @@ -41,7 +41,7 @@ import { browser } from '$app/environment'; // App switcher items - const appItems = getPillAppItems('picture'); + let appItems = $derived(getPillAppItems('picture', undefined, undefined, authStore.user?.tier)); // Live query for shared tags (local-first) const allTags = useAllSharedTags(); diff --git a/apps/presi/apps/web/src/routes/(app)/+layout.svelte b/apps/presi/apps/web/src/routes/(app)/+layout.svelte index d07d46caa..543aa73d1 100644 --- a/apps/presi/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/presi/apps/web/src/routes/(app)/+layout.svelte @@ -28,7 +28,7 @@ import { presiStore } from '$lib/data/local-store'; // App switcher items - const appItems = getPillAppItems('presi'); + let appItems = $derived(getPillAppItems('presi', undefined, undefined, auth.user?.tier)); // Shared tag store (local-first) const allTags = useAllSharedTags(); diff --git a/apps/questions/apps/web/src/routes/(app)/+layout.svelte b/apps/questions/apps/web/src/routes/(app)/+layout.svelte index 1870f27de..ba84ffe33 100644 --- a/apps/questions/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/questions/apps/web/src/routes/(app)/+layout.svelte @@ -40,7 +40,7 @@ const allQuestions = useAllQuestions(); // App switcher items - const appItems = getPillAppItems('questions'); + let appItems = $derived(getPillAppItems('questions', undefined, undefined, authStore.user?.tier)); // Mobile detection let isMobile = $state(false); diff --git a/apps/storage/apps/web/src/routes/+layout.svelte b/apps/storage/apps/web/src/routes/+layout.svelte index 924c46b14..5ae730a3f 100644 --- a/apps/storage/apps/web/src/routes/+layout.svelte +++ b/apps/storage/apps/web/src/routes/+layout.svelte @@ -31,7 +31,7 @@ import '../app.css'; // App switcher items - const appItems = getPillAppItems('storage'); + let appItems = $derived(getPillAppItems('storage', undefined, undefined, authStore.user?.tier)); // Live query for shared tags (local-first) const allTags = useAllSharedTags(); diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index e10e08a9d..8aeefbf73 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -77,8 +77,8 @@ } } - // App switcher items - const appItems = getPillAppItems('todo'); + // App switcher items (filtered by user's access tier) + let appItems = $derived(getPillAppItems('todo', undefined, undefined, authStore.user?.tier)); // Split-Panel Store für Split-Screen Feature const splitPanel = setSplitPanelContext('todo', DEFAULT_APPS); diff --git a/apps/uload/apps/web/src/routes/(app)/+layout.svelte b/apps/uload/apps/web/src/routes/(app)/+layout.svelte index 64dfdb96d..01263380b 100644 --- a/apps/uload/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/uload/apps/web/src/routes/(app)/+layout.svelte @@ -12,7 +12,7 @@ let { children } = $props(); - const appItems = getPillAppItems(); + let appItems = $derived(getPillAppItems(undefined, undefined, undefined, authStore.user?.tier)); let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : ''); diff --git a/apps/wisekeep/apps/web/src/routes/(protected)/+layout.svelte b/apps/wisekeep/apps/web/src/routes/(protected)/+layout.svelte index a2e60652b..86657aeab 100644 --- a/apps/wisekeep/apps/web/src/routes/(protected)/+layout.svelte +++ b/apps/wisekeep/apps/web/src/routes/(protected)/+layout.svelte @@ -10,7 +10,7 @@ import { wisekeepStore } from '$lib/data/local-store'; let { children } = $props(); - const appItems = getPillAppItems(); + let appItems = $derived(getPillAppItems(undefined, undefined, undefined, authStore.user?.tier)); let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : ''); let showGuestWelcome = $state(false); let isCollapsed = $state(false); diff --git a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte index c3e2c23cc..b07bc2f03 100644 --- a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte @@ -54,7 +54,7 @@ let showGuestWelcome = $state(false); // App switcher items - const appItems = getPillAppItems('zitare'); + let appItems = $derived(getPillAppItems('zitare', undefined, undefined, authStore.user?.tier)); // User settings for nav visibility const userSettings = createUserSettingsStore({ diff --git a/games/arcade/apps/web/src/routes/(app)/+layout.svelte b/games/arcade/apps/web/src/routes/(app)/+layout.svelte index 184dc6684..431c5e2e5 100644 --- a/games/arcade/apps/web/src/routes/(app)/+layout.svelte +++ b/games/arcade/apps/web/src/routes/(app)/+layout.svelte @@ -45,7 +45,7 @@ } } - const appItems = getPillAppItems('arcade'); + let appItems = $derived(getPillAppItems('arcade', undefined, undefined, authStore.user?.tier)); let { children } = $props(); diff --git a/packages/shared-auth-ui/src/components/AuthGate.svelte b/packages/shared-auth-ui/src/components/AuthGate.svelte index bf7104ce5..fe923963c 100644 --- a/packages/shared-auth-ui/src/components/AuthGate.svelte +++ b/packages/shared-auth-ui/src/components/AuthGate.svelte @@ -5,17 +5,19 @@ - Auth store initialization - Loading spinner while checking auth - Redirect to login if not authenticated (unless allowGuest) + - Access tier check (blocks apps the user doesn't have access to) - Calling onReady callback after auth is confirmed - Rendering children only when ready Usage: - + --> -{#if !ready} +{#if tierDenied} +
+
+ {#if appName} +

{appName}

+ {/if} +
🔒
+

+ Diese App ist aktuell in der geschlossenen {requiredTierLabel}-Phase. +

+
+
+ Dein Zugang: + {userTierLabel} +
+
+ Benötigt: + {requiredTierLabel} +
+
+
+ +
+
+
+{:else if !ready}
{:else} {@render children()} {/if} + + diff --git a/packages/shared-auth/src/core/jwtUtils.ts b/packages/shared-auth/src/core/jwtUtils.ts index 0eb39c9c9..ce1ca2cf7 100644 --- a/packages/shared-auth/src/core/jwtUtils.ts +++ b/packages/shared-auth/src/core/jwtUtils.ts @@ -78,6 +78,7 @@ export function getUserFromToken(token: string, storedEmail?: string): UserData id: payload.sub, email: email || 'user@example.com', role: payload.role || 'user', + tier: payload.tier || 'public', }; } catch (error) { console.error('Error extracting user from token:', error); diff --git a/packages/shared-auth/src/types/index.ts b/packages/shared-auth/src/types/index.ts index 2f8f9f467..026951119 100644 --- a/packages/shared-auth/src/types/index.ts +++ b/packages/shared-auth/src/types/index.ts @@ -24,6 +24,7 @@ export interface DecodedToken { sub: string; email?: string; role?: string; + tier?: string; exp: number; iat: number; aud?: string; @@ -50,6 +51,7 @@ export interface UserData { id: string; email: string; role: string; + tier: string; } /** diff --git a/packages/shared-branding/src/index.ts b/packages/shared-branding/src/index.ts index 9212348bc..31c158140 100644 --- a/packages/shared-branding/src/index.ts +++ b/packages/shared-branding/src/index.ts @@ -54,12 +54,17 @@ export { getManaAppsByStatus, getAvailableManaApps, getActiveManaApps, + getAccessibleManaApps, + hasAppAccess, + getTierLevel, APP_STATUS_LABELS, APP_SLIDER_LABELS, APP_URLS, + ACCESS_TIER_LABELS, getPillAppItems, type ManaApp, type AppStatus, + type AccessTier, type PillAppItemConfig, } from './mana-apps'; diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index dc3aa75d8..f4efba5a9 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -8,6 +8,56 @@ import type { AppIconId } from './app-icons'; export type AppStatus = 'published' | 'beta' | 'development' | 'planning'; +/** + * Access tier hierarchy (higher number = more access): + * guest(0) < public(1) < beta(2) < alpha(3) < founder(4) + */ +export type AccessTier = 'guest' | 'public' | 'beta' | 'alpha' | 'founder'; + +const TIER_LEVELS: Record = { + guest: 0, + public: 1, + beta: 2, + alpha: 3, + founder: 4, +}; + +/** + * Check if a user's tier meets the required tier for an app + */ +export function hasAppAccess(userTier: string, requiredTier: AccessTier): boolean { + const userLevel = TIER_LEVELS[userTier as AccessTier] ?? 0; + const requiredLevel = TIER_LEVELS[requiredTier] ?? 0; + return userLevel >= requiredLevel; +} + +/** + * Get the numeric level for a tier (for comparisons) + */ +export function getTierLevel(tier: string): number { + return TIER_LEVELS[tier as AccessTier] ?? 0; +} + +/** + * Tier display labels + */ +export const ACCESS_TIER_LABELS = { + de: { + guest: 'Gast', + public: 'Standard', + beta: 'Beta', + alpha: 'Alpha', + founder: 'Founder', + }, + en: { + guest: 'Guest', + public: 'Standard', + beta: 'Beta', + alpha: 'Alpha', + founder: 'Founder', + }, +} as const; + export interface ManaApp { id: AppIconId; name: string; @@ -23,6 +73,8 @@ export interface ManaApp { color: string; comingSoon: boolean; status: AppStatus; + /** Minimum access tier required to use this app */ + requiredTier: AccessTier; url?: string; // Optional URL for the app /** Whether this app is archived (in apps-archived folder) */ archived?: boolean; @@ -48,6 +100,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#0ea5e9', comingSoon: false, status: 'beta', + requiredTier: 'alpha', }, { id: 'memoro', @@ -64,6 +117,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f8d62b', comingSoon: false, status: 'published', + requiredTier: 'founder', archived: true, }, { @@ -81,6 +135,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f97316', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'manadeck', @@ -97,6 +152,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#8b5cf6', comingSoon: true, status: 'development', + requiredTier: 'alpha', }, { id: 'picture', @@ -113,6 +169,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#22c55e', comingSoon: true, status: 'development', + requiredTier: 'alpha', }, { id: 'zitare', @@ -129,6 +186,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f59e0b', comingSoon: true, status: 'development', + requiredTier: 'beta', }, { id: 'wisekeep', @@ -145,6 +203,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#6366f1', comingSoon: true, status: 'planning', + requiredTier: 'founder', archived: true, }, { @@ -162,6 +221,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#10b981', comingSoon: false, status: 'development', + requiredTier: 'founder', archived: true, }, { @@ -179,6 +239,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#3b82f6', comingSoon: false, status: 'development', + requiredTier: 'beta', }, { id: 'calendar', @@ -195,6 +256,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#0ea5e9', comingSoon: false, status: 'development', + requiredTier: 'beta', }, { id: 'storage', @@ -211,6 +273,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#3b82f6', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'clock', @@ -227,6 +290,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f59e0b', comingSoon: false, status: 'development', + requiredTier: 'beta', }, { id: 'todo', @@ -243,6 +307,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#8b5cf6', comingSoon: false, status: 'development', + requiredTier: 'beta', }, { id: 'mail', @@ -259,6 +324,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#6366f1', comingSoon: false, status: 'development', + requiredTier: 'founder', }, { id: 'moodlit', @@ -275,6 +341,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#8b5cf6', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'inventory', @@ -291,6 +358,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#14b8a6', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'questions', @@ -307,6 +375,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#8b5cf6', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'matrix', @@ -323,6 +392,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#8b5cf6', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'context', @@ -339,6 +409,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#0ea5e9', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'times', @@ -355,6 +426,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f59e0b', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'citycorners', @@ -371,6 +443,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#2563eb', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'uload', @@ -387,6 +460,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#6366f1', comingSoon: false, status: 'development', + requiredTier: 'alpha', }, { id: 'reader', @@ -403,6 +477,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#f97316', comingSoon: false, status: 'development', + requiredTier: 'founder', }, { id: 'news', @@ -419,6 +494,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#10b981', comingSoon: false, status: 'development', + requiredTier: 'founder', }, { id: 'calc', @@ -435,6 +511,7 @@ export const MANA_APPS: ManaApp[] = [ color: '#ec4899', comingSoon: false, status: 'development', + requiredTier: 'beta', }, ]; @@ -466,6 +543,14 @@ export function getActiveManaApps(): ManaApp[] { return MANA_APPS.filter((app) => !app.archived); } +/** + * Get apps accessible to a user based on their tier + * Only returns active (non-archived) apps the user has access to + */ +export function getAccessibleManaApps(userTier: string): ManaApp[] { + return MANA_APPS.filter((app) => !app.archived && hasAppAccess(userTier, app.requiredTier)); +} + /** * Status labels in German and English */ @@ -548,21 +633,24 @@ export interface PillAppItemConfig { /** * Get app items for PillNavigation app switcher - * Only returns active (non-archived) apps + * Only returns apps the user has access to (non-archived, tier-gated) * @param currentAppId - The ID of the current app to mark as active * @param isDev - Whether to use development URLs (default: auto-detect) * @param customUrls - Optional custom URL overrides per app + * @param userTier - The user's access tier (default: 'public') */ export function getPillAppItems( currentAppId?: AppIconId, isDev?: boolean, - customUrls?: Partial> + customUrls?: Partial>, + userTier?: string ): PillAppItemConfig[] { const isDevMode = isDev ?? (typeof window !== 'undefined' && window.location.hostname === 'localhost'); - // Only show active (non-archived) apps - return getActiveManaApps().map((app) => ({ + const tier = userTier || 'public'; + // Only show apps the user has access to + return getAccessibleManaApps(tier).map((app) => ({ id: app.id, name: app.name, url: customUrls?.[app.id] || (isDevMode ? APP_URLS[app.id].dev : APP_URLS[app.id].prod), diff --git a/services/mana-auth/src/auth/better-auth.config.ts b/services/mana-auth/src/auth/better-auth.config.ts index e4f4471fb..006e7a953 100644 --- a/services/mana-auth/src/auth/better-auth.config.ts +++ b/services/mana-auth/src/auth/better-auth.config.ts @@ -68,6 +68,9 @@ export interface JWTCustomPayload { /** Session ID for reference */ sid: string; + + /** Access tier for app-level gating (guest, public, beta, alpha, founder) */ + tier: string; } /** @@ -368,6 +371,7 @@ export function createBetterAuth(databaseUrl: string) { email: user.email, role: (user as { role?: string }).role || 'user', sid: session.id, + tier: (user as { accessTier?: string }).accessTier || 'public', }; }, }, diff --git a/services/mana-auth/src/db/schema/auth.ts b/services/mana-auth/src/db/schema/auth.ts index 355f86f71..cdcaf5b24 100644 --- a/services/mana-auth/src/db/schema/auth.ts +++ b/services/mana-auth/src/db/schema/auth.ts @@ -15,6 +15,16 @@ export const authSchema = pgSchema('auth'); // Enum for user roles export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'service']); +// Enum for access tiers (controls which apps a user can access) +// Hierarchy: founder > alpha > beta > public > guest +export const accessTierEnum = pgEnum('access_tier', [ + 'guest', + 'public', + 'beta', + 'alpha', + 'founder', +]); + // Users table (Better Auth schema) export const users = authSchema.table('users', { id: text('id').primaryKey(), // Better Auth generates nanoid @@ -26,6 +36,7 @@ export const users = authSchema.table('users', { updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), // Custom fields (not required by Better Auth) role: userRoleEnum('role').default('user').notNull(), + accessTier: accessTierEnum('access_tier').default('public').notNull(), twoFactorEnabled: boolean('two_factor_enabled').default(false), deletedAt: timestamp('deleted_at', { withTimezone: true }), }); diff --git a/services/mana-auth/src/index.ts b/services/mana-auth/src/index.ts index 3384d138f..5a28169d5 100644 --- a/services/mana-auth/src/index.ts +++ b/services/mana-auth/src/index.ts @@ -21,6 +21,7 @@ import { createAuthRoutes } from './routes/auth'; import { createGuildRoutes } from './routes/guilds'; import { createApiKeyRoutes, createApiKeyValidationRoute } from './routes/api-keys'; import { createMeRoutes } from './routes/me'; +import { createAdminRoutes } from './routes/admin'; // ─── Bootstrap ────────────────────────────────────────────── @@ -81,6 +82,11 @@ app.route('/api/v1/api-keys', createApiKeyValidationRoute(apiKeysService)); app.use('/api/v1/me/*', jwtAuth(config.baseUrl)); app.route('/api/v1/me', createMeRoutes(db)); +// ─── Admin ────────────────────────────────────────────────── + +app.use('/api/v1/admin/*', jwtAuth(config.baseUrl)); +app.route('/api/v1/admin', createAdminRoutes(db)); + // ─── Internal API ─────────────────────────────────────────── app.use('/api/v1/internal/*', serviceAuth(config.serviceKey)); diff --git a/services/mana-auth/src/routes/admin.ts b/services/mana-auth/src/routes/admin.ts new file mode 100644 index 000000000..4fb78a8ae --- /dev/null +++ b/services/mana-auth/src/routes/admin.ts @@ -0,0 +1,108 @@ +/** + * Admin routes — User tier management + * + * Protected by JWT auth + admin role check. + * Only users with role 'admin' can manage tiers. + */ + +import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; +import type { AuthUser } from '../middleware/jwt-auth'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { users } from '../db/schema/auth'; + +const VALID_TIERS = ['guest', 'public', 'beta', 'alpha', 'founder'] as const; +type AccessTier = (typeof VALID_TIERS)[number]; + +export function createAdminRoutes(db: PostgresJsDatabase) { + const app = new Hono<{ Variables: { user: AuthUser } }>(); + + // Admin role check middleware + app.use('*', async (c, next) => { + const user = c.get('user'); + if (user.role !== 'admin') { + return c.json({ error: 'Forbidden', message: 'Admin access required' }, 403); + } + await next(); + }); + + // ─── Update user's access tier ───────────────────────────── + + app.put('/users/:userId/tier', async (c) => { + const { userId } = c.req.param(); + const body = await c.req.json(); + const { tier } = body as { tier: string }; + + if (!tier || !VALID_TIERS.includes(tier as AccessTier)) { + return c.json( + { + error: 'Invalid tier', + message: `Tier must be one of: ${VALID_TIERS.join(', ')}`, + }, + 400 + ); + } + + const [updated] = await db + .update(users) + .set({ accessTier: tier as AccessTier, updatedAt: new Date() }) + .where(eq(users.id, userId)) + .returning({ id: users.id, email: users.email, accessTier: users.accessTier }); + + if (!updated) { + return c.json({ error: 'Not found', message: 'User not found' }, 404); + } + + return c.json({ + success: true, + user: updated, + }); + }); + + // ─── Get user's current tier ─────────────────────────────── + + app.get('/users/:userId/tier', async (c) => { + const { userId } = c.req.param(); + + const [user] = await db + .select({ id: users.id, email: users.email, accessTier: users.accessTier }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) { + return c.json({ error: 'Not found', message: 'User not found' }, 404); + } + + return c.json(user); + }); + + // ─── List all users with their tiers ─────────────────────── + + app.get('/users', async (c) => { + const tier = c.req.query('tier'); + const limit = parseInt(c.req.query('limit') || '50', 10); + const offset = parseInt(c.req.query('offset') || '0', 10); + + let query = db + .select({ + id: users.id, + email: users.email, + name: users.name, + role: users.role, + accessTier: users.accessTier, + createdAt: users.createdAt, + }) + .from(users); + + if (tier && VALID_TIERS.includes(tier as AccessTier)) { + query = query.where(eq(users.accessTier, tier as AccessTier)) as typeof query; + } + + const result = await query.limit(limit).offset(offset); + + return c.json({ users: result, count: result.length }); + }); + + return app; +}