feat(core): Verschluesselung, Validierung und gemeinsame Helfer

- crypto.ts: AES-256-GCM fuer die Git-Access-Tokens, Schluessel per scrypt
  aus APP_SECRET abgeleitet. Chiffrat ist versioniert (v1.<iv>.<tag>.<data>),
  damit ein spaeterer Formatwechsel moeglich bleibt. Fehlt APP_SECRET, warnt
  die App und faellt auf einen Entwicklungsschluessel zurueck.
- validation.ts: Zod-Schemas fuer alle Eingaben an der API-Grenze, inklusive
  Umwandlung leerer Strings zu null und Feldfehlern fuer die Oberflaeche.
- status.ts: Metadaten zu Status, Prioritaeten und Akzentfarben an einer
  Stelle, damit Board, Karte und Detailseite nicht auseinanderlaufen.
- utils.ts: Slug-Erzeugung, deutsche Datumsformate mit fester Zeitzone
  (verhindert Abweichungen zwischen Server-Render und Hydration) und
  relative Zeitangaben.
- projects.ts: wiederverwendete Prisma-Selects. Die Verbindungsauswahl laesst
  tokenCipher bewusst weg, damit kein Token in eine Client-Komponente gelangt.
- db.ts: Prisma-Client mit Cache ueber globalThis gegen Verbindungslecks beim
  Hot Reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 19:33:35 +02:00
co-authored by Claude Opus 5
parent 58c493ddbd
commit c9e09a2762
7 changed files with 473 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import { z } from "zod";
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
const ACCENTS = ["violet", "cyan", "emerald", "amber", "rose", "blue"] as const;
const emptyToNull = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? null : value;
export const projectCreateSchema = z.object({
name: z.string().trim().min(1, "Name darf nicht leer sein").max(120),
summary: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()),
description: z.preprocess(emptyToNull, z.string().trim().max(20000).nullable().optional()),
status: z.enum(STATUS).default("IDEA"),
priority: z.coerce.number().int().min(1).max(4).default(2),
progress: z.coerce.number().int().min(0).max(100).default(0),
accent: z.enum(ACCENTS).default("violet"),
tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]),
favorite: z.boolean().default(false),
repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()),
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
});
export const projectUpdateSchema = projectCreateSchema.partial();
export const noteCreateSchema = z.object({
title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()),
content: z.string().trim().min(1, "Notiz darf nicht leer sein").max(20000),
pinned: z.boolean().default(false),
});
export const noteUpdateSchema = noteCreateSchema.partial();
export const connectionCreateSchema = z.object({
name: z.string().trim().min(1, "Name darf nicht leer sein").max(80),
provider: z.enum(PROVIDERS),
baseUrl: z
.string()
.trim()
.min(1, "Basis-URL fehlt")
.max(300)
.refine((value) => /^https?:\/\//i.test(value), "URL muss mit http:// oder https:// beginnen"),
username: z.preprocess(emptyToNull, z.string().trim().max(120).nullable().optional()),
token: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()),
});
export const connectionUpdateSchema = connectionCreateSchema.partial();
export type ProjectCreateInput = z.infer<typeof projectCreateSchema>;
export type ProjectUpdateInput = z.infer<typeof projectUpdateSchema>;
export type NoteCreateInput = z.infer<typeof noteCreateSchema>;
export type ConnectionCreateInput = z.infer<typeof connectionCreateSchema>;
/** Formt Zod-Fehler in ein flaches Objekt fuer die API-Antwort. */
export function formatZodError(error: z.ZodError): Record<string, string> {
const result: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".") || "_";
if (!result[key]) result[key] = issue.message;
}
return result;
}