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

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