From 76d4e9208e35fa178fa9ed00eb0dd25dfbe2f486 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 8 May 2026 20:45:08 +0200 Subject: [PATCH] Phase 6: Tier-Awareness + mana-credits-Client (Plumbing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth-middleware setzt jetzt zusätzlich `tier`-Claim aus dem JWT (public/beta/alpha/founder, default public bei unbekannt) und `authMode` (jwt|dev-stub). Dev-Stub bekommt founder-Tier — Tests und der Anki-Importer (X-User-Id) bleiben unbeeinträchtigt. `requireTier(min)`-Middleware-Helper für künftige Premium-Pfade, liefert 403 mit `{need, have}` bei Tier-Unterlauf. Aktuell nicht auf Cards-Endpoints angewendet — der MVP ist tier-frei für jeden authentifizierten User. Plumbing ist da, sobald Premium-Features auftauchen (AI-Bulk-Convert, Image-Generation, …). services/credits-client.ts: schmaler Service-to-Service-Wrapper um mana-credits (https://credits.mana.how/api/v1/internal/*) für balance / reserve / commit / refund-reservation. Auth via X-Service-Key (Cards-App-Key aus mana-auth, env CARDS_MANA_SERVICE_KEY). Cards-MVP nutzt das nicht produktiv — ist der Pre-Wire-Layer für später. 56 API-Tests grün, type-check sauber. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/middleware/auth.ts | 74 +++++++++++++++++++--- apps/api/src/services/credits-client.ts | 83 +++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/services/credits-client.ts diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 5529bc7..f0ddd46 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -15,7 +15,25 @@ import { createRemoteJWKSet, jwtVerify } from 'jose'; * Implementations-Notiz: jose ist die offizielle Lib auch von mana-auth * (siehe services/mana-auth/src/middleware/jwt-auth.ts). */ -export type AuthVars = { userId: string }; +export type Tier = 'guest' | 'public' | 'beta' | 'alpha' | 'founder'; + +export type AuthVars = { + userId: string; + tier: Tier; + authMode: 'jwt' | 'dev-stub'; +}; + +const TIER_RANK: Record = { + guest: 0, + public: 1, + beta: 2, + alpha: 3, + founder: 4, +}; + +export function tierAtLeast(have: Tier, need: Tier): boolean { + return TIER_RANK[have] >= TIER_RANK[need]; +} const MANA_AUTH_URL = process.env.MANA_AUTH_URL ?? 'https://auth.mana.how'; const ALLOW_DEV_STUB = process.env.CARDS_AUTH_DEV_STUB !== 'false'; @@ -28,14 +46,25 @@ function getJwks() { return jwksCache; } -async function verifyBearer(token: string): Promise { +interface VerifiedClaims { + sub: string; + tier: Tier; +} + +function tierFromClaim(raw: unknown): Tier { + if (typeof raw !== 'string') return 'public'; + if (raw === 'guest' || raw === 'public' || raw === 'beta' || raw === 'alpha' || raw === 'founder') { + return raw; + } + return 'public'; +} + +async function verifyBearer(token: string): Promise { try { - const { payload } = await jwtVerify(token, getJwks(), { - // mana-auth setzt `iss` auf BASE_URL — wir akzeptieren alles vom - // konfigurierten Auth-Host. Strenger Check kann später hier rein. - }); + const { payload } = await jwtVerify(token, getJwks(), {}); const sub = typeof payload.sub === 'string' ? payload.sub : null; - return sub; + if (!sub) return null; + return { sub, tier: tierFromClaim(payload.tier) }; } catch { return null; } @@ -45,9 +74,11 @@ export const authMiddleware: MiddlewareHandler<{ Variables: AuthVars }> = async const authHeader = c.req.header('Authorization'); if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.slice(7).trim(); - const sub = await verifyBearer(token); - if (sub) { - c.set('userId', sub); + const claims = await verifyBearer(token); + if (claims) { + c.set('userId', claims.sub); + c.set('tier', claims.tier); + c.set('authMode', 'jwt'); await next(); return; } @@ -58,6 +89,8 @@ export const authMiddleware: MiddlewareHandler<{ Variables: AuthVars }> = async const userId = c.req.header('X-User-Id'); if (userId) { c.set('userId', userId); + c.set('tier', 'founder'); // Dev-Stub bekommt founder-Tier — Tests + c.set('authMode', 'dev-stub'); await next(); return; } @@ -78,3 +111,24 @@ export const authMiddleware: MiddlewareHandler<{ Variables: AuthVars }> = async export function getUserId(c: Context<{ Variables: AuthVars }>): string { return c.get('userId'); } + +/** + * Tier-Gate für Premium-Features. Nutze als Hono-Middleware: + * r.post('/premium-thing', requireTier('beta'), async (c) => …) + * Liefert 403 wenn der eingeloggte User nicht mindestens den + * verlangten Tier hat. Aktuell auf cards-api nicht aktiv genutzt + * (Cards-MVP ist tier-frei für authentifizierte User), aber das + * Plumbing ist da für künftige Premium-Pfade (z.B. AI-Bulk-Convert). + */ +export function requireTier(min: Tier): MiddlewareHandler<{ Variables: AuthVars }> { + return async (c, next) => { + const have = c.get('tier'); + if (!tierAtLeast(have, min)) { + return c.json( + { error: 'tier_required', need: min, have }, + 403 + ); + } + await next(); + }; +} diff --git a/apps/api/src/services/credits-client.ts b/apps/api/src/services/credits-client.ts new file mode 100644 index 0000000..7fea0fa --- /dev/null +++ b/apps/api/src/services/credits-client.ts @@ -0,0 +1,83 @@ +/** + * Mana-Credits-Client für Service-to-Service-Calls. + * + * Aktuell hat Cards keinen credits-pflichtigen Pfad — der MVP ist + * tier-frei für jeden authentifizierten User. Dieser Client ist + * Plumbing für Premium-Features später (z.B. AI-Bulk-Convert beim + * Anki-Import, Image-Generation für Image-Occlusion-Masken, …). + * + * Spec: mana-credits hat /api/v1/credits/use für User-Auth-Calls und + * /api/v1/internal/credits/* für Service-Auth-Calls (X-Service-Key). + * + * Konfiguration: + * MANA_CREDITS_URL Base-URL (Default https://credits.mana.how) + * CARDS_MANA_SERVICE_KEY Cards-App-Service-Key (aus mana-auth) + */ + +const CREDITS_URL = process.env.MANA_CREDITS_URL ?? 'https://credits.mana.how'; + +function authHeader(): Record { + const key = process.env.CARDS_MANA_SERVICE_KEY; + if (!key) return {}; + return { 'X-Service-Key': key }; +} + +export class CreditsClient { + /** + * Liest den aktuellen Credits-Balance eines Users (Service-Auth). + * Wirft wenn mana-credits nicht erreichbar ist; Caller entscheidet, + * ob fail-open (kein Block) oder fail-closed (Block bei Service-Down). + */ + async getBalance(userId: string): Promise<{ balance: number }> { + const r = await fetch(`${CREDITS_URL}/api/v1/internal/credits/balance/${userId}`, { + headers: { ...authHeader() }, + }); + if (!r.ok) throw new Error(`mana-credits ${r.status}`); + return r.json() as Promise<{ balance: number }>; + } + + /** + * Reserviert Credits — `commit` oder `refund-reservation` muss + * folgen. Pattern für teure asynchrone Operationen. + */ + async reserve(input: { userId: string; amount: number; reason: string }) { + const r = await fetch(`${CREDITS_URL}/api/v1/internal/credits/reserve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeader() }, + body: JSON.stringify({ + user_id: input.userId, + amount: input.amount, + reason: input.reason, + app_id: 'cards', + }), + }); + if (!r.ok) throw new Error(`mana-credits reserve ${r.status}`); + return r.json() as Promise<{ reservation_id: string }>; + } + + async commit(reservationId: string) { + const r = await fetch(`${CREDITS_URL}/api/v1/internal/credits/commit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeader() }, + body: JSON.stringify({ reservation_id: reservationId }), + }); + if (!r.ok) throw new Error(`mana-credits commit ${r.status}`); + return r.json(); + } + + async refundReservation(reservationId: string) { + const r = await fetch(`${CREDITS_URL}/api/v1/internal/credits/refund-reservation`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeader() }, + body: JSON.stringify({ reservation_id: reservationId }), + }); + if (!r.ok) throw new Error(`mana-credits refund ${r.status}`); + return r.json(); + } +} + +let cached: CreditsClient | null = null; +export function getCreditsClient(): CreditsClient { + if (!cached) cached = new CreditsClient(); + return cached; +}