feat: Datenmodell und Regeln fuer die oeffentliche Schnittstelle

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 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-12 11:09:03 +02:00
co-authored by Claude Opus 5
parent bbac268c59
commit d5c7974af3
4 changed files with 405 additions and 0 deletions
+177
View File
@@ -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)
})
})
+169
View File
@@ -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<Recht, string> = {
lesen: 'Lesen',
erfassen: 'Erfassen',
schreiben: 'Ändern',
}
export const RECHT_BESCHREIBUNG: Record<Recht, string> = {
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_<praefix>_<geheimnis>`
*
* 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 <token>` und — weil manche Werkzeuge
* nichts anderes können — `X-Planer-Token: <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<typeof aufgabeEingabe>
/** 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<typeof aenderungEingabe>
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')),
})