feat: scaffold klarbild foundation (astro+postgres+s3, auth, migrations, health)

- Astro 5 SSR (node standalone) + React, OKLCH tokens (no tailwind)
- migrations/001_init.sql: full schema per 03-datenmodell-api
- lib: db+migrations, crypto (AES-256-GCM), auth (argon2+signed session, ratelimit),
  storage (S3/MinIO, presigned URLs), openrouter (POST /v1/images, cost)
- middleware: init-once + session guard + admin gate; /api/health (db+storage)
- login + studio placeholder; seeds (till/lea, default recipes); Dockerfile
- verified: astro build passes
This commit is contained in:
2026-07-23 11:11:08 +00:00
commit b46dbbe889
26 changed files with 9651 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
// AES-256-GCM. ENCRYPTION_KEY = 64 Hex-Zeichen (32 Byte).
function key(): Buffer {
const hex = process.env.ENCRYPTION_KEY || '';
if (hex.length !== 64) {
throw new Error('ENCRYPTION_KEY muss 64 Hex-Zeichen (32 Byte) lang sein');
}
return Buffer.from(hex, 'hex');
}
/** Verschlüsselt Klartext → "iv.tag.ciphertext" (base64url-Teile). */
export function encrypt(plain: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key(), iv);
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv, tag, enc].map((b) => b.toString('base64url')).join('.');
}
export function decrypt(payload: string): string {
const [ivB, tagB, dataB] = payload.split('.');
const decipher = createDecipheriv('aes-256-gcm', key(), Buffer.from(ivB, 'base64url'));
decipher.setAuthTag(Buffer.from(tagB, 'base64url'));
return Buffer.concat([
decipher.update(Buffer.from(dataB, 'base64url')),
decipher.final(),
]).toString('utf8');
}
/** Maskiert einen Schlüssel für die Anzeige: sk-or-…4f2a */
export function maskSecret(secret: string): string {
if (!secret) return '';
const tail = secret.slice(-4);
const head = secret.slice(0, Math.min(5, secret.length));
return `${head}${tail}`;
}