From d5c7974af3b2bce9dfda7ff8fe9665a9e4338076 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 12 Sep 2026 11:09:03 +0200 Subject: [PATCH] feat: Datenmodell und Regeln fuer die oeffentliche Schnittstelle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gespeichert wird nur der Hash eines Tokens. Der Klartext existiert genau einmal — beim Anlegen, auf dem Bildschirm. Ein Token ist ein Passwort; es gibt keinen Grund, es zurueckzulesen, und jeden Grund, es nicht zu koennen. Drei Rechte, und keines schliesst ein anderes ein. Ein Dienst, der nur meldet "hier ist etwas Neues", braucht den Bestand nicht zu sehen — genau das ist der haeufigste Fall und der, bei dem ein mitgelesener Token am wenigsten anrichtet. Der sichtbare Anfang "planer_..." ist Absicht: Wer so einen Wert versehentlich in ein oeffentliches Verzeichnis schiebt, soll ihn beim Durchsehen erkennen. 30 Tests. Co-Authored-By: Claude Opus 5 --- .../20260912082447_api_token/migration.sql | 27 +++ prisma/schema.prisma | 32 ++++ src/lib/api.test.ts | 177 ++++++++++++++++++ src/lib/api.ts | 169 +++++++++++++++++ 4 files changed, 405 insertions(+) create mode 100644 prisma/migrations/20260912082447_api_token/migration.sql create mode 100644 src/lib/api.test.ts create mode 100644 src/lib/api.ts diff --git a/prisma/migrations/20260912082447_api_token/migration.sql b/prisma/migrations/20260912082447_api_token/migration.sql new file mode 100644 index 0000000..e41c224 --- /dev/null +++ b/prisma/migrations/20260912082447_api_token/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "api_token" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "praefix" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "rechte" TEXT NOT NULL, + "gueltigBis" TIMESTAMP(3), + "letzteNutzung" TIMESTAMP(3), + "widerrufenAm" TIMESTAMP(3), + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "api_token_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_token_praefix_key" ON "api_token"("praefix"); + +-- CreateIndex +CREATE UNIQUE INDEX "api_token_hash_key" ON "api_token"("hash"); + +-- CreateIndex +CREATE INDEX "api_token_benutzerId_idx" ON "api_token"("benutzerId"); + +-- AddForeignKey +ALTER TABLE "api_token" ADD CONSTRAINT "api_token_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 772bf88..3c64c3d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,7 @@ model Benutzer { pushGeraete PushGeraet[] meldungen Meldungsnachweis[] telegramChats TelegramChat[] + apiTokens ApiToken[] @@index([aktiv]) @@map("benutzer") @@ -538,3 +539,34 @@ model TelegramKopplung { @@index([gueltigBis]) @@map("telegram_kopplung") } + +/// Zugangstoken fuer die oeffentliche Schnittstelle. +/// +/// Gespeichert wird nur der Hash. Der Klartext existiert genau einmal – beim +/// Anlegen, auf dem Bildschirm. Ein Token ist ein Passwort; wer es verliert, +/// legt ein neues an, statt das alte irgendwo nachzuschlagen. +model ApiToken { + id String @id @default(cuid()) + benutzerId String + + name String + /// Sichtbarer Anfang, macht die Zeile auffindbar und wiedererkennbar. + praefix String @unique + /// SHA-256 des vollstaendigen Tokens. + hash String @unique + + /// Kommagetrennt, siehe src/lib/api.ts. + rechte String + + gueltigBis DateTime? + letzteNutzung DateTime? + /// Gesetzt heisst: gilt nicht mehr. Die Zeile bleibt fuer das Protokoll. + widerrufenAm DateTime? + + erstelltAm DateTime @default(now()) + + benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + + @@index([benutzerId]) + @@map("api_token") +} diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts new file mode 100644 index 0000000..80bd639 --- /dev/null +++ b/src/lib/api.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from 'vitest' +import { + aenderungEingabe, + abfrage, + aufgabeEingabe, + darf, + rechteLesen, + tokenAusKopfzeilen, + tokenZerlegen, + type Recht, +} from '@/lib/api' + +describe('darf', () => { + test('lässt durch, was ausdrücklich erlaubt ist', () => { + expect(darf(['lesen'], 'lesen')).toBe(true) + }) + + test('Erfassen schließt Lesen nicht ein', () => { + // Ein Dienst, der nur meldet „hier ist etwas Neues", braucht den + // Bestand nicht zu sehen. + expect(darf(['erfassen'], 'lesen')).toBe(false) + }) + + test('Schreiben schließt Erfassen nicht ein', () => { + expect(darf(['schreiben'], 'erfassen')).toBe(false) + }) + + test('mehrere Rechte gelten nebeneinander', () => { + const rechte: Recht[] = ['lesen', 'erfassen'] + expect(darf(rechte, 'lesen')).toBe(true) + expect(darf(rechte, 'erfassen')).toBe(true) + expect(darf(rechte, 'schreiben')).toBe(false) + }) +}) + +describe('rechteLesen', () => { + test('liest die gespeicherte Liste', () => { + expect(rechteLesen('lesen,erfassen')).toEqual(['lesen', 'erfassen']) + }) + + test('verträgt Leerzeichen', () => { + expect(rechteLesen(' lesen , schreiben ')).toEqual(['lesen', 'schreiben']) + }) + + test('wirft Unbekanntes weg, statt daran zu scheitern', () => { + // Ein Recht, das es nicht mehr gibt, darf keine Anfrage abbrechen – + // es soll schlicht nicht gelten. + expect(rechteLesen('lesen,zaubern')).toEqual(['lesen']) + }) + + test('aus leer wird leer', () => { + expect(rechteLesen('')).toEqual([]) + }) +}) + +describe('tokenZerlegen', () => { + const gueltig = `planer_ab12cd34_${'x'.repeat(43)}` + + test('zerlegt einen gültigen Token', () => { + expect(tokenZerlegen(gueltig)).toEqual({ + praefix: 'ab12cd34', + geheimnis: 'x'.repeat(43), + }) + }) + + test('verträgt Leerzeichen drumherum', () => { + expect(tokenZerlegen(` ${gueltig} `)?.praefix).toBe('ab12cd34') + }) + + test('lehnt eine fremde Marke ab', () => { + expect(tokenZerlegen(`fremd_ab12cd34_${'x'.repeat(43)}`)).toBeNull() + }) + + test('lehnt ein zu kurzes Präfix ab', () => { + expect(tokenZerlegen(`planer_ab12_${'x'.repeat(43)}`)).toBeNull() + }) + + test('lehnt ein zu kurzes Geheimnis ab', () => { + expect(tokenZerlegen('planer_ab12cd34_kurz')).toBeNull() + }) + + test('lehnt leeren Text ab', () => { + expect(tokenZerlegen('')).toBeNull() + }) +}) + +describe('tokenAusKopfzeilen', () => { + test('liest Bearer', () => { + expect(tokenAusKopfzeilen('Bearer abc', null)).toBe('abc') + }) + + test('liest Bearer auch klein geschrieben', () => { + expect(tokenAusKopfzeilen('bearer abc', null)).toBe('abc') + }) + + test('liest die eigene Kopfzeile', () => { + expect(tokenAusKopfzeilen(null, 'abc')).toBe('abc') + }) + + test('Bearer geht vor', () => { + expect(tokenAusKopfzeilen('Bearer aus-bearer', 'aus-eigener')).toBe('aus-bearer') + }) + + test('ohne beides null', () => { + expect(tokenAusKopfzeilen(null, null)).toBeNull() + }) + + test('leerer Bearer zählt nicht', () => { + expect(tokenAusKopfzeilen('Bearer ', null)).toBeNull() + }) +}) + +describe('aufgabeEingabe', () => { + test('nimmt das Nötigste an', () => { + const gelesen = aufgabeEingabe.safeParse({ titel: 'Angebot schreiben' }) + expect(gelesen.success).toBe(true) + expect(gelesen.success && gelesen.data.ref).toBeNull() + }) + + test('lehnt einen leeren Titel ab', () => { + expect(aufgabeEingabe.safeParse({ titel: ' ' }).success).toBe(false) + }) + + test('lehnt einen unmöglichen Aufwand ab', () => { + // Mehr als ein Tag am Stück ist keine Aufgabe mehr, sondern ein Projekt. + expect(aufgabeEingabe.safeParse({ titel: 'x', aufwandMinuten: 2000 }).success).toBe( + false, + ) + }) + + test('lehnt eine kaputte Fälligkeit ab', () => { + expect(aufgabeEingabe.safeParse({ titel: 'x', faelligAm: 'morgen' }).success).toBe( + false, + ) + }) + + test('kennt Status, Bereich und Projekt nicht', () => { + const gelesen = aufgabeEingabe.safeParse({ + titel: 'x', + status: 'ERLEDIGT', + bereichId: 'b1', + }) + expect(gelesen.success).toBe(true) + expect(gelesen.success && 'status' in gelesen.data).toBe(false) + expect(gelesen.success && 'bereichId' in gelesen.data).toBe(false) + }) +}) + +describe('aenderungEingabe', () => { + test('nimmt eine einzelne Änderung an', () => { + expect(aenderungEingabe.safeParse({ erledigt: true }).success).toBe(true) + }) + + test('lehnt eine leere Änderung ab', () => { + expect(aenderungEingabe.safeParse({}).success).toBe(false) + }) +}) + +describe('abfrage', () => { + test('setzt Voreinstellungen', () => { + const gelesen = abfrage.safeParse({}) + expect(gelesen.success && gelesen.data).toEqual({ + grenze: 25, + versatz: 0, + offen: null, + }) + }) + + test('deckelt die Grenze', () => { + expect(abfrage.safeParse({ grenze: '5000' }).success).toBe(false) + }) + + test('liest offen als Wahrheitswert', () => { + const gelesen = abfrage.safeParse({ offen: 'true' }) + expect(gelesen.success && gelesen.data.offen).toBe(true) + }) +}) diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..89fedc5 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,169 @@ +import { z } from 'zod' + +/** + * Regeln der öffentlichen Schnittstelle. + * + * Ein Token ist ein Passwort, das jemand in eine fremde Anwendung einträgt. + * Deshalb gilt hier dieselbe Vorsicht wie bei der Anmeldung: so wenig Rechte + * wie möglich, jede Nutzung nachvollziehbar, und der Klartext existiert genau + * einmal — beim Anlegen. + * + * Reine Logik ohne Datenbank, ohne Netz und ohne Systemzeit. + */ + +// --- Rechte ---------------------------------------------------------------- + +export const RECHTE = ['lesen', 'erfassen', 'schreiben'] as const +export type Recht = (typeof RECHTE)[number] + +export const RECHT_NAME: Record = { + lesen: 'Lesen', + erfassen: 'Erfassen', + schreiben: 'Ändern', +} + +export const RECHT_BESCHREIBUNG: Record = { + lesen: 'Aufgaben, Bereiche und Projekte abfragen.', + erfassen: 'Neue Aufgaben anlegen — mehr nicht.', + schreiben: 'Bestehende Aufgaben ändern und abhaken.', +} + +/** + * Erfassen schließt Lesen **nicht** ein. + * + * Ein Dienst, der nur meldet „hier ist etwas Neues", braucht den Bestand + * nicht zu sehen. Genau das ist der häufigste Fall — und der, bei dem ein + * mitgelesenes Token am wenigsten anrichtet. + */ +export function darf(rechte: readonly Recht[], gebraucht: Recht): boolean { + return rechte.includes(gebraucht) +} + +export function rechteLesen(gespeichert: string): Recht[] { + return gespeichert + .split(',') + .map((r) => r.trim()) + .filter((r): r is Recht => (RECHTE as readonly string[]).includes(r)) +} + +// --- Aufbau des Tokens ----------------------------------------------------- + +/** + * `planer__` + * + * Das Präfix steht im Klartext in der Datenbank und macht die Zeile + * auffindbar, ohne dass irgendwo der ganze Token liegt. Es taugt zugleich als + * Wiedererkennung in der Liste — „welchen habe ich da eigentlich vergeben?". + * + * Der sichtbare Anfang ist auch der Grund für das Wort „planer" davor: Wer + * einen solchen Wert versehentlich in ein öffentliches Verzeichnis schiebt, + * soll ihn beim Durchsehen erkennen. + */ +export const TOKEN_MARKE = 'planer' +export const PRAEFIX_LAENGE = 8 + +const TOKEN_MUSTER = /^planer_([A-Za-z0-9]{8})_([A-Za-z0-9_-]{32,86})$/ + +export type TokenTeile = { praefix: string; geheimnis: string } + +export function tokenZerlegen(wert: string): TokenTeile | null { + const treffer = TOKEN_MUSTER.exec(wert.trim()) + if (!treffer) return null + const [, praefix, geheimnis] = treffer + if (!praefix || !geheimnis) return null + return { praefix, geheimnis } +} + +/** + * Liest den Token aus der Kopfzeile. + * + * Erlaubt sind `Authorization: Bearer ` und — weil manche Werkzeuge + * nichts anderes können — `X-Planer-Token: `. + */ +export function tokenAusKopfzeilen( + autorisierung: string | null, + eigene: string | null, +): string | null { + if (autorisierung?.toLowerCase().startsWith('bearer ')) { + return autorisierung.slice(7).trim() || null + } + return eigene?.trim() || null +} + +// --- Eingaben -------------------------------------------------------------- + +/** + * Eine Aufgabe, wie sie von außen hereinkommt. + * + * Bewusst schmal: Status, Bereich und Projekt lassen sich über die + * Schnittstelle nicht setzen. Was von außen kommt, landet in der Inbox und + * wird hier einsortiert — sonst räumt ein fremder Dienst die eigene Ordnung um. + */ +export const aufgabeEingabe = z.object({ + titel: z.string().trim().min(1, 'Ohne Titel geht es nicht.').max(200), + notiz: z.string().trim().max(4000).nullable().default(null), + /** + * Kennung beim Aufrufer. Zweimal dieselbe ergibt **eine** Aufgabe. + * + * Das ist der Unterschied zwischen einer Schnittstelle, die man gefahrlos + * wiederholen kann, und einer, die bei jedem Netzhänger Karteileichen + * anlegt. + */ + ref: z.string().trim().min(1).max(64).nullable().default(null), + url: z.url().max(500).nullable().default(null), + faelligAm: z.iso.date().nullable().default(null), + prioritaet: z.coerce.number().int().min(1).max(5).nullable().default(null), + aufwandMinuten: z.coerce.number().int().min(1).max(1440).nullable().default(null), +}) + +export type AufgabeEingabe = z.infer + +/** Was sich an einer bestehenden Aufgabe ändern lässt. */ +export const aenderungEingabe = z + .object({ + titel: z.string().trim().min(1).max(200).optional(), + notiz: z.string().trim().max(4000).nullable().optional(), + faelligAm: z.iso.date().nullable().optional(), + prioritaet: z.coerce.number().int().min(1).max(5).optional(), + aufwandMinuten: z.coerce.number().int().min(1).max(1440).nullable().optional(), + erledigt: z.boolean().optional(), + }) + .refine((w) => Object.keys(w).length > 0, 'Es wurde nichts zum Ändern angegeben.') + +export type AenderungEingabe = z.infer + +export const tokenEingabe = z.object({ + name: z.string().trim().min(1, 'Der Token braucht einen Namen.').max(60), + rechte: z.array(z.enum(RECHTE)).min(1, 'Mindestens ein Recht.'), + /** Tage bis zum Ablauf; null heißt unbegrenzt. */ + gueltigTage: z.coerce.number().int().min(1).max(3650).nullable().default(null), +}) + +// --- Antworten ------------------------------------------------------------- + +/** + * Fehlerantworten sind knapp und immer gleich aufgebaut. + * + * Kein Hinweis darauf, ob ein Token unbekannt, abgelaufen oder widerrufen + * ist: Von außen soll sich nicht abfragen lassen, welche Tokens es gibt. + */ +export const FEHLER = { + nichtAngemeldet: { status: 401, fehler: 'Kein gültiger Token.' }, + nichtErlaubt: { status: 403, fehler: 'Dieser Token darf das nicht.' }, + ungueltig: { status: 400, fehler: 'Die Anfrage war fehlerhaft.' }, + nichtGefunden: { status: 404, fehler: 'Gibt es nicht.' }, +} as const + +/** Wie viele Datensätze eine Abfrage höchstens liefert. */ +export const SEITE_MAX = 100 +export const SEITE_STANDARD = 25 + +export const abfrage = z.object({ + grenze: z.coerce.number().int().min(1).max(SEITE_MAX).default(SEITE_STANDARD), + versatz: z.coerce.number().int().min(0).default(0), + offen: z + .enum(['true', 'false']) + .nullable() + .default(null) + .transform((w) => (w === null ? null : w === 'true')), +})