From 76d11a84ee848578c702ab821968381a299df2e8 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 19 Apr 2026 17:38:06 +0200 Subject: [PATCH] feat(auth): server-side tier gating via requireTier middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude/guidelines/authentication.md | 21 +++++++++ apps/api/src/index.ts | 28 ++++++++++++ packages/shared-hono/package.json | 1 + packages/shared-hono/src/auth.ts | 24 ++++++++-- packages/shared-hono/src/index.ts | 3 +- packages/shared-hono/src/tier.test.ts | 63 +++++++++++++++++++++++++++ packages/shared-hono/src/tier.ts | 59 +++++++++++++++++++++++++ packages/shared-hono/src/types.ts | 9 ++++ packages/shared-hono/tsconfig.json | 2 +- pnpm-lock.yaml | 3 ++ 10 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 packages/shared-hono/src/tier.test.ts create mode 100644 packages/shared-hono/src/tier.ts diff --git a/.claude/guidelines/authentication.md b/.claude/guidelines/authentication.md index 7e3ce3672..7080d8683 100644 --- a/.claude/guidelines/authentication.md +++ b/.claude/guidelines/authentication.md @@ -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=` 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', }; ``` diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 04347ed47..82db2e7de 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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 ────────────────────────────────────────── diff --git a/packages/shared-hono/package.json b/packages/shared-hono/package.json index 1d330a12d..be5a0bed7 100644 --- a/packages/shared-hono/package.json +++ b/packages/shared-hono/package.json @@ -28,6 +28,7 @@ "postgres": "^3.4.5" }, "devDependencies": { + "@types/bun": "latest", "@types/node": "^24.10.1", "typescript": "^5.9.3" } diff --git a/packages/shared-hono/src/auth.ts b/packages/shared-hono/src/auth.ts index a39bf02dc..71087ec5c 100644 --- a/packages/shared-hono/src/auth.ts +++ b/packages/shared-hono/src/auth.ts @@ -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 = 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; c.set('userId', payload.sub); - c.set('userEmail', (payload as Record).email ?? ''); - c.set('userRole', (payload as Record).role ?? 'user'); - c.set('sessionId', (payload as Record).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; diff --git a/packages/shared-hono/src/index.ts b/packages/shared-hono/src/index.ts index fda2d4558..9e4046a44 100644 --- a/packages/shared-hono/src/index.ts +++ b/packages/shared-hono/src/index.ts @@ -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'; diff --git a/packages/shared-hono/src/tier.test.ts b/packages/shared-hono/src/tier.test.ts new file mode 100644 index 000000000..d41144281 --- /dev/null +++ b/packages/shared-hono/src/tier.test.ts @@ -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); + }); +}); diff --git a/packages/shared-hono/src/tier.ts b/packages/shared-hono/src/tier.ts new file mode 100644 index 000000000..b6a6c5baa --- /dev/null +++ b/packages/shared-hono/src/tier.ts @@ -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 = { + 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(); + }; +} diff --git a/packages/shared-hono/src/types.ts b/packages/shared-hono/src/types.ts index 4bd2e3848..6cfa34ac3 100644 --- a/packages/shared-hono/src/types.ts +++ b/packages/shared-hono/src/types.ts @@ -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; } diff --git a/packages/shared-hono/tsconfig.json b/packages/shared-hono/tsconfig.json index d84bd5f24..003fb4525 100644 --- a/packages/shared-hono/tsconfig.json +++ b/packages/shared-hono/tsconfig.json @@ -8,7 +8,7 @@ "skipLibCheck": true, "outDir": "dist", "declaration": true, - "types": ["node"] + "types": ["node", "bun"] }, "include": ["src/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1107ae999..5916d46c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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