feat(api): Agent-API mit Enrollment, Report und Ping (#004)
Gehashte Tokens mit Praefix, Rate-Limit je IP und Token, zod-Validierung mit durchgaengig optionalen Feldern, idempotente Reports ueber reportId, Key- und Server-ID-Historie sowie automatischer Laufzeit-Vorschlag. 32 Tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
-7
@@ -2,8 +2,10 @@ import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Prisma-Client als Singleton. Im Entwicklungsmodus lädt Next.js Module neu,
|
||||
* ohne den Prozess zu beenden – ohne Singleton entstünden dabei immer neue Verbindungspools.
|
||||
* Prisma-Client als Singleton, der erst beim ersten Zugriff verbindet.
|
||||
* Das ist wichtig, weil Next.js beim Bauen Module lädt, ohne dass `DATABASE_URL` gesetzt sein muss.
|
||||
* Im Entwicklungsmodus verhindert der globale Zwischenspeicher, dass jeder Hot-Reload
|
||||
* einen neuen Verbindungspool öffnet.
|
||||
*/
|
||||
declare global {
|
||||
var proxledgerPrisma: PrismaClient | undefined;
|
||||
@@ -14,11 +16,17 @@ function erzeugeClient(): PrismaClient {
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL ist nicht gesetzt. Beispielwerte stehen in .env.example.");
|
||||
}
|
||||
return new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
|
||||
const client = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
|
||||
if (process.env.NODE_ENV !== "production") globalThis.proxledgerPrisma = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
export const prisma: PrismaClient = globalThis.proxledgerPrisma ?? erzeugeClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.proxledgerPrisma = prisma;
|
||||
export function getPrisma(): PrismaClient {
|
||||
return globalThis.proxledgerPrisma ?? erzeugeClient();
|
||||
}
|
||||
|
||||
export const prisma: PrismaClient = new Proxy({} as PrismaClient, {
|
||||
get(_ziel, eigenschaft, empfaenger) {
|
||||
return Reflect.get(getPrisma(), eigenschaft, empfaenger);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Einfaches Token-Bucket-Rate-Limit im Prozessspeicher.
|
||||
* Für eine einzelne Instanz ausreichend; bei mehreren Instanzen müsste der Zustand
|
||||
* in die Datenbank oder einen gemeinsamen Cache wandern (siehe #014).
|
||||
*/
|
||||
|
||||
interface Eimer {
|
||||
tokens: number;
|
||||
zuletzt: number;
|
||||
}
|
||||
|
||||
export interface RateLimitErgebnis {
|
||||
erlaubt: boolean;
|
||||
/** Sekunden bis zum nächsten erlaubten Versuch (nur wenn abgelehnt). */
|
||||
wiederholenNach: number;
|
||||
verbleibend: number;
|
||||
}
|
||||
|
||||
export class RateLimit {
|
||||
private eimer = new Map<string, Eimer>();
|
||||
|
||||
constructor(
|
||||
private readonly kapazitaet: number,
|
||||
private readonly nachfuellProSekunde: number,
|
||||
) {}
|
||||
|
||||
pruefe(schluessel: string, jetzt: number = Date.now()): RateLimitErgebnis {
|
||||
const vorhanden = this.eimer.get(schluessel) ?? { tokens: this.kapazitaet, zuletzt: jetzt };
|
||||
const vergangen = Math.max(0, (jetzt - vorhanden.zuletzt) / 1000);
|
||||
const tokens = Math.min(this.kapazitaet, vorhanden.tokens + vergangen * this.nachfuellProSekunde);
|
||||
|
||||
if (tokens < 1) {
|
||||
this.eimer.set(schluessel, { tokens, zuletzt: jetzt });
|
||||
return {
|
||||
erlaubt: false,
|
||||
wiederholenNach: Math.ceil((1 - tokens) / this.nachfuellProSekunde),
|
||||
verbleibend: 0,
|
||||
};
|
||||
}
|
||||
|
||||
this.eimer.set(schluessel, { tokens: tokens - 1, zuletzt: jetzt });
|
||||
return { erlaubt: true, wiederholenNach: 0, verbleibend: Math.floor(tokens - 1) };
|
||||
}
|
||||
|
||||
/** Entfernt Einträge, die wieder voll aufgefüllt sind (Aufräumen bei vielen Hosts). */
|
||||
aufraeumen(jetzt: number = Date.now()) {
|
||||
const vollNach = (this.kapazitaet / this.nachfuellProSekunde) * 1000;
|
||||
for (const [schluessel, eimer] of this.eimer) {
|
||||
if (jetzt - eimer.zuletzt > vollNach) this.eimer.delete(schluessel);
|
||||
}
|
||||
}
|
||||
|
||||
zuruecksetzen() {
|
||||
this.eimer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** 10 Anfragen pro Minute je Token bzw. IP – ein Agent meldet sich einmal täglich. */
|
||||
export const agentRateLimit = new RateLimit(10, 10 / 60);
|
||||
|
||||
/** Registrierungen sind seltener und werden strenger begrenzt. */
|
||||
export const enrollRateLimit = new RateLimit(5, 5 / 60);
|
||||
|
||||
/** Ermittelt die Client-IP hinter einem Reverse Proxy. */
|
||||
export function clientIp(headers: Headers): string {
|
||||
const forwarded = headers.get("x-forwarded-for");
|
||||
if (forwarded) return forwarded.split(",")[0].trim();
|
||||
return headers.get("x-real-ip") ?? "unbekannt";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* API-Tokens für Agents. In der Datenbank liegt ausschließlich der SHA-256-Hash:
|
||||
* Die Tokens haben volle Entropie (32 Byte Zufall), deshalb ist kein langsames
|
||||
* Passwort-Hashing nötig – SHA-256 erlaubt zudem das direkte Nachschlagen per Index.
|
||||
*/
|
||||
|
||||
export const PRAEFIX_ENROLLMENT = "plx_e_";
|
||||
export const PRAEFIX_HOST = "plx_h_";
|
||||
|
||||
export interface ErzeugterToken {
|
||||
/** Vollständiger Token im Klartext – wird nur einmal ausgegeben. */
|
||||
klartext: string;
|
||||
hash: string;
|
||||
/** Erkennbarer Anfang für Anzeige und Protokoll, z. B. `plx_h_9f3a2b`. */
|
||||
praefix: string;
|
||||
}
|
||||
|
||||
export function erzeugeToken(typ: "enrollment" | "host"): ErzeugterToken {
|
||||
const kennung = typ === "enrollment" ? PRAEFIX_ENROLLMENT : PRAEFIX_HOST;
|
||||
const zufall = randomBytes(32).toString("base64url");
|
||||
const klartext = `${kennung}${zufall}`;
|
||||
return {
|
||||
klartext,
|
||||
hash: hashToken(klartext),
|
||||
praefix: klartext.slice(0, kennung.length + 6),
|
||||
};
|
||||
}
|
||||
|
||||
export function hashToken(klartext: string): string {
|
||||
return createHash("sha256").update(klartext.trim()).digest("hex");
|
||||
}
|
||||
|
||||
/** Vergleicht zwei Hashes ohne zeitliche Seitenkanäle. */
|
||||
export function hashGleich(a: string, b: string): boolean {
|
||||
const links = Buffer.from(a, "hex");
|
||||
const rechts = Buffer.from(b, "hex");
|
||||
if (links.length !== rechts.length || links.length === 0) return false;
|
||||
return timingSafeEqual(links, rechts);
|
||||
}
|
||||
|
||||
/** Liest den Bearer-Token aus dem Authorization-Header. */
|
||||
export function tokenAusHeader(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const treffer = /^Bearer\s+(.+)$/i.exec(header.trim());
|
||||
return treffer ? treffer[1].trim() : null;
|
||||
}
|
||||
Reference in New Issue
Block a user