Phase 6: Tier-Awareness + mana-credits-Client (Plumbing)
Some checks are pending
CI / validate (push) Waiting to run
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:
parent
506aec3357
commit
76d4e9208e
2 changed files with 147 additions and 10 deletions
|
|
@ -15,7 +15,25 @@ import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
* Implementations-Notiz: jose ist die offizielle Lib auch von mana-auth
|
* Implementations-Notiz: jose ist die offizielle Lib auch von mana-auth
|
||||||
* (siehe services/mana-auth/src/middleware/jwt-auth.ts).
|
* (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 MANA_AUTH_URL = process.env.MANA_AUTH_URL ?? 'https://auth.mana.how';
|
||||||
const ALLOW_DEV_STUB = process.env.CARDS_AUTH_DEV_STUB !== 'false';
|
const ALLOW_DEV_STUB = process.env.CARDS_AUTH_DEV_STUB !== 'false';
|
||||||
|
|
@ -28,14 +46,25 @@ function getJwks() {
|
||||||
return jwksCache;
|
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 {
|
try {
|
||||||
const { payload } = await jwtVerify(token, getJwks(), {
|
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 sub = typeof payload.sub === 'string' ? payload.sub : null;
|
const sub = typeof payload.sub === 'string' ? payload.sub : null;
|
||||||
return sub;
|
if (!sub) return null;
|
||||||
|
return { sub, tier: tierFromClaim(payload.tier) };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -45,9 +74,11 @@ export const authMiddleware: MiddlewareHandler<{ Variables: AuthVars }> = async
|
||||||
const authHeader = c.req.header('Authorization');
|
const authHeader = c.req.header('Authorization');
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
const token = authHeader.slice(7).trim();
|
const token = authHeader.slice(7).trim();
|
||||||
const sub = await verifyBearer(token);
|
const claims = await verifyBearer(token);
|
||||||
if (sub) {
|
if (claims) {
|
||||||
c.set('userId', sub);
|
c.set('userId', claims.sub);
|
||||||
|
c.set('tier', claims.tier);
|
||||||
|
c.set('authMode', 'jwt');
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +89,8 @@ export const authMiddleware: MiddlewareHandler<{ Variables: AuthVars }> = async
|
||||||
const userId = c.req.header('X-User-Id');
|
const userId = c.req.header('X-User-Id');
|
||||||
if (userId) {
|
if (userId) {
|
||||||
c.set('userId', userId);
|
c.set('userId', userId);
|
||||||
|
c.set('tier', 'founder'); // Dev-Stub bekommt founder-Tier — Tests
|
||||||
|
c.set('authMode', 'dev-stub');
|
||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -78,3 +111,24 @@ export const authMiddleware: MiddlewareHandler<{ Variables: AuthVars }> = async
|
||||||
export function getUserId(c: Context<{ Variables: AuthVars }>): string {
|
export function getUserId(c: Context<{ Variables: AuthVars }>): string {
|
||||||
return c.get('userId');
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
83
apps/api/src/services/credits-client.ts
Normal file
83
apps/api/src/services/credits-client.ts
Normal file
|
|
@ -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<string, string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue