feat(auth): add access tier system for phased app releases

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 21:50:06 +02:00
parent 4f68215e68
commit b737240ec1
33 changed files with 494 additions and 39 deletions

View file

@ -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
<AuthGate requiredTier="beta">
<slot />
</AuthGate>
```
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:

View file

@ -53,7 +53,7 @@
}
}
const appItems = getPillAppItems('calc');
let appItems = $derived(getPillAppItems('calc', undefined, undefined, authStore.user?.tier));
let { children } = $props();

View file

@ -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);

View file

@ -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();

View file

@ -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);

View file

@ -59,7 +59,7 @@
}
// App switcher items
const appItems = getPillAppItems('clock');
let appItems = $derived(getPillAppItems('clock', undefined, undefined, authStore.user?.tier));
let { children } = $props();

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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 = {

View file

@ -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();

View file

@ -78,7 +78,7 @@
}
// App switcher items
const appItems = getPillAppItems('matrix');
let appItems = $derived(getPillAppItems('matrix'));
interface Props {
children: Snippet;

View file

@ -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);

View file

@ -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);

View file

@ -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[] = [

View file

@ -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();

View file

@ -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();

View file

@ -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);

View file

@ -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();

View file

@ -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);

View file

@ -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 ?? '') : '');

View file

@ -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);

View file

@ -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({

View file

@ -45,7 +45,7 @@
}
}
const appItems = getPillAppItems('arcade');
let appItems = $derived(getPillAppItems('arcade', undefined, undefined, authStore.user?.tier));
let { children } = $props();

View file

@ -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:
<AuthGate authStore={authStore} onReady={loadAppData}>
<AuthGate authStore={authStore} onReady={loadAppData} requiredTier="beta">
<AppContent />
</AuthGate>
-->
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import { hasAppAccess, ACCESS_TIER_LABELS, type AccessTier } from '@manacore/shared-branding';
/**
* Minimal interface that all app auth stores must satisfy.
@ -24,6 +26,7 @@
interface AuthStoreInterface {
initialize(): Promise<void>;
readonly isAuthenticated: boolean;
readonly user: { tier?: string; email?: string } | null;
}
interface Props {
@ -33,6 +36,10 @@
loginHref?: string;
/** If true, render children even when not authenticated (for guest-mode apps) */
allowGuest?: boolean;
/** Minimum access tier required to use this app */
requiredTier?: AccessTier;
/** App name shown on the access denied screen */
appName?: string;
/** Callback invoked after auth is confirmed, before children are rendered.
* Use this for loading app-specific data (projects, calendars, etc.) */
onReady?: () => void | Promise<void>;
@ -46,12 +53,17 @@
authStore,
loginHref = '/login',
allowGuest = false,
requiredTier,
appName,
onReady,
goto: gotoFn,
children,
}: Props = $props();
let ready = $state(false);
let tierDenied = $state(false);
let userTierLabel = $state('');
let requiredTierLabel = $state('');
function navigate(url: string) {
if (gotoFn) {
@ -61,6 +73,12 @@
}
}
function goHome() {
const isLocal = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const homeUrl = isLocal ? 'http://localhost:5173' : 'https://mana.how';
window.location.href = homeUrl;
}
onMount(async () => {
await authStore.initialize();
@ -69,6 +87,17 @@
return;
}
// Check access tier if required and user is authenticated
if (requiredTier && authStore.isAuthenticated && authStore.user) {
const userTier = authStore.user.tier || 'public';
if (!hasAppAccess(userTier, requiredTier)) {
userTierLabel = ACCESS_TIER_LABELS['de'][userTier as AccessTier] || userTier;
requiredTierLabel = ACCESS_TIER_LABELS['de'][requiredTier] || requiredTier;
tierDenied = true;
return;
}
}
if (onReady) {
await onReady();
}
@ -77,10 +106,129 @@
});
</script>
{#if !ready}
{#if tierDenied}
<div class="tier-denied">
<div class="tier-denied-card">
{#if appName}
<h1 class="tier-denied-title">{appName}</h1>
{/if}
<div class="tier-denied-icon">🔒</div>
<p class="tier-denied-message">
Diese App ist aktuell in der geschlossenen <strong>{requiredTierLabel}</strong>-Phase.
</p>
<div class="tier-denied-info">
<div class="tier-row">
<span class="tier-label">Dein Zugang:</span>
<span class="tier-value">{userTierLabel}</span>
</div>
<div class="tier-row">
<span class="tier-label">Benötigt:</span>
<span class="tier-value tier-required">{requiredTierLabel}</span>
</div>
</div>
<div class="tier-denied-actions">
<button class="tier-btn-primary" onclick={goHome}> Zur Übersicht </button>
</div>
</div>
</div>
{:else if !ready}
<div class="flex items-center justify-center h-screen bg-background">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else}
{@render children()}
{/if}
<style>
.tier-denied {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: hsl(var(--background, 0 0% 100%));
padding: 1.5rem;
}
.tier-denied-card {
max-width: 24rem;
width: 100%;
text-align: center;
padding: 2.5rem 2rem;
border-radius: 1rem;
border: 1px solid hsl(var(--border, 0 0% 90%));
background: hsl(var(--card, 0 0% 100%));
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
}
.tier-denied-title {
font-size: 1.25rem;
font-weight: 700;
color: hsl(var(--foreground, 0 0% 9%));
margin: 0 0 1rem;
}
.tier-denied-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.tier-denied-message {
font-size: 0.9375rem;
color: hsl(var(--muted-foreground, 0 0% 45%));
margin: 0 0 1.5rem;
line-height: 1.5;
}
.tier-denied-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
border-radius: 0.75rem;
background: hsl(var(--muted, 0 0% 96%));
margin-bottom: 1.5rem;
}
.tier-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
}
.tier-label {
color: hsl(var(--muted-foreground, 0 0% 45%));
}
.tier-value {
font-weight: 600;
color: hsl(var(--foreground, 0 0% 9%));
}
.tier-required {
color: #8b5cf6;
}
.tier-denied-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tier-btn-primary {
width: 100%;
padding: 0.625rem 1rem;
border-radius: 0.5rem;
border: none;
background: hsl(var(--primary, 239 84% 67%));
color: hsl(var(--primary-foreground, 0 0% 100%));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.tier-btn-primary:hover {
opacity: 0.9;
}
</style>

View file

@ -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);

View file

@ -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;
}
/**

View file

@ -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';

View file

@ -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<AccessTier, number> = {
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<Record<AppIconId, string>>
customUrls?: Partial<Record<AppIconId, string>>,
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),

View file

@ -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',
};
},
},

View file

@ -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 }),
});

View file

@ -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));

View file

@ -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<any>) {
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;
}