feat(auth): server-side tier gating via requireTier middleware

The JWT already carried a `tier` claim but nothing on the server read it
— AuthGate enforcement was client-only, so a valid JWT could hit paid
LLM/research endpoints regardless of the user's access tier.

- shared-hono authMiddleware now extracts `tier` into `c.userTier`,
  defaulting unknown/missing claims to `public` (never silently grants
  higher access).
- New `requireTier(minTier)` middleware + `hasTier`/`getTierLevel`
  helpers. Tier hierarchy (guest < public < beta < alpha < founder) is
  mirrored locally to avoid pulling the Svelte-facing shared-branding
  package into Bun services.
- Applied `requireTier('beta')` as defense-in-depth on resource-heavy
  apps/api modules (chat, context, food, guides, news-research, picture,
  plants, research, traces, who) and the MCP endpoint. Pure CRUD modules
  stay auth-only — access there is gated by ownership, not tier.
- DEV_BYPASS_AUTH now injects `userTier` (defaults to founder, override
  via DEV_USER_TIER).
- Authentication guideline documents the pattern + test suite covers
  hierarchy, passes-at-minimum, and rejection paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-19 17:38:06 +02:00
parent 4efdcfffdb
commit 76d11a84ee
10 changed files with 208 additions and 5 deletions

View file

@ -656,6 +656,25 @@ Response:
}
```
## Server-Side Tier Gating
The JWT carries a `tier` claim (`guest | public | beta | alpha | founder`) sourced from `auth.users.access_tier`. Client-side `AuthGate` enforcement is not enough — a user can still call the API directly with their token. For any endpoint that consumes shared infrastructure (LLM calls, external search, image/video generation), add a server-side `requireTier` gate on top of `authMiddleware`.
```typescript
import { authMiddleware, requireTier } from '@mana/shared-hono';
app.use('/api/*', authMiddleware());
app.use('/api/v1/research/*', requireTier('beta'));
app.use('/api/v1/picture/*', requireTier('beta'));
```
Rules:
- Apply at the module-group level in `index.ts`, not inside handlers — easy to audit in one place.
- `requireTier` always runs after `authMiddleware`; it relies on the `userTier` context variable the middleware sets.
- Missing / unknown `tier` claims default to `public`, so a malformed JWT cannot accidentally grant `alpha`.
- Pure CRUD modules that only expose a user's own records don't need a tier gate — the access check is ownership, not tier.
- `DEV_BYPASS_AUTH=true` sets `userTier=founder` by default; override with `DEV_USER_TIER=<tier>` when testing rejection paths locally.
## Development Bypass
For local development, you can bypass auth:
@ -663,6 +682,7 @@ For local development, you can bypass auth:
```env
DEV_BYPASS_AUTH=true
DEV_USER_ID=dev-user-123
DEV_USER_TIER=founder # optional — defaults to founder
```
The guard will inject a mock user:
@ -673,6 +693,7 @@ request.user = {
userId: process.env.DEV_USER_ID || 'dev-user',
email: 'dev@example.com',
role: 'user',
tier: process.env.DEV_USER_TIER || 'founder',
};
```

View file

@ -13,6 +13,7 @@ import {
errorHandler,
notFoundHandler,
rateLimitMiddleware,
requireTier,
type AuthVariables,
} from '@mana/shared-hono';
@ -57,8 +58,35 @@ app.route('/api/v1/wetter', wetterRoutes);
app.use('/api/*', authMiddleware());
// ─── Tier Gating ────────────────────────────────────────────
// Defense-in-depth on top of per-route credits validation.
// Routes that call LLMs, image-gen, or external search APIs are gated
// to `beta`+ so that unauthenticated guest fallbacks (tier='public'
// from a missing claim) can't hit paid infrastructure.
// Pure CRUD modules (calendar, contacts, music, storage, todo, news,
// presi, moodlit) rely on authMiddleware alone — users access only
// their own records.
const RESOURCE_MODULES = [
'chat',
'context',
'food',
'guides',
'news-research',
'picture',
'plants',
'research',
'traces',
'who',
] as const;
for (const mod of RESOURCE_MODULES) {
app.use(`/api/v1/${mod}/*`, requireTier('beta'));
}
// ─── MCP Endpoint ──────────────────────────────────────────
// Streamable HTTP transport: POST (messages), GET (SSE stream), DELETE (close)
// MCP exposes the full tool catalog including LLM/research tools, so it
// gets the same minimum tier.
app.use('/api/v1/mcp', requireTier('beta'));
app.all('/api/v1/mcp', (c) => handleMcpRequest(c.req.raw, c.get('userId')));
// ─── Module Routes ──────────────────────────────────────────

View file

@ -28,6 +28,7 @@
"postgres": "^3.4.5"
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^24.10.1",
"typescript": "^5.9.3"
}

View file

@ -10,6 +10,21 @@
import type { Context, Next } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { AccessTier } from './types';
const VALID_TIERS: ReadonlySet<AccessTier> = new Set([
'guest',
'public',
'beta',
'alpha',
'founder',
]);
function readTierClaim(raw: unknown): AccessTier {
return typeof raw === 'string' && VALID_TIERS.has(raw as AccessTier)
? (raw as AccessTier)
: 'public';
}
const AUTH_URL = () => process.env.MANA_AUTH_URL ?? 'http://localhost:3001';
const SERVICE_KEY = () => process.env.MANA_SERVICE_KEY ?? '';
@ -65,6 +80,7 @@ export function authMiddleware() {
c.set('userId', process.env.DEV_USER_ID ?? '00000000-0000-0000-0000-000000000000');
c.set('userEmail', 'dev@example.com');
c.set('userRole', 'user');
c.set('userTier', readTierClaim(process.env.DEV_USER_TIER ?? 'founder'));
return next();
}
@ -88,10 +104,12 @@ export function authMiddleware() {
throw new HTTPException(401, { message: 'Token missing subject claim' });
}
const claims = payload as Record<string, unknown>;
c.set('userId', payload.sub);
c.set('userEmail', (payload as Record<string, unknown>).email ?? '');
c.set('userRole', (payload as Record<string, unknown>).role ?? 'user');
c.set('sessionId', (payload as Record<string, unknown>).sid ?? '');
c.set('userEmail', claims.email ?? '');
c.set('userRole', claims.role ?? 'user');
c.set('userTier', readTierClaim(claims.tier));
c.set('sessionId', claims.sid ?? '');
return next();
} catch (err) {
if (err instanceof HTTPException) throw err;

View file

@ -33,6 +33,7 @@
*/
export { authMiddleware, serviceAuthMiddleware } from './auth';
export { requireTier, hasTier, getTierLevel } from './tier';
export { createDb } from './db';
export type { DbOptions } from './db';
export { healthRoute } from './health';
@ -43,4 +44,4 @@ export type { CreditBalance, CreditValidationResult } from './credits';
export { rateLimitMiddleware } from './rate-limit';
export { requestLogger, initLogger } from './logger';
export { logger } from '@mana/shared-logger';
export type { CurrentUserData, AuthVariables } from './types';
export type { CurrentUserData, AuthVariables, AccessTier } from './types';

View file

@ -0,0 +1,63 @@
import { describe, it, expect } from 'bun:test';
import { Hono } from 'hono';
import type { AccessTier, AuthVariables } from './types';
import { getTierLevel, hasTier, requireTier } from './tier';
describe('tier helpers', () => {
it('orders tiers guest < public < beta < alpha < founder', () => {
expect(getTierLevel('guest')).toBeLessThan(getTierLevel('public'));
expect(getTierLevel('public')).toBeLessThan(getTierLevel('beta'));
expect(getTierLevel('beta')).toBeLessThan(getTierLevel('alpha'));
expect(getTierLevel('alpha')).toBeLessThan(getTierLevel('founder'));
});
it('treats unknown and missing tiers as 0', () => {
expect(getTierLevel(undefined)).toBe(0);
expect(getTierLevel('nobody')).toBe(0);
});
it('hasTier returns true when user tier meets or exceeds min', () => {
expect(hasTier('founder', 'alpha')).toBe(true);
expect(hasTier('alpha', 'alpha')).toBe(true);
expect(hasTier('beta', 'alpha')).toBe(false);
expect(hasTier(undefined, 'public')).toBe(false);
});
});
function buildApp(presetTier: AccessTier | undefined) {
const app = new Hono<{ Variables: AuthVariables }>();
app.use('*', async (c, next) => {
if (presetTier !== undefined) c.set('userTier', presetTier);
return next();
});
app.get('/protected', requireTier('beta'), (c) => c.text('ok'));
return app;
}
describe('requireTier middleware', () => {
it('passes when the user tier exceeds the minimum', async () => {
const res = await buildApp('alpha').request('/protected');
expect(res.status).toBe(200);
expect(await res.text()).toBe('ok');
});
it('passes when the user tier matches the minimum exactly', async () => {
const res = await buildApp('beta').request('/protected');
expect(res.status).toBe(200);
});
it('rejects with 403 when the user tier is below the minimum', async () => {
const res = await buildApp('public').request('/protected');
expect(res.status).toBe(403);
});
it('rejects with 403 when no tier is set on the context (defensive default)', async () => {
const res = await buildApp(undefined).request('/protected');
expect(res.status).toBe(403);
});
it('rejects a guest caller', async () => {
const res = await buildApp('guest').request('/protected');
expect(res.status).toBe(403);
});
});

View file

@ -0,0 +1,59 @@
/**
* Access tier helpers for server-side gating.
*
* Mirrors the hierarchy in @mana/shared-branding/mana-apps.ts. Kept here so
* Bun/Hono services don't need to pull in a Svelte-facing package.
*
* If you change the hierarchy, update BOTH files.
*/
import type { Context, Next } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { AccessTier } from './types';
const TIER_LEVELS: Record<AccessTier, number> = {
guest: 0,
public: 1,
beta: 2,
alpha: 3,
founder: 4,
};
function normalizeTier(value: unknown): AccessTier {
if (typeof value === 'string' && value in TIER_LEVELS) {
return value as AccessTier;
}
return 'public';
}
export function getTierLevel(tier: string | undefined): number {
if (!tier) return 0;
return TIER_LEVELS[tier as AccessTier] ?? 0;
}
export function hasTier(userTier: string | undefined, minTier: AccessTier): boolean {
return getTierLevel(userTier) >= TIER_LEVELS[minTier];
}
/**
* Require a minimum access tier on a Hono route.
*
* Must run AFTER `authMiddleware()` so `userTier` is set on the context.
*
* Usage:
* ```ts
* app.use('/api/*', authMiddleware());
* app.post('/api/v1/ai/generate', requireTier('alpha'), handler);
* ```
*/
export function requireTier(minTier: AccessTier) {
return async (c: Context, next: Next) => {
const userTier = normalizeTier(c.get('userTier'));
if (!hasTier(userTier, minTier)) {
throw new HTTPException(403, {
message: `Requires access tier '${minTier}' — current tier '${userTier}' is insufficient.`,
});
}
return next();
};
}

View file

@ -1,3 +1,10 @@
/**
* Access tier hierarchy mirrored from @mana/shared-branding.
* Kept local here to avoid pulling a Svelte-facing package into Bun servers.
* If you add a tier, update BOTH this file and shared-branding/mana-apps.ts.
*/
export type AccessTier = 'guest' | 'public' | 'beta' | 'alpha' | 'founder';
/**
* User data extracted from a verified JWT token.
* Compatible with @mana/shared-nestjs-auth CurrentUserData.
@ -6,6 +13,7 @@ export interface CurrentUserData {
userId: string;
email: string;
role: string;
tier: AccessTier;
sessionId?: string;
}
@ -16,5 +24,6 @@ export interface AuthVariables {
userId: string;
userEmail: string;
userRole: string;
userTier: AccessTier;
sessionId?: string;
}

View file

@ -8,7 +8,7 @@
"skipLibCheck": true,
"outDir": "dist",
"declaration": true,
"types": ["node"]
"types": ["node", "bun"]
},
"include": ["src/**/*.ts"]
}

3
pnpm-lock.yaml generated
View file

@ -2883,6 +2883,9 @@ importers:
specifier: ^3.4.5
version: 3.4.9
devDependencies:
'@types/bun':
specifier: latest
version: 1.3.12
'@types/node':
specifier: ^24.10.1
version: 24.12.2