Phase 6: Tier-Awareness + mana-credits-Client (Plumbing)
Some checks are pending
CI / validate (push) Waiting to run

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 20:45:08 +02:00
parent 506aec3357
commit 76d4e9208e
2 changed files with 147 additions and 10 deletions

View file

@ -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<Tier, number> = {
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<string | null> {
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<VerifiedClaims | null> {
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();
};
}